14 Commits
Author SHA1 Message Date
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
17 changed files with 2009 additions and 248 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;
}
+127 -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);
@@ -433,6 +501,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()
{
+13
View File
@@ -65,6 +65,12 @@ 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);
~MFDSplitView();
Logical
@@ -128,4 +134,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"
+682 -78
View File
@@ -3821,6 +3821,496 @@ 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;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// 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 +4332,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 +4354,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 +4455,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 +4479,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 +4530,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 +4599,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
+38
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,30 @@ 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; }
protected:
Logical
splitViews;
MFDSplitView
*splitView[SplitViewCount];
HWND
cockpitViewscreen; // child pane the 3D scene presents into
static SVGA16
*activeCockpit; // the instance owning the cockpit window
};
//########################################################################
+26 -13
View File
@@ -1,16 +1,16 @@
# Red Planet 4.12 — the Steamification
**Red Planet** is VWE's pod-racing game: eight-player VTV races on Mars,
originally played from inside full-motion cockpit pods. This repo is the
third life of that code:
**Red Planet** is VWE's pod-racing game: eight-player VTV contests on Mars
**Martian Death Race** and **Martian Football** — originally played from
inside VWE Tesla cockpit pods. This repo is the third life of that code:
| Generation | What it was |
|------------|-------------|
| **Red Planet 4.10** | The original game, running on the **Tesla 1** pod platform (DOS-era MUNGA engine, serial RIO cockpit hardware, operator console, batch-file relaunch between missions) |
| **Red Planet 4.11** | The **Win32 port** of 4.10 ([RP411](https://gitea.mysticmachines.com/VWE/RP411.git)) — DirectX 9, WinSock TCP, TeslaConsole/TeslaLauncher session control; still arcade pods, still a LAN |
| **Red Planet 4.12** | **This repo: the Steamification of 4.11** — the same engine, the same wire protocol, the same missions, made sellable on Steam and playable over the internet with no cockpit hardware |
| **Red Planet 4.11** | The **Win32 port** of 4.10 ([RP411](https://gitea.mysticmachines.com/VWE/RP411.git)) — DirectX 9, WinSock TCP, TeslaConsole/TeslaLauncher session control; still runs in pods, still a LAN |
| **Red Planet 4.12** | **This repo: the Steamification of 4.11** — the same engine, the same wire protocol, the same missions, made distributable on Steam and playable over the internet with no cockpit hardware |
The architecture is deliberately conservative: the arcade's design survives
The architecture is deliberately conservative: the VWE's design survives
intact, with each pod-era dependency replaced by a consumer equivalent
behind the engine's existing seams.
@@ -19,23 +19,34 @@ behind the engine's existing seams.
| RIO cockpit board (serial) | **PadRIO** — virtual RIO from XInput pad + keyboard, fully rebindable (`bindings.txt`, vRIO profile format) |
| Seven physical displays | **Single-window cockpit** — all displays composed on a locked 1920×1080 canvas around the viewscreen, with the real button banks lamp-lit and clickable |
| Pod button lamps | On-screen lamps, plus an **RGB keyboard mirror** (Windows Dynamic Lighting) |
| TeslaConsole operator | **In-game front end** — race setup menu builds the mission egg locally; an in-process console marshals every race (missions still only end on a console stop, exactly as designed in 1995) |
| TeslaConsole operator | **In-game front end** — race setup menu builds the mission egg locally; an in-process console marshals every race (missions still only end on a console stop, exactly as designed in 1994) |
| TeslaLauncher relaunch-per-mission | **Single binary** — menu → race → results → menu in one process |
| WinSock TCP LAN mesh | **NetTransport seam** — the deterministic pod mesh unchanged, running over plain TCP (LAN/dev) or **Steam Networking Sockets** (FakeIP + Steam Datagram Relay) |
| Site network / fixed IPs | **Steam lobbies** — lobby owner is the console; members exchange FakeIPs and loadouts as lobby data |
**Status: it works.** Release
[v4.12.1](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.1)
carries the first verified end-to-end internet build: three machines, three
**Status: it works.**
[v4.12.2](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.2)
carried the first verified end-to-end internet build: three machines, three
Steam accounts, lobby → mesh → marshaled five-minute race → deaths and
respawns → timed stop → results on every machine → rematch from the same
lobby.
[v4.12.3](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.3)
is about the screen. The cockpit now scales up as well as down and re-fits
whenever the window changes, keeping 16:9 so a wide panel letterboxes
instead of stretching; `-fit` runs it borderless over the whole monitor and
picks the render size to land on the viewscreen 1:1. The displays are the
player's to arrange — each of the six can be scaled on its own and the
radar moved out of the middle of the road (`environ.ini`). Each display's
button bank now reaches under its glass, so the picture itself is the press
target rather than a sliver at the edge.
## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
run `start-windowed.bat` — the game boots into the race setup menu. Steam
multiplayer: see
run `start-windowed.bat` — the game boots into the setup menu, where the
SCENARIO group picks Death Race or Football (Football swaps in the
team/position columns and its own track list). Steam multiplayer: see
[docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412
has its own AppID it runs under Spacewar, 480).
@@ -45,7 +56,8 @@ 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.
mission. Full map with pad and keyboard diagrams:
[docs/CONTROLS.md](docs/CONTROLS.md).
## Building
@@ -58,6 +70,7 @@ mission.
| Doc | Contents |
|-----|----------|
| [docs/CONTROLS.md](docs/CONTROLS.md) | Controls map — pad and keyboard diagrams, the panel button banks, rebinding |
| [docs/RP412-ROADMAP.md](docs/RP412-ROADMAP.md) | The original plan and workstreams |
| [docs/RP412-FRONTEND-DESIGN.md](docs/RP412-FRONTEND-DESIGN.md) | TeslaConsole analysis, the egg format, the console protocol, and the Steam mapping — with status notes as each layer landed |
| [docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) | Multiplayer test procedure, Steam Input notes, the abort key |
+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.2" << std::endl << std::flush;
DEBUG_STREAM << "Red Planet 4.12.3" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM
+484 -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,102 @@ void
strcpy(badge, kBadges[b].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,
+36
View File
@@ -50,6 +50,39 @@ int
void
RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge);
//---------------------------------------------------------------
// 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 +93,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(
+117 -7
View File
@@ -45,6 +45,7 @@ namespace
int gLastGoNonce = 0; // launches we already answered
int gShownResultsNonce = 0; // score sheets we already displayed
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
// async call-result plumbing
Logical gCallDone = False;
@@ -120,6 +121,12 @@ namespace
}
}
Logical IsOwner()
{
return gInLobby &&
SteamMatchmaking()->GetLobbyOwner(gLobby) == SteamUser()->GetSteamID();
}
void PublishMemberData()
{
char value[64];
@@ -140,12 +147,30 @@ 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()));
// the owner also publishes the scenario, so members know
// whether their team/position pick matters
if (IsOwner())
{
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
}
}
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);
}
//---------------------------------------------------------------
@@ -162,6 +187,8 @@ namespace
char vehicle[24];
char color[16];
char badge[24];
char team[32]; // football pick
char position[16];
Logical published;
};
@@ -196,6 +223,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 +274,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 +309,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,8 +341,9 @@ 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;
@@ -328,7 +367,25 @@ namespace
is_owner_row ? " [HOST]" : "");
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
if (member->vehicle[0] != '\0')
if (FootballLobby())
{
// football: the roster is a team sheet
if (member->team[0] != '\0')
{
const char *position_name = "?";
for (int i = 0; i < RPL4FrontEnd_PositionCount(); ++i)
{
if (strcmp(member->position, RPL4FrontEnd_PositionKey(i)) == 0)
{
position_name = RPL4FrontEnd_PositionName(i);
break;
}
}
sprintf(text, "%s - %s", member->team, position_name);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
else if (member->vehicle[0] != '\0')
{
sprintf(text, "%s / %s", member->vehicle, member->color);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
@@ -347,11 +404,30 @@ namespace
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
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 +471,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 +553,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 +587,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);
+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)`.
+184 -31
View File
@@ -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,18 @@ 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
# --- 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 +104,98 @@ 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
# 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 +206,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 +283,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.2
Red Planet 4.12.3
=================
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 +323,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 +352,8 @@ 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, and more). CONTROLS.txt has the full controls
map with pad and keyboard diagrams.
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 +367,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.2.zip'
$zipPath = Join-Path $root 'RedPlanet-4.12.3.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
}