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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    sample = ourNow - theirStamp = trueOffset + oneWayLatency

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things the wiring turned up:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things had to move for it to make sense.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 08:12:23 -05:00
33 changed files with 4314 additions and 363 deletions
+14
View File
@@ -40,6 +40,20 @@ ipch/
*.log
rpl4.log
# Where the game window and the exploded view's panes were last dragged
# to (RP412MFDLAYOUT). Per-machine by nature - it is screen coordinates
# on somebody's desk.
mfd_layout.cfg
# The pilot's callsign, remembered between sessions. Whoever is sitting
# at this machine, which is not the repo's business.
pilot.cfg
# Generated by stamp-version.ps1 as RP_L4's pre-build step. The patch
# number in it IS this repository's commit count, so a committed copy
# would be stale the moment it was committed.
/RP_L4/rpl4build.h
# Build-output static libs that land in lib/ (the two committed dependency
# libs, OpenAL32.lib and libsndfile-1.lib, stay tracked).
/lib/Munga_L4.lib
+50 -3
View File
@@ -50,10 +50,57 @@ The solution is [WinTesla.sln](WinTesla.sln) with four v143 projects:
Build order is resolved by `ProjectReference` (RP_L4 and RPL4TOOL both reference
Munga_L4).
**Versioning:** the patch number *is* the repository's commit count, so a
build always names the commit it came from and there is no question about
which changes a given binary contains.
[stamp-version.ps1](stamp-version.ps1) runs as RP_L4's pre-build step and
writes the generated, uncommitted `RP_L4\rpl4build.h`:
```
#define RP412_VERSION "4.12.96"
#define RP412_VERSION_LONG "4.12.96 (a1b2c3d)"
```
The game logs the long form on its first line. A trailing `+` on the hash
means the tree had uncommitted changes to tracked files when it was built —
useful when a test machine reports something a clean build cannot reproduce.
Only the `4.12` product line is set by hand, at the top of the script.
**Test builds expire.** `$expireDays` at the top of the same script is the
shelf life in days (currently **14**, counted from the day it was *built*,
not the day the code was written — rebuilding an old commit gives a usable
binary rather than one born stale). An expired build says so in a dialog,
names its version and expiry date, points at the releases page, and exits
without running. It stops a tester spending an afternoon on something that
was fixed a week ago.
> ⚠️ **Set `$expireDays = 0` for a real release.** A shipped build that
> expires is a catastrophe, and that one line is what decides it.
`RP412NOEXPIRY=1` waives the check when an old build has to be run on
purpose, and says so in the log so a waived build is never mistaken for a
current one. It is deliberately **not** listed in `environ.ini` — a bypass
every tester can see is a bypass every tester will use. Negative
`$expireDays` backdates the expiry, which is how the refusal gets tested
without touching the machine's clock.
The header is deliberately not committed: the commit that recorded a
hardcoded number would itself change the count, so the file would be stale
the moment it landed. It is rewritten only when the stamp actually changes,
so ordinary rebuilds do not recompile `RPL4.CPP` for nothing. Building
outside a git checkout stamps `4.12.x (no repository)` rather than inventing
a number that would sort against real ones.
**Packaging:** [pack-dist.ps1](pack-dist.ps1) assembles a runnable game into
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, desktop
`environ.ini`, `start-windowed.bat`, README). Pass `-Zip` to also produce
`dist\RedPlanet412-prototype.zip` for handing to someone else.
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, launch scripts,
HANDBOOK.html, README). It deliberately does **not** write `environ.ini`
the exe carries that template and writes it on first run
([RP_L4/RPL4ENVIRON.cpp](RP_L4/RPL4ENVIRON.cpp)), so a tester can drop a new
build over an old folder without losing their settings. Pass `-Zip` to also produce
`RedPlanet-<version>.zip` for handing to someone else. It reads the version
from `rpl4build.h` rather than asking git again, so the package and the
binary inside it cannot disagree, and it warns if the build it is packing
came from a modified tree.
## 3. VS2022 migration notes (what changed and why)
+23 -2
View File
@@ -649,8 +649,23 @@ Time endIntercom = Now();
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
//
// Ask the console first: it owns the clock that actually ends the
// race, so this is the countdown the buzzer will agree with. Its
// own reckoning is the fallback for everything with no console of
// its own - see gMissionClockHook in APPMGR.h.
//
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
routePacketFinished = False;
@@ -1125,6 +1140,12 @@ void
Check(this);
Check(egg_notation_file);
//
// Forget every peer's clock offset: the hosts in the next race are not
// the hosts in the last one, and a HostID gets reused.
//
NetClock_Reset();
//
//--------------------------------------------------------------------------
// Create mission from egg notation file
+3
View File
@@ -15,6 +15,9 @@ Logical gConsoleMarshalsLaunch = False;
// losing the console mid-mission ends it (lobby-member races)
Logical gConsoleLossEndsMission = False;
// the console's countdown, when a console is marshalling (see APPMGR.h)
Logical (*gMissionClockHook)(Scalar *seconds_remaining) = NULL;
ApplicationManager* ApplicationManager::CurrentAppManager = NULL;
ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this)
+22
View File
@@ -20,6 +20,28 @@ extern Logical gConsoleMarshalsLaunch;
// console to return, exactly as always.
extern Logical gConsoleLossEndsMission;
//
// The console's own countdown, when there is a console to ask.
//
// A mission ends when the console says so, but secondsRemainingInGame was
// computed here from the engine clock and its own idea of when the race
// started - a different clock, from a different epoch, than the one that
// actually fires the buzzer. The two agree to within a frame or so, which
// is why nobody noticed, but they are not the same number: the cockpit
// clock could read 0:00 with the race still running, and the camera
// directors' "last 30 seconds" behaviour switched on the engine's reading
// rather than on the real remaining time.
//
// Set by the console when it is marshalling; NULL restores the engine's
// own reckoning, which is what the arcade -net pods, lobby members and
// mission review all use (none of them run a console locally, and their
// clock is anchored by the console's RunMission arriving anyway).
//
// Returns False when it has no answer yet - the window between the
// application reaching RunningMission and the console noticing.
//
extern Logical (*gMissionClockHook)(Scalar *seconds_remaining);
class ApplicationManager : public Node
{
public:
+25
View File
@@ -377,8 +377,28 @@ void
//------------------------------------------------------------------------
// Step through each block until there are no more remaining, and send the
// update out the the simulation indicated by the subsystemID
//
// This is the only point on the receive path that knows WHOSE update
// this is - the records themselves carry a timestamp but not an owner -
// so the sender is published here for the net clock to align against.
// Every record in the message, and the damage zones nested inside them,
// came from the same machine in the same frame.
//------------------------------------------------------------------------
//
//
// Only for an entity somebody else owns. Our own clock needs no
// aligning, and an update we somehow handed ourselves would otherwise
// drag lastUpdate back by a frame for no reason.
//
Check(application);
Check(application->GetHostManager());
Logical remote_owner =
GetOwnerID() != application->GetHostManager()->GetLocalHostID();
if (remote_owner)
{
NetClock_BeginUpdate(GetOwnerID());
}
while (stream.GetBytesRemaining())
{
Simulation::UpdateRecord *update =
@@ -389,6 +409,11 @@ void
simulation->ReadUpdateRecord(update);
stream.AdvancePointer(update->recordLength);
}
if (remote_owner)
{
NetClock_EndUpdate();
}
Check_Fpu();
}
+181 -1
View File
@@ -264,6 +264,109 @@ Simulation::SharedData
// Model support
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//##########################################################################
// Net clock - see SIMULATE.h for why the sender's timestamp is estimated
// rather than used as it stands.
//##########################################################################
namespace
{
enum
{
netClockMaxPeers = 16,
// Samples per rolling minimum. A peer sends one record per
// simulation per frame, so at eight vehicles and 60 fps this is
// well under a second - fast enough to follow a route change,
// long enough that the minimum means something.
netClockWindow = 128,
// The furthest back we will believe a timestamp. Beyond this the
// packet is stale or the estimate is wrong, and extrapolating a
// vehicle half a second forward does more harm than the lag we
// are correcting.
netClockMaxLagTicks = 500
};
struct PeerClock
{
HostID host;
Logical inUse;
Logical settled;
long offsetTicks; // our clock - their clock
long windowMinTicks;
int windowCount;
};
PeerClock gPeerClocks[netClockMaxPeers];
HostID gUpdateSender = 0;
Logical gUpdateSenderValid = False;
Logical NetClockEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412NETCLOCK");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - "
<< "replicants dead-reckon from arrival time\n" << std::flush;
}
}
return enabled ? True : False;
}
PeerClock *FindPeer(HostID host)
{
PeerClock *free_slot = NULL;
for (int i = 0; i < netClockMaxPeers; ++i)
{
if (gPeerClocks[i].inUse)
{
if (gPeerClocks[i].host == host)
{
return &gPeerClocks[i];
}
}
else if (free_slot == NULL)
{
free_slot = &gPeerClocks[i];
}
}
if (free_slot != NULL)
{
free_slot->inUse = True;
free_slot->host = host;
free_slot->settled = False;
free_slot->offsetTicks = 0;
free_slot->windowMinTicks = 0;
free_slot->windowCount = 0;
}
return free_slot;
}
}
void NetClock_BeginUpdate(HostID sender)
{
gUpdateSender = sender;
gUpdateSenderValid = True;
}
void NetClock_EndUpdate()
{
gUpdateSenderValid = False;
}
void NetClock_Reset()
{
memset(gPeerClocks, 0, sizeof(gPeerClocks));
gUpdateSenderValid = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -272,7 +375,84 @@ void
Check(this);
Check_Pointer(message);
lastUpdate = Now(); // HACK - should be based upon message->timeStamp
//
//------------------------------------------------------------------
// When this update arrived is not when it was taken. Put lastUpdate
// at the sender's sampling moment, expressed in our clock, so the
// dead reckoner extrapolates over the network latency instead of
// starting from scratch once it has already elapsed.
//------------------------------------------------------------------
//
long now_ticks = Now().ticks;
long local_ticks = now_ticks;
PeerClock *peer = gUpdateSenderValid && NetClockEnabled()
? FindPeer(gUpdateSender) : NULL;
if (peer != NULL)
{
//
// sample = trueOffset + oneWayLatency, so the running minimum
// converges on the offset from above.
//
long sample = now_ticks - message->timeStamp.ticks;
if (!peer->settled)
{
peer->settled = True;
peer->offsetTicks = sample;
peer->windowMinTicks = sample;
peer->windowCount = 0;
DEBUG_STREAM << "NetClock: host " << peer->host
<< " first sample, offset " << sample << " ms\n" << std::flush;
}
else
{
if (sample < peer->windowMinTicks)
{
peer->windowMinTicks = sample;
}
if (sample < peer->offsetTicks)
{
peer->offsetTicks = sample; // a shorter path: believe it now
}
if (++peer->windowCount >= netClockWindow)
{
//
// Close the window: adopt its minimum even if it is
// LARGER than the running estimate, which is how the
// figure follows clock drift and a route that got
// slower rather than staying pinned to one old packet.
//
long moved = peer->windowMinTicks - peer->offsetTicks;
if (moved > 50 || moved < -50)
{
DEBUG_STREAM << "NetClock: host " << peer->host
<< " offset " << peer->offsetTicks << " -> "
<< peer->windowMinTicks << " ms\n" << std::flush;
}
peer->offsetTicks = peer->windowMinTicks;
peer->windowMinTicks = sample;
peer->windowCount = 0;
}
}
local_ticks = message->timeStamp.ticks + peer->offsetTicks;
//
// Never ahead of our own clock, and never further back than we
// are willing to extrapolate.
//
if (local_ticks > now_ticks)
{
local_ticks = now_ticks;
}
else if (now_ticks - local_ticks > netClockMaxLagTicks)
{
local_ticks = now_ticks - netClockMaxLagTicks;
}
}
lastUpdate.ticks = local_ticks;
SetSimulationState(message->simulationState);
Check_Fpu();
}
+35
View File
@@ -4,6 +4,41 @@
#include "receiver.h"
#include "time.h"
#include "resource.h"
#include "hostid.h"
//##########################################################################
//########################### Net clock ##############################
//##########################################################################
//
// Aligning a peer's clock with ours, so a replicant is dead-reckoned from
// when its update was SENT rather than when it happened to arrive.
//
// Every update record carries the sender's own timestamp. The receiver
// used to throw it away and stamp lastUpdate with its own Now() - the
// original code says so: "HACK - should be based upon message->timeStamp".
// The dead reckoner then extrapolates over (lastPerformance - lastUpdate),
// so starting that clock at ARRIVAL rather than at SEND leaves every
// remote vehicle exactly one network latency behind where it should be.
// On the 1 ms arcade LAN that was invisible. Over Steam Datagram Relay it
// is a constant 50-150 ms of positional lag - a bias, not jitter.
//
// The timestamp cannot be used raw: two machines' clocks share no epoch,
// both being QueryPerformanceCounter since their own boot. So we estimate
// the offset per peer. Each arriving record gives
//
// sample = ourNow - theirStamp = trueOffset + oneWayLatency
//
// and since latency is never negative, the SMALLEST sample seen is the
// closest to the true offset. Taking a minimum over a short rolling
// window tracks crystal drift and re-adapts when the route changes,
// instead of being pinned forever by one lucky packet.
//
// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time
// behaviour, so a test machine can A/B it without a rebuild.
//
void NetClock_BeginUpdate(HostID sender); // around one message's records
void NetClock_EndUpdate();
void NetClock_Reset(); // forget every peer (new mission)
class Simulation__SharedData;
class Simulation__IndexData;
+1052
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
//===========================================================================//
// File: l4joy.h //
// Project: MUNGA Brick: generic joystick reader //
// Contents: DirectInput 8 sticks, HOTAS throttles and pedals //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
//########################################################################
//
// L4JOY - the generic-joystick reader.
//
// PadRIO reads XInput, which covers Xbox-class pads and nothing else.
// This layer adds every OTHER game device Windows knows - flight sticks,
// HOTAS throttles, twist grips, rudder pedals, wheels - through
// DirectInput 8, the standard generic-HID game API. It exposes up to
// joyMaxDevices attached devices as normalized state blocks; the PadRIO
// poll maps them onto the pod's control channels through the joydev /
// joyaxis / joybutton / joyhat rows of bindings.txt (L4PADBINDINGS.h),
// the same binding machinery the pad and keyboard already use.
//
// XInput-class devices are EXCLUDED here, or they would double-feed
// through both APIs and every input would count twice. A DirectInput
// device whose VID/PID also appears in a RawInput device path containing
// the "IG_" marker is an XInput device - the documented detection that
// does not drag in WMI.
//
// This is distinct from the legacy L4DINPUT.cpp DIJoystick, the 1995-era
// single-device `Joystick` engine interface reachable only through the
// old L4CONTROLS=DIJOYSTICK profile. That path is untouched.
//
// RP412JOYCONFIG=1 runs the interactive setup wizard at boot: it asks
// the player to move each control, works out which device and axis moved
// and which way, and writes the joystick section of bindings.txt.
// RP412JOYLOG=1 logs device attach/detach.
//
// Ported from BT411, whose glass cockpit needed the same thing.
//
//########################################################################
enum
{
joyMaxDevices = 4,
joyAxisCount = 8, // X Y Z RX RY RZ SL0 SL1 (DIJOYSTATE2 order)
joyButtonCount = 32, // buttons exposed to bindings (DI carries 128)
joyHatCount = 4
};
struct RPJoyDeviceState
{
int attached;
float axis[joyAxisCount]; // normalized -1..1, raw: deadzones
// are the binding layer's business
unsigned buttons; // bit n = button n held
int hat[joyHatCount]; // POV in centidegrees, -1 = centered
char name[64]; // product name ("T.16000M", ...)
};
//
// Lifecycle. Init is lazy-safe (Poll calls it) and returns the attached
// non-XInput device count. Re-enumeration for hot-plug happens inside
// Poll on a ~3 s cadence whenever nothing is attached.
//
int RPJoyInit(void);
void RPJoyShutdown(void);
void RPJoyPoll(void);
int RPJoyDeviceCount(void);
const RPJoyDeviceState *RPJoyDevice(int index); // NULL out of range/detached
//
// Case-insensitive product-name substring match to a device index, -1 for
// no match. This is what a named joydev slot resolves through.
//
int RPJoyFindDevice(const char *name_substring);
//
// The RP412JOYCONFIG capture wizard (console UI; called from RPL4.CPP
// before the front end). Returns 0 if it wrote a config, non-zero on
// abort or no device.
//
int RPJoyConfigWizard(void);
+356
View File
@@ -10,6 +10,74 @@ namespace
const int buttonGap = 4;
//---------------------------------------------------------------
// Sticky placement for the exploded view. See L4MFDVIEW.h; ported
// from BT411's BT_GLASS_LAYOUT.
//---------------------------------------------------------------
enum { LayoutOff = 0, LayoutLoad, LayoutSave };
const char layoutFileName[] = "mfd_layout.cfg";
// every window taking part, so Save can walk them
enum { maxLayoutWindows = 12 };
struct LayoutWindow
{
void *window;
const char *title;
Logical withSize;
Logical bare; // frame stripped, per the file
};
LayoutWindow layoutWindows[maxLayoutWindows];
int layoutWindowCount = 0;
//---------------------------------------------------------------
// Take a window's frame off: no title bar, no border, nothing to
// drag it by. WS_SYSMENU stays - it draws nothing once the caption
// is gone, but without one DefWindowProc will not honour Alt+F4,
// and a bare window with no way to close it is a trap.
//---------------------------------------------------------------
void StripWindowFrame(HWND window)
{
LONG_PTR style = GetWindowLongPtrA(window, GWL_STYLE);
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_MINIMIZEBOX |
WS_MAXIMIZEBOX | WS_BORDER | WS_DLGFRAME);
style |= WS_POPUP;
SetWindowLongPtrA(window, GWL_STYLE, style);
}
int LayoutMode()
{
static int mode = -1;
if (mode < 0)
{
const char *setting = getenv("RP412MFDLAYOUT");
if (setting == NULL || setting[0] == '\0')
{
mode = LayoutOff;
}
else if (!stricmp(setting, "off") || !stricmp(setting, "0"))
{
mode = LayoutOff;
}
else if (!stricmp(setting, "load") || !stricmp(setting, "restore"))
{
mode = LayoutLoad;
}
else // save / adjust / on / 1
{
mode = LayoutSave;
}
if (mode != LayoutOff)
{
DEBUG_STREAM << "MFDLayout: RP412MFDLAYOUT="
<< ((mode == LayoutSave) ? "save" : "load")
<< " (" << layoutFileName << ")\n" << std::flush;
}
}
return mode;
}
//---------------------------------------------------------------
// Button banks run UNDER the glass: a button reaches this far in
// from the display edge, but only the indicator strip clears that
@@ -108,6 +176,15 @@ namespace
}
break;
case WM_EXITSIZEMOVE:
//
// A drag just finished. Write the whole arrangement now rather
// than trusting teardown, so a hard kill still leaves the
// layout that was on screen. No-op unless the mode is save.
//
RPWindowLayout_Save();
return 0;
case WM_CLOSE:
// Part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE);
@@ -148,6 +225,8 @@ MFDSplitView::MFDSplitView(
sourceHeight = source_height;
buttonCount = 0;
pressedIndex = -1;
paneTitle = title;
ownWindow = (parent == NULL) ? True : False;
pixels = new unsigned long[source_width * source_height];
memset(pixels, 0, source_width * source_height * sizeof(unsigned long));
@@ -230,6 +309,14 @@ MFDSplitView::MFDSplitView(
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, (LONG_PTR) this);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
// only the exploded view's own windows can be dragged, so only
// those have a placement worth remembering. Position only - see
// RPWindowLayout_Register.
if (ownWindow)
{
RPWindowLayout_Register(window, paneTitle, False);
}
}
else
{
@@ -241,6 +328,9 @@ MFDSplitView::~MFDSplitView()
{
Check_Pointer(this);
// out of the layout registry before the window goes
RPWindowLayout_Forget(window);
if (window != NULL)
{
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, 0);
@@ -259,6 +349,272 @@ Logical
return pixels != NULL;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Join the saved layout. Registering twice just refreshes the entry, so
// a window re-registering after being rebuilt is harmless.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Register(void *window, const char *title, Logical with_size)
{
if (window == NULL || title == NULL)
{
return;
}
for (int i = 0; i < layoutWindowCount; ++i)
{
if (layoutWindows[i].window == window)
{
layoutWindows[i].title = title;
layoutWindows[i].withSize = with_size;
return;
}
}
if (layoutWindowCount >= maxLayoutWindows)
{
return;
}
layoutWindows[layoutWindowCount].window = window;
layoutWindows[layoutWindowCount].title = title;
layoutWindows[layoutWindowCount].withSize = with_size;
layoutWindows[layoutWindowCount].bare = False;
++layoutWindowCount;
}
void
RPWindowLayout_Forget(void *window)
{
for (int i = 0; i < layoutWindowCount; ++i)
{
if (layoutWindows[i].window == window)
{
layoutWindows[i] = layoutWindows[--layoutWindowCount];
layoutWindows[layoutWindowCount].window = NULL;
layoutWindows[layoutWindowCount].title = NULL;
return;
}
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Sticky placement: restore the saved placement over the computed one.
//
// Called once everything has been built and placed, so a window the file
// does not mention simply keeps where it was put. Size comes back only
// for windows registered with it - see the header.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Load()
{
if (LayoutMode() == LayoutOff || layoutWindowCount == 0)
{
return;
}
FILE *file = fopen(layoutFileName, "rt");
if (file == NULL)
{
DEBUG_STREAM << "MFDLayout: no " << layoutFileName
<< " yet (using the computed arrangement)\n" << std::flush;
return;
}
int restored = 0;
char line[256];
while (fgets(line, sizeof(line), file) != NULL)
{
char *cursor = line;
while (*cursor == ' ' || *cursor == '\t')
{
++cursor;
}
if (*cursor == '#' || *cursor == '\r' || *cursor == '\n' || *cursor == '\0')
{
continue;
}
// titles carry no '=', so the first one splits the line
char *equals = strchr(cursor, '=');
if (equals == NULL)
{
continue;
}
*equals = '\0';
char *end = equals;
while (end > cursor && (end[-1] == ' ' || end[-1] == '\t'))
{
--end;
}
*end = '\0';
int x = 0, y = 0, w = 0, h = 0;
if (sscanf(equals + 1, "%d,%d,%d,%d", &x, &y, &w, &h) < 2)
{
continue;
}
//
// Optional trailing keyword, "x,y,w,h,noframe". Searched for
// rather than parsed in place so a hand-edited line survives a
// stray space or a missing comma.
//
Logical bare = (strstr(equals + 1, "noframe") != NULL) ? True : False;
for (int i = 0; i < layoutWindowCount; ++i)
{
LayoutWindow &entry = layoutWindows[i];
if (entry.title == NULL || strcmp(entry.title, cursor) != 0)
{
continue;
}
HWND target = (HWND) entry.window;
if (target == NULL || !IsWindow(target))
{
break;
}
// a saved size of nothing is a corrupt line, not an instruction
Logical size_it = (entry.withSize && w > 0 && h > 0) ? True : False;
//
// Saved coordinates belong to whatever monitors were plugged
// in that day. If the placement lands on none of today's, drop
// it and keep the computed one - restoring the game window
// somewhere invisible would leave nothing to drag back.
//
RECT landing;
landing.left = x;
landing.top = y;
if (size_it)
{
landing.right = x + w;
landing.bottom = y + h;
}
else
{
RECT current;
if (!GetWindowRect(target, &current))
{
break;
}
landing.right = x + (current.right - current.left);
landing.bottom = y + (current.bottom - current.top);
}
if (MonitorFromRect(&landing, MONITOR_DEFAULTTONULL) == NULL)
{
DEBUG_STREAM << "MFDLayout: " << cursor << " was saved at "
<< x << "," << y << " - off every monitor now, ignoring\n"
<< std::flush;
break;
}
//
// Frame off, if the line asked. A bare window's rect IS its
// client rect, so whatever client area it has now is the size
// to hand back - which also makes the round trip stable: once
// bare, what Save records is already the client.
//
entry.bare = bare;
if (bare)
{
if (!size_it)
{
RECT client;
if (GetClientRect(target, &client))
{
w = client.right;
h = client.bottom;
size_it = True;
}
}
StripWindowFrame(target);
}
SetWindowPos(target, NULL, x, y, w, h,
SWP_NOZORDER | SWP_NOACTIVATE |
(size_it ? 0 : SWP_NOSIZE) | (bare ? SWP_FRAMECHANGED : 0));
++restored;
break;
}
}
fclose(file);
DEBUG_STREAM << "MFDLayout: restored " << restored << " window placement(s) from "
<< layoutFileName << "\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Write where every registered window currently is. The whole file each
// time - it is a handful of lines, and a rewrite leaves nothing
// half-written for a hard kill to catch.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
RPWindowLayout_Save()
{
if (LayoutMode() != LayoutSave || layoutWindowCount == 0)
{
return;
}
FILE *file = fopen(layoutFileName, "wt");
if (file == NULL)
{
DEBUG_STREAM << "MFDLayout: could not write " << layoutFileName
<< "\n" << std::flush;
return;
}
fputs("# RP412 window placement. RP412MFDLAYOUT=save writes this on each\n"
"# finished drag and on exit; =load restores it.\n"
"# <title>=<x>,<y>,<w>,<h>. The game window gets its size back too;\n"
"# for the display panes the size is reference only, since theirs\n"
"# follows their content.\n"
"# Append ,noframe to take that window's title bar and border off.\n"
"# Put it where you want it first - a bare window has nothing to\n"
"# drag by, so its line stops changing once the frame is gone.\n", file);
int wrote = 0;
for (int i = 0; i < layoutWindowCount; ++i)
{
LayoutWindow &entry = layoutWindows[i];
if (entry.title == NULL)
{
continue;
}
HWND entry_window = (HWND) entry.window;
if (entry_window == NULL || !IsWindow(entry_window))
{
continue;
}
//
// rcNormalPosition rather than GetWindowRect: a minimised window
// reports a nonsense rect and a maximised one reports the screen,
// and neither is what to come back to. This is the restored
// placement whatever state the window is in, so quitting from
// maximised still records where the window will reappear.
//
WINDOWPLACEMENT placement;
memset(&placement, 0, sizeof(placement));
placement.length = sizeof(placement);
if (!GetWindowPlacement(entry_window, &placement))
{
continue;
}
RECT bounds = placement.rcNormalPosition;
// the flag is the player's instruction, not something measured off
// the window, so a rewrite has to carry it back out
fprintf(file, "%s=%ld,%ld,%ld,%ld%s\n", entry.title,
(long) bounds.left, (long) bounds.top,
(long) (bounds.right - bounds.left),
(long) (bounds.bottom - bounds.top),
entry.bare ? ",noframe" : "");
++wrote;
}
fclose(file);
DEBUG_STREAM << "MFDLayout: saved " << wrote << " window placement(s) to "
<< layoutFileName << "\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Compute the display rectangle and the button rectangles, growing the
// client area to make room for the strips.
+83
View File
@@ -77,6 +77,14 @@ public:
void
Hide();
// caption and HWND, for the sticky-placement file
const char *
Title() const
{ return paneTitle; }
void *
Window()
{ return window; }
~MFDSplitView();
Logical
@@ -147,4 +155,79 @@ protected:
int
buttonAnchorA,
buttonAnchorB;
// the window caption, and the key this pane is saved under
const char
*paneTitle;
// True for a pane of its own on the desktop (the exploded view); the
// composited cockpit's panes are chrome-less children and cannot be
// dragged, so there is nothing to remember for them.
Logical
ownWindow;
};
//########################################################################
// Sticky window placement.
//
// The game window and, in the exploded view, each display pane are all
// draggable, but their placement is recomputed on every launch - so
// putting a window somewhere useful never survived the menu-race-menu
// loop. RP412MFDLAYOUT remembers it, in mfd_layout.cfg beside
// bindings.txt:
//
// off / 0 / unset computed placement only, no file (default)
// load / restore restore saved placement at startup, never write
// save / adjust restore, then rewrite on each finished drag and
// on teardown - the round trip
//
// One "<title>=x,y,w,h" line per window, plus an optional ",noframe"
// that takes that window's title bar and border off - for a cockpit that
// fills a monitor edge to edge without -fit's all-or-nothing, or an
// exploded pane photographed without chrome. A bare window has nothing
// to drag by, so place it first and add the flag after; Save carries the
// flag back out, since it is an instruction rather than something
// measured off the window. Windows are always built framed, so deleting
// the flag is all it takes to get the frame back.
//
// Whether the size comes back depends on the window, which is why
// Register takes it as a flag:
//
// display panes position only. A pane's size follows its content and
// its button banks, so an old size from a different
// build must not distort it. The plasma glass registers
// the same way - its size is L4PLASMASCALE's business.
// the game window position and size. Nothing derives that size - -res
// only decides how sharp the scene is, and the cockpit
// fits itself to whatever client area it is given - so
// a window sized to suit a monitor should come back
// that way, and half-restoring it would be the strange
// behaviour. RPL4.CPP registers it before the console
// screen, so the saved placement is there from the
// first frame rather than arriving when a race starts.
//
// A placement that lands on none of the monitors currently plugged in is
// ignored rather than applied - restoring the game window off screen
// would leave nothing to drag back.
//
// Lives here because the display panes were the first to need it; the
// game window joined later rather than grow a second copy of the same
// file format.
//
// Ported from BT411's BT_GLASS_LAYOUT.
//########################################################################
// Take part in the saved layout. title is the key in the file and must
// outlive the window (the callers pass string literals).
void
RPWindowLayout_Register(void *window, const char *title, Logical with_size);
void
RPWindowLayout_Forget(void *window);
// Apply the saved placement over the computed one. Call once everything
// has been built and placed.
void
RPWindowLayout_Load();
// Write every registered window's placement. No-op unless mode is save.
void
RPWindowLayout_Save();
+197 -26
View File
@@ -2,6 +2,7 @@
#pragma hdrstop
#include "l4padbindings.h"
#include "l4joy.h" // joyButtonCount / joyHatCount, the parse limits
#include <XInput.h>
#include <stdio.h>
@@ -79,6 +80,20 @@ namespace
{ "Throttle", BindAxisThrottle },
{ "LeftPedal", BindAxisLeftPedal }, { "RightPedal", BindAxisRightPedal },
{ "JoystickY", BindAxisJoystickY }, { "JoystickX", BindAxisJoystickX },
{ "Pedals", BindAxisPedals },
};
// DirectInput's axis order, which is what the joy* rows name
const NameValue kJoyAxisNames[] =
{
{ "X", BindJoyAxisX }, { "Y", BindJoyAxisY }, { "Z", BindJoyAxisZ },
{ "RX", BindJoyAxisRX }, { "RY", BindJoyAxisRY }, { "RZ", BindJoyAxisRZ },
{ "SL0", BindJoyAxisSL0 }, { "SL1", BindJoyAxisSL1 },
};
const NameValue kJoyHatNames[] =
{
{ "up", 0 }, { "right", 1 }, { "down", 2 }, { "left", 3 },
};
Logical NameEquals(const char *a, const char *b)
@@ -185,10 +200,83 @@ namespace
}
//---------------------------------------------------------------
// One line of the profile grammar
// Shared tail of the two axis-source rows: [invert] [deadzone <d>]
// [rate <n>], in any order.
//---------------------------------------------------------------
Logical ParseLine(char *tokens[], int token_count, PadBindingProfile *profile)
Logical ParseAxisOptions(
char *tokens[], int token_count, int first,
Logical *invert, Scalar *deadzone, Scalar *rate)
{
for (int i = first; i < token_count; ++i)
{
if (NameEquals(tokens[i], "invert"))
{
*invert = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], deadzone))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], rate))
{
return False;
}
}
else
{
return False;
}
}
return True;
}
//---------------------------------------------------------------
// One line of the profile grammar. joy_slot carries the joydev
// state forward from line to line - joy rows attach to the slot
// most recently declared.
//---------------------------------------------------------------
Logical ParseLine(
char *tokens[], int token_count, PadBindingProfile *profile,
int *joy_slot)
{
//
// joydev is the one row that can be two tokens long ("joydev 1"),
// so it is answered before the four-token floor below.
//
if (NameEquals(tokens[0], "joydev") && token_count >= 2)
{
int slot = -1;
if (sscanf(tokens[1], "%d", &slot) != 1 ||
slot < 0 || slot >= BindJoyDeviceSlots)
{
return False;
}
*joy_slot = slot;
//
// The rest of the line is a product-name substring, rejoined
// with single spaces ("Saitek Pro Flight" is four tokens).
//
profile->joyDeviceMatch[slot][0] = '\0';
for (int i = 2; i < token_count; ++i)
{
if (i > 2)
{
strncat(profile->joyDeviceMatch[slot], " ",
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
strncat(profile->joyDeviceMatch[slot], tokens[i],
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
return True;
}
if (token_count < 4)
{
return False;
@@ -271,31 +359,75 @@ namespace
memset(binding, 0, sizeof(*binding));
binding->source = source;
binding->axis = axis;
for (int i = 4; i < token_count; ++i)
return ParseAxisOptions(tokens, token_count, 4,
&binding->invert, &binding->deadzone, &binding->rate);
}
//---------------------------------------------------------------
// Generic joystick rows, all attaching to the current joydev slot
//---------------------------------------------------------------
if (NameEquals(tokens[0], "joyaxis") && NameEquals(tokens[2], "axis"))
{
int source = LookupTable(kJoyAxisNames,
sizeof(kJoyAxisNames) / sizeof(kJoyAxisNames[0]), tokens[1]);
int axis = LookupTable(kRioAxisNames,
sizeof(kRioAxisNames) / sizeof(kRioAxisNames[0]), tokens[3]);
if (source < 0 || axis < 0 ||
profile->joyAxisCount >= PadBindingProfile::maxJoyAxes)
{
if (NameEquals(tokens[i], "invert"))
{
binding->invert = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->deadzone))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->rate))
{
return False;
}
}
else
{
return False;
}
return False;
}
PadJoyAxisBinding *binding = &profile->joyAxes[profile->joyAxisCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->source = source;
binding->axis = axis;
return ParseAxisOptions(tokens, token_count, 4,
&binding->invert, &binding->deadzone, &binding->rate);
}
if (NameEquals(tokens[0], "joybutton") && NameEquals(tokens[2], "button"))
{
int button = -1;
int address;
if (sscanf(tokens[1], "%d", &button) != 1 ||
button < 0 || button >= joyButtonCount ||
!ParseAddress(tokens[3], &address) ||
profile->joyButtonCount >= PadBindingProfile::maxJoyButtons)
{
return False;
}
Logical toggle = (token_count > 4 && NameEquals(tokens[4], "toggle"));
PadJoyButtonBinding *binding =
&profile->joyButtons[profile->joyButtonCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->button = button;
binding->address = address;
binding->toggle = toggle;
return True;
}
if (NameEquals(tokens[0], "joyhat") && token_count >= 5 &&
NameEquals(tokens[3], "button"))
{
int hat = -1;
int direction = LookupTable(kJoyHatNames,
sizeof(kJoyHatNames) / sizeof(kJoyHatNames[0]), tokens[2]);
int address;
if (sscanf(tokens[1], "%d", &hat) != 1 ||
hat < 0 || hat >= joyHatCount || direction < 0 ||
!ParseAddress(tokens[4], &address) ||
profile->joyHatCount >= PadBindingProfile::maxJoyHats)
{
return False;
}
PadJoyHatBinding *binding = &profile->joyHats[profile->joyHatCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->hat = hat;
binding->direction = direction;
binding->address = address;
return True;
}
@@ -320,10 +452,17 @@ namespace
"# key <name> axis <axis> rate <n-per-second>\n"
"# pad <button> button <addr> [toggle]\n"
"# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\n"
"# joydev <slot> [product-name substring]\n"
"# joyaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\n"
"# joybutton <n> button <addr> [toggle]\n"
"# joyhat <n> <up|down|left|right> button <addr>\n"
"#\n"
"# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad\n"
"# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).\n"
"# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX\n"
"# | Pedals - a signed axis that works the pedal PAIR, positive\n"
"# for the right pedal and negative for the left, so one rudder\n"
"# bar or twist grip drives both.\n"
"# <name> Keys name: A-Z, D0-D9 (digit row), F1-F12, NumPad0-NumPad9,\n"
"# Up, Down, Left, Right, Space, Enter, PageUp, PageDown,\n"
"# OemMinus, Oemplus, Oemcomma, OemPeriod, ...\n"
@@ -336,6 +475,31 @@ namespace
"# back on release; 'rate' walks the axis by <n> per second and the\n"
"# position sticks (the throttle). Every lamp button is also clickable\n"
"# on the on-screen cockpit, so unbound addresses are never stranded.\n"
"#\n"
"# ---- Flight sticks, HOTAS throttles and rudder pedals --------------\n"
"#\n"
"# EASIEST: run joyconfig.bat once. It asks you to move each control,\n"
"# works out which device and axis you moved and which way round it\n"
"# reads, and writes the joy* rows below a marker line at the end of\n"
"# this file. Everything you have written yourself is kept. Xbox-class\n"
"# pads need none of this - they are the pad* rows above.\n"
"#\n"
"# By hand: joydev picks the device for the rows that follow it - a\n"
"# name substring binds that product, a bare slot number binds the Nth\n"
"# stick Windows lists. <src> for joyaxis is the DirectInput axis name,\n"
"# X Y Z RX RY RZ SL0 SL1: a twist grip is usually RZ and a HOTAS\n"
"# throttle usually Z or SL0. A joyaxis on Throttle with no 'rate' is\n"
"# treated as a real lever and OWNS the channel - its full travel is\n"
"# the full throttle range, rather than nudging the position the way a\n"
"# spring-centred pad stick has to.\n"
"#\n"
"# joydev 0 T.16000M\n"
"# joyaxis X axis JoystickX invert deadzone 0.08\n"
"# joyaxis Y axis JoystickY invert deadzone 0.08\n"
"# joyaxis RZ axis Pedals deadzone 0.08\n"
"# joyaxis SL0 axis Throttle deadzone 0\n"
"# joybutton 0 button 0x40\n"
"# joyhat 0 up button 0x42\n"
"\n"
"# ---- Flight: number pad + modifiers -------------------------------\n"
"# The whole letter board stays free for the MFD banks; flight lives\n"
@@ -499,6 +663,7 @@ void
char line[256];
int line_number = 0;
int error_count = 0;
int joy_slot = 0; // joy rows before any joydev belong to slot 0
const char *cursor = source;
while (*cursor != '\0')
{
@@ -523,7 +688,7 @@ void
{
continue;
}
if (!ParseLine(tokens, token_count, profile))
if (!ParseLine(tokens, token_count, profile, &joy_slot))
{
++error_count;
DEBUG_STREAM << "PadBindings: " << kBindingsFileName << " line "
@@ -542,4 +707,10 @@ void
<< profile->padAxisCount << " pad axes"
<< (error_count ? " (with rejected lines)" : "")
<< "\n" << std::flush;
if (profile->joyAxisCount || profile->joyButtonCount || profile->joyHatCount)
{
DEBUG_STREAM << "PadBindings: joystick - " << profile->joyAxisCount
<< " axes, " << profile->joyButtonCount << " buttons, "
<< profile->joyHatCount << " hat directions\n" << std::flush;
}
}
+82 -1
View File
@@ -18,12 +18,25 @@
// key <name> axis <axis> deflect <n> | rate <n>
// pad <button> button <addr> [toggle]
// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joydev <slot> [product-name substring...]
// joyaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joybutton <n> button <addr> [toggle]
// joyhat <n> <up|down|left|right> button <addr>
//
// <addr> is a RIO input address: lamp buttons 0x00-0x47, internal
// keypad 0x50-0x5F, external keypad 0x60-0x6F. Loaded from
// bindings.txt beside the exe; written there (self-documenting, with
// the full default profile) on first run. Bad lines are logged and
// skipped, good lines always win.
//
// The joy* rows drive generic DirectInput devices - flight sticks,
// HOTAS throttles, twist grips, rudder pedals (L4JOY.h). They attach to
// the most recent joydev slot, or slot 0 if no joydev came first. A slot
// naming a product substring binds THAT device; a bare slot binds the
// Nth attached non-XInput device. Source names follow the DirectInput
// layout - X Y Z RX RY RZ SL0 SL1 - where a twist grip is usually RZ
// and a HOTAS throttle usually Z or SL0. RP412JOYCONFIG=1 writes these
// rows for you by asking the player to move each control.
//########################################################################
enum PadBindRioAxis
@@ -33,6 +46,14 @@ enum PadBindRioAxis
BindAxisRightPedal,
BindAxisJoystickY,
BindAxisJoystickX,
//
// A signed composite, not a channel the pod has: positive presses
// the right pedal, negative the left. One physical control - a
// rudder bar, a twist grip, a stick axis - works the pedal pair the
// way a foot never could, one or the other and never both. It
// decomposes into the real pair when the poll applies it.
//
BindAxisPedals,
BindAxisCount
};
@@ -89,6 +110,54 @@ struct PadPadAxisBinding
Scalar rate; // 0 = direct position, >0 = speed integrate
};
//
// Generic joystick (DirectInput - L4JOY.h). device is a joydev SLOT,
// resolved to a live device at poll time by the slot's name substring or
// by its ordinal, so unplugging and replugging does not rewrite the file.
//
enum { BindJoyDeviceSlots = 4 };
enum PadBindJoyAxis
{
BindJoyAxisX = 0,
BindJoyAxisY,
BindJoyAxisZ,
BindJoyAxisRX,
BindJoyAxisRY,
BindJoyAxisRZ,
BindJoyAxisSL0,
BindJoyAxisSL1,
BindJoyAxisCount
};
struct PadJoyAxisBinding
{
int device; // joydev slot
int source; // PadBindJoyAxis
int axis; // PadBindRioAxis
Logical invert;
Scalar deadzone; // normalized 0..1
Scalar rate; // 0 = direct position, >0 = speed integrate
};
struct PadJoyButtonBinding
{
int device; // joydev slot
int button; // 0-31
int address;
Logical toggle;
Logical wasDown;
Logical latched;
};
struct PadJoyHatBinding
{
int device; // joydev slot
int hat; // 0-3
int direction; // 0 up, 1 right, 2 down, 3 left
int address;
};
struct PadBindingProfile
{
enum
@@ -96,7 +165,10 @@ struct PadBindingProfile
maxKeyButtons = 128,
maxKeyAxes = 32,
maxPadButtons = 32,
maxPadAxes = 16
maxPadAxes = 16,
maxJoyAxes = 24,
maxJoyButtons = 48,
maxJoyHats = 16
};
PadKeyButtonBinding keyButtons[maxKeyButtons];
@@ -107,6 +179,15 @@ struct PadBindingProfile
int padButtonCount;
PadPadAxisBinding padAxes[maxPadAxes];
int padAxisCount;
PadJoyAxisBinding joyAxes[maxJoyAxes];
int joyAxisCount;
PadJoyButtonBinding joyButtons[maxJoyButtons];
int joyButtonCount;
PadJoyHatBinding joyHats[maxJoyHats];
int joyHatCount;
// "" = take the slot's ordinal attached device
char joyDeviceMatch[BindJoyDeviceSlots][64];
};
// Load bindings.txt from the working directory into the profile,
+214 -1
View File
@@ -3,6 +3,7 @@
#include "l4padrio.h"
#include "l4keylight.h"
#include "l4joy.h"
#include <XInput.h>
#pragma comment(lib, "xinput9_1_0.lib")
@@ -41,6 +42,51 @@ namespace
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
}
//
// A generic stick axis is already normalized -1..1, so the deadzone
// is a plain cut about centre with the remainder rescaled - press
// just past the edge and you get just past zero, not a step.
//
Scalar JoyAxisValue(Scalar raw, Scalar deadzone)
{
if (deadzone <= 0.0f)
{
return raw;
}
if (raw > -deadzone && raw < deadzone)
{
return (Scalar) 0;
}
Scalar value = (raw > 0.0f)
? (raw - deadzone) / (1.0f - deadzone)
: (raw + deadzone) / (1.0f - deadzone);
if (value > 1.0f) value = 1.0f;
if (value < -1.0f) value = -1.0f;
return value;
}
//
// A POV hat reports centidegrees clockwise from up, or -1 centered.
// The 45-degree window each way is what makes the diagonals press
// both of their neighbours, which is how a four-way hat is read.
//
Logical JoyHatHeld(int centidegrees, int direction)
{
if (centidegrees < 0)
{
return False;
}
int degrees = (centidegrees / 100) % 360;
switch (direction)
{
case 0: return (degrees >= 315 || degrees <= 45) ? True : False;
case 1: return (degrees >= 45 && degrees <= 135) ? True : False;
case 2: return (degrees >= 135 && degrees <= 225) ? True : False;
case 3: return (degrees >= 225 && degrees <= 315) ? True : False;
}
return False;
}
void KeyLightLog(const char *line)
{
DEBUG_STREAM << line << "\n" << std::flush;
@@ -166,6 +212,29 @@ PadRIO::PadRIO()
activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush;
//
// Only open DirectInput when the profile actually asks for it. A
// player on keyboard and pad should not pay for an enumeration of
// every HID on the machine, and joyconfig.bat is what writes the
// rows that turn this on.
//
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
{
int found = RPJoyInit();
DEBUG_STREAM << "PadRIO: joystick bindings present, " << found
<< " generic device(s) attached\n" << std::flush;
for (int d = 0; d < found; ++d)
{
const RPJoyDeviceState *state = RPJoyDevice(d);
if (state != NULL)
{
DEBUG_STREAM << "PadRIO: [" << d << "] " << state->name
<< "\n" << std::flush;
}
}
}
}
PadRIO::~PadRIO()
@@ -244,6 +313,11 @@ void
profile.padButtons[i].latched = False;
profile.padButtons[i].wasDown = False;
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
profile.joyButtons[i].latched = False;
profile.joyButtons[i].wasDown = False;
}
}
void
@@ -391,6 +465,81 @@ void
}
}
}
//---------------------------------------------------------------
// Generic joysticks. The slots are resolved every poll rather than
// cached, so a stick unplugged mid-race simply stops answering and
// one plugged back in picks up where it left off.
//---------------------------------------------------------------
int joyDevice[BindJoyDeviceSlots];
Logical joyLive = False;
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = -1;
}
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
{
RPJoyPoll();
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = (profile.joyDeviceMatch[slot][0] != '\0')
? RPJoyFindDevice(profile.joyDeviceMatch[slot])
: ((RPJoyDevice(slot) != NULL) ? slot : -1);
if (joyDevice[slot] >= 0)
{
joyLive = True;
}
}
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
PadJoyButtonBinding *binding = &profile.joyButtons[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
Logical down = (state != NULL) &&
(state->buttons & (1u << binding->button)) != 0;
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
}
binding->wasDown = down;
if (binding->toggle ? binding->latched : down)
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < profile.joyHatCount; ++i)
{
const PadJoyHatBinding *binding = &profile.joyHats[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
if (state != NULL &&
JoyHatHeld(state->hat[binding->hat], binding->direction))
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < buttonUnits; ++i)
{
if (screenButton[i])
@@ -502,6 +651,68 @@ void
}
}
//---------------------------------------------------------------
// Joystick axes. A physical throttle lever is the one source that
// does not add into the pile: it has an absolute position, so its
// full travel IS the channel and it takes ownership rather than
// nudging an accumulator that a spring-centred pad stick has to.
//---------------------------------------------------------------
Logical throttleLever = False;
Scalar throttleLeverValue = (Scalar) 0;
if (joyLive)
{
for (int i = 0; i < profile.joyAxisCount; ++i)
{
const PadJoyAxisBinding *binding = &profile.joyAxes[i];
if (binding->device < 0 || binding->device >= BindJoyDeviceSlots)
{
continue;
}
const RPJoyDeviceState *state = RPJoyDevice(joyDevice[binding->device]);
if (state == NULL)
{
continue;
}
Scalar raw = (Scalar) state->axis[binding->source];
if (binding->invert)
{
raw = -raw;
}
if (binding->axis == BindAxisThrottle && binding->rate == 0.0f)
{
// -1..1 of lever travel onto the 0..1 the pod runs on
throttleLeverValue = (raw + 1.0f) * 0.5f;
throttleLever = True;
continue;
}
Scalar value = JoyAxisValue(raw, binding->deadzone);
if (binding->rate > 0.0f)
{
rate[binding->axis] += value * binding->rate;
}
else
{
deflect[binding->axis] += value;
}
}
}
//
// The composite pedal axis becomes the pair the pod actually has.
// One signed source presses one pedal or the other, never both,
// which is what a rudder bar or a twist grip does.
//
Scalar pedals = deflect[BindAxisPedals];
if (pedals > 0.0f)
{
deflect[BindAxisRightPedal] += pedals;
}
else if (pedals < 0.0f)
{
deflect[BindAxisLeftPedal] += -pedals;
}
throttleAccum = Clamp01(throttleAccum + rate[BindAxisThrottle] * delta_t);
Scalar x = deflect[BindAxisJoystickX];
@@ -511,7 +722,9 @@ void
if (y > 1.0f) y = 1.0f;
if (y < -1.0f) y = -1.0f;
Throttle = Clamp01(throttleAccum + deflect[BindAxisThrottle]);
Throttle = throttleLever
? Clamp01(throttleLeverValue)
: Clamp01(throttleAccum + deflect[BindAxisThrottle]);
LeftPedal = Clamp01(deflect[BindAxisLeftPedal]);
RightPedal = Clamp01(deflect[BindAxisRightPedal]);
// The profile encodes the pod's stick sign convention; L4PADFLIP
+27
View File
@@ -3,6 +3,7 @@
#include "l4plasmascreen.h"
#include "l4app.h"
#include "l4mfdview.h" // RPWindowLayout_*
namespace
{
@@ -45,6 +46,15 @@ namespace
}
break;
case WM_EXITSIZEMOVE:
//
// Dragged to a new spot. Write the whole arrangement now rather
// than trusting teardown, so a hard kill still leaves what was
// on screen. No-op unless RP412MFDLAYOUT is save.
//
RPWindowLayout_Save();
return 0;
case WM_CLOSE:
// The glass is part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE);
@@ -176,6 +186,7 @@ PlasmaScreen::~PlasmaScreen()
}
if (window != NULL)
{
RPWindowLayout_Forget(window);
RemovePropA((HWND) window, "PlasmaBitmapInfo");
RemovePropA((HWND) window, "PlasmaPixels");
DestroyWindow((HWND) window);
@@ -275,6 +286,22 @@ void
SetPropA((HWND) window, "PlasmaPixels", pixelBuffer->Data.MapPointer);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
activeInstance = this;
//
//-----------------------------------------------------------
// Join the saved layout. Position only, like the display
// panes: the glass is 128x32 at L4PLASMASCALE, so its size
// is a setting rather than something to drag.
//
// Loading here rather than leaving it to SVGA16 keeps this
// independent of which is built first - the glass comes from
// the gauge renderer, the panes from the video mode, and
// whichever runs second re-applies a placement the first
// already has.
//-----------------------------------------------------------
//
RPWindowLayout_Register(window, "Plasma Display", False);
RPWindowLayout_Load();
}
else
{
+39
View File
@@ -3839,6 +3839,11 @@ static LRESULT CALLBACK
cockpit->LayoutCockpit(LOWORD(lParam), HIWORD(lParam));
}
}
//
// WM_EXITSIZEMOVE is not handled here: RPL4.CPP's own WndProc takes
// it, and this chains straight through to that, so the shell saves on
// every finished drag whether or not a cockpit has been built.
//
return CallWindowProcA(gCockpitBaseProc, hwnd, message, wParam, lParam);
}
@@ -4457,6 +4462,15 @@ SVGA16::SVGA16(
splitView[SplitMFDLowerRight]->SetPosition(
work.left + right_x, work.top + bottom_y);
splitView[SplitMap]->SetPosition(work.left + map_x, work.top + map_y);
//
// ...and then let RP412MFDLAYOUT put everything back where it was
// dragged to last time. Windows the file does not mention keep the
// arrangement just computed above. The game window is registered
// already - RPL4.CPP does that before the console screen ever
// shows - so this picks it up alongside the panes.
//
RPWindowLayout_Load();
}
else if (splitViews)
{
@@ -4668,6 +4682,23 @@ SVGA16::SVGA16(
// catch maximise / restore / drag-resize and re-fit
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
//
// Sticky placement for the shell. Position AND size: nothing
// derives that size here - -res only decides how sharp the
// scene is - so a window sized to suit a monitor should come
// back that way. RPL4.CPP registered it before the console
// screen; this reload undoes the sizing just done above, and
// runs after the subclass on purpose so its WM_SIZE puts
// LayoutCockpit over the restored client area.
//
// -fit sits it borderless over the whole monitor, so there is
// no placement of the player's to remember.
//
if (!fit_display)
{
RPWindowLayout_Load();
}
}
else
{
@@ -4863,6 +4894,14 @@ SVGA16::~SVGA16()
//---------------------------------------------------------
//SVGASetSplitterClock(False);
//
// Backstop for the sticky placement: every finished drag has already
// been written, but a window moved and then closed straight away would
// otherwise be missed. Must run before the panes go - it reads their
// live window rects, and the game window's.
//
RPWindowLayout_Save();
for (int view = 0; view < SplitViewCount; ++view)
{
delete splitView[view];
+2
View File
@@ -263,6 +263,7 @@
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<ClCompile Include=".\L4PADBINDINGS.cpp" />
<ClCompile Include=".\L4JOY.cpp" />
<ClCompile Include=".\L4PADRIO.cpp" />
<ClCompile Include=".\L4PCSPAK.cpp" />
<ClCompile Include=".\L4PLASMA.cpp" />
@@ -454,6 +455,7 @@
<ClInclude Include=".\L4NETTRANSPORT.h" />
<ClInclude Include=".\L4KEYLIGHT.h" />
<ClInclude Include=".\L4PADBINDINGS.h" />
<ClInclude Include=".\L4JOY.h" />
<ClInclude Include=".\L4STEAMTRANSPORT.h" />
<ClInclude Include=".\L4PARTICLES.h" />
<ClInclude Include=".\L4MFDVIEW.h" />
+33 -3
View File
@@ -61,6 +61,32 @@ cockpit glass cleared away, held for a few seconds before the results screen.
Three pieces of it had been stubbed out in the D3D9 port and are working
again.
[v4.12.6](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.6)
is about the windows. Where you put them now survives the
menu-race-menu loop: `RP412MFDLAYOUT` remembers the game window, the
exploded view's display panes and the plasma glass in `mfd_layout.cfg`
beside `bindings.txt`. Append `,noframe` to a line to take that
window's title bar and border off — a cockpit filling the monitor edge
to edge at a rect you chose, rather than `-fit` taking the whole
screen — and the setup screen carries its own **EXIT GAME** button,
since a window with no title bar needs a way out. The Steam buttons
now dim and say *STEAM NOT RUNNING* instead of disappearing. And the
Winners Circle camera is framed off the award stand itself rather than
off whoever is standing on it, so the shot is the same one for every
player at every head count.
[v4.12.7](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.7)
is about sticks. Anything that is not an Xbox-class pad — a flight stick,
a HOTAS throttle, a twist grip, rudder pedals, a wheel — comes in through
DirectInput rather than XInput, and the game could not see any of it.
Now it can, and `joyconfig.bat` sets it up: the wizard asks you to move
each control in turn, works out which device and axis answered and which
way round it reads, and writes the joystick rows of `bindings.txt`,
leaving anything you have edited yourself alone. A twist grip or rudder
bar drives both pedals through one signed `Pedals` axis, and a real
throttle lever owns its channel outright rather than nudging a position
the way a spring-centred stick has to. Ported from the sibling BT411.
## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
@@ -70,9 +96,13 @@ team/position columns and its own track list). Steam multiplayer: see
[docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412
has its own AppID it runs under Spacewar, 480).
The two config files beside the exe are self-documenting: **environ.ini**
(every engine option, commented) and **bindings.txt** (every key, pad
button, and axis; written with the full default layout on first run).
The config files beside the exe are self-documenting and none of them
ship: the game writes each one the first time it needs it and then leaves
it alone, so a new build dropped over an existing folder keeps every
setting. **environ.ini** is every engine option, commented; **bindings.txt**
every key, pad button and axis; **pilot.cfg** your callsign and loadout;
**mfd_layout.cfg** where you dragged the windows. Delete any of them to
start that part over with the current defaults.
Default controls: numpad flies (8/2/4/6 stick, 7/9 pedals, 0 trigger),
Shift/Ctrl throttle, Alt reverse, arrows look, Space fires, letter rows
are the MFD button banks as printed on the panel. **Alt+Q** aborts a
+166 -19
View File
@@ -22,11 +22,15 @@
#include "rpl4pb.h"
#include "rpl4fe.h"
#include "rpl4environ.h"
#include "rpl4console.h"
#include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h"
#include "..\munga_l4\l4splr.h"
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h"
#include "rpl4build.h" // generated: RP412_VERSION / RP412_VERSION_LONG
#include "..\munga\resver.h"
#include "..\munga\resource.h"
// added for game status drawing support
@@ -38,6 +42,7 @@
#include <strsafe.h>
#include <direct.h>
#include <shellapi.h>
#include <time.h> // the test-build expiry check
#define SPOOL_SIZE 0x600000
@@ -123,6 +128,15 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
return 0;
}
break;
case WM_EXITSIZEMOVE:
//
// A drag or resize just finished. Here rather than in the
// cockpit's subclass so it also covers the console screen, which
// is where the window gets moved before there is any cockpit to
// subclass. No-op unless RP412MFDLAYOUT is save.
//
RPWindowLayout_Save();
return 0;
}
return DefWindowProc(hWnd, uMsg, wParam, lParam);
}
@@ -138,6 +152,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
SetUnhandledExceptionFilter(RPL4CrashDumpFilter);
//
// Which build this is, before anything else can fail. The patch number
// is this repository's commit count and the hash beside it names the
// commit, so a log from a test machine says exactly where it came from.
// A trailing '+' means the tree had uncommitted changes when it was
// built. See stamp-version.ps1.
//
DEBUG_STREAM << "Red Planet " << RP412_VERSION_LONG << std::endl << std::flush;
// load up our environment variables
//controls
if(getenv("L4CONTROLS") == NULL)
@@ -152,32 +175,106 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
putenv("TARGETFPS=60");
if(getenv("MAXPARTICLES") == NULL)
putenv("MAXPARTICLES=8192");
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
//
// environ.ini: written on first run and read here. The exe owns the
// template rather than the packaging script laying one down on every
// unzip, so a tester can drop a new build over an old folder and keep
// their settings. See rpl4environ.h.
//
RPL4Environ_Load();
//
//-------------------------------------------------------------------------
// Test builds have a shelf life.
//
// A tester still racing a fortnight-old binary reports things that were
// fixed a week ago, and the afternoon spent chasing them is gone. So the
// build says plainly that it is out of date and stops, rather than
// running on and being quietly wrong about what it is.
//
// This is a nudge, not a lock: the date comes from the machine's own
// clock, so anyone determined can wind it back, and RP412NOEXPIRY=1 is
// there for us when an old build has to be run on purpose. It is
// deliberately not listed in environ.ini - a bypass every tester can see
// is a bypass every tester will use, and then the build never goes stale
// for the one person it was meant to stop.
//
// $expireDays in stamp-version.ps1 is what sets this, and 0 turns it off
// for a real release.
//-------------------------------------------------------------------------
//
#if RP412_EXPIRES
{
while (!feof(file))
const char *no_expiry = getenv("RP412NOEXPIRY");
Logical overridden = (no_expiry != NULL && atoi(no_expiry) != 0);
__time64_t raw_now = _time64(NULL);
struct tm today;
if (!overridden && _localtime64_s(&today, &raw_now) == 0)
{
if (fgets(line, sizeof(line), file))
int now_stamp =
(today.tm_year + 1900) * 10000 + (today.tm_mon + 1) * 100 + today.tm_mday;
int expiry_stamp =
RP412_EXPIRY_YEAR * 10000 + RP412_EXPIRY_MONTH * 100 + RP412_EXPIRY_DAY;
if (now_stamp > expiry_stamp)
{
for (int i = strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r')
line[i] = 0;
// the file is self-documenting: skip comments, blanks,
// and anything that is not KEY=VALUE
char *setting = line;
while (*setting == ' ' || *setting == '\t')
++setting;
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
continue;
putenv(setting);
DEBUG_STREAM << "Build expired on " << RP412_EXPIRY_TEXT
<< " - refusing to run\n" << std::flush;
char notice[512];
sprintf(notice,
"This Red Planet test build has expired.\n\n"
" Build %s\n"
" Expired %s\n\n"
"Test builds are good for a fortnight so that nobody spends an "
"afternoon chasing something that was fixed a week ago.\n\n"
"Grab the current one:\n"
"https://gitea.mysticmachines.com/VWE/RP412/releases",
RP412_VERSION_LONG, RP412_EXPIRY_TEXT);
MessageBoxA(NULL, notice, "Red Planet - test build expired",
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
return 1;
}
//
// The last few days get a line in the log, so somebody reading a
// report can see the build was nearly out rather than wondering.
//
struct tm expiry_day;
memset(&expiry_day, 0, sizeof(expiry_day));
expiry_day.tm_year = RP412_EXPIRY_YEAR - 1900;
expiry_day.tm_mon = RP412_EXPIRY_MONTH - 1;
expiry_day.tm_mday = RP412_EXPIRY_DAY;
//
// The END of the expiry day, because that is the rule the check
// above enforces - the build is good for all of that date and
// refuses the morning after. Anchoring at midday instead would
// report one day fewer than the build actually has left.
//
expiry_day.tm_hour = 23;
expiry_day.tm_min = 59;
expiry_day.tm_sec = 59;
expiry_day.tm_isdst = -1;
__time64_t expiry_time = _mktime64(&expiry_day);
if (expiry_time != (__time64_t) -1)
{
int days_left = (int)((expiry_time - raw_now) / (24 * 60 * 60));
if (days_left <= 3)
{
DEBUG_STREAM << "Build expires " << RP412_EXPIRY_TEXT << " ("
<< days_left << " day(s) left)\n" << std::flush;
}
}
}
fclose(file);
else if (overridden)
{
DEBUG_STREAM << "Build expiry (" << RP412_EXPIRY_TEXT
<< ") waived by RP412NOEXPIRY\n" << std::flush;
}
}
#endif
DEBUG_STREAM << "Red Planet 4.12.5" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM
@@ -254,6 +351,43 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
return FALSE;
ShowWindow(hWnd, nShowCmd);
//
// Sticky placement, from the first frame the player sees. SVGA16 also
// registers and reloads when it builds the cockpit, but that is not
// until a mission starts - without this the console screen would come
// up at the default rect with its title bar still on, and only jump
// to the saved placement once a race began. -fit takes the whole
// monitor and has no placement of the player's to restore.
//
if (!L4Application::GetFitDisplay() && !L4Application::GetFullscreen())
{
RPWindowLayout_Register(hWnd, "RPL4", True);
RPWindowLayout_Load();
}
//
//-------------------------------------------------------------------------
// RP412JOYCONFIG=1 (joyconfig.bat): the joystick setup wizard, before
// anything else claims the screen. It asks the player to move each
// control on their stick, HOTAS or pedals and writes the joy* rows of
// bindings.txt, then falls through into the game so they can try them
// straight away.
//
// One shot: the variable is cleared from this process so a rebuilt
// PadRIO later in the session cannot run the wizard a second time.
//-------------------------------------------------------------------------
//
{
const char *joyconfig = getenv("RP412JOYCONFIG");
if (joyconfig != NULL && *joyconfig != '\0' && atoi(joyconfig) != 0)
{
RPJoyConfigWizard();
SetEnvironmentVariableA("RP412JOYCONFIG", NULL);
_putenv("RP412JOYCONFIG=");
}
}
#if !_DEBUG
// Arcade pods have no mouse - but desktop/windowed play needs the
// cursor for the on-screen cockpit buttons, so hide it only when
@@ -475,6 +609,19 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
}
//
// Last word on the window's placement. Every finished drag has already
// written it and SVGA16 writes again as the cockpit comes down, but a
// session that never started a race has neither - and quitting from
// the console screen is exactly how somebody would leave after moving
// the window to where they want it. No-op unless the mode is save.
//
if (IsWindow(hWnd))
{
RPWindowLayout_Save();
}
RPWindowLayout_Forget(hWnd);
#if !_DEBUG
// symmetric with the fullscreen-only hide at startup
if (L4Application::GetFullscreen())
+95 -39
View File
@@ -133,20 +133,27 @@ void
// meaningful in both.
//---------------------------------------------------------------------
//
//
//---------------------------------------------------------------------
// The stand's own geometry first, before anybody stands on it.
//
// The eight spots are fixed furniture on every map, so reading them up
// front gives one set of numbers to both jobs below: where to stand
// the finishers, and where to put the camera. Only the first is
// allowed to care who finished.
//---------------------------------------------------------------------
//
enum { winnersSpotCount = 8 };
DropZone *spot[winnersSpotCount];
int spots_found = 0;
char winners_spot[] = "win?";
char *place = winners_spot + 3;
int placed = 0;
Point3D standFront(0.0f, 0.0f, 0.0f);
Point3D standCentre(0.0f, 0.0f, 0.0f);
Player *p;
for (int rank = 0; (p = CameraDirector::FindPlayerByRank(rank)) != NULL; ++rank)
for (int i = 0; i < winnersSpotCount; ++i)
{
if (rank > 7)
{
break; // only eight spots exist on the stand
}
*place = (char) ('1' + rank);
*place = (char) ('1' + i);
spot[i] = NULL;
ChainIteratorOf<Node*> iterator(dropzones->groupMembers);
DropZone *dropzone;
@@ -154,10 +161,36 @@ void
{
if (!strcmp(dropzone->GetDropZoneName(), winners_spot))
{
spot[i] = dropzone;
++spots_found;
Point3D at = dropzone->localOrigin.linearPosition;
DEBUG_STREAM << "WinnersCircle: " << winners_spot << " at "
<< at.x << "," << at.y << "," << at.z << "\n" << std::flush;
break;
}
}
if (dropzone == NULL)
}
if (spot[0] == NULL)
{
DEBUG_STREAM << "WinnersCircle: no win1 spot on this map\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Stand the finishers up, in finishing order.
//---------------------------------------------------------------------
//
int placed = 0;
Player *p;
for (int rank = 0; (p = CameraDirector::FindPlayerByRank(rank)) != NULL; ++rank)
{
if (rank >= winnersSpotCount)
{
break; // only eight spots exist on the stand
}
if (spot[rank] == NULL)
{
continue;
}
@@ -168,20 +201,9 @@ void
continue;
}
VTV *vtv = (VTV*) vehicle;
vtv->Reset(dropzone->localOrigin, VTV::MissionReviewReset);
vtv->Reset(spot[rank]->localOrigin, VTV::MissionReviewReset);
vtv->SetPerformance(&VTV::DoNothing);
vtv->FlushEvents();
Point3D spot = dropzone->localOrigin.linearPosition;
if (placed == 0)
{
// remember where the front of the stand is - the shot is framed
// off it rather than off hardcoded map coordinates
standFront = spot;
}
standCentre += spot;
DEBUG_STREAM << "WinnersCircle: " << winners_spot << " at "
<< spot.x << "," << spot.y << "," << spot.z << "\n" << std::flush;
++placed;
}
@@ -239,25 +261,52 @@ void
// line-up frames up. The stand runs from z~3 (rank 1, low) to z~39
// (ranks 4-8, high) and x~1180..1219; at 45 degrees that needs roughly
// 30 units of standoff. Eye is above the top tier looking slightly
// down at the middle of the group.
// down at the winner's spot.
//
// Framed off the stand, NOT off who is standing on it. This used to
// average the filled spots and anchor on the first one filled, which
// meant the camera moved with the head count: eight finishers put the
// anchor in the middle of the tiers and the eye about 58 units out,
// one finisher collapsed it onto the winner's spot and brought the eye
// in to 36. Same stand, different shot per race - and per machine, if
// a remote player's vehicle was not there to be placed. The furniture
// does not move, so the camera does not either.
//---------------------------------------------------------------------
//
standCentre.x /= (Scalar) placed;
standCentre.y /= (Scalar) placed;
standCentre.z /= (Scalar) placed;
Point3D standFront = spot[0]->localOrigin.linearPosition;
//
// Stand off along the line from the middle of the group out through the
// front spot, so the shot faces the stand however the map has it turned,
// and lift the eye above the top tier to look down on the line-up.
// The stand's own axis: back rows to the winner's spot, which points
// out of the front however the map has the stand turned. Taken from
// the geometry rather than assumed, so a map that mounts its stand at
// another angle still gets photographed from the front.
//
Point3D standBack(0.0f, 0.0f, 0.0f);
int back_count = 0;
for (int i = 1; i < winnersSpotCount; ++i)
{
if (spot[i] != NULL)
{
standBack += spot[i]->localOrigin.linearPosition;
++back_count;
}
}
if (back_count > 0)
{
standBack.x /= (Scalar) back_count;
standBack.y /= (Scalar) back_count;
standBack.z /= (Scalar) back_count;
}
Vector3D facing;
facing.x = standFront.x - standCentre.x;
facing.x = standFront.x - standBack.x;
facing.y = 0.0f;
facing.z = standFront.z - standCentre.z;
facing.z = standFront.z - standBack.z;
Scalar reach = (Scalar) sqrt(facing.x * facing.x + facing.z * facing.z);
if (reach < 0.01f)
{
// a one-spot stand, or all eight stacked: nothing to take a
// direction from, so fall back to the way the shipped stands face
facing.x = 0.0f; facing.z = -1.0f; reach = 1.0f;
}
//
@@ -285,10 +334,12 @@ void
if (tune != NULL) aim_lift = (Scalar) atof(tune);
Point3D eye;
eye.x = standCentre.x + (facing.x / reach) * standoff;
eye.y = standCentre.y + height;
eye.z = standCentre.z + (facing.z / reach) * standoff;
standCentre.y += aim_lift;
eye.x = standFront.x + (facing.x / reach) * standoff;
eye.y = standFront.y + height;
eye.z = standFront.z + (facing.z / reach) * standoff;
Point3D aim = standFront;
aim.y += aim_lift;
//
// RP412PODIUMCAM=0 leaves the view in the cockpit, which is also the
@@ -315,7 +366,7 @@ void
if (camera_mode == NULL || atoi(camera_mode) != 0)
{
dpl_renderer->SetViewAngle(Degree(45.0f));
dpl_renderer->SetPresentationCamera(eye, standCentre);
dpl_renderer->SetPresentationCamera(eye, aim);
}
else
{
@@ -340,8 +391,13 @@ void
}
dpl_renderer->StartPresentationFadeIn(fade_in);
DEBUG_STREAM << "WinnersCircle: " << placed << " placed; centre "
<< standCentre.x << "," << standCentre.y << "," << standCentre.z
//
// placed is reported for diagnosis only - the camera numbers beside it
// must not move when it does.
//
DEBUG_STREAM << "WinnersCircle: " << placed << " placed on "
<< spots_found << " spots; aim "
<< aim.x << "," << aim.y << "," << aim.z
<< " eye " << eye.x << "," << eye.y << "," << eye.z
<< "\n" << std::flush;
}
+53
View File
@@ -352,6 +352,55 @@ namespace
gPhase = PhaseStopped;
}
//---------------------------------------------------------------
// The countdown the engine shows, taken from the clock that will
// actually end the race (gMissionClockHook - see APPMGR.h).
//
// Called on the game thread, reading two volatile LONGs the console
// thread writes with InterlockedExchange. Aligned 32-bit reads, and
// a torn value could only mistime the cockpit clock by one tick of
// a countdown nobody reads to the millisecond - not worth a lock on
// the frame path.
//---------------------------------------------------------------
Logical MissionClock(Scalar *seconds_remaining)
{
//
// Only answer for the race this console is actually marshalling.
// Nothing ever uninstalls the hook, so a player who hosts a race
// and then joins somebody else's lobby still has it wired up -
// and in that race the console is a bystander whose gLengthMs and
// gRunStartTick belong to the previous mission entirely.
//
if (gWatchedApp == NULL || gWatchedApp != application)
{
return False;
}
if (!gMissionRunning)
{
return False; // not started, or already stopped
}
LONG length_ms = gLengthMs;
if (length_ms <= 0)
{
return False; // endless: nothing to count down
}
// DWORD subtraction, so a GetTickCount wrap costs nothing
LONG elapsed_ms = (LONG)(GetTickCount() - (DWORD) gRunStartTick);
LONG left_ms = length_ms - elapsed_ms;
if (left_ms < 0)
{
//
// The console polls at 250 ms, so the clock reaches zero
// slightly before the stop is dispatched. Hold at zero
// rather than showing negative time in the cockpit.
//
left_ms = 0;
}
*seconds_remaining = (Scalar) left_ms / 1000.0f;
return True;
}
//---------------------------------------------------------------
// The game-thread tick: state reporting + engine-safe execution
//---------------------------------------------------------------
@@ -522,6 +571,10 @@ namespace
// game-thread execution point
gPerFrameHook = &ConsoleTick;
// the cockpit clock now counts down the same clock that will stop
// the race, rather than the engine's own reckoning of it
gMissionClockHook = &MissionClock;
// results intake from the RP layer
gConsoleScoreSink = &CollectFinalScore;
+516
View File
@@ -0,0 +1,516 @@
#include "rpl4.h"
#pragma hdrstop
#include "rpl4environ.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//########################################################################
// environ.ini - see rpl4environ.h for why the exe owns this rather than
// the packaging script.
//########################################################################
namespace
{
const char kEnvironFileName[] = "environ.ini";
//-------------------------------------------------------------------
// The shipped configuration, verbatim. Lifted out of pack-dist.ps1
// so there is one source of truth and the exe alone can produce a
// working install.
//-------------------------------------------------------------------
const char kEnvironTemplate[] =
"# ============================================================================\n"
"# environ.ini - Red Planet 4.12 configuration\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments; anything without an = is ignored. Delete a line (or\n"
"# comment it out) to fall back to the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside the exe (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"#\n"
"# Your callsign and loadout are remembered in pilot.cfg beside the exe.\n"
"# Set them on the setup screen once and they come back every session,\n"
"# however you left - launching, joining a lobby, or quitting. Delete\n"
"# that file to start over.\n"
"\n"
"# ---- Core (the shipped configuration) --------------------------------------\n"
"\n"
"# Control stack: tokens separated by ; or , processed left to right.\n"
"# PAD the virtual RIO (XInput controller + keyboard,\n"
"# rebindable via bindings.txt)\n"
"# RIO real serial cockpit hardware on COM1\n"
"# RIO:COMn same, on another port (RIO:COM3, ...)\n"
"# KEYBOARD the engine keyboard handler\n"
"# MOUSE, JOYSTICK, FLIGHTSTICKPRO, THRUSTMASTER, DIJOYSTICK\n"
"# legacy pointer/joystick drivers (untested here)\n"
"# Unset falls back to KEYBOARD alone.\n"
"L4CONTROLS=PAD;KEYBOARD\n"
"\n"
"# Renderer bring-up argument. Only its presence is checked (the DPL\n"
"# resolution parsing it once fed is gone) and the game refuses to start\n"
"# without it - any non-empty value works. Leave as shipped.\n"
"DPLARG=1\n"
"\n"
"# DPL (renderer/scene) configuration file, searched beside the exe.\n"
"# Any notation file name; RPDPL.INI is the one that ships.\n"
"L4DPLCFG=RPDPL.INI\n"
"\n"
"# Gauge (MFD/instrument) canvas. Must name a page of GAUGE\\L4GAUGE.INI:\n"
"# 640x480x8 | 640x480x16 | 800x600x16\n"
"# Unset disables the gauge renderer (and with it all MFDs).\n"
"L4GAUGE=640x480x16\n"
"\n"
"# Plasma display.\n"
"# SCREEN render the pod's plasma glass in-window (currently\n"
"# parked off-layout)\n"
"# COM1, COM2... drive real plasma glass on that serial port\n"
"# (9600 baud, N81)\n"
"# Unset = no plasma display.\n"
"L4PLASMA=SCREEN\n"
"\n"
"# 0 = classic separate gauge windows; 1 = the single-window glass\n"
"# cockpit (all seven displays composed on a locked 1920x1080 canvas\n"
"# around the viewscreen); 2 = exploded diagnostic view (each display\n"
"# in its own native-resolution desktop window - MFDs 640x480, map\n"
"# 480x640 - decoded exactly as the pod's VDB split them, no downscale).\n"
"L4MFDSPLIT=1\n"
"\n"
"# The game window - and in the exploded view (L4MFDSPLIT=2) each display\n"
"# window - is placed fresh every launch, so moving one somewhere useful\n"
"# never survived the menu-race-menu loop. This remembers where you put\n"
"# them, in mfd_layout.cfg beside this file:\n"
"# off / 0 / unset computed placement only, no file (default)\n"
"# load put the windows back where they were saved\n"
"# save the same, and re-save on every finished drag\n"
"# The game window gets its size back too, so you can size the cockpit to\n"
"# suit your monitor once and keep it. The display windows get position\n"
"# only: their size follows their content and their button banks, so an\n"
"# old one is never restored over them. Arrange everything once with\n"
"# save, then leave it on load.\n"
"#\n"
"# The plasma display window takes part too, under \"Plasma Display\".\n"
"#\n"
"# Each line in mfd_layout.cfg reads <title>=<x>,<y>,<w>,<h>, and you can\n"
"# append ,noframe to take that window's title bar and border off - a\n"
"# cockpit that fills the monitor edge to edge without -fit taking the\n"
"# whole screen. Put the window where you want it first: a bare window\n"
"# has nothing to drag by. Delete the flag to get the frame back.\n"
"#RP412MFDLAYOUT=off\n"
"\n"
"# Size of the six secondary displays in the glass cockpit, as a\n"
"# percentage of their pod size. The pod bolted them down at one size;\n"
"# on a big panel there is room to trade viewscreen for instrument, so\n"
"# turn these up if you want to actually read the other displays while\n"
"# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and\n"
"# unreadable values fall back to the group setting, then to 100).\n"
"#\n"
"# The scaling is applied in canvas units, before the cockpit is fitted\n"
"# to your window, so a given number looks the same on every monitor.\n"
"# The layout stays legal whatever you ask for - the panes are clamped\n"
"# against their actual neighbours, shrinking uniformly so a display\n"
"# never comes out stretched. They do overlap the viewscreen, exactly\n"
"# as the pod's bezels did, but never each other.\n"
"#\n"
"# L4MFDSCALE sets all five green MFDs at once.\n"
"L4MFDSCALE=100\n"
"\n"
"# ...and any single display can override it. Uncomment one to size it\n"
"# on its own - useful if you only care about, say, the damage readout.\n"
"# UL upper left UC upper center UR upper right\n"
"# LL lower left LR lower right\n"
"#L4MFDSCALE_UL=100\n"
"#L4MFDSCALE_UC=100\n"
"#L4MFDSCALE_UR=100\n"
"#L4MFDSCALE_LL=100\n"
"#L4MFDSCALE_LR=100\n"
"\n"
"# The portrait radar/map, sized on its own (it already sits at 1.35x\n"
"# the MFDs by default). It shares the canvas with whichever MFD is\n"
"# above it, so at extreme settings one of the two gives way.\n"
"L4RADARSCALE=100\n"
"\n"
"# Where the radar sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)\n"
"# Anywhere but CENTER stops it blocking the middle of the road, which\n"
"# is worth having on a wide screen.\n"
"#\n"
"# In a bottom corner it is one of three panes along the bottom, and the\n"
"# lower MFD whose corner it takes slides inboard beside it. Halfway up\n"
"# a side it leaves the bottom row entirely and sits between that side's\n"
"# two MFDs - roomy on a tall radar, but if the MFDs on that side are\n"
"# also scaled up, the radar is the one that gives way (it has to clear\n"
"# both of them, and it grows from the middle in both directions).\n"
"L4RADARPOS=CENTER\n"
"\n"
"# The Winners Circle: at the end of a race the finishers are stood on\n"
"# the award platform in finishing order, with each pilot's callsign on\n"
"# the plate beside their spot, and held there for a few seconds before\n"
"# the results screen. 1 = show it, 0 = straight to the results.\n"
"RP412PODIUM=1\n"
"\n"
"# The shot is framed for you, but these move the camera if you want it\n"
"# somewhere else. Distances are in game units, measured from the middle\n"
"# of the group of finishers.\n"
"# STANDOFF how far out in front of the stand the camera sits\n"
"# HEIGHT how far above the group\n"
"# AIM height of the point it looks at, relative to the group -\n"
"# negative tilts down, positive tilts up\n"
"# ASPECT the stand was composed for a 4:3 pod monitor, so the shot\n"
"# is cropped to that shape with black either side. 0 runs it\n"
"# full width instead.\n"
"# FADEIN seconds to come up out of the black after the race fades\n"
"# CAM 0 watches from your own cockpit rather than off the stand\n"
"#RP412PODIUMSTANDOFF=36\n"
"#RP412PODIUMHEIGHT=12\n"
"#RP412PODIUMAIM=2\n"
"#RP412PODIUMASPECT=1.333\n"
"#RP412PODIUMFADEIN=0.45\n"
"#RP412PODIUMCAM=1\n"
"\n"
"# Override the game length the menu picked, in seconds. The shortest the\n"
"# menu offers is 3:00, which is a long wait when what you are testing is\n"
"# what happens at the buzzer. Unset = use the menu's choice.\n"
"#RP412MISSIONSECONDS=20\n"
"\n"
"# Simulation/render frame rate, integer frames/second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"TARGETFPS=60\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client\n"
"# running and steam_appid.txt beside the exe; without them the game\n"
"# logs the reason and falls back to plain TCP. 0 = TCP only.\n"
"RP412STEAM=1\n"
"\n"
"# Line up each remote player's clock with ours, so their vehicle is\n"
"# extrapolated from when its update was SENT rather than when it\n"
"# arrived. Without it every remote pod sits one network latency behind\n"
"# where it should be - invisible on the 1ms arcade LAN the engine was\n"
"# written for, a constant 50-150ms of lag over the internet. 0 restores\n"
"# the old arrival-time behaviour if you want to compare.\n"
"#RP412NETCLOCK=0\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
"# lamp buttons glow with the panel, flash modes and all.\n"
"# Unset or nonzero = on (the default); 0 = off.\n"
"#RP412KEYLIGHT=0\n"
"\n"
"# Invert the stick on top of whatever bindings.txt produces:\n"
"# X = invert X only, Y = invert Y only, XY = both (case-insensitive).\n"
"#L4PADFLIP=XY\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9:\n"
"# 0 = off, else 2..16 as the GPU supports (1 selects the driver's\n"
"# \"nonmaskable\" mode; unsupported counts fail device creation).\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# On-screen plasma glass (L4PLASMA=SCREEN only). SCALE = integer pixel\n"
"# size 1..16, default 4 (out-of-range values are ignored). POS = window\n"
"# top-left as X,Y screen coordinates; unset = auto, parked below the\n"
"# main window.\n"
"#L4PLASMASCALE=4\n"
"#L4PLASMAPOS=0,0\n"
"\n"
"# Fixed random seed (repeatable runs): any unsigned integer.\n"
"# Unset seeds from the clock.\n"
"#RANDOM=12345\n"
"\n"
"# ---- LAN play without Steam -------------------------------------------------\n"
"# Host a race over plain TCP: list the member pods' console channels\n"
"# (members run: rpl4opt.exe -windowed -res 1920 1080 -net 1501).\n"
"# RP412HOSTPODS comma-separated IP[:port] list, one entry per member\n"
"# pod; port defaults to 1501 per entry\n"
"# RP412HOSTPORT this machine's console port, integer > 0\n"
"# (default 1501)\n"
"# RP412HOSTADDR this machine's LAN IP as members can reach it\n"
"# (default 127.0.0.1)\n"
"#RP412HOSTPODS=192.168.1.20:1501,192.168.1.21:1501\n"
"#RP412HOSTPORT=1501\n"
"#RP412HOSTADDR=192.168.1.10\n"
"\n"
"# ---- Developer / testing ----------------------------------------------------\n"
"\n"
"# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,\n"
"# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.\n"
"# 0 or unset = off. (Alt+Q, the mission abort, is always live.)\n"
"#RP412DEVKEYS=1\n"
"\n"
"# Console race-length override, integer seconds (short test races).\n"
"# Values <= 0 are ignored.\n"
"#L4CONSOLELEN=30\n"
"\n"
"# Nonzero = Steam transport loopback self-test at boot (logs PASS/FAIL).\n"
"#RP412STEAMSELFTEST=1\n"
"\n"
"# ---- Arcade heritage (multi-monitor pods; not used on the desktop) ----------\n"
"# PRIMGAUGE / SECGAUGE / MFDGAUGE / MFDGAUGE2 pin a display to a monitor\n"
"# by adapter index (0, 1, 2...). SPANDISABLE: 0 = let the MFDs span one\n"
"# wide surface, nonzero = separate windows (setting MFDGAUGE2 alone also\n"
"# forces spanning off). L4EYES = \"x y z xrot yrot zrot [type]\" floats\n"
"# for a detached camera; a type starting with r offsets it relative to\n"
"# the pod. L4INTERCOM enables the crew intercom - only its presence\n"
"# matters (traditionally COM2). NOMODES skips the mode/lamp programming;\n"
"# presence alone triggers it, even NOMODES=0. LOGSIZE > 0 sizes the\n"
"# trace log in dev builds compiled with tracing.\n"
"#PRIMGAUGE=1\n"
"#SECGAUGE=2\n"
"#MFDGAUGE=3\n"
"#MFDGAUGE2=4\n"
"#SPANDISABLE=1\n"
"#L4EYES=1\n"
"#L4INTERCOM=COM2\n"
"#NOMODES=1\n"
"#LOGSIZE=1000000\n"
;
//-------------------------------------------------------------------
// Does the player's file mention this key at all - set, or commented
// out, or with whitespace in front of it?
//
// Deliberately generous: a key that is mentioned in ANY form is left
// alone. The alternative failure is worse than a missed notice, since
// environ.ini is applied line by line and a second copy of a key
// further down the file would silently override the player's own.
//-------------------------------------------------------------------
Logical FileMentionsKey(const char *text, const char *key, int key_length)
{
const char *cursor = text;
while ((cursor = strstr(cursor, key)) != NULL)
{
//
// Must be a whole key: preceded by start-of-line, whitespace
// or a comment mark, and followed by '='.
//
const char *after = cursor + key_length;
Logical starts_token =
(cursor == text) ||
(cursor[-1] == '\n') || (cursor[-1] == '\r') ||
(cursor[-1] == ' ') || (cursor[-1] == '\t') ||
(cursor[-1] == '#') || (cursor[-1] == ';');
if (starts_token)
{
const char *scan = after;
while (*scan == ' ' || *scan == '\t')
{
++scan;
}
if (*scan == '=')
{
return True;
}
}
cursor = after;
}
return False;
}
//-------------------------------------------------------------------
// Name every template key the player's file has never heard of. Not
// a fix - their file stays theirs - but it puts the reason for a
// missing feature in the log we already ask testers for.
//-------------------------------------------------------------------
void ReportUnmentionedKeys(const char *file_text)
{
char missing[1024]; // what gets printed
char seen[1024]; // the same keys as "KEY=", so the mention
// test above can dedupe against them
missing[0] = '\0';
seen[0] = '\0';
int count = 0; // how many are missing
int listed = 0; // how many fitted in the line
const char *cursor = kEnvironTemplate;
while (*cursor != '\0')
{
const char *line = cursor;
const char *end = strchr(line, '\n');
int length = (end != NULL) ? (int)(end - line) : (int) strlen(line);
cursor = (end != NULL) ? (end + 1) : (line + length);
//
// A template key line is "KEY=..." or "#KEY=..." - the
// commented ones are options that ship switched off, and a
// player who has never seen them wants to know they exist.
//
const char *scan = line;
int remaining = length;
if (remaining > 0 && *scan == '#')
{
++scan;
--remaining;
}
if (remaining <= 0 || !(isalpha((unsigned char) *scan) || *scan == '_'))
{
continue;
}
int key_length = 0;
while (key_length < remaining &&
(isalnum((unsigned char) scan[key_length]) || scan[key_length] == '_'))
{
++key_length;
}
if (key_length >= remaining || scan[key_length] != '=' || key_length > 60)
{
continue;
}
char key[64];
memcpy(key, scan, key_length);
key[key_length] = '\0';
if (FileMentionsKey(file_text, key, key_length))
{
continue;
}
//
// Templates list some keys twice (documented once, shown
// again in an example); do not name one twice.
//
if (FileMentionsKey(seen, key, key_length))
{
continue;
}
++count;
if (strlen(seen) + key_length + 3 < sizeof(seen))
{
strcat(seen, key);
strcat(seen, "=\n");
}
if (strlen(missing) + key_length + 3 < sizeof(missing))
{
if (missing[0] != '\0')
{
strcat(missing, ", ");
}
strcat(missing, key);
++listed;
}
}
if (count > 0)
{
//
// Say when the list is short of the count rather than letting
// a full buffer quietly shorten the answer.
//
DEBUG_STREAM << "Environ: " << kEnvironFileName << " does not mention "
<< count << " option(s) this build knows: " << missing;
if (listed < count)
{
DEBUG_STREAM << ", and " << (count - listed) << " more";
}
DEBUG_STREAM << "\nEnviron: they are at their built-in defaults - delete "
<< kEnvironFileName << " to get the documented file back\n"
<< std::flush;
}
}
}
void
RPL4Environ_Load()
{
//
// First run: lay down the documented default. From here on the file
// belongs to whoever is sitting at this machine.
//
FILE *file = fopen(kEnvironFileName, "rb");
if (file == NULL)
{
FILE *out = fopen(kEnvironFileName, "wb");
if (out != NULL)
{
fwrite(kEnvironTemplate, 1, strlen(kEnvironTemplate), out);
fclose(out);
DEBUG_STREAM << "Environ: wrote default " << kEnvironFileName
<< "\n" << std::flush;
}
else
{
DEBUG_STREAM << "Environ: could not write " << kEnvironFileName
<< " - running on built-in defaults\n" << std::flush;
}
file = fopen(kEnvironFileName, "rb");
}
if (file == NULL)
{
return;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0)
{
fclose(file);
return;
}
char *text = new char[size + 1];
size_t read = fread(text, 1, size, file);
text[read] = '\0';
fclose(file);
//
// One KEY=VALUE per line. Comments, blanks and anything without an
// '=' are skipped; everything else goes into the environment, which
// is why a line here beats a variable set in the shell.
//
int applied = 0;
char line[1024];
const char *cursor = text;
while (*cursor != '\0')
{
int length = 0;
while (cursor[length] != '\0' && cursor[length] != '\n' &&
length < (int) sizeof(line) - 1)
{
line[length] = cursor[length];
++length;
}
line[length] = '\0';
cursor += length;
while (*cursor == '\n' || *cursor == '\r')
{
++cursor;
}
for (int i = length - 1; i >= 0; --i)
{
if (line[i] == '\r' || line[i] == '\n')
{
line[i] = '\0';
}
}
char *setting = line;
while (*setting == ' ' || *setting == '\t')
{
++setting;
}
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
{
continue;
}
putenv(setting);
++applied;
}
DEBUG_STREAM << "Environ: " << applied << " setting(s) from "
<< kEnvironFileName << "\n" << std::flush;
ReportUnmentionedKeys(text);
delete[] text;
}
+44
View File
@@ -0,0 +1,44 @@
//===========================================================================//
// File: rpl4environ.h //
// Project: MUNGA Brick: Red Planet LBE Application //
// Contents: environ.ini - written on first run, then the player's //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
#include "..\munga\style.h"
//########################################################################
//
// environ.ini is the game's configuration: one KEY=VALUE per line, read
// once at startup and pushed into the environment, so every option the
// engine reads through getenv can be set from a file a player can open.
//
// The exe owns the template and writes it when the file is absent, the
// same way bindings.txt works, rather than the packaging script laying
// one down on every unzip. That is what lets a tester drop a new build
// over an old folder and keep their settings: the file is theirs from
// the moment it exists, and nothing overwrites it.
//
// It cannot simply be optional. Without it L4GAUGE is unset, which
// disables the gauge renderer and takes every MFD with it, and
// L4MFDSPLIT is unset, which is the packed-window arcade layout rather
// than the glass cockpit. The shipped values are the desktop game; the
// built-in getenv fallbacks are the 1995 pod.
//
// The cost of a file that is never overwritten is that a tester carrying
// one across many builds stops being offered new options. Options added
// later default to "behave as before", so nothing breaks - but it does
// go unnoticed, so the load names any template key the player's file
// does not mention. That line in rpl4.log is what turns "the podium does
// not work" into "your environ.ini predates RP412PODIUM".
//
//########################################################################
// Write environ.ini if it is not there, then read it into the
// environment. Call once, before anything reads a setting.
void
RPL4Environ_Load();
+336 -15
View File
@@ -177,6 +177,7 @@ namespace
GroupLaunch,
GroupSteamHost, // buttons, not selections (Steam builds only)
GroupSteamJoin,
GroupExit,
GroupCount
};
@@ -230,6 +231,241 @@ namespace
Logical gHavePersist = False;
char gLastPilotName[24] = "Pilot";
//---------------------------------------------------------------
// What the player set last time, in pilot.cfg beside bindings.txt:
// the callsign they typed and the loadout they picked.
//
// The loadout has always survived a race - gPersistSelection below
// is why the menu reopens the way you left it - but only for as
// long as the process lives. BT411 keeps the same things in
// fe_last.ini and had to, since it relaunches between missions;
// RP412 stayed in one process and so never needed a file. Closing
// the game was still a reset, which is what this fixes.
//
// KEY=VALUE like environ.ini, one line per group.
//---------------------------------------------------------------
const char kPilotFileName[] = "pilot.cfg";
//
// Stable file keys, independent of the on-screen headings - those
// carry spaces ("TIME OF DAY") and are free to be reworded.
//
struct GroupKey
{
int group;
const char *key;
};
const GroupKey kGroupKeys[] =
{
{ GroupScenario, "scenario" }, { GroupMap, "track" },
{ GroupVehicle, "vehicle" }, { GroupColor, "color" },
{ GroupBadge, "badge" }, { GroupTeam, "team" },
{ GroupPosition, "position" }, { GroupTime, "time" },
{ GroupWeather, "weather" }, { GroupLength, "length" },
};
//
// How many rows a group offers, so a stale or hand-edited index
// cannot select past the end of a list. The track list is the one
// that moves - football and the death race carry different maps -
// so it answers for whichever scenario is selected.
//
int GroupSize(int group, const int *selection)
{
switch (group)
{
case GroupScenario: return FE_COUNT(kScenarios);
case GroupMap:
{
int count = 0;
ActiveMaps(selection, &count);
return count;
}
case GroupVehicle: return FE_COUNT(kVehicles);
case GroupColor: return FE_COUNT(kColors);
case GroupBadge: return FE_COUNT(kBadges);
case GroupTeam: return FE_COUNT(kTeams);
case GroupPosition: return FE_COUNT(kPositions);
case GroupTime: return FE_COUNT(kTimes);
case GroupWeather: return FE_COUNT(kWeather);
case GroupLength: return FE_COUNT(kLengths);
}
return 0;
}
//
// A callsign is quoted into frontend.egg, joined into a
// comma-separated list for the results screen, and published as
// Steam lobby member data. Anything that could end a token early
// therefore has to go - a comma alone would split one pilot into
// two on the score sheet. Applied to what is typed as well as to
// what is read back, so the file cannot hold what the game will
// not accept.
//
void SanitizeCallsign(char *name, int size)
{
char clean[64];
int out = 0;
for (int i = 0; name[i] != '\0' && out < (int) sizeof(clean) - 1; ++i)
{
unsigned char c = (unsigned char) name[i];
if (c < 32 || c > 126) continue; // controls, high bytes
if (c == ',' || c == '"') continue; // egg and CSV delimiters
if (c == '#' || c == ';') continue; // pilot.cfg comment marks
clean[out++] = (char) c;
}
clean[out] = '\0';
char *start = clean;
while (*start == ' ' || *start == '\t')
{
++start;
}
int end = (int) strlen(start);
while (end > 0 && (start[end - 1] == ' ' || start[end - 1] == '\t'))
{
start[--end] = '\0';
}
if (start[0] == '\0')
{
strcpy(start, "Pilot");
}
strncpy(name, start, size - 1);
name[size - 1] = '\0';
}
void SavePilotSettings(const char *name, const int *selection)
{
FILE *file = fopen(kPilotFileName, "wt");
if (file == NULL)
{
DEBUG_STREAM << "FrontEnd: could not write " << kPilotFileName
<< "\n" << std::flush;
return;
}
fputs("# RP412 pilot settings, written by the game on the way out of\n"
"# the setup screen. Delete this file to start over.\n", file);
fprintf(file, "callsign=%s\n", name);
if (selection != NULL)
{
for (int i = 0; i < FE_COUNT(kGroupKeys); ++i)
{
fprintf(file, "%s=%d\n", kGroupKeys[i].key,
selection[kGroupKeys[i].group]);
}
}
fclose(file);
DEBUG_STREAM << "FrontEnd: saved callsign \"" << name << "\""
<< ((selection != NULL) ? " and loadout" : "")
<< " to " << kPilotFileName << "\n" << std::flush;
}
//
// Read once per run. Absent or unreadable simply leaves the
// built-in defaults in place - a missing file is a first run, not
// an error, and every value is range-checked so a hand-edited or
// out-of-date file cannot select past the end of a list.
//
void EnsurePilotSettingsLoaded()
{
static Logical loaded = False;
if (loaded)
{
return;
}
loaded = True;
FILE *file = fopen(kPilotFileName, "rt");
if (file == NULL)
{
return;
}
int selection[GroupCount];
memset(selection, 0, sizeof(selection));
selection[GroupLength] = 2; // 5:00, as the menu defaults
Logical have_loadout = False;
char line[256];
while (fgets(line, sizeof(line), file) != NULL)
{
char *cursor = line;
while (*cursor == ' ' || *cursor == '\t')
{
++cursor;
}
if (*cursor == '#' || *cursor == ';')
{
continue;
}
char *equals = strchr(cursor, '=');
if (equals == NULL)
{
continue;
}
*equals = '\0';
char *key = cursor;
char *value = equals + 1;
while (*value == ' ' || *value == '\t')
{
++value;
}
char *newline = strpbrk(value, "\r\n");
if (newline != NULL)
{
*newline = '\0';
}
if (_stricmp(key, "callsign") == 0)
{
char candidate[24];
strncpy(candidate, value, sizeof(candidate) - 1);
candidate[sizeof(candidate) - 1] = '\0';
SanitizeCallsign(candidate, sizeof(candidate));
strcpy(gLastPilotName, candidate);
continue;
}
for (int i = 0; i < FE_COUNT(kGroupKeys); ++i)
{
if (_stricmp(key, kGroupKeys[i].key) != 0)
{
continue;
}
int index = atoi(value);
if (index >= 0 && index < GroupSize(kGroupKeys[i].group, selection))
{
selection[kGroupKeys[i].group] = index;
have_loadout = True;
}
break;
}
}
fclose(file);
//
// The track list belongs to the scenario, and the file is read in
// whatever order it was written, so re-check the track once the
// scenario is settled - the same clamp the menu applies when the
// scenario is switched by hand.
//
if (have_loadout)
{
int map_count = 0;
ActiveMaps(selection, &map_count);
if (selection[GroupMap] >= map_count)
{
selection[GroupMap] = 0;
}
memcpy(gPersistSelection, selection, sizeof(gPersistSelection));
gHavePersist = True;
}
DEBUG_STREAM << "FrontEnd: callsign \"" << gLastPilotName << "\""
<< (have_loadout ? " and loadout" : "")
<< " from " << kPilotFileName << "\n" << std::flush;
}
// [pilots]-order names of the last launched race (owner first),
// comma separated - the network console labels results with them
char gLastPilotNamesCsv[256] = "";
@@ -413,8 +649,9 @@ namespace
launch->rect.right = col3 + col_w;
launch->rect.bottom = client_h - row_h;
// Steam lobby buttons (only when the Steam wire is live)
if (RPL4Lobby_Available())
// Steam lobby buttons, offered whenever environ.ini asked for Steam.
// Painting greys them out and says so if the wire never came up.
if (RPL4Lobby_Configured())
{
FEItem *host = &fe->items[fe->itemCount++];
host->group = GroupSteamHost;
@@ -433,6 +670,23 @@ namespace
join->rect.bottom = client_h - (7 * row_h) / 2;
}
//
// Exit, bottom left. Diagonally opposite LAUNCH on purpose: it is
// the one button on this screen you cannot undo, so it does not go
// next to the one people are aiming for. Half width for the same
// reason - it is a way out, not a peer of LAUNCH.
//
// The window frame used to be the way out, and mfd_layout.cfg's
// ,noframe takes it away, so the screen has to offer its own.
//
FEItem *quit = &fe->items[fe->itemCount++];
quit->group = GroupExit;
quit->index = 0;
quit->rect.left = col1;
quit->rect.top = client_h - 2 * row_h;
quit->rect.right = col1 + col_w / 2;
quit->rect.bottom = client_h - row_h;
// pilot name edit sits at the top of column 3
if (fe->nameEdit != NULL)
{
@@ -484,6 +738,7 @@ namespace
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";
case GroupExit: return "EXIT GAME";
}
return "";
}
@@ -544,12 +799,38 @@ namespace
RECT row = item->rect;
if (item->group >= GroupLaunch)
{
HBRUSH launch_brush = CreateSolidBrush(kGreenBright);
//
// The lobby buttons are offered whenever environ.ini asked
// for Steam, but they only work once the client is actually
// there. Dim them and say why rather than leaving them out -
// two buttons quietly missing looks like a broken build.
//
Logical steam_button =
(item->group == GroupSteamHost || item->group == GroupSteamJoin);
Logical dead = steam_button && !RPL4Lobby_Available();
COLORREF ink = dead ? kGreenDim : kGreenBright;
HBRUSH launch_brush = CreateSolidBrush(ink);
FrameRect(mem, &row, launch_brush);
DeleteObject(launch_brush);
SetTextColor(mem, kGreenBright);
SetTextColor(mem, ink);
DrawTextA(mem, ItemName(item->group, item->index), -1, &row,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
//
// The reason, on its own line above the pair. It does not
// fit inside a button - they are about sixteen characters
// wide and this is more than twice that.
//
if (dead && item->group == GroupSteamHost)
{
RECT notice = row;
notice.bottom = row.top;
notice.top = row.top - (row.bottom - row.top);
SetTextColor(mem, kGreenDim);
DrawTextA(mem, "STEAM NOT RUNNING", -1, &notice,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
continue;
}
@@ -613,7 +894,17 @@ namespace
}
else if (item->group == GroupSteamHost || item->group == GroupSteamJoin)
{
fe->steamAction = (item->group == GroupSteamHost) ? 1 : 2;
// dead until the Steam client is there; the button says so
if (RPL4Lobby_Available())
{
fe->steamAction = (item->group == GroupSteamHost) ? 1 : 2;
PostMessageA(fe->menuWindow, WM_NULL, 0, 0);
}
}
else if (item->group == GroupExit)
{
// the same door closing the window goes through
fe->closed = True;
PostMessageA(fe->menuWindow, WM_NULL, 0, 0);
}
else
@@ -1163,6 +1454,10 @@ Logical
{
gLastLaunchMode = FELaunchSingle;
// before the lobby branch below: a member rejoining a room publishes
// the callsign as member data without the menu ever opening
EnsurePilotSettingsLoaded();
//---------------------------------------------------------------
// Coming back from a race while still in a lobby: straight to
// the room (the lobby outlives races - single binary payoff)
@@ -1192,6 +1487,7 @@ Logical
{
FEState fe;
memset(&fe, 0, sizeof(fe));
EnsurePilotSettingsLoaded();
strcpy(fe.pilotName, gLastPilotName);
if (gHavePersist)
{
@@ -1290,20 +1586,41 @@ Logical
}
//---------------------------------------------------------------
// Harvest the loadout whichever way we leave the menu - the
// lobby publishes it as member data, launches build from it
// Harvest the callsign whichever way we leave the menu, INCLUDING
// a close: typing a name and then quitting is how somebody sets it
// for next time, and losing it there would be the one case that
// makes the whole thing feel unreliable. The loadout still only
// persists on a real exit - it is picked, not typed, and the menu
// reopens with it visible anyway.
//---------------------------------------------------------------
if (!fe.closed)
if (fe.nameEdit != NULL)
{
fe.pilotName[0] = '\0';
GetWindowTextA(fe.nameEdit, fe.pilotName, sizeof(fe.pilotName) - 1);
if (fe.pilotName[0] == '\0')
{
strcpy(fe.pilotName, "Pilot");
}
strcpy(gLastPilotName, fe.pilotName);
memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection));
gHavePersist = True;
SanitizeCallsign(fe.pilotName, sizeof(fe.pilotName));
}
else
{
strcpy(fe.pilotName, gLastPilotName);
}
strcpy(gLastPilotName, fe.pilotName);
memcpy(gPersistSelection, fe.selection, sizeof(gPersistSelection));
gHavePersist = True;
//
// Written on the way out however the player leaves - launching,
// stepping into a lobby, or quitting. BT411 saves only on a
// launch, which loses a callsign typed by somebody who then
// changed their mind, and that is the one moment this feature
// exists for.
//
// Unconditionally, rather than only when something changed: the
// file is a few hundred bytes, and writing it every time means a
// value that was hand-edited out of range comes back corrected
// instead of being quietly re-rejected on every launch forever.
//
SavePilotSettings(gLastPilotName, gPersistSelection);
Logical launched = fe.launched;
Logical closed = fe.closed;
@@ -1769,6 +2086,10 @@ Logical
return False;
}
// this screen labels a row with the pilot's own callsign, and in the
// -egg and lobby paths it can be the first screen of the session
EnsurePilotSettingsLoaded();
ResultsState rs;
memset(&rs, 0, sizeof(rs));
+13
View File
@@ -11,6 +11,7 @@
//########################################################################
Logical RPL4Lobby_Available() { return False; }
Logical RPL4Lobby_Configured() { return False; }
Logical RPL4Lobby_InRoom() { return False; }
int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; }
@@ -919,6 +920,18 @@ Logical
return SteamNetTransport_GetFakeAddressString()[0] != '\0';
}
//
// The environ.ini switch, read the same way RPL4.CPP reads it to decide
// whether to install the transport at all. Says nothing about whether the
// Steam client was actually there.
//
Logical
RPL4Lobby_Configured()
{
const char *steam_switch = getenv("RP412STEAM");
return (steam_switch != NULL && atoi(steam_switch) != 0) ? True : False;
}
Logical
RPL4Lobby_InRoom()
{
+9
View File
@@ -33,6 +33,15 @@ enum RPL4LobbyOutcome
Logical
RPL4Lobby_Available();
// True when this build has Steam and environ.ini asked for it, whether
// or not it actually came up. The menu offers the lobby buttons on this
// and greys them out on Available() - a player who turned Steam on and
// then launched without the client running should be told so, not left
// looking at a menu that quietly has two fewer buttons than the last
// time they saw it.
Logical
RPL4Lobby_Configured();
// True while we sit in a lobby (races return to the room).
Logical
RPL4Lobby_InRoom();
+14 -2
View File
@@ -357,8 +357,20 @@ Logical
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
// same rule as Application::ExecuteForeground - the console's
// countdown when there is one, our own reckoning otherwise. There
// is no console in mission review, so this takes the fallback.
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
CLEAR_FOREGROUND_PROCESSING();
+11
View File
@@ -70,6 +70,15 @@
<RandomizedBaseAddress>false</RandomizedBaseAddress>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<!-- rpl4build.h is generated, not committed: the patch number is the
repository's commit count, so a hardcoded one would be stale the
moment it was committed. The script rewrites the header only when
the stamp actually changes, so this does not drag RPL4.CPP through
a recompile on every build. -->
<PreBuildEvent>
<Command>powershell -NoProfile -ExecutionPolicy Bypass -File "$(ProjectDir)..\stamp-version.ps1"</Command>
<Message>Stamping the build version from git</Message>
</PreBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
@@ -109,6 +118,7 @@
<ClCompile Include=".\RPL4APP.cpp" />
<ClCompile Include=".\RPL4CONSOLE.cpp" />
<ClCompile Include=".\RPL4FE.cpp" />
<ClCompile Include=".\RPL4ENVIRON.cpp" />
<ClCompile Include=".\RPL4LOBBY.cpp" />
<ClCompile Include=".\RPL4ARND.cpp" />
<ClCompile Include=".\RPL4GAUG.cpp" />
@@ -147,6 +157,7 @@
<ClInclude Include=".\RPL4APP.h" />
<ClInclude Include=".\RPL4CONSOLE.h" />
<ClInclude Include=".\RPL4FE.h" />
<ClInclude Include=".\rpl4environ.h" />
<ClInclude Include=".\RPL4LOBBY.h" />
<ClInclude Include=".\RPL4ARND.h" />
<ClInclude Include=".\RPL4GAUG.h" />
+37 -1
View File
@@ -144,6 +144,42 @@ it off.
---
## Flight sticks, HOTAS and rudder pedals
Anything that is not an Xbox-class pad — a flight stick, a HOTAS
throttle, a twist grip, rudder pedals, a wheel — comes in through
DirectInput rather than XInput, and needs to be told which axis is
which. **Run `joyconfig.bat` once.** It asks you to move each control in
turn, works out which device and axis you moved and which way round it
reads, and writes the joystick rows of `bindings.txt`. Then it carries
on into the setup screen so you can try them straight away.
It only writes its own section, between two marker lines, so anything
you have edited yourself survives re-running it. Xbox-class controllers
need none of this — they are the pad rows above and work out of the box.
By hand, the rows look like this:
```
joydev 0 T.16000M # or a bare slot number
joyaxis X axis JoystickX invert deadzone 0.08
joyaxis Y axis JoystickY invert deadzone 0.08
joyaxis RZ axis Pedals deadzone 0.08 # twist grip works both pedals
joyaxis SL0 axis Throttle deadzone 0 # a real lever owns the channel
joybutton 0 button 0x40 # trigger
joyhat 0 up button 0x42
```
Axis names are DirectInput's — `X Y Z RX RY RZ SL0 SL1` — where a twist
grip is usually `RZ` and a HOTAS throttle usually `Z` or `SL0`.
Two things are worth knowing. `Pedals` is a signed axis that drives the
pedal *pair*, positive for the right pedal and negative for the left, so
one rudder bar or twist grip works both the way a foot never could. And
a `joyaxis` on `Throttle` with no `rate` is treated as a real lever: its
full travel *is* the throttle range, rather than nudging a position the
way a spring-centred pad stick has to.
## Rebinding
`bindings.txt` sits beside the exe, written with the full default layout
@@ -163,7 +199,7 @@ 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`
`JoystickY`, `Pedals`. 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`,
@@ -1,4 +1,4 @@
<title>Red Planet 4.12.3Controls</title>
<title>Red Planet 4.12.7Handbook</title>
<style>
:root {
@@ -754,13 +754,14 @@
<div class="wrap">
<header class="masthead">
<p class="eyebrow">Virtual World Entertainment · Tesla pod · RP 4.12.3</p>
<h1>Red Planet<br>Controls Map</h1>
<p class="eyebrow">Virtual World Entertainment · Tesla pod · RP 4.12.7</p>
<h1>Red Planet<br>Handbook</h1>
<p class="lede">
Every input the pod answers to, on the gamepad and the keyboard. The
keyboard is the pod's button board: the letter and number rows are the
MFD banks laid out as they are printed on the panel, and flight moved to
the number pad so the board stays free.
Every input the pod answers to, on the gamepad and the keyboard, and
every file it keeps beside the exe. The keyboard is the pod's button
board: the letter and number rows are the MFD banks laid out as they are
printed on the panel, and flight moved to the number pad so the board
stays free.
</p>
<p class="provenance">Generated from the default <code>bindings.txt</code> · all of it rebindable</p>
</header>
@@ -1149,6 +1150,119 @@
</p>
</section>
<section>
<h2>Bring your own stick</h2>
<p class="sub">
A gamepad speaks XInput, and the game reads it without being asked.
Everything else — a flight stick, a HOTAS throttle, a twist grip,
rudder pedals, a wheel — speaks <b>DirectInput</b>, which names its axes
<span class="mono">X Y Z RX RY RZ SL0 SL1</span> and says nothing about
what they are <i>for</i>. Somebody has to decide that your twist grip is
the rudder and that pushing it right means right. That is what
<code>joyconfig.bat</code> is.
</p>
<div class="panel">
<dl class="glance">
<div>
<dt>Run it once</dt>
<dd>joyconfig.bat<small>beside the exe; re-run any time</small></dd>
</div>
<div>
<dt>It asks</dt>
<dd>Move that one<small>steer · pitch · pedals · throttle · buttons</small></dd>
</div>
<div>
<dt>It watches</dt>
<dd>Which way<small>the direction you moved sets the sign</small></dd>
</div>
<div>
<dt>Then</dt>
<dd>Straight in<small>the game carries on to the setup screen</small></dd>
</div>
</dl>
</div>
<div class="callout">
<p>
<strong>The direction is the whole point.</strong> A stick that reads
positive pushed right and one that reads negative are equally common,
and nothing printed on the box tells you which you own. So the wizard
does not ask you to know — it asks you to <i>move</i>, and reads the
answer off the movement. Skip a prompt with <code>SPACE</code> if you
have no such control, <code>ESC</code> to abort without writing.
</p>
</div>
<p class="sub">
It writes only its own section of <code>bindings.txt</code>, between two
marker lines, so anything you have written yourself is kept — including a
deadzone you tuned by hand after trying it. Before it starts it prints
every axis <i>at rest</i>: a stick whose driver reports an odd range
shows up there as <span class="mono">X +1.00</span> on an untouched
stick, rather than as a mystery later. Xbox-class pads are excluded from
all of this, so nothing ever counts twice.
</p>
<div class="two-col">
<pre><i># what it writes</i>
<b>joydev</b> 0 Logitech Extreme 3D
<b>joyaxis</b> X <b>axis</b> JoystickX invert deadzone 0.08
<b>joyaxis</b> Y <b>axis</b> JoystickY deadzone 0.08
<b>joyaxis</b> RZ <b>axis</b> Pedals deadzone 0.08
<b>joyaxis</b> SL0 <b>axis</b> Throttle invert deadzone 0
<b>joybutton</b> 0 <b>button</b> 0x40
<b>joyhat</b> 0 up <b>button</b> 0x42</pre>
<pre><i># grammar</i>
<b>joydev</b> &lt;slot&gt; [product-name substring]
<b>joyaxis</b> &lt;src&gt; <b>axis</b> &lt;axis&gt; [invert]
[deadzone &lt;d&gt;] [rate &lt;n&gt;]
<b>joybutton</b> &lt;n&gt; <b>button</b> &lt;addr&gt; [toggle]
<b>joyhat</b> &lt;n&gt; &lt;up|down|left|right&gt;
<b>button</b> &lt;addr&gt;
<i># a named slot binds that product; a bare
# one binds the Nth stick Windows lists</i></pre>
</div>
<div class="tbl-scroll" style="margin-top:22px">
<table>
<caption>Two rules the pod's shape asks for</caption>
<tbody>
<tr>
<th class="mono">Pedals</th>
<td>
A <b>signed</b> axis that works the pedal <b>pair</b> — positive
for the right pedal, negative for the left. The pod has one
under each foot; a twist grip or rudder bar is a single control
that presses one or the other and never both, which is exactly
what this says. It is a channel name like any other, so a gamepad
stick can drive the turn with it too.
</td>
</tr>
<tr>
<th class="mono">Throttle</th>
<td>
A <span class="mono">joyaxis</span> on the throttle with no
<span class="mono">rate</span> is treated as a <b>real lever</b>
and owns the channel: its full travel <i>is</i> the throttle
range. Spring-centred sticks have to nudge a position instead,
which is what <span class="mono">rate</span> does — a lever that
stays where you put it needs none of that.
</td>
</tr>
</tbody>
</table>
</div>
<p class="sub" style="margin-bottom:0">
A twist grip is usually <span class="mono">RZ</span>; a HOTAS throttle is
usually <span class="mono">Z</span> or <span class="mono">SL0</span>. Set
<code>RP412JOYLOG=1</code> to log devices as they attach and drop away.
</p>
</section>
<section>
<h2>Rebinding</h2>
<p class="sub">
@@ -1166,6 +1280,8 @@
<b>pad</b> &lt;button&gt; <b>button</b> &lt;addr&gt; [toggle]
<b>padaxis</b> &lt;src&gt; <b>axis</b> &lt;axis&gt; [invert]
[deadzone &lt;d&gt;] [rate &lt;n&gt;]
<b>joydev joyaxis joybutton joyhat</b>
<i>see above</i>
<i># deflect springs back, rate sticks where
# you leave it (that is the throttle)</i></pre>
@@ -1185,21 +1301,107 @@
<table>
<caption>Reference — axes, address space, key names</caption>
<tbody>
<tr><th>Axes</th><td class="mono">Throttle · LeftPedal · RightPedal · JoystickX · JoystickY</td></tr>
<tr><th>Axes</th><td class="mono">Throttle · LeftPedal · RightPedal · JoystickX · JoystickY · Pedals</td></tr>
<tr><th>Buttons</th><td class="addr">0x00 0x47</td></tr>
<tr><th>Keypads</th><td class="addr">0x50 0x6F <span style="color:var(--ink-quiet);font-family:var(--sans)">— unbound by default; the game never reads them</span></td></tr>
<tr><th>Key names</th><td class="mono">AZ · D0D9 · F1F12 · NumPad09 · Up Down Left Right · Space · Return · Shift · Ctrl · Alt · OemMinus · Oemplus · Oemcomma · OemPeriod</td></tr>
<tr><th>Pad</th><td class="mono">A B X Y · DPadUp/Down/Left/Right · Start · Back · LeftShoulder · RightShoulder · LeftThumb · RightThumb</td></tr>
<tr><th>Pad axes</th><td class="mono">LeftStickX/Y · RightStickX/Y · LeftTrigger · RightTrigger</td></tr>
<tr><th>Joystick axes</th><td class="mono">X · Y · Z · RX · RY · RZ · SL0 · SL1 <span style="color:var(--ink-quiet);font-family:var(--sans)">— DirectInput's own names</span></td></tr>
</tbody>
</table>
</div>
</section>
<section>
<h2>Files beside the exe</h2>
<p class="sub">
Four files in the game folder are yours. <b>None of them ship.</b> The
game writes each one the first time it needs it and then never touches
it again, so a new build dropped over this folder keeps everything you
have set — and deleting any of them simply starts that part over with
the current defaults.
</p>
<div class="tbl-scroll">
<table>
<caption>Yours to edit</caption>
<tbody>
<tr>
<th class="mono">environ.ini</th>
<td>
Every engine option, commented in place — displays, renderer,
Steam, the podium, the lot. The one to read first. Written on
first run; without it the MFDs do not come up at all, so the
game will always put one back.
</td>
</tr>
<tr>
<th class="mono">bindings.txt</th>
<td>
Every key, pad button, axis and joystick row. Written with the
full documented default layout the first time the game runs.
</td>
</tr>
<tr>
<th class="mono">pilot.cfg</th>
<td>
Your callsign and loadout, saved on the way out of the setup
screen so they are there next time.
</td>
</tr>
<tr>
<th class="mono">mfd_layout.cfg</th>
<td>
Where you dragged the windows, and the <code>,noframe</code>
flag. Only written when <code>RP412MFDLAYOUT=save</code>.
</td>
</tr>
</tbody>
</table>
</div>
<div class="callout">
<p>
<strong>Two that catch people out.</strong>
<code>environ.ini</code> is applied <i>over</i> the environment, so a
variable you set in a shell loses to an uncommented line in the file —
comment the line out rather than fighting it. And nothing here is ever
overwritten once it exists, which is exactly what lets you keep a
folder across builds — but it means a file carried through several
updates stops being offered new options. The game names any it has not
heard of in <code>rpl4.log</code>; <b>delete the file</b> to get the
fully documented current one back.
</p>
</div>
<h3>The rest of the folder</h3>
<p class="sub">
Shipped data the engine reads. Nothing here is meant to be edited, but
it is worth knowing what is what.
</p>
<div class="tbl-scroll">
<table>
<caption>Engine data</caption>
<tbody>
<tr><th class="mono">RPDPL.INI</th><td>Renderer and scene configuration. Named by <code>L4DPLCFG=</code>, so it can be swapped.</td></tr>
<tr><th class="mono">GAUGE\L4GAUGE.INI</th><td>Gauge canvas pages. <code>L4GAUGE=</code> picks one by name — a name that is not in here switches the MFDs off entirely.</td></tr>
<tr><th class="mono">GAUGE\L4GAUGE.CFG</th><td>The gauge layout data that page points at.</td></tr>
<tr><th class="mono">AUDIO\AUDIO.INI</th><td>Sound banks. <span class="mono">AUDIOMR.INI</span> is the mission-review twin.</td></tr>
<tr><th class="mono">JOYSTICK.INI</th><td>The 1995 single-stick calibration, for the legacy <code>L4CONTROLS=DIJOYSTICK</code> path only. Modern sticks are configured in <code>bindings.txt</code> — see above.</td></tr>
<tr><th class="mono">*.CFG <span style="color:var(--ink-quiet);font-family:var(--sans)">in AUDIO\</span></th><td>Pod audio-hardware mixer tables. Dead weight on a desktop; kept because the arcade path still reads them.</td></tr>
<tr><th class="mono">RPL4.RES</th><td>Resource blob, not text.</td></tr>
<tr><th class="mono">steam_appid.txt</th><td>Steam runs under Spacewar (480) until Red Planet has its own AppID.</td></tr>
</tbody>
</table>
</div>
</section>
<footer>
<span>Red Planet 4.12.3</span>
<span>Red Planet 4.12.7</span>
<span>gitea.mysticmachines.com/VWE/RP412</span>
<span>docs/CONTROLS.md · CONTROLS.txt ships with the game</span>
<span>docs/rp412-handbook.html · HANDBOOK.html ships with the game</span>
<span>RGB keyboards mirror the pod lamps · RP412KEYLIGHT=0 to disable</span>
</footer>
</div>
+137 -240
View File
@@ -8,13 +8,15 @@
# TEST.EGG) - but not the arcade launch scripts or the old 4.10 exe
# - libsndfile-1.dll beside the exe; OpenAL32.dll copied from the system
# when installed, with oalinst.exe included as the fallback installer
# - a desktop environ.ini (PAD;KEYBOARD controls, on-screen plasma)
# - start-windowed.bat and a README
# (environ.ini is NOT shipped - the exe writes it on first run)
# - start/joyconfig scripts, HANDBOOK.html, CONTROLS.txt and a README
#
# Usage: powershell -ExecutionPolicy Bypass -File pack-dist.ps1 [-Zip]
# Usage: powershell -ExecutionPolicy Bypass -File pack-dist.ps1 [-Zip] [-Fresh]
#
param(
[switch]$Zip
[switch]$Zip,
# Wipe the player's files too, for testing what a first run does.
[switch]$Fresh
)
$ErrorActionPreference = 'Stop'
@@ -27,6 +29,23 @@ if (-not (Test-Path $exe)) {
throw "Release\rpl4opt.exe not found - build first (see BUILD.md 2)."
}
# --- version ---------------------------------------------------------------
# Read the stamp the exe was BUILT with rather than asking git again: a
# commit between the build and the pack would otherwise have the package
# claiming a version the binary inside it does not report.
$buildHeader = Join-Path $root 'RP_L4\rpl4build.h'
if (-not (Test-Path $buildHeader)) {
throw "RP_L4\rpl4build.h not found - build first, or run stamp-version.ps1."
}
$stamp = Get-Content $buildHeader -Raw
$version = ([regex]::Match($stamp, '#define\s+RP412_VERSION\s+"([^"]+)"')).Groups[1].Value
$versionLong = ([regex]::Match($stamp, '#define\s+RP412_VERSION_LONG\s+"([^"]+)"')).Groups[1].Value
if (-not $version) { throw "could not read RP412_VERSION from $buildHeader" }
if ($stamp -match '#define\s+RP412_BUILD_DIRTY\s+1') {
Write-Warning "packing a build made from a modified tree ($versionLong)"
}
Write-Host "Version $versionLong"
# Refuse to touch a dist the game is currently running from.
$running = Get-Process rpl4opt -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "$dist\*" }
@@ -35,6 +54,27 @@ if ($running) {
}
Write-Host "Packing into $dist"
# --- the player's own files ------------------------------------------------
# None of these ship: the game writes each one the first time it needs it and
# then leaves it alone, so a new build dropped over a folder keeps every
# setting. This script rebuilds dist\ from scratch, which would throw exactly
# those away - the one place the promise did not hold, and it is the folder we
# do most of our own testing in. Carry them across. -Fresh to start over.
$keepFiles = @('environ.ini', 'bindings.txt', 'pilot.cfg', 'mfd_layout.cfg')
$kept = @{}
if (-not $Fresh) {
foreach ($name in $keepFiles) {
$path = Join-Path $dist $name
if (Test-Path $path) { $kept[$name] = [System.IO.File]::ReadAllBytes($path) }
}
if ($kept.Count -gt 0) {
Write-Host " keeping $($kept.Keys -join ', ')"
}
} elseif (Test-Path $dist) {
Write-Host " -Fresh: the player's files go too"
}
if (Test-Path $dist) { Remove-Item -Recurse -Force $dist }
New-Item -ItemType Directory -Force "$dist\SPOOLS" | Out-Null
@@ -64,9 +104,8 @@ 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.
# The controls half as plain text, for Notepad. Flattened to ASCII so it
# reads correctly there - 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, "'"),
@@ -76,13 +115,18 @@ foreach ($pair in @(
}
Set-Content -Path "$dist\CONTROLS.txt" -Encoding ascii -Value $controls
# The same map as a page, for anyone who would rather look at the
# diagrams than read them. Its source is the published artifact, which is
# a fragment - the publisher supplies the document shell - so wrap it to
# The handbook as a page: the controls map with the diagrams, plus the
# joystick setup and what every file in the folder is for. Its source is
# the published artifact, which is a fragment - the publisher supplies
# the document shell - so wrap it to
# stand alone: without a doctype the browser drops into quirks mode, and
# without a charset the typography arrives as mojibake. Written without a
# BOM so the charset declaration is the only thing speaking.
$controlsPage = Get-Content (Join-Path $root 'docs\rp412-controls.html') -Raw -Encoding UTF8
$handbookPage = Get-Content (Join-Path $root 'docs\rp412-handbook.html') -Raw -Encoding UTF8
# Stamp the shipped copy with the build's own version. The source keeps a
# readable one for publishing; only "4.12.<n>" is touched, which on this
# page is always the version and never anything else.
$handbookPage = [regex]::Replace($handbookPage, '4\.12\.\d+', $version)
$page = @"
<!doctype html>
<html lang="en">
@@ -91,12 +135,12 @@ $page = @"
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
$controlsPage
$handbookPage
</body>
</html>
"@
[System.IO.File]::WriteAllText(
"$dist\CONTROLS.html", $page, (New-Object System.Text.UTF8Encoding $false))
"$dist\HANDBOOK.html", $page, (New-Object System.Text.UTF8Encoding $false))
# --- OpenAL runtime --------------------------------------------------------
# The exe links OpenAL32.dll (32-bit). Prefer shipping the already-installed
@@ -113,225 +157,12 @@ if (Test-Path $openal) {
}
# --- desktop configuration -------------------------------------------------
Set-Content -Path "$dist\environ.ini" -Encoding ascii -Value @"
# ============================================================================
# environ.ini - Red Planet 4.12 configuration
# ============================================================================
# One KEY=VALUE per line, read at game start. Lines starting with # or ;
# are comments; anything without an = is ignored. Delete a line (or
# comment it out) to fall back to the built-in default.
#
# Input bindings live in bindings.txt beside the exe (written with the
# full documented layout on first run; delete it to restore defaults).
# ---- Core (the shipped configuration) --------------------------------------
# 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 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. 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 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
# 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
# Size of the six secondary displays in the glass cockpit, as a
# percentage of their pod size. The pod bolted them down at one size;
# on a big panel there is room to trade viewscreen for instrument, so
# turn these up if you want to actually read the other displays while
# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and
# unreadable values fall back to the group setting, then to 100).
#
# The scaling is applied in canvas units, before the cockpit is fitted
# to your window, so a given number looks the same on every monitor.
# The layout stays legal whatever you ask for - the panes are clamped
# against their actual neighbours, shrinking uniformly so a display
# never comes out stretched. They do overlap the viewscreen, exactly
# as the pod's bezels did, but never each other.
#
# L4MFDSCALE sets all five green MFDs at once.
L4MFDSCALE=100
# ...and any single display can override it. Uncomment one to size it
# on its own - useful if you only care about, say, the damage readout.
# UL upper left UC upper center UR upper right
# LL lower left LR lower right
#L4MFDSCALE_UL=100
#L4MFDSCALE_UC=100
#L4MFDSCALE_UR=100
#L4MFDSCALE_LL=100
#L4MFDSCALE_LR=100
# The portrait radar/map, sized on its own (it already sits at 1.35x
# the MFDs by default). It shares the canvas with whichever MFD is
# above it, so at extreme settings one of the two gives way.
L4RADARSCALE=100
# Where the radar sits:
# CENTER bottom centre, under the viewscreen, as the pod had it
# (default; BOTTOM and CENTRE mean the same)
# LEFT bottom left corner (or BOTTOMLEFT)
# RIGHT bottom right corner (or BOTTOMRIGHT)
# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)
# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)
# Anywhere but CENTER stops it blocking the middle of the road, which
# is worth having on a wide screen.
#
# In a bottom corner it is one of three panes along the bottom, and the
# lower MFD whose corner it takes slides inboard beside it. Halfway up
# a side it leaves the bottom row entirely and sits between that side's
# two MFDs - roomy on a tall radar, but if the MFDs on that side are
# also scaled up, the radar is the one that gives way (it has to clear
# both of them, and it grows from the middle in both directions).
L4RADARPOS=CENTER
# The Winners Circle: at the end of a race the finishers are stood on
# the award platform in finishing order, with each pilot's callsign on
# the plate beside their spot, and held there for a few seconds before
# the results screen. 1 = show it, 0 = straight to the results.
RP412PODIUM=1
# The shot is framed for you, but these move the camera if you want it
# somewhere else. Distances are in game units, measured from the middle
# of the group of finishers.
# STANDOFF how far out in front of the stand the camera sits
# HEIGHT how far above the group
# AIM height of the point it looks at, relative to the group -
# negative tilts down, positive tilts up
# ASPECT the stand was composed for a 4:3 pod monitor, so the shot
# is cropped to that shape with black either side. 0 runs it
# full width instead.
# FADEIN seconds to come up out of the black after the race fades
# CAM 0 watches from your own cockpit rather than off the stand
#RP412PODIUMSTANDOFF=36
#RP412PODIUMHEIGHT=12
#RP412PODIUMAIM=2
#RP412PODIUMASPECT=1.333
#RP412PODIUMFADEIN=0.45
#RP412PODIUMCAM=1
# Override the game length the menu picked, in seconds. The shortest the
# menu offers is 3:00, which is a long wait when what you are testing is
# what happens at the buzzer. Unset = use the menu's choice.
#RP412MISSIONSECONDS=20
# Simulation/render frame rate, integer frames/second. The desktop
# default is 60; the arcade pods shipped at 25.
TARGETFPS=60
# 1 = Steam networking (lobbies, FakeIP mesh). Needs the Steam client
# running and steam_appid.txt beside the exe; without them the game
# logs the reason and falls back to plain TCP. 0 = TCP only.
RP412STEAM=1
# ---- Optional ---------------------------------------------------------------
# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to
# 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 = invert X only, Y = invert Y only, XY = both (case-insensitive).
#L4PADFLIP=XY
# 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, integer. Default 8192.
#MAXPARTICLES=8192
# 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): 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).
# 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 ----------------------------------------------------
# Nonzero arms the debug keys: Alt+W wireframe, Alt+V predator vision,
# Alt+F frame dump, Alt+/ perf stats, Alt+E event-queue dump.
# 0 or unset = off. (Alt+Q, the mission abort, is always live.)
#RP412DEVKEYS=1
# Console race-length override, integer seconds (short test races).
# Values <= 0 are ignored.
#L4CONSOLELEN=30
# 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
#MFDGAUGE2=4
#SPANDISABLE=1
#L4EYES=1
#L4INTERCOM=COM2
#NOMODES=1
#LOGSIZE=1000000
"@
# --- desktop configuration -------------------------------------------------
# environ.ini is NOT shipped. The exe carries the template and writes it on
# first run (RPL4ENVIRON.cpp), the same way it writes bindings.txt - so a
# tester can drop a new build over an old folder and keep every setting they
# have changed. Laying one down here would overwrite their file on every
# unzip, which is the whole problem.
Set-Content -Path "$dist\start-windowed.bat" -Encoding ascii -Value @"
@echo off
@@ -356,14 +187,36 @@ cd /d "%~dp0"
start rpl4opt.exe -fit
"@
Set-Content -Path "$dist\joyconfig.bat" -Encoding ascii -Value @"
@echo off
rem Red Planet 4.12 - one-time JOYSTICK / HOTAS / rudder-pedal setup.
rem A console wizard asks you to move each control (stick, twist or
rem rudder, throttle lever, fire buttons); it works out what you moved
rem and which way it reads, and writes the joystick section of
rem bindings.txt. When it finishes the game carries on into the setup
rem screen so you can try the bindings straight away.
rem Xbox-class controllers need NO setup - they work out of the box.
rem Re-run this any time to redo the bindings; anything you have edited
rem yourself in bindings.txt is kept.
cd /d "%~dp0"
set RP412JOYCONFIG=1
start /wait rpl4opt.exe -windowed -res 1920 1080
set RP412JOYCONFIG=
"@
Set-Content -Path "$dist\README.txt" -Encoding ascii -Value @"
Red Planet 4.12.5
Red Planet $version
=================
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.
Got a flight stick, HOTAS or rudder pedals? Run joyconfig.bat once. It
asks you to move each control, works out which axis you moved and which
way round it reads, and writes the joystick rows of bindings.txt. Xbox-
class controllers need none of that - they work out of the box.
Controls (XInput controller and/or keyboard) - EVERY input is
rebindable: edit bindings.txt beside the exe (written with the full
documented default layout on first run; delete it to restore).
@@ -402,13 +255,42 @@ at its left, and the lower MFDs flanking the portrait map. The red
buttons around each MFD and the amber buttons beside the map are the
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, display scaling and radar placement, and more).
environ.ini is self-documenting: the game writes it on first run with
every option in it and a comment on each (Steam networking, keyboard
lighting, stick inversion, LAN hosting, developer keys, display scaling
and radar placement, and more).
CONTROLS.html is the full controls map - open it in a browser for the
pad, keyboard and pod-panel diagrams. CONTROLS.txt is the same thing as
plain text.
HANDBOOK.html is the full manual - open it in a browser for the pad,
keyboard and pod-panel diagrams, the joystick setup, and what every file
in this folder is for. CONTROLS.txt is the controls half as plain text.
Four files here are yours. None of them ship - the game writes each one
the first time it needs it and then leaves it alone, so a new build
unzipped over this folder keeps everything you have set. Delete any of
them to start that part over:
environ.ini every engine option, commented in place
bindings.txt every key, pad button, axis and joystick row
pilot.cfg your callsign and loadout
mfd_layout.cfg where you dragged the windows (RP412MFDLAYOUT)
Two that catch people out: environ.ini is applied OVER the environment,
so a variable set in a shell loses to an uncommented line in the file;
and none of these four is ever overwritten once it exists, which is what
lets you keep a folder across builds. The trade is that a file carried
through several updates stops being offered new options - rpl4.log names
any it has not heard of, and deleting the file brings back the fully
documented current one.
THIS IS A TEST BUILD AND IT GOES STALE. Two weeks after it was built it
will tell you so and refuse to start, rather than let you spend an
afternoon on something that was fixed the week before. When that happens,
grab the current build:
https://gitea.mysticmachines.com/VWE/RP412/releases
Unzip it over this folder - your environ.ini, bindings.txt, pilot.cfg and
mfd_layout.cfg all survive.
Known prototype notes: pods race untextured (the player1-8 skins come
from the presets system, not shipped data), and text drawn on the plasma
@@ -422,9 +304,13 @@ $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.5.zip'
$zipPath = Join-Path $root "RedPlanet-$version.zip"
Write-Host "zipping to $zipPath..."
# Taken BEFORE the player's files go back, so a release never carries
# somebody's callsign, key bindings or window positions to everyone who
# downloads it. A fresh unzip must look like a first run.
#
# 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.
@@ -435,3 +321,14 @@ if ($Zip) {
Compress-Archive -Path "$stage\RP412" -DestinationPath $zipPath -Force
Remove-Item -Recurse -Force $stage
}
# --- the player's own files, back where they were --------------------------
# Last of all: after the rebuilt tree, so nothing the pack writes can land on
# top of them, and after the zip, so the release stays clean. Zipping should
# not cost you your own settings.
if ($kept.Count -gt 0) {
foreach ($name in $kept.Keys) {
[System.IO.File]::WriteAllBytes((Join-Path $dist $name), $kept[$name])
}
Write-Host " restored $($kept.Keys -join ', ')"
}
+148
View File
@@ -0,0 +1,148 @@
# ============================================================================
# stamp-version.ps1 - write RP_L4\rpl4build.h from the repository's own state
# ============================================================================
#
# The patch number IS the commit count, so a build names the commit it came
# from and there is never a question about which changes are in a binary
# somebody is holding. Run as RP_L4's pre-build step; also readable by
# pack-dist.ps1, so the package and the exe cannot disagree.
#
# A hardcoded version could not do this: the commit that records "4.12.96"
# is itself commit 96, so the file is stale the moment it is committed.
#
# Usage: powershell -ExecutionPolicy Bypass -File stamp-version.ps1
#
$ErrorActionPreference = 'Stop'
# The product line. Bump this by hand when the line moves; the patch
# number after it looks after itself.
$line = '4.12'
# How many days a build stays good for. Test builds go stale so nobody is
# still racing a fortnight-old binary and reporting things that were fixed
# a week ago - the expired build says so and stops, rather than quietly
# wasting everyone's afternoon.
#
# SET THIS TO 0 FOR A REAL RELEASE. A shipped build that expires is a
# catastrophe, and this is the one line that decides it.
$expireDays = 14
$root = Split-Path -Parent $MyInvocation.MyCommand.Path
$header = Join-Path $root 'RP_L4\rpl4build.h'
$count = 0
$commit = 'nogit'
$dirty = 0
#
# Every git call goes through cmd so that stderr never reaches PowerShell's
# error stream. Windows PowerShell turns a native command's stderr into
# ErrorRecords, and with $ErrorActionPreference = 'Stop' git's routine
# "LF will be replaced by CRLF" warning is enough to throw - which silently
# skipped the dirty check and stamped every modified build as clean.
#
try {
Push-Location $root
$c = cmd /c "git rev-list --count HEAD 2>NUL"
if ($LASTEXITCODE -eq 0 -and $c) {
$count = [int]$c
$commit = (cmd /c "git rev-parse --short HEAD 2>NUL").Trim()
#
# Tracked modifications only. An untracked scratch file in the tree
# is not in the binary, and marking every build dirty for one would
# make the marker mean nothing.
#
cmd /c "git diff --quiet HEAD -- 2>NUL"
if ($LASTEXITCODE -ne 0) { $dirty = 1 }
}
} catch {
# no git, or not a repository: fall through to the placeholder below
} finally {
Pop-Location
}
if ($count -eq 0) {
# Built outside the repository (a source drop, say). Say so plainly
# rather than inventing a number that would sort against real ones.
$version = "$line.x"
$long = "$line.x (no repository)"
} else {
$version = "$line.$count"
$long = "$version ($commit$(if ($dirty) { '+' } else { '' }))"
}
#
# Expiry, from the day it was BUILT rather than the day the code was
# written: rebuilding an old commit to chase something should give a
# usable binary, not one that was born stale.
#
# The date is what makes this header change from one day to the next, so
# the first build of each day recompiles RPL4.CPP and the rest of that
# day's builds do not. One file, a couple of seconds.
#
# Only 0 means "never". A NEGATIVE count backdates the expiry, which is how
# the refusal gets tested without touching the machine's clock.
if ($expireDays -ne 0) {
$expiry = (Get-Date).Date.AddDays($expireDays)
$expires = 1
$expiryY = $expiry.Year
$expiryM = $expiry.Month
$expiryD = $expiry.Day
$expiryTxt = $expiry.ToString('d MMMM yyyy', [Globalization.CultureInfo]::InvariantCulture)
} else {
$expires = 0
$expiryY = 0
$expiryM = 0
$expiryD = 0
$expiryTxt = 'never'
}
$content = @"
//===========================================================================//
// File: rpl4build.h GENERATED - do not edit, do not commit //
//---------------------------------------------------------------------------//
// Written by stamp-version.ps1 as RP_L4's pre-build step. The patch number //
// is the repository's commit count and the hash beside it names the exact //
// commit, so a running build always says where it came from. A trailing '+' //
// means the tree had uncommitted changes to tracked files when it was built. //
// //
// EXPIRES is the test-build shelf life - see \$expireDays in the script. A //
// release must be built with it at 0. //
//===========================================================================//
#pragma once
#define RP412_BUILD_COUNT $count
#define RP412_BUILD_COMMIT "$commit"
#define RP412_BUILD_DIRTY $dirty
#define RP412_VERSION "$version"
#define RP412_VERSION_LONG "$long"
#define RP412_EXPIRES $expires
#define RP412_EXPIRY_YEAR $expiryY
#define RP412_EXPIRY_MONTH $expiryM
#define RP412_EXPIRY_DAY $expiryD
#define RP412_EXPIRY_TEXT "$expiryTxt"
"@
#
# Only rewrite when something actually changed: an unconditional write
# would touch the header on every build and drag RPL4.CPP through a
# recompile each time.
#
$existing = if (Test-Path $header) { [IO.File]::ReadAllText($header) } else { '' }
$note = if ($expires) { "expires $expiryTxt" } else { "no expiry" }
if ($existing -ne $content) {
[IO.File]::WriteAllText($header, $content, (New-Object System.Text.ASCIIEncoding))
Write-Host "stamp-version: $long, $note"
} else {
Write-Host "stamp-version: $long, $note (unchanged)"
}
#
# Explicitly: this runs as a pre-build step, and the last thing above it is
# "git diff --quiet", which exits 1 to mean "there are changes". Letting that
# escape would fail the build on every modified tree - the exact case a
# developer builds in.
#
exit 0