22 Commits
Author SHA1 Message Date
CydandClaude Opus 5 52da65bc0d Release 4.12.7
Flight sticks, HOTAS throttles, twist grips and rudder pedals, none of
which the game could see before: they arrive through DirectInput rather
than XInput, and PadRIO only read XInput.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things had to move for it to make sense.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  setup lines + gap + eight rows + gap + hint

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 14:39:17 -05:00
30 changed files with 5509 additions and 81 deletions
+5
View File
@@ -40,6 +40,11 @@ ipch/
*.log *.log
rpl4.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
# Build-output static libs that land in lib/ (the two committed dependency # Build-output static libs that land in lib/ (the two committed dependency
# libs, OpenAL32.lib and libsndfile-1.lib, stay tracked). # libs, OpenAL32.lib and libsndfile-1.lib, stay tracked).
/lib/Munga_L4.lib /lib/Munga_L4.lib
+78 -1
View File
@@ -13,6 +13,7 @@ L4TEXOP::WrapType d3d_OBJECT::mLastWrapV = L4TEXOP::WrapType::REPEAT;
bool d3d_OBJECT::mLastTexturingState = true; bool d3d_OBJECT::mLastTexturingState = true;
long d3d_OBJECT::mNextID = 1; long d3d_OBJECT::mNextID = 1;
stdext::hash_map<std::string, L4TEXOP> d3d_OBJECT::mTextureCache; stdext::hash_map<std::string, L4TEXOP> d3d_OBJECT::mTextureCache;
LPDIRECT3DTEXTURE9 d3d_OBJECT::mNamePlateMarkers[d3d_OBJECT::kNamePlateCount + 1] = { NULL };
void chgext(char *filePath, const char *newExtension) void chgext(char *filePath, const char *newExtension)
{ {
@@ -156,7 +157,36 @@ d3d_OBJECT *d3d_OBJECT::LoadObject(LPDIRECT3DDEVICE9 device, char *fileName)
{ {
sprintf(textureFilename, "VIDEO\\%s", materials[i].pTextureFilename); sprintf(textureFilename, "VIDEO\\%s", materials[i].pTextureFilename);
} }
object->mDrawOps[i].texture = LoadTexture(device, textureFilename); //
// playerN is not a file - it is a pilot's callsign, drawn into
// a texture at run time, so loading it always fails. Give the
// plate its marker texture instead: it keeps the eight sign
// faces as eight separate draw ops through consolidation, and
// the Winners Circle swaps the real callsign in later.
//
const char *base = strrchr(textureFilename, '\\');
base = (base != NULL) ? base + 1 : textureFilename;
int plate = 0;
if (_strnicmp(base, "player", 6) == 0 &&
base[6] >= '1' && base[6] <= '8')
{
plate = base[6] - '0';
}
if (plate != 0)
{
memset(&object->mDrawOps[i].texture, 0, sizeof(L4TEXOP));
object->mDrawOps[i].texture.texture =
NamePlateMarker(device, plate);
if (object->mDrawOps[i].texture.texture != NULL)
{
object->mDrawOps[i].texture.texture->AddRef();
}
}
else
{
object->mDrawOps[i].texture = LoadTexture(device, textureFilename);
}
} }
if (nextDetailOp < numDetailOps && detailOps[nextDetailOp] == i) if (nextDetailOp < numDetailOps && detailOps[nextDetailOp] == i)
@@ -286,6 +316,53 @@ void d3d_OBJECT::FlushTextureCache()
(*iter).second.texture->Release(); (*iter).second.texture->Release();
} }
mTextureCache.clear(); mTextureCache.clear();
for (int plate = 0; plate <= kNamePlateCount; ++plate)
{
if (mNamePlateMarkers[plate] != NULL)
{
mNamePlateMarkers[plate]->Release();
mNamePlateMarkers[plate] = NULL;
}
}
}
//
// A distinct 1x1 texture per plate, so the eight sign faces stay eight
// separate draw ops through mesh consolidation instead of merging into one.
// Its content never shows - the callsign replaces it before the podium.
//
LPDIRECT3DTEXTURE9 d3d_OBJECT::NamePlateMarker(LPDIRECT3DDEVICE9 device, int plate)
{
if (plate < 1 || plate > kNamePlateCount || device == NULL)
{
return NULL;
}
if (mNamePlateMarkers[plate] == NULL)
{
device->CreateTexture(1, 1, 1, 0, D3DFMT_A4R4G4B4, D3DPOOL_MANAGED,
&mNamePlateMarkers[plate], NULL);
}
return mNamePlateMarkers[plate];
}
//
// Which plate a texture is the marker for, or 0 if it is not one.
//
int d3d_OBJECT::NamePlateFor(LPDIRECT3DTEXTURE9 texture)
{
if (texture == NULL)
{
return 0;
}
for (int plate = 1; plate <= kNamePlateCount; ++plate)
{
if (mNamePlateMarkers[plate] == texture)
{
return plate;
}
}
return 0;
} }
d3d_OBJECT::d3d_OBJECT(LPDIRECT3DDEVICE9 device, int vertCount) d3d_OBJECT::d3d_OBJECT(LPDIRECT3DDEVICE9 device, int vertCount)
+24
View File
@@ -111,6 +111,27 @@ public:
// race loop tears the renderer down between missions). // race loop tears the renderer down between missions).
static void FlushTextureCache(); static void FlushTextureCache();
//------------------------------------------------------------------
// Pilot name plates.
//
// The Winners Circle signs ask for textures called player1..player8,
// which are not files - the renderer draws each pilot's callsign into
// a texture at run time. Loading them fails and the plates come out
// blank, so the draw ops that wanted them are noted here as the
// geometry loads, and bound to the real textures once the finishing
// order is known.
//
// Cleared with the texture cache: the entries point at objects that
// belong to the mission being torn down.
//------------------------------------------------------------------
// Each plate is given a distinct marker texture as it loads. Without
// one they are eight identical untextured draw ops, and consolidation
// merges every static mesh by material - all eight plates would
// collapse into a single shared op that can only ever show one name.
enum { kNamePlateCount = 8 };
static LPDIRECT3DTEXTURE9 NamePlateMarker(LPDIRECT3DDEVICE9 device, int plate);
static int NamePlateFor(LPDIRECT3DTEXTURE9 texture);
private: private:
static d3d_OBJECT* LoadSpheres(LPDIRECT3DDEVICE9 device, char *fileName); static d3d_OBJECT* LoadSpheres(LPDIRECT3DDEVICE9 device, char *fileName);
@@ -150,6 +171,9 @@ private:
static long mNextID; static long mNextID;
static stdext::hash_map< std::string , L4TEXOP > mTextureCache; static stdext::hash_map< std::string , L4TEXOP > mTextureCache;
// index 1..8; [0] unused so the index is the plate number
static LPDIRECT3DTEXTURE9 mNamePlateMarkers[kNamePlateCount + 1];
}; };
extern int gNumBatches; extern int gNumBatches;
+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);
+370
View File
@@ -10,6 +10,74 @@ namespace
const int buttonGap = 4; 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 // Button banks run UNDER the glass: a button reaches this far in
// from the display edge, but only the indicator strip clears that // from the display edge, but only the indicator strip clears that
@@ -108,6 +176,15 @@ namespace
} }
break; 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: case WM_CLOSE:
// Part of the cockpit; just hide it. // Part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE); ShowWindow(hwnd, SW_HIDE);
@@ -148,6 +225,8 @@ MFDSplitView::MFDSplitView(
sourceHeight = source_height; sourceHeight = source_height;
buttonCount = 0; buttonCount = 0;
pressedIndex = -1; pressedIndex = -1;
paneTitle = title;
ownWindow = (parent == NULL) ? True : False;
pixels = new unsigned long[source_width * source_height]; pixels = new unsigned long[source_width * source_height];
memset(pixels, 0, source_width * source_height * sizeof(unsigned long)); memset(pixels, 0, source_width * source_height * sizeof(unsigned long));
@@ -230,6 +309,14 @@ MFDSplitView::MFDSplitView(
{ {
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, (LONG_PTR) this); SetWindowLongPtrA((HWND) window, GWLP_USERDATA, (LONG_PTR) this);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE); 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 else
{ {
@@ -241,6 +328,9 @@ MFDSplitView::~MFDSplitView()
{ {
Check_Pointer(this); Check_Pointer(this);
// out of the layout registry before the window goes
RPWindowLayout_Forget(window);
if (window != NULL) if (window != NULL)
{ {
SetWindowLongPtrA((HWND) window, GWLP_USERDATA, 0); SetWindowLongPtrA((HWND) window, GWLP_USERDATA, 0);
@@ -259,6 +349,272 @@ Logical
return pixels != NULL; 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 // Compute the display rectangle and the button rectangles, growing the
// client area to make room for the strips. // client area to make room for the strips.
@@ -490,6 +846,20 @@ void
// one message per frame, and queued WM_PAINTs (lowest priority) starve // one message per frame, and queued WM_PAINTs (lowest priority) starve
// behind it - panes would freeze on their first frame. // behind it - panes would freeze on their first frame.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Off screen, and stay off: the pane keeps its pixels and geometry, it
// simply stops being shown. Repaint() on a hidden window is harmless.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
MFDSplitView::Hide()
{
Check_Pointer(this);
if (window != NULL)
{
ShowWindow((HWND) window, SW_HIDE);
}
}
void void
MFDSplitView::SetPosition(int x, int y) MFDSplitView::SetPosition(int x, int y)
{ {
+89
View File
@@ -71,6 +71,20 @@ public:
void void
Resize(int display_width, int display_height); Resize(int display_width, int display_height);
// Take the pane off screen. Used for the Winners Circle, which wants
// the whole viewscreen: the panes overlap it like the pod's bezels,
// so hiding them uncovers the full canvas underneath.
void
Hide();
// caption and HWND, for the sticky-placement file
const char *
Title() const
{ return paneTitle; }
void *
Window()
{ return window; }
~MFDSplitView(); ~MFDSplitView();
Logical Logical
@@ -141,4 +155,79 @@ protected:
int int
buttonAnchorA, buttonAnchorA,
buttonAnchorB; 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 #pragma hdrstop
#include "l4padbindings.h" #include "l4padbindings.h"
#include "l4joy.h" // joyButtonCount / joyHatCount, the parse limits
#include <XInput.h> #include <XInput.h>
#include <stdio.h> #include <stdio.h>
@@ -79,6 +80,20 @@ namespace
{ "Throttle", BindAxisThrottle }, { "Throttle", BindAxisThrottle },
{ "LeftPedal", BindAxisLeftPedal }, { "RightPedal", BindAxisRightPedal }, { "LeftPedal", BindAxisLeftPedal }, { "RightPedal", BindAxisRightPedal },
{ "JoystickY", BindAxisJoystickY }, { "JoystickX", BindAxisJoystickX }, { "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) 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) if (token_count < 4)
{ {
return False; return False;
@@ -271,31 +359,75 @@ namespace
memset(binding, 0, sizeof(*binding)); memset(binding, 0, sizeof(*binding));
binding->source = source; binding->source = source;
binding->axis = axis; 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")) return False;
{
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;
}
} }
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; return True;
} }
@@ -320,10 +452,17 @@ namespace
"# key <name> axis <axis> rate <n-per-second>\n" "# key <name> axis <axis> rate <n-per-second>\n"
"# pad <button> button <addr> [toggle]\n" "# pad <button> button <addr> [toggle]\n"
"# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\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" "#\n"
"# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad\n" "# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad\n"
"# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).\n" "# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).\n"
"# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX\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" "# <name> Keys name: A-Z, D0-D9 (digit row), F1-F12, NumPad0-NumPad9,\n"
"# Up, Down, Left, Right, Space, Enter, PageUp, PageDown,\n" "# Up, Down, Left, Right, Space, Enter, PageUp, PageDown,\n"
"# OemMinus, Oemplus, Oemcomma, OemPeriod, ...\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" "# 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" "# position sticks (the throttle). Every lamp button is also clickable\n"
"# on the on-screen cockpit, so unbound addresses are never stranded.\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" "\n"
"# ---- Flight: number pad + modifiers -------------------------------\n" "# ---- Flight: number pad + modifiers -------------------------------\n"
"# The whole letter board stays free for the MFD banks; flight lives\n" "# The whole letter board stays free for the MFD banks; flight lives\n"
@@ -499,6 +663,7 @@ void
char line[256]; char line[256];
int line_number = 0; int line_number = 0;
int error_count = 0; int error_count = 0;
int joy_slot = 0; // joy rows before any joydev belong to slot 0
const char *cursor = source; const char *cursor = source;
while (*cursor != '\0') while (*cursor != '\0')
{ {
@@ -523,7 +688,7 @@ void
{ {
continue; continue;
} }
if (!ParseLine(tokens, token_count, profile)) if (!ParseLine(tokens, token_count, profile, &joy_slot))
{ {
++error_count; ++error_count;
DEBUG_STREAM << "PadBindings: " << kBindingsFileName << " line " DEBUG_STREAM << "PadBindings: " << kBindingsFileName << " line "
@@ -542,4 +707,10 @@ void
<< profile->padAxisCount << " pad axes" << profile->padAxisCount << " pad axes"
<< (error_count ? " (with rejected lines)" : "") << (error_count ? " (with rejected lines)" : "")
<< "\n" << std::flush; << "\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> // key <name> axis <axis> deflect <n> | rate <n>
// pad <button> button <addr> [toggle] // pad <button> button <addr> [toggle]
// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>] // 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 // <addr> is a RIO input address: lamp buttons 0x00-0x47, internal
// keypad 0x50-0x5F, external keypad 0x60-0x6F. Loaded from // keypad 0x50-0x5F, external keypad 0x60-0x6F. Loaded from
// bindings.txt beside the exe; written there (self-documenting, with // bindings.txt beside the exe; written there (self-documenting, with
// the full default profile) on first run. Bad lines are logged and // the full default profile) on first run. Bad lines are logged and
// skipped, good lines always win. // 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 enum PadBindRioAxis
@@ -33,6 +46,14 @@ enum PadBindRioAxis
BindAxisRightPedal, BindAxisRightPedal,
BindAxisJoystickY, BindAxisJoystickY,
BindAxisJoystickX, 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 BindAxisCount
}; };
@@ -89,6 +110,54 @@ struct PadPadAxisBinding
Scalar rate; // 0 = direct position, >0 = speed integrate 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 struct PadBindingProfile
{ {
enum enum
@@ -96,7 +165,10 @@ struct PadBindingProfile
maxKeyButtons = 128, maxKeyButtons = 128,
maxKeyAxes = 32, maxKeyAxes = 32,
maxPadButtons = 32, maxPadButtons = 32,
maxPadAxes = 16 maxPadAxes = 16,
maxJoyAxes = 24,
maxJoyButtons = 48,
maxJoyHats = 16
}; };
PadKeyButtonBinding keyButtons[maxKeyButtons]; PadKeyButtonBinding keyButtons[maxKeyButtons];
@@ -107,6 +179,15 @@ struct PadBindingProfile
int padButtonCount; int padButtonCount;
PadPadAxisBinding padAxes[maxPadAxes]; PadPadAxisBinding padAxes[maxPadAxes];
int padAxisCount; 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, // Load bindings.txt from the working directory into the profile,
+214 -1
View File
@@ -3,6 +3,7 @@
#include "l4padrio.h" #include "l4padrio.h"
#include "l4keylight.h" #include "l4keylight.h"
#include "l4joy.h"
#include <XInput.h> #include <XInput.h>
#pragma comment(lib, "xinput9_1_0.lib") #pragma comment(lib, "xinput9_1_0.lib")
@@ -41,6 +42,51 @@ namespace
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0; 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) void KeyLightLog(const char *line)
{ {
DEBUG_STREAM << line << "\n" << std::flush; DEBUG_STREAM << line << "\n" << std::flush;
@@ -166,6 +212,29 @@ PadRIO::PadRIO()
activeInstance = this; activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush; 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() PadRIO::~PadRIO()
@@ -244,6 +313,11 @@ void
profile.padButtons[i].latched = False; profile.padButtons[i].latched = False;
profile.padButtons[i].wasDown = 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 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) for (int i = 0; i < buttonUnits; ++i)
{ {
if (screenButton[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); throttleAccum = Clamp01(throttleAccum + rate[BindAxisThrottle] * delta_t);
Scalar x = deflect[BindAxisJoystickX]; Scalar x = deflect[BindAxisJoystickX];
@@ -511,7 +722,9 @@ void
if (y > 1.0f) y = 1.0f; if (y > 1.0f) y = 1.0f;
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]); LeftPedal = Clamp01(deflect[BindAxisLeftPedal]);
RightPedal = Clamp01(deflect[BindAxisRightPedal]); RightPedal = Clamp01(deflect[BindAxisRightPedal]);
// The profile encodes the pod's stick sign convention; L4PADFLIP // The profile encodes the pod's stick sign convention; L4PADFLIP
+27
View File
@@ -3,6 +3,7 @@
#include "l4plasmascreen.h" #include "l4plasmascreen.h"
#include "l4app.h" #include "l4app.h"
#include "l4mfdview.h" // RPWindowLayout_*
namespace namespace
{ {
@@ -45,6 +46,15 @@ namespace
} }
break; 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: case WM_CLOSE:
// The glass is part of the cockpit; just hide it. // The glass is part of the cockpit; just hide it.
ShowWindow(hwnd, SW_HIDE); ShowWindow(hwnd, SW_HIDE);
@@ -176,6 +186,7 @@ PlasmaScreen::~PlasmaScreen()
} }
if (window != NULL) if (window != NULL)
{ {
RPWindowLayout_Forget(window);
RemovePropA((HWND) window, "PlasmaBitmapInfo"); RemovePropA((HWND) window, "PlasmaBitmapInfo");
RemovePropA((HWND) window, "PlasmaPixels"); RemovePropA((HWND) window, "PlasmaPixels");
DestroyWindow((HWND) window); DestroyWindow((HWND) window);
@@ -275,6 +286,22 @@ void
SetPropA((HWND) window, "PlasmaPixels", pixelBuffer->Data.MapPointer); SetPropA((HWND) window, "PlasmaPixels", pixelBuffer->Data.MapPointer);
ShowWindow((HWND) window, SW_SHOWNOACTIVATE); ShowWindow((HWND) window, SW_SHOWNOACTIVATE);
activeInstance = this; 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 else
{ {
+62
View File
@@ -3839,6 +3839,11 @@ static LRESULT CALLBACK
cockpit->LayoutCockpit(LOWORD(lParam), HIWORD(lParam)); 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); return CallWindowProcA(gCockpitBaseProc, hwnd, message, wParam, lParam);
} }
@@ -4161,6 +4166,29 @@ void
} }
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Clear the glass off the screen for the Winners Circle.
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
void
SVGA16::HideSecondaryDisplays()
{
Check_Pointer(this);
if (!splitViews)
{
return;
}
for (int view = 0; view < SplitViewCount; ++view)
{
if (splitView[view] != NULL)
{
splitView[view]->Hide();
}
}
DEBUG_STREAM << "SVGA16: secondary displays hidden for the podium\n"
<< std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Fit the 1920x1080 cockpit canvas into the given client area: one // Fit the 1920x1080 cockpit canvas into the given client area: one
// uniform scale (so the canvas never stretches - a wider desktop just // uniform scale (so the canvas never stretches - a wider desktop just
@@ -4434,6 +4462,15 @@ SVGA16::SVGA16(
splitView[SplitMFDLowerRight]->SetPosition( splitView[SplitMFDLowerRight]->SetPosition(
work.left + right_x, work.top + bottom_y); work.left + right_x, work.top + bottom_y);
splitView[SplitMap]->SetPosition(work.left + map_x, work.top + map_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) else if (splitViews)
{ {
@@ -4645,6 +4682,23 @@ SVGA16::SVGA16(
// catch maximise / restore / drag-resize and re-fit // catch maximise / restore / drag-resize and re-fit
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA( gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc); 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 else
{ {
@@ -4840,6 +4894,14 @@ SVGA16::~SVGA16()
//--------------------------------------------------------- //---------------------------------------------------------
//SVGASetSplitterClock(False); //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) for (int view = 0; view < SplitViewCount; ++view)
{ {
delete splitView[view]; delete splitView[view];
+12
View File
@@ -349,6 +349,18 @@ public:
GetCockpit() GetCockpit()
{ return activeCockpit; } { return activeCockpit; }
//------------------------------------------------------------------
// Take the six secondary displays off screen and leave the whole
// canvas to the viewscreen. The Winners Circle wants the podium
// uncluttered, and the panes are only bezels over the 3D - hiding
// them uncovers what is already being drawn behind.
//
// There is no matching show: the mission is over by the time this
// is called, and the next race builds a fresh cockpit.
//------------------------------------------------------------------
void
HideSecondaryDisplays();
protected: protected:
Logical Logical
splitViews; splitViews;
+437 -22
View File
@@ -1797,6 +1797,19 @@ DPLRenderer::DPLRenderer(
backgroundGreen = 0.0f; backgroundGreen = 0.0f;
backgroundBlue = 0.0f; backgroundBlue = 0.0f;
viewAngle = 30.0f; viewAngle = 30.0f;
// the Winners Circle camera is off until the podium asks for it. No
// restore path is needed: a fresh DPLRenderer is built per mission, so
// the next race re-reads viewangle from RPDPL.INI.
mPresentationCamera = False;
mPresentationFog = False;
mPresentationAspect = 0.0f;
mPresentationFadeTime = 0.0f;
mPresentationFogRed = 0.0f;
mPresentationFogGreen = 0.0f;
mPresentationFogBlue = 0.0f;
mPresentationFogNear = 0.0f;
mPresentationFogFar = 0.0f;
D3DXMatrixIdentity(&mPresentationView);
dplMainView = NULL; dplMainView = NULL;
dplDeathZone = NULL; dplDeathZone = NULL;
dplMainZone = NULL; dplMainZone = NULL;
@@ -2343,10 +2356,53 @@ void
} }
break; break;
case winnersCircleFogStyle: case winnersCircleFogStyle:
//
// The presentation shot, restored from the DPL body below. The
// stand sits far off the track in open ground, so the track's own
// fog leaves it in the dark - this is the lighter blue-violet the
// original used to lift it, with the fog pushed back to 100/1050.
// //
// HACK!! This really shouldn't reset the clip planes, but since // HACK!! This really shouldn't reset the clip planes, but since
// it only happens at the end of the review, it should be safe for now. // it only happens at the end of the review, it should be safe for now.
// //
fogRed = 0.32f;
fogGreen = 0.30f;
fogBlue = 0.65f;
fogNear = 100.0f;
fogFar = 1050.0f;
// the per-frame FOGSTART/FOGEND come from these
currentFogNear = fogNear;
currentFogFar = fogFar;
clipNear = 0.25f;
clipFar = 1100.0f;
// tells the end-of-mission fade to stand down
mPresentationFog = True;
//
// Crop to the shape the stand was composed for. 4:3 unless
// RP412PODIUMASPECT says otherwise; 0 turns it off and lets
// the podium run full width.
//
mPresentationAspect = 4.0f / 3.0f;
{
const char *aspect = getenv("RP412PODIUMASPECT");
if (aspect != NULL)
{
mPresentationAspect = (float) atof(aspect);
}
}
// unconditional: fogUpdating is off while a vehicle drives its own
// headlight fog, and the podium overrides all of that
if (mDevice != NULL)
{
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
// dpl_SetViewClipPlanes ( dplMainView, 0.25f, 1100.0f ); // dpl_SetViewClipPlanes ( dplMainView, 0.25f, 1100.0f );
// dpl_SetViewFog( // dpl_SetViewFog(
// dplMainView, // dplMainView,
@@ -5608,6 +5664,24 @@ void
intersect_mask = INTERSECT_ALL; intersect_mask = INTERSECT_ALL;
} }
//
// RP412RENDERDIAG=1: name what actually gets built, so a missing
// Winners Circle can be told from one that is simply out of shot.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
if (diag != NULL && atoi(diag) != 0)
{
DEBUG_STREAM << "RenderDiag: building class"
<< entity->GetClassID() << " res "
<< entity->GetResourceID() << " at "
<< entity->localOrigin.linearPosition.x << ","
<< entity->localOrigin.linearPosition.y << ","
<< entity->localOrigin.linearPosition.z
<< "\n" << std::flush;
}
}
Logical first_object = True; Logical first_object = True;
video_iterator.First(); video_iterator.First();
@@ -5997,12 +6071,110 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
if (mCamShipHUD) if (mCamShipHUD)
mCamShipHUD->Execute(); mCamShipHUD->Execute();
//
// The eye renderable has just written D3DTS_VIEW from the viewpoint
// entity. For the Winners Circle the shot comes from off the stand
// instead, so stomp it here - before the read-back below that feeds
// every draw call.
//
if (mPresentationCamera)
{
mDevice->SetTransform(D3DTS_VIEW, &mPresentationView);
}
//
// RP412RENDERDIAG=1: report what the frame is actually made of once the
// mission is ending, which is the only way to tell an empty scene from a
// misaimed camera.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
static int reported = 0;
if (diag != NULL && atoi(diag) != 0 &&
currentAppState == Application::EndingMission && reported < 4)
{
++reported;
int dynamic_count = 0;
SChainIteratorOf<HierarchicalDrawComponent*> diag_iter(&mRenderables);
while (diag_iter.ReadAndNext() != NULL)
{
++dynamic_count;
}
DEBUG_STREAM << "RenderDiag: ending frame " << reported
<< " dead=" << (l4_application->IsDead() ? 1 : 0)
<< " statics=" << (int) mConsolidatedStaticObjects.size()
<< " renderables=" << dynamic_count
<< " camera=" << (mPresentationCamera ? 1 : 0)
<< "\n" << std::flush;
}
}
gNumBatches = 0; gNumBatches = 0;
static Time lastFrameTime = mTargetRenderTime; static Time lastFrameTime = mTargetRenderTime;
Scalar dT = mTargetRenderTime - lastFrameTime; Scalar dT = mTargetRenderTime - lastFrameTime;
lastFrameTime = mTargetRenderTime; lastFrameTime = mTargetRenderTime;
currentFrameTime = Now(); currentFrameTime = Now();
//
// Fade the presentation up out of black, if one is running. Same lever
// the end-of-mission fade pulls: scale the fog colour and both fog
// distances, here from nothing up to what the podium asked for.
//
if (mPresentationFadeTime > 0.0f)
{
Scalar remaining = mPresentationFadeEnd - Now();
Scalar fade = 1.0f - (remaining / mPresentationFadeTime);
if (fade >= 1.0f)
{
fade = 1.0f;
mPresentationFadeTime = 0.0f; // done
}
if (fade < 0.0f)
{
fade = 0.0f;
}
fogRed = mPresentationFogRed * fade;
fogGreen = mPresentationFogGreen * fade;
fogBlue = mPresentationFogBlue * fade;
currentFogNear = mPresentationFogNear * fade;
currentFogFar = mPresentationFogFar * fade;
mDevice->SetRenderState(D3DRS_FOGCOLOR,
D3DCOLOR_XRGB((int)(255 * fogRed), (int)(255 * fogGreen),
(int)(255 * fogBlue)));
}
//
// Pillarbox: black the whole target first, then narrow the viewport so
// everything after this - clear, scene, reticle - lands inside the crop
// and the surround stays black. Restored after Present.
//
if (mPresentationAspect > 0.0f)
{
D3DVIEWPORT9 full;
full.X = 0;
full.Y = 0;
full.Width = mPresentParams.BackBufferWidth;
full.Height = mPresentParams.BackBufferHeight;
full.MinZ = 0.0f;
full.MaxZ = 1.0f;
mDevice->SetViewport(&full);
mDevice->Clear(0, NULL, D3DCLEAR_TARGET, D3DCOLOR_XRGB(0, 0, 0), 1.0f, 0);
DWORD cropped = (DWORD)
(mPresentParams.BackBufferHeight * mPresentationAspect);
if (cropped > mPresentParams.BackBufferWidth)
{
cropped = mPresentParams.BackBufferWidth;
}
D3DVIEWPORT9 crop = full;
crop.Width = cropped;
crop.X = (mPresentParams.BackBufferWidth - cropped) / 2;
mDevice->SetViewport(&crop);
}
DWORD currentFog; DWORD currentFog;
mDevice->GetRenderState(D3DRS_FOGCOLOR, &currentFog); mDevice->GetRenderState(D3DRS_FOGCOLOR, &currentFog);
hr = mDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, currentFog, 1.0f, 0); hr = mDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, currentFog, 1.0f, 0);
@@ -6152,15 +6324,70 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
mDevice->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1); mDevice->SetRenderState(D3DRS_DIFFUSEMATERIALSOURCE, D3DMCS_COLOR1);
mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE); mDevice->SetTextureStageState(1, D3DTSS_COLOROP, D3DTOP_DISABLE);
if (mReticle && !l4_application->IsDead()) //
// No gunsight on the podium - the race is over and nothing is being
// aimed at. It is drawn in the 2D pass, so it survives everything else
// the presentation turns off.
//
if (mReticle && !l4_application->IsDead() && !InPresentation())
mReticle->Render(0, &viewTransform); mReticle->Render(0, &viewTransform);
if (mCamShipHUD) if (mCamShipHUD && !InPresentation())
mCamShipHUD->Render(0, &viewTransform); mCamShipHUD->Render(0, &viewTransform);
hr = mDevice->EndScene(); hr = mDevice->EndScene();
hr = mDevice->Present(NULL, NULL, gMainPresentWindow, NULL); hr = mDevice->Present(NULL, NULL, gMainPresentWindow, NULL);
// hand the whole target back
if (mPresentationAspect > 0.0f)
{
D3DVIEWPORT9 full;
full.X = 0;
full.Y = 0;
full.Width = mPresentParams.BackBufferWidth;
full.Height = mPresentParams.BackBufferHeight;
full.MinZ = 0.0f;
full.MaxZ = 1.0f;
mDevice->SetViewport(&full);
}
//
// RP412RENDERDIAG=1: did the ending frame actually reach the window, and
// what colour did it clear to? A black clear with a blue-violet fog set
// means something is overwriting the fog behind us.
//
{
static const char *diag = getenv("RP412RENDERDIAG");
static int presented = 0;
if (diag != NULL && atoi(diag) != 0 &&
application->GetApplicationState() == Application::EndingMission &&
presented < 4)
{
++presented;
DWORD fog_now = 0;
mDevice->GetRenderState(D3DRS_FOGCOLOR, &fog_now);
HWND present_window = (HWND) gMainPresentWindow;
RECT present_rect;
present_rect.left = present_rect.top = 0;
present_rect.right = present_rect.bottom = 0;
if (present_window != NULL)
{
GetClientRect(present_window, &present_rect);
}
DEBUG_STREAM << "RenderDiag: present " << presented
<< " hr=0x" << std::hex << (unsigned int) hr << std::dec
<< " fogcolor=0x" << std::hex << (unsigned int) fog_now << std::dec
<< " window=" << (void*) present_window
<< " visible=" << (present_window != NULL &&
IsWindowVisible(present_window) ? 1 : 0)
<< " rect=" << present_rect.right << "x" << present_rect.bottom
<< " backbuf=" << mPresentParams.BackBufferWidth << "x"
<< mPresentParams.BackBufferHeight
<< "\n" << std::flush;
}
}
if (hr == D3DERR_DEVICELOST) if (hr == D3DERR_DEVICELOST)
{ {
int bbCount = mPresentParams.BackBufferCount; int bbCount = mPresentParams.BackBufferCount;
@@ -6736,27 +6963,167 @@ void
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// //
//
// Un-stubbed for the Winners Circle, which asks for 45 degrees to get all
// eight racers in frame. The DPL body below it built a dpl_View; the D3D9
// path builds the same two matrices DPLReadINIPage does and pushes them,
// because mProjectionMatrix only reaches the device on the LaunchingMission
// edge and on the sky-pass restore - rebuilding alone would not show until
// the next frame.
//
// Pass degrees, as the INI does. RPDPL.INI ships viewangle=40, which is what
// to hand back afterwards - not the 30 in the constructor, which the INI
// overwrites at load.
//
void DPLRenderer::SetViewAngle(Degree new_angle) void DPLRenderer::SetViewAngle(Degree new_angle)
{ {
//STUBBED: DPL RB 1/14/07 Check(this);
//Check(this);
//// viewAngle = (float) new_angle.angle;
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//// Convert From Degree To Radian Radian view_angle;
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ view_angle = new_angle;
//// // tan(half angle): the culling helper GetViewRatio() reads this, and
//Radian view_angle; // nothing has written it since the DPL body was stubbed out
//view_angle = new_angle; viewRatio = (float) tan(view_angle / 2.0f);
//viewAngle = view_angle; aspectRatio = (float) y_size / (float) x_size;
//viewRatio = tan(viewAngle/2.0f);
//// // pillarboxed shots render into a narrower viewport, so the projection
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // has to use that shape or the scene comes out squashed rather than
//// Calc Aspect Ratio and Set View Projection // cropped
////~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ float projection_aspect = (mPresentationAspect > 0.0f)
//// ? mPresentationAspect
//aspectRatio = (float) y_size / (float) x_size; : ((float) x_size / (float) y_size);
//dpl_SetViewProjection ( dplMainView, -1.0f, -aspectRatio, 1.0f, aspectRatio, 1.0f/viewRatio);
//dpl_FlushView(dplMainView); D3DXMatrixIdentity(&mProjectionMatrix);
D3DXMatrixPerspectiveFovLH(
&mProjectionMatrix,
viewAngle * (PI / 180.0f),
projection_aspect,
clipNear,
clipFar);
mProjectionMatrix(0, 0) *= -1; // handedness flip - the view is RH
mDecalProjectionMatrix = mProjectionMatrix;
mDecalProjectionMatrix._33 -= mDecalEpsilon;
if (mDevice != NULL)
{
mDevice->SetTransform(D3DTS_PROJECTION, &mProjectionMatrix);
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The presentation camera. DPLEyeRenderable writes D3DTS_VIEW from the
// viewpoint entity every frame; ExecuteImplementation reads it straight back
// out of the device and hands it to every draw call. Overriding it between
// those two points is enough to move the shot without touching the eye
// renderable or building a CameraShip.
//
void
DPLRenderer::SetPresentationCamera(const Point3D &eye, const Point3D &look_at)
{
Check(this);
D3DXVECTOR3 from((float) eye.x, (float) eye.y, (float) eye.z);
D3DXVECTOR3 at((float) look_at.x, (float) look_at.y, (float) look_at.z);
//
// LookAt*LH*, to match the LH projection. The two differ by exactly the
// sign of the view direction, and RH here points the camera the opposite
// way: ask to look down at the stand and you get sky behind you.
//
// The engine's own eye renderable does use RH, and is right to - its
// forward and up come out of the entity matrix already in that
// convention. A camera aimed with plain world coordinates does not, so
// it wants the handedness the projection was built with.
//
D3DXVECTOR3 up(0.0f, 1.0f, 0.0f);
D3DXMatrixLookAtLH(&mPresentationView, &from, &at, &up);
mPresentationCamera = True;
}
void
DPLRenderer::ClearPresentationCamera()
{
Check(this);
mPresentationCamera = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Give the pilot a vehicle to look at. Their own is built insideEntity - a
// cockpit and no hull - so on the podium they would be the one empty spot.
// outsideEntity is what builds the exterior, and the renderables for it are
// added to what is already there.
//
void
DPLRenderer::ShowViewpointFromOutside()
{
Check(this);
Entity *viewpoint = GetLinkedEntity();
if (viewpoint == NULL)
{
DEBUG_STREAM << "WinnersCircle: no viewpoint entity to turn around\n"
<< std::flush;
return;
}
//
// Add the exterior alongside what is already there rather than tearing
// the entity down and rebuilding it. The teardown path is safe for
// scenery going out of range, but not for the viewpoint entity: it is
// never uninteresting, the eye renderable goes with it, and doing it
// mid-mission stops the scene rendering altogether.
//
// This is what NotifyOfNewInterestingEntity does, minus the teardown
// and with the view type forced.
//
Check(application);
ResourceFile *resource_file = application->GetResourceFile();
Check(resource_file);
ResourceDescription *video_resource = resource_file->SearchList(
viewpoint->GetResourceID(),
ResourceDescription::VideoModelResourceType);
if (video_resource == NULL)
{
DEBUG_STREAM << "WinnersCircle: own vehicle has no exterior model\n"
<< std::flush;
return;
}
video_resource->Lock();
MakeEntityRenderables(viewpoint, video_resource, outsideEntity);
video_resource->Unlock();
DEBUG_STREAM << "WinnersCircle: own vehicle given an exterior\n"
<< std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Come up out of the black. SetFogStyle(winnersCircleFogStyle) has already
// put the target fog in place, so remember it and ramp toward it - the same
// multiply the end-of-mission fade uses, run the other way.
//
void
DPLRenderer::StartPresentationFadeIn(Scalar seconds)
{
Check(this);
mPresentationFogRed = fogRed;
mPresentationFogGreen = fogGreen;
mPresentationFogBlue = fogBlue;
mPresentationFogNear = currentFogNear;
mPresentationFogFar = currentFogFar;
mPresentationFadeTime = (seconds > 0.0f) ? seconds : 0.01f;
mPresentationFadeEnd = Now();
mPresentationFadeEnd += mPresentationFadeTime;
} }
// //
//############################################################################# //#############################################################################
@@ -6800,8 +7167,56 @@ void DPLRenderer::SortAndReloadNameBitmaps()
LoadBitSliceTexture(name_bitmap, mNameTextures[index]); LoadBitSliceTexture(name_bitmap, mNameTextures[index]);
} }
} }
LoadOrdinalBitmaps(); LoadOrdinalBitmaps();
BindNamePlates();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Put the callsigns on the Winners Circle plates.
//
// mNameTextures is indexed by finishing place (rank + 1), which is how the
// signs are numbered, so the plate beside each spot names whoever is standing
// on it. The plates are static geometry and have already been merged into the
// consolidated mesh by now, so it is the consolidated draw ops that have to be
// re-pointed - the objects they were built from are no longer drawn.
//
void DPLRenderer::BindNamePlates()
{
int name_count = (int) (sizeof(mNameTextures) / sizeof(mNameTextures[0]));
int bound = 0;
int found = 0;
std::list<d3d_OBJECT*>::const_iterator iter;
for (iter = mConsolidatedStaticObjects.begin();
iter != mConsolidatedStaticObjects.end(); ++iter)
{
d3d_OBJECT *object = *iter;
if (object == NULL)
{
continue;
}
for (int op = 0; op < object->GetDrawOpCount(); ++op)
{
L4DRAWOP *draw_op = object->GetDrawOp(op);
int plate = d3d_OBJECT::NamePlateFor(draw_op->texture.texture);
if (plate == 0)
{
continue;
}
++found;
if (plate < name_count && mNameTextures[plate] != NULL)
{
draw_op->texture.texture = mNameTextures[plate];
++bound;
}
}
}
DEBUG_STREAM << "NamePlates: " << bound << " of " << found
<< " plates given a callsign\n" << std::flush;
} }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+63
View File
@@ -310,10 +310,57 @@ public:
void SetViewAngle(Degree new_angle); void SetViewAngle(Degree new_angle);
//------------------------------------------------------------------
// Winners Circle presentation camera.
//
// The eye normally rides the viewpoint entity - your own vehicle -
// and DPLEyeRenderable writes D3DTS_VIEW from it each frame. For the
// podium the shot has to come from off the stand looking back, with
// every racer including you in frame, so this overrides that view
// for as long as it is set. Culling still runs from the viewpoint
// entity, which is standing on the podium, so the stand and everyone
// on it stay resident.
//
// Eye and target are world space. ClearPresentationCamera() hands
// the view back to the entity.
//------------------------------------------------------------------
void SetPresentationCamera(const Point3D &eye, const Point3D &look_at);
void ClearPresentationCamera();
//------------------------------------------------------------------
// True once the Winners Circle has taken the screen. The end-of-
// mission fade-to-black checks this and stands down: it multiplies
// the fog to black over FADE_OUT_TIME, which would otherwise black
// out the podium no matter what is drawn.
//------------------------------------------------------------------
Logical InPresentation() const { return mPresentationFog; }
//------------------------------------------------------------------
// Bring the presentation up out of black over the given seconds. The
// race's own fade-to-black has already run by this point; this is
// its mirror image, ramping the fog colour and both fog distances
// back up from nothing to what the Winners Circle asked for.
//------------------------------------------------------------------
void StartPresentationFadeIn(Scalar seconds);
//------------------------------------------------------------------
// Rebuild the viewpoint entity - the pilot's own vehicle - as an
// exterior. It is normally built insideEntity, which is a cockpit
// and no hull, so from any camera that is not in it you see nothing
// where your own vehicle should be. Disconnected_Eye is the engine's
// own switch for this: "so higher level renderers can fix the eye in
// one spot and watch the viewpoint entity drive around."
//------------------------------------------------------------------
void ShowViewpointFromOutside();
unsigned int* MakeBitSliceStorage(); unsigned int* MakeBitSliceStorage();
void SortAndReloadNameBitmaps(); void SortAndReloadNameBitmaps();
// re-point the Winners Circle sign faces at the current callsign
// textures; the plates live in the consolidated static mesh
void BindNamePlates();
void LoadNameBitmaps(); void LoadNameBitmaps();
void LoadOrdinalBitmaps(); void LoadOrdinalBitmaps();
@@ -438,6 +485,22 @@ private:
D3DXMATRIX mDecalProjectionMatrix; D3DXMATRIX mDecalProjectionMatrix;
float mDecalEpsilon; float mDecalEpsilon;
// Winners Circle: overrides D3DTS_VIEW while set (see
// SetPresentationCamera)
D3DXMATRIX mPresentationView;
Logical mPresentationCamera;
Logical mPresentationFog;
// >0 pillarboxes the scene to this aspect. The Winners Circle was
// built to be looked at from a 4:3 pod monitor and its platform runs
// out at the sides of a 16:9 canvas, so the shot is cropped to the
// shape it was composed for and the surround left black.
float mPresentationAspect;
// fade-in: end time, duration, and the fog it is ramping toward
Time mPresentationFadeEnd;
Scalar mPresentationFadeTime;
float mPresentationFogRed, mPresentationFogGreen, mPresentationFogBlue;
float mPresentationFogNear, mPresentationFogFar;
void FindBestAdapterIndices(bool isWindowed); void FindBestAdapterIndices(bool isWindowed);
float mCloudRed, mCloudGreen, mCloudBlue; float mCloudRed, mCloudGreen, mCloudBlue;
+14
View File
@@ -2283,6 +2283,20 @@ void
// //
case FadeOutState: case FadeOutState:
{ {
//
// The Winners Circle has taken the screen, so leave it alone.
// This fade multiplies the fog colour and both fog distances
// toward zero every frame, which blacks out the whole scene -
// correct when the race just ends, fatal to a podium shown
// afterwards. Stand down and stop running.
//
if (myRenderer->InPresentation())
{
myState = WaitForStartState;
myRenderer->RemoveDynamicRenderable(this);
break;
}
percent_time_left = (myStateTimer - current_time)/FADE_OUT_TIME; percent_time_left = (myStateTimer - current_time)/FADE_OUT_TIME;
if(percent_time_left <= 0.0f) if(percent_time_left <= 0.0f)
{ {
+2
View File
@@ -263,6 +263,7 @@
<ConformanceMode>true</ConformanceMode> <ConformanceMode>true</ConformanceMode>
</ClCompile> </ClCompile>
<ClCompile Include=".\L4PADBINDINGS.cpp" /> <ClCompile Include=".\L4PADBINDINGS.cpp" />
<ClCompile Include=".\L4JOY.cpp" />
<ClCompile Include=".\L4PADRIO.cpp" /> <ClCompile Include=".\L4PADRIO.cpp" />
<ClCompile Include=".\L4PCSPAK.cpp" /> <ClCompile Include=".\L4PCSPAK.cpp" />
<ClCompile Include=".\L4PLASMA.cpp" /> <ClCompile Include=".\L4PLASMA.cpp" />
@@ -454,6 +455,7 @@
<ClInclude Include=".\L4NETTRANSPORT.h" /> <ClInclude Include=".\L4NETTRANSPORT.h" />
<ClInclude Include=".\L4KEYLIGHT.h" /> <ClInclude Include=".\L4KEYLIGHT.h" />
<ClInclude Include=".\L4PADBINDINGS.h" /> <ClInclude Include=".\L4PADBINDINGS.h" />
<ClInclude Include=".\L4JOY.h" />
<ClInclude Include=".\L4STEAMTRANSPORT.h" /> <ClInclude Include=".\L4STEAMTRANSPORT.h" />
<ClInclude Include=".\L4PARTICLES.h" /> <ClInclude Include=".\L4PARTICLES.h" />
<ClInclude Include=".\L4MFDVIEW.h" /> <ClInclude Include=".\L4MFDVIEW.h" />
+46
View File
@@ -41,6 +41,52 @@ radar moved out of the middle of the road (`environ.ini`). Each display's
button bank now reaches under its glass, so the picture itself is the press button bank now reaches under its glass, so the picture itself is the press
target rather than a sliver at the edge. target rather than a sliver at the edge.
[v4.12.4](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.4)
is about the lobby. Both rooms now show the host's mission setup — the track,
then time of day, weather and game length — since everyone flies the owner's
picks and it was the one thing in the room nobody could see for themselves.
The football team sheet names each player's VTV beside their team colours and
position. And a lobby holds **eight** players rather than four, a full grid as
the pod hall ran it, with the room sizing its roster to whatever space the
window gives it.
[v4.12.5](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.5)
restores the **Winners Circle**. The pod hall stood the finishers on a
numbered award platform when the race ended, and all of it was still in this
repo — the stand, eight ranked spots in every map, and the code to put racers
on them — wired only into the mission-review build and so never once run by a
pod. The race now fades out and fades back in on the platform: finishers in
finishing order, each pilot's callsign on the plate beside their spot, the
cockpit glass cleared away, held for a few seconds before the results screen.
Three pieces of it had been stubbed out in the D3D9 port and are working
again.
[v4.12.6](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.6)
is about the windows. Where you put them now survives the
menu-race-menu loop: `RP412MFDLAYOUT` remembers the game window, the
exploded view's display panes and the plasma glass in `mfd_layout.cfg`
beside `bindings.txt`. Append `,noframe` to a line to take that
window's title bar and border off — a cockpit filling the monitor edge
to edge at a rect you chose, rather than `-fit` taking the whole
screen — and the setup screen carries its own **EXIT GAME** button,
since a window with no title bar needs a way out. The Steam buttons
now dim and say *STEAM NOT RUNNING* instead of disappearing. And the
Winners Circle camera is framed off the award stand itself rather than
off whoever is standing on it, so the shot is the same one for every
player at every head count.
[v4.12.7](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.7)
is about sticks. Anything that is not an Xbox-class pad — a flight stick,
a HOTAS throttle, a twist grip, rudder pedals, a wheel — comes in through
DirectInput rather than XInput, and the game could not see any of it.
Now it can, and `joyconfig.bat` sets it up: the wizard asks you to move
each control in turn, works out which device and axis answered and which
way round it reads, and writes the joystick rows of `bindings.txt`,
leaving anything you have edited yourself alone. A twist grip or rudder
bar drives both pedals through one signed `Pedals` axis, and a real
throttle lever owns its channel outright rather than nudging a position
the way a spring-centred stick has to. Ported from the sibling BT411.
## Playing ## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player: Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
+28
View File
@@ -11,6 +11,16 @@
#include "..\munga\hostmgr.h" #include "..\munga\hostmgr.h"
#include "..\munga\nttmgr.h" #include "..\munga\nttmgr.h"
//
// How long the mission stays up after the buzzer, for the Winners Circle.
// This timer is the only thing holding the simulation and the renderer
// open once the race is over - when it runs out the player dispatches the
// StopMission that retires the application. The stock 3 seconds is the
// fade; the rest is the podium. Kept under the +30s LightsOut post so that
// never fires while the stand is up.
//
const Scalar winnersCircleHoldTime = 11.0f;
//############################################################################# //#############################################################################
//######################## RPPlayer__StatusMessage ###################### //######################## RPPlayer__StatusMessage ######################
//############################################################################# //#############################################################################
@@ -188,6 +198,24 @@ void
ForceUpdate(); ForceUpdate();
} }
} }
//
//---------------------------------------------------------------------
// Hold the mission open for the Winners Circle.
//
// The base handler sets a 3 second fade, and when it runs out the
// player dispatches the StopMission that retires the application and
// takes the renderer with it. That is the only thing keeping the sim
// and the renderer alive after the race, so the podium gets exactly as
// long as this timer says. Sim and render both keep running throughout
// EndingMission - neither has a case for it that bails out.
//
// Only on a clean finish: an abort should still leave promptly.
//
if (application->GetApplicationState() == Application::EndingMission)
{
fadeTimeRemaining = winnersCircleHoldTime;
}
Check_Fpu(); Check_Fpu();
} }
+62 -1
View File
@@ -26,6 +26,8 @@
#include "rpl4lobby.h" #include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h" #include "..\munga_l4\l4steamtransport.h"
#include "..\munga_l4\l4splr.h" #include "..\munga_l4\l4splr.h"
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h" #include "rpl4ver.h"
#include "..\munga\resver.h" #include "..\munga\resver.h"
#include "..\munga\resource.h" #include "..\munga\resource.h"
@@ -123,6 +125,15 @@ LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
return 0; return 0;
} }
break; 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); return DefWindowProc(hWnd, uMsg, wParam, lParam);
} }
@@ -177,7 +188,7 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
fclose(file); fclose(file);
} }
DEBUG_STREAM << "Red Planet 4.12.3" << std::endl << std::flush; DEBUG_STREAM << "Red Planet 4.12.7" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush; DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM #ifdef RP412_STEAM
@@ -254,6 +265,43 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
return FALSE; return FALSE;
ShowWindow(hWnd, nShowCmd); 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 #if !_DEBUG
// Arcade pods have no mouse - but desktop/windowed play needs the // Arcade pods have no mouse - but desktop/windowed play needs the
// cursor for the on-screen cockpit buttons, so hide it only when // cursor for the on-screen cockpit buttons, so hide it only when
@@ -475,6 +523,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 #if !_DEBUG
// symmetric with the fullscreen-only hide at startup // symmetric with the fullscreen-only hide at startup
if (L4Application::GetFullscreen()) if (L4Application::GetFullscreen())
+427
View File
@@ -15,12 +15,56 @@
#include "..\rp\vtv.h" #include "..\rp\vtv.h"
#include "rpl4mppr.h" #include "rpl4mppr.h"
#include "..\rp\rpplayer.h" #include "..\rp\rpplayer.h"
#include "..\munga_l4\l4video.h"
#include "..\munga\dropzone.h"
#include "..\munga\director.h"
#include "..\munga\nttmgr.h"
#include "..\munga_l4\l4vb16.h"
// //
//############################################################################# //#############################################################################
// RPL4Application // RPL4Application
//############################################################################# //#############################################################################
// //
const Receiver::HandlerEntry
RPL4Application::MessageHandlerEntries[]=
{
MESSAGE_ENTRY(RPL4Application, StopMission),
MESSAGE_ENTRY(RPL4Application, WinnersCircle)
};
Receiver::MessageHandlerSet& RPL4Application::GetMessageHandlers()
{
static Receiver::MessageHandlerSet messageHandlers(
ELEMENTS(RPL4Application::MessageHandlerEntries),
RPL4Application::MessageHandlerEntries,
L4Application::GetMessageHandlers());
return messageHandlers;
}
Derivation* RPL4Application::GetClassDerivations()
{
static Derivation classDerivations(
L4Application::GetClassDerivations(), "RPL4Application");
return &classDerivations;
}
RPL4Application::SharedData
RPL4Application::DefaultData(
RPL4Application::GetClassDerivations(),
RPL4Application::GetMessageHandlers()
);
//
// How long to sit on black between the race fading out and the podium
// fading in. The race fade is FADE_OUT_TIME (half a second), so this is
// that plus a beat, to land on black rather than on the tail of it.
//
const Scalar winnersCircleFadeOutTime = 0.7f;
// and how quickly the stand comes up out of the black afterwards
const Scalar winnersCircleFadeInTime = 0.45f;
RPL4Application::RPL4Application( RPL4Application::RPL4Application(
HINSTANCE hInstance, HINSTANCE hInstance,
HWND hWnd, HWND hWnd,
@@ -43,6 +87,389 @@ RPL4Application::~RPL4Application()
Check_Fpu(); Check_Fpu();
} }
//
//#############################################################################
// ShowWinnersCircle
//#############################################################################
//
// The pod hall's award platform. Every map carries a "wcircle" stand at
// (1200,0,0) with eight ranked dropzones named win1..win8 on it - rank 1 at
// the front on the low tier, ranks 4-8 across the back on the high one. All
// this shipped; only the mission-review build ever drove it.
//
// Stand the finishers on their spots in finishing order, freeze them, and
// look back at the stand from in front of it.
//
void
RPL4Application::ShowWinnersCircle()
{
Check(this);
// RP412PODIUM=0 skips the whole thing
const char *podium_mode = getenv("RP412PODIUM");
if (podium_mode != NULL && atoi(podium_mode) == 0)
{
DEBUG_STREAM << "WinnersCircle: disabled\n" << std::flush;
return;
}
EntityManager *entity_manager = GetEntityManager();
if (entity_manager == NULL)
{
return;
}
EntityGroup *dropzones = entity_manager->FindGroup("DropZones");
if (dropzones == NULL)
{
DEBUG_STREAM << "WinnersCircle: no DropZones group\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Walk the finishing order. Player::CalcRanking has been ranking every
// scoring player by score each frame, in football as much as in a race
// (CalcFootballRanking exists but is never called), so rank order is
// meaningful in both.
//---------------------------------------------------------------------
//
//
//---------------------------------------------------------------------
// The stand's own geometry first, before anybody stands on it.
//
// The eight spots are fixed furniture on every map, so reading them up
// front gives one set of numbers to both jobs below: where to stand
// the finishers, and where to put the camera. Only the first is
// allowed to care who finished.
//---------------------------------------------------------------------
//
enum { winnersSpotCount = 8 };
DropZone *spot[winnersSpotCount];
int spots_found = 0;
char winners_spot[] = "win?";
char *place = winners_spot + 3;
for (int i = 0; i < winnersSpotCount; ++i)
{
*place = (char) ('1' + i);
spot[i] = NULL;
ChainIteratorOf<Node*> iterator(dropzones->groupMembers);
DropZone *dropzone;
while ((dropzone = (DropZone*) iterator.ReadAndNext()) != NULL)
{
if (!strcmp(dropzone->GetDropZoneName(), winners_spot))
{
spot[i] = dropzone;
++spots_found;
Point3D at = dropzone->localOrigin.linearPosition;
DEBUG_STREAM << "WinnersCircle: " << winners_spot << " at "
<< at.x << "," << at.y << "," << at.z << "\n" << std::flush;
break;
}
}
}
if (spot[0] == NULL)
{
DEBUG_STREAM << "WinnersCircle: no win1 spot on this map\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Stand the finishers up, in finishing order.
//---------------------------------------------------------------------
//
int placed = 0;
Player *p;
for (int rank = 0; (p = CameraDirector::FindPlayerByRank(rank)) != NULL; ++rank)
{
if (rank >= winnersSpotCount)
{
break; // only eight spots exist on the stand
}
if (spot[rank] == NULL)
{
continue;
}
Entity *vehicle = p->GetPlayerVehicle();
if (vehicle == NULL || vehicle->GetClassID() != VTVClassID)
{
continue;
}
VTV *vtv = (VTV*) vehicle;
vtv->Reset(spot[rank]->localOrigin, VTV::MissionReviewReset);
vtv->SetPerformance(&VTV::DoNothing);
vtv->FlushEvents();
++placed;
}
if (placed == 0)
{
DEBUG_STREAM << "WinnersCircle: nobody to place\n" << std::flush;
return;
}
//
//---------------------------------------------------------------------
// Switch the world back on.
//
// Dying collapses the view and sets the application "dead" until the
// pilot reincarnates - while that flag is up the renderer skips every
// static object, which is the whole map. A pilot killed near the buzzer
// is still waiting for a respawn that will never come, so the flag is
// still up and the podium would play out against a black screen.
//
// The mission-review build never hit this: it watches from a CameraShip,
// which cannot die, so nothing ever set the flag there.
//
// Standing the finishers up IS the resurrection, so clear it.
//---------------------------------------------------------------------
//
SetIsDead(false);
DPLRenderer *dpl_renderer = GetVideoRenderer();
if (dpl_renderer == NULL)
{
return;
}
//
//---------------------------------------------------------------------
// Your own vehicle is built as a cockpit with no hull, so from any
// camera outside it there is nothing where you should be - on the stand
// you would be the one empty spot. Turn it inside out before the shot.
//---------------------------------------------------------------------
//
dpl_renderer->ShowViewpointFromOutside();
//
//---------------------------------------------------------------------
// The name plates are drawn per rank slot, so they have to be re-sorted
// now that the finishing order is final - otherwise the signs read in
// whatever order the players were created.
//---------------------------------------------------------------------
//
dpl_renderer->SortAndReloadNameBitmaps();
//
//---------------------------------------------------------------------
// Widen to 45 degrees and pull back in front of the stand so the whole
// line-up frames up. The stand runs from z~3 (rank 1, low) to z~39
// (ranks 4-8, high) and x~1180..1219; at 45 degrees that needs roughly
// 30 units of standoff. Eye is above the top tier looking slightly
// down at the winner's spot.
//
// Framed off the stand, NOT off who is standing on it. This used to
// average the filled spots and anchor on the first one filled, which
// meant the camera moved with the head count: eight finishers put the
// anchor in the middle of the tiers and the eye about 58 units out,
// one finisher collapsed it onto the winner's spot and brought the eye
// in to 36. Same stand, different shot per race - and per machine, if
// a remote player's vehicle was not there to be placed. The furniture
// does not move, so the camera does not either.
//---------------------------------------------------------------------
//
Point3D standFront = spot[0]->localOrigin.linearPosition;
//
// The stand's own axis: back rows to the winner's spot, which points
// out of the front however the map has the stand turned. Taken from
// the geometry rather than assumed, so a map that mounts its stand at
// another angle still gets photographed from the front.
//
Point3D standBack(0.0f, 0.0f, 0.0f);
int back_count = 0;
for (int i = 1; i < winnersSpotCount; ++i)
{
if (spot[i] != NULL)
{
standBack += spot[i]->localOrigin.linearPosition;
++back_count;
}
}
if (back_count > 0)
{
standBack.x /= (Scalar) back_count;
standBack.y /= (Scalar) back_count;
standBack.z /= (Scalar) back_count;
}
Vector3D facing;
facing.x = standFront.x - standBack.x;
facing.y = 0.0f;
facing.z = standFront.z - standBack.z;
Scalar reach = (Scalar) sqrt(facing.x * facing.x + facing.z * facing.z);
if (reach < 0.01f)
{
// a one-spot stand, or all eight stacked: nothing to take a
// direction from, so fall back to the way the shipped stands face
facing.x = 0.0f; facing.z = -1.0f; reach = 1.0f;
}
//
// Framing is tunable while the shot is being dialled in:
// RP412PODIUMSTANDOFF distance out in front of the stand
// RP412PODIUMHEIGHT eye height above the group
// RP412PODIUMAIM height of the aim point above the group
//
//
// Framed off the stand itself: down low and tilted up across the tiers,
// which is how you photograph a podium. It is a balance in both
// directions - drop the camera further or tilt harder and the sky takes
// the top half while the winner's spot slides off the bottom; tilt down
// instead and it becomes a floor plan. Pillarboxing to 4:3 is what lets
// it sit this close without the platform trailing off at the sides.
//
Scalar standoff = 36.0f;
Scalar height = 12.0f;
Scalar aim_lift = 2.0f;
const char *tune = getenv("RP412PODIUMSTANDOFF");
if (tune != NULL && atof(tune) != 0.0) standoff = (Scalar) atof(tune);
tune = getenv("RP412PODIUMHEIGHT");
if (tune != NULL && atof(tune) != 0.0) height = (Scalar) atof(tune);
tune = getenv("RP412PODIUMAIM");
if (tune != NULL) aim_lift = (Scalar) atof(tune);
Point3D eye;
eye.x = standFront.x + (facing.x / reach) * standoff;
eye.y = standFront.y + height;
eye.z = standFront.z + (facing.z / reach) * standoff;
Point3D aim = standFront;
aim.y += aim_lift;
//
// RP412PODIUMCAM=0 leaves the view in the cockpit, which is also the
// way to tell a bad camera from a scene that is not drawing at all.
//
//
// Clear the cockpit glass away. The MFDs and the radar sit over the
// viewscreen like the pod's bezels and have nothing to say once the
// race is over; the podium gets the whole canvas.
//
SVGA16 *cockpit = SVGA16::GetCockpit();
if (cockpit != NULL)
{
cockpit->HideSecondaryDisplays();
}
//
// Fog first: it pulls the clip plane in to 1100, and SetViewAngle is what
// rebuilds the projection that reads it.
//
dpl_renderer->SetFogStyle(DPLRenderer::winnersCircleFogStyle);
const char *camera_mode = getenv("RP412PODIUMCAM");
if (camera_mode == NULL || atoi(camera_mode) != 0)
{
dpl_renderer->SetViewAngle(Degree(45.0f));
dpl_renderer->SetPresentationCamera(eye, aim);
}
else
{
// still rebuild the projection so the new clip plane takes effect
dpl_renderer->SetViewAngle(Degree(40.0f));
}
if (camera_mode != NULL && atoi(camera_mode) == 0)
{
DEBUG_STREAM << "WinnersCircle: presentation camera disabled\n"
<< std::flush;
}
//
// Everything is in place behind the black - bring it up.
// RP412PODIUMFADEIN sets the ramp in seconds.
//
Scalar fade_in = winnersCircleFadeInTime;
const char *fade_tune = getenv("RP412PODIUMFADEIN");
if (fade_tune != NULL && atof(fade_tune) > 0.0)
{
fade_in = (Scalar) atof(fade_tune);
}
dpl_renderer->StartPresentationFadeIn(fade_in);
//
// placed is reported for diagnosis only - the camera numbers beside it
// must not move when it does.
//
DEBUG_STREAM << "WinnersCircle: " << placed << " placed on "
<< spots_found << " spots; aim "
<< aim.x << "," << aim.y << "," << aim.z
<< " eye " << eye.x << "," << eye.y << "," << eye.z
<< "\n" << std::flush;
}
//
//#############################################################################
// StopMissionMessageHandler
//#############################################################################
//
// The mission is over. Show the podium, then let the base handler run - it
// puts the player into MissionEndingState, whose fade timer is what actually
// keeps the sim and the renderer alive until teardown.
//
void
RPL4Application::StopMissionMessageHandler(StopMissionMessage *message)
{
Check(this);
//
// StopMission arrives twice: once from the console at the buzzer, and
// again from the player when the ending fade runs out - that second one
// is what actually retires the application. Only the first is the end of
// the race.
//
if (GetApplicationState() != Application::EndingMission)
{
//
//-------------------------------------------------------------
// Don't cut straight to the podium. The race gets its own
// fade-to-black first - that fade is already running by the time
// this returns - and the Winners Circle comes up out of the
// black afterwards. Standing everyone up now would just fade out
// the podium instead of the race.
//
// FADE_OUT_TIME is half a second; a beat more than that lands on
// black rather than on the tail of the fade.
//-------------------------------------------------------------
//
Receiver::Message podium_message(
WinnersCircleMessageID, sizeof(Receiver::Message));
Time event_time;
event_time = Now();
event_time += winnersCircleFadeOutTime;
Post(LowEventPriority, this, &podium_message, event_time);
DEBUG_STREAM << "WinnersCircle: race over, fading out\n" << std::flush;
}
L4Application::StopMissionMessageHandler(message);
Check_Fpu();
}
//
//#############################################################################
// WinnersCircleMessageHandler
//#############################################################################
//
// The race has faded to black. Set the stand up behind the black and fade
// back in to it.
//
void
RPL4Application::WinnersCircleMessageHandler(Receiver::Message *)
{
Check(this);
DEBUG_STREAM << "WinnersCircle: standing the finishers up\n" << std::flush;
ShowWinnersCircle();
Check_Fpu();
}
// //
//############################################################################# //#############################################################################
// MakeRegistry // MakeRegistry
+34
View File
@@ -26,4 +26,38 @@ private:
Mission* MakeMission(NotationFile *notation_file, ResourceFile *resources); Mission* MakeMission(NotationFile *notation_file, ResourceFile *resources);
Entity* MakeViewpointEntity(Entity__MakeMessage *); Entity* MakeViewpointEntity(Entity__MakeMessage *);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The Winners Circle.
//
// The podium is engine content that only the mission-review build ever
// ran: geometry, the eight ranked dropzones and the presentation code all
// ship, but the sequence lived on RPL4PlaybackApplication behind a spool
// file. This is the same sequence on the path the pods actually race.
//
public:
enum
{
// posted at the buzzer, fires once the race has faded out
WinnersCircleMessageID = L4Application::NextMessageID,
NextMessageID
};
static const HandlerEntry
MessageHandlerEntries[];
static MessageHandlerSet& GetMessageHandlers();
static Derivation *GetClassDerivations();
static SharedData DefaultData;
void
StopMissionMessageHandler(StopMissionMessage *message);
// the podium proper, once the screen is already black
void
WinnersCircleMessageHandler(Receiver::Message *message);
private:
// stand the finishers on their ranked spots and frame the shot
void
ShowWinnersCircle();
}; };
+13
View File
@@ -498,6 +498,19 @@ namespace
<< mission_seconds << "s\n" << std::flush; << mission_seconds << "s\n" << std::flush;
} }
//
// RP412MISSIONSECONDS overrides the menu's game length. The shortest
// the menu offers is 3:00, which is a long wait when what you are
// testing is what happens at the buzzer.
//
const char *seconds_override = getenv("RP412MISSIONSECONDS");
if (seconds_override != NULL && atoi(seconds_override) > 0)
{
mission_seconds = atoi(seconds_override);
DEBUG_STREAM << "LocalConsole: length overridden to "
<< mission_seconds << "s by RP412MISSIONSECONDS\n" << std::flush;
}
gMissionSeconds = mission_seconds; gMissionSeconds = mission_seconds;
InterlockedExchange(&gLengthMs, (LONG) mission_seconds * 1000); InterlockedExchange(&gLengthMs, (LONG) mission_seconds * 1000);
gPhase = PhaseWaiting; gPhase = PhaseWaiting;
+177 -5
View File
@@ -177,6 +177,7 @@ namespace
GroupLaunch, GroupLaunch,
GroupSteamHost, // buttons, not selections (Steam builds only) GroupSteamHost, // buttons, not selections (Steam builds only)
GroupSteamJoin, GroupSteamJoin,
GroupExit,
GroupCount GroupCount
}; };
@@ -413,8 +414,9 @@ namespace
launch->rect.right = col3 + col_w; launch->rect.right = col3 + col_w;
launch->rect.bottom = client_h - row_h; launch->rect.bottom = client_h - row_h;
// Steam lobby buttons (only when the Steam wire is live) // Steam lobby buttons, offered whenever environ.ini asked for Steam.
if (RPL4Lobby_Available()) // Painting greys them out and says so if the wire never came up.
if (RPL4Lobby_Configured())
{ {
FEItem *host = &fe->items[fe->itemCount++]; FEItem *host = &fe->items[fe->itemCount++];
host->group = GroupSteamHost; host->group = GroupSteamHost;
@@ -433,6 +435,23 @@ namespace
join->rect.bottom = client_h - (7 * row_h) / 2; 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 // pilot name edit sits at the top of column 3
if (fe->nameEdit != NULL) if (fe->nameEdit != NULL)
{ {
@@ -484,6 +503,7 @@ namespace
case GroupLaunch: return "L A U N C H G A M E"; case GroupLaunch: return "L A U N C H G A M E";
case GroupSteamHost: return "HOST STEAM GAME"; case GroupSteamHost: return "HOST STEAM GAME";
case GroupSteamJoin: return "JOIN STEAM GAME"; case GroupSteamJoin: return "JOIN STEAM GAME";
case GroupExit: return "EXIT GAME";
} }
return ""; return "";
} }
@@ -544,12 +564,38 @@ namespace
RECT row = item->rect; RECT row = item->rect;
if (item->group >= GroupLaunch) 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); FrameRect(mem, &row, launch_brush);
DeleteObject(launch_brush); DeleteObject(launch_brush);
SetTextColor(mem, kGreenBright); SetTextColor(mem, ink);
DrawTextA(mem, ItemName(item->group, item->index), -1, &row, DrawTextA(mem, ItemName(item->group, item->index), -1, &row,
DT_CENTER | DT_VCENTER | DT_SINGLELINE); 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; continue;
} }
@@ -613,7 +659,17 @@ namespace
} }
else if (item->group == GroupSteamHost || item->group == GroupSteamJoin) 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); PostMessageA(fe->menuWindow, WM_NULL, 0, 0);
} }
else else
@@ -1380,6 +1436,122 @@ void
strcpy(badge, kBadges[b].key); strcpy(badge, kBadges[b].key);
} }
//########################################################################
// The host's mission setup, as display names.
//
// The lobby shows everyone what they are about to fly into, and only the
// owner knows it - so the owner publishes these into lobby data and the
// members read them back. Names rather than catalog keys: the map lists
// differ between race and football, so resolving here means the room
// never has to know which scenario's table a key came from.
//########################################################################
const char *
RPL4FrontEnd_SelectedMapName()
{
if (!gHavePersist)
{
return kMaps[0].name;
}
int count = 0;
const CatalogEntry *maps = ActiveMaps(gPersistSelection, &count);
int index = gPersistSelection[GroupMap];
if (index < 0 || index >= count)
{
index = 0;
}
return maps[index].name;
}
const char *
RPL4FrontEnd_SelectedTimeName()
{
int index = gHavePersist ? gPersistSelection[GroupTime] : 0;
if (index < 0 || index >= FE_COUNT(kTimes))
{
index = 0;
}
return kTimes[index].name;
}
const char *
RPL4FrontEnd_SelectedWeatherName()
{
int index = gHavePersist ? gPersistSelection[GroupWeather] : 0;
if (index < 0 || index >= FE_COUNT(kWeather))
{
index = 0;
}
return kWeather[index].name;
}
const char *
RPL4FrontEnd_SelectedLengthName()
{
// the menu defaults this one to 5:00, not to entry zero
int index = gHavePersist ? gPersistSelection[GroupLength] : 2;
if (index < 0 || index >= FE_COUNT(kLengths))
{
index = 0;
}
return kLengths[index].name;
}
//########################################################################
// Catalog key -> display name. Member data travels as keys, so the room
// needs these to show "Battle Barge" where the wire says "bttlbrg".
//########################################################################
namespace
{
const char *
NameForKey(const CatalogEntry *table, int count, const char *key)
{
if (key == NULL || key[0] == '\0')
{
return "";
}
for (int i = 0; i < count; ++i)
{
if (strcmp(table[i].key, key) == 0)
{
return table[i].name;
}
}
return key; // unknown to this build: show it raw rather than blank
}
}
const char *
RPL4FrontEnd_VehicleNameForKey(const char *key)
{
return NameForKey(kVehicles, FE_COUNT(kVehicles), key);
}
const char *
RPL4FrontEnd_PositionNameForKey(const char *key)
{
return NameForKey(kPositions, FE_COUNT(kPositions), key);
}
const char *
RPL4FrontEnd_TeamNameForKey(const char *key)
{
if (key == NULL || key[0] == '\0')
{
return "";
}
for (int i = 0; i < FE_COUNT(kTeams); ++i)
{
if (strcmp(kTeams[i].key, key) == 0)
{
return kTeams[i].name;
}
}
return key;
}
//######################################################################## //########################################################################
// Football team / position picks (the lobby publishes and cycles these) // Football team / position picks (the lobby publishes and cycles these)
//######################################################################## //########################################################################
+27
View File
@@ -50,6 +50,33 @@ int
void void
RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge); RPL4FrontEnd_GetLoadout(char *vehicle, char *color, char *badge);
//---------------------------------------------------------------
// This player's mission setup as display names. Only the host's
// picks decide the mission, so the lobby owner publishes these for
// everyone else to read - names rather than keys, because the map
// catalog differs between race and football and resolving it here
// saves the room from knowing which table a key belongs to.
//---------------------------------------------------------------
const char *
RPL4FrontEnd_SelectedMapName();
const char *
RPL4FrontEnd_SelectedTimeName();
const char *
RPL4FrontEnd_SelectedWeatherName();
const char *
RPL4FrontEnd_SelectedLengthName();
//---------------------------------------------------------------
// Catalog key -> display name, for the keys that travel as lobby
// member data. Unknown keys come back unchanged rather than blank.
//---------------------------------------------------------------
const char *
RPL4FrontEnd_VehicleNameForKey(const char *key);
const char *
RPL4FrontEnd_PositionNameForKey(const char *key);
const char *
RPL4FrontEnd_TeamNameForKey(const char *key);
//--------------------------------------------------------------- //---------------------------------------------------------------
// Football team / position: the catalogs, and this player's picks. // Football team / position: the catalogs, and this player's picks.
// The lobby publishes the picks as member data and lets members // The lobby publishes the picks as member data and lets members
+209 -19
View File
@@ -11,6 +11,7 @@
//######################################################################## //########################################################################
Logical RPL4Lobby_Available() { return False; } Logical RPL4Lobby_Available() { return False; }
Logical RPL4Lobby_Configured() { return False; }
Logical RPL4Lobby_InRoom() { return False; } Logical RPL4Lobby_InRoom() { return False; }
int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; } int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; } int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; }
@@ -36,7 +37,10 @@ void RPL4Lobby_PullRaceResults() { }
namespace namespace
{ {
enum { kMaxLobbyMembers = 4 }; // A full grid is eight pods, as the pod hall ran it. The egg builder
// carries the owner plus maxExtraPilots (8), so eight members fit
// with room to spare.
enum { kMaxLobbyMembers = 8 };
const char kLobbyTagKey[] = "rp412"; const char kLobbyTagKey[] = "rp412";
const char kGoKey[] = "go"; const char kGoKey[] = "go";
@@ -47,6 +51,12 @@ namespace
const char kResultsKey[] = "res"; const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc"; const char kScenarioKey[] = "sc";
// the owner's mission setup, shown to everyone in the room
const char kMapKey[] = "mp";
const char kTimeKey[] = "td";
const char kWeatherKey[] = "wx";
const char kLengthKey[] = "ln";
// async call-result plumbing // async call-result plumbing
Logical gCallDone = False; Logical gCallDone = False;
Logical gCallOK = False; Logical gCallOK = False;
@@ -154,12 +164,24 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps", SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex())); RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
// the owner also publishes the scenario, so members know //---------------------------------------------------------------
// whether their team/position pick matters // Only the owner's menu decides the mission, so the owner also
// publishes what it picked: the scenario (members need it to know
// whether their team/position pick matters) and the setup
// everyone is about to fly into.
//---------------------------------------------------------------
if (IsOwner()) if (IsOwner())
{ {
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey, SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race"); RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
RPL4FrontEnd_SelectedMapName());
SteamMatchmaking()->SetLobbyData(gLobby, kTimeKey,
RPL4FrontEnd_SelectedTimeName());
SteamMatchmaking()->SetLobbyData(gLobby, kWeatherKey,
RPL4FrontEnd_SelectedWeatherName());
SteamMatchmaking()->SetLobbyData(gLobby, kLengthKey,
RPL4FrontEnd_SelectedLengthName());
} }
} }
@@ -173,6 +195,21 @@ namespace
return (scenario != NULL && strcmp(scenario, "football") == 0); return (scenario != NULL && strcmp(scenario, "football") == 0);
} }
//-------------------------------------------------------------------
// Lobby data read back as a string, empty when the owner has not
// published it yet (a member can be in the room a beat before the
// owner's first publish lands).
//-------------------------------------------------------------------
const char *LobbyText(const char *key)
{
if (!gInLobby)
{
return "";
}
const char *text = SteamMatchmaking()->GetLobbyData(gLobby, key);
return (text != NULL) ? text : "";
}
//--------------------------------------------------------------- //---------------------------------------------------------------
// Launch: roster into lobby data, peers into the transport, and // Launch: roster into lobby data, peers into the transport, and
// the hosted-race path primed on the owner // the hosted-race path primed on the owner
@@ -346,11 +383,143 @@ namespace
-1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE); -1, &title, DT_CENTER | DT_TOP | DT_SINGLELINE);
SelectObject(mem, room->textFont); SelectObject(mem, room->textFont);
int row_h = client.bottom / 16; // wider than the old middle-half: the right column now carries
int top = client.bottom / 4; // full catalog names, and in football three of them
int left = client.right / 4; int left = client.right / 6;
int right = 3 * client.right / 4; int right = client.right - client.right / 6;
char text[128]; char text[128];
//---------------------------------------------------------------
// The buttons are laid out upward from the bottom edge while the
// roster runs down from the top, so everything between the title
// and the topmost button has to share one band.
//---------------------------------------------------------------
int ceiling = client.bottom;
if (FootballLobby() && room->teamRect.top > 0)
{
ceiling = room->teamRect.top;
}
else if (IsOwner() && room->launchRect.top > 0)
{
ceiling = room->launchRect.top;
}
else if (room->leaveRect.top > 0)
{
ceiling = room->leaveRect.top;
}
//---------------------------------------------------------------
// Size the rows to that band rather than to a fixed fraction of
// the window: a full lobby is eight players, and eight rows at a
// sixteenth each would run off the bottom of every window we
// support. Counted in half rows, the band holds
//
// setup lines + gap + kMaxLobbyMembers rows + gap + hint
//
// so the row height falls out of the space actually available.
// It never grows past the old sixteenth (a two-player lobby
// should not have circus-sized rows) and never shrinks below the
// font, which is what would actually break - overlapping text.
//---------------------------------------------------------------
// tmHeight already includes the font's own leading, so a row that
// tall cannot clip or collide - asking for more than that just
// costs setup lines the room would rather keep.
TEXTMETRICA metrics;
GetTextMetricsA(mem, &metrics);
int min_row = metrics.tmHeight + 2;
int max_row = client.bottom / 16;
int setup_top = title.bottom + client.bottom / 64;
int band = ceiling - setup_top;
int setup_lines = 2;
int row_h = 0;
for (;;)
{
// halves: setup, half gap, roster, half gap, hint
int halves = 2 * setup_lines + 1 + 2 * kMaxLobbyMembers + 1 + 2;
row_h = (2 * band) / halves;
if (row_h >= min_row || setup_lines == 0)
{
break;
}
--setup_lines; // buy room back from the setup lines
}
if (row_h > max_row) row_h = max_row;
if (row_h < 12) row_h = 12;
//---------------------------------------------------------------
// On a window too short to give eight rows the room the standard
// font wants, draw this block smaller rather than letting the
// rows overlap each other or run under the buttons. Nobody plays
// at that size, but a squeezed roster still has to be readable.
//---------------------------------------------------------------
HFONT squeezed = NULL;
HFONT previous = NULL;
if (row_h < min_row)
{
squeezed = CreateFontA(-(row_h * 2 / 3), 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_TT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
FIXED_PITCH | FF_MODERN, "Consolas");
if (squeezed != NULL)
{
previous = (HFONT) SelectObject(mem, squeezed);
}
}
int top = setup_top;
const char *map_name = LobbyText(kMapKey);
if (map_name[0] != '\0' && setup_lines > 0)
{
RECT setup;
setup.left = client.right / 12;
setup.right = client.right - client.right / 12;
setup.top = setup_top;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenBright);
DrawTextA(mem, map_name, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
//-----------------------------------------------------------
// Conditions on the line below. Game length is the host's
// pick too, and the one people ask about first.
//-----------------------------------------------------------
const char *time_name = LobbyText(kTimeKey);
const char *weather_name = LobbyText(kWeatherKey);
const char *length_name = LobbyText(kLengthKey);
text[0] = '\0';
if (time_name[0] != '\0')
{
strcat(text, time_name);
}
if (weather_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, weather_name);
}
if (length_name[0] != '\0')
{
if (text[0] != '\0') strcat(text, " - ");
strcat(text, length_name);
}
if (text[0] != '\0' && setup_lines > 1)
{
setup.top = setup.bottom;
setup.bottom = setup.top + row_h;
SetTextColor(mem, kGreenDim);
DrawTextA(mem, text, -1, &setup,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
}
if (setup.bottom + row_h / 2 > top)
{
top = setup.bottom + row_h / 2;
}
}
for (int i = 0; i < room->memberCount; ++i) for (int i = 0; i < room->memberCount; ++i)
{ {
MemberInfo *member = &room->members[i]; MemberInfo *member = &room->members[i];
@@ -367,27 +536,29 @@ namespace
is_owner_row ? " [HOST]" : ""); is_owner_row ? " [HOST]" : "");
DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE); DrawTextA(mem, text, -1, &row, DT_LEFT | DT_VCENTER | DT_SINGLELINE);
//-----------------------------------------------------------
// The right column is what this player brings. Football is a
// team sheet - team colors and position - but the VTV still
// decides how they play it, so name it there too. Keys
// travel on the wire; the catalogs turn them back into
// names, so nobody reads "bttlbrg".
//-----------------------------------------------------------
if (FootballLobby()) if (FootballLobby())
{ {
// football: the roster is a team sheet
if (member->team[0] != '\0') if (member->team[0] != '\0')
{ {
const char *position_name = "?"; sprintf(text, "%s - %s - %s",
for (int i = 0; i < RPL4FrontEnd_PositionCount(); ++i) RPL4FrontEnd_VehicleNameForKey(member->vehicle),
{ RPL4FrontEnd_TeamNameForKey(member->team),
if (strcmp(member->position, RPL4FrontEnd_PositionKey(i)) == 0) RPL4FrontEnd_PositionNameForKey(member->position));
{
position_name = RPL4FrontEnd_PositionName(i);
break;
}
}
sprintf(text, "%s - %s", member->team, position_name);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE); DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
} }
} }
else if (member->vehicle[0] != '\0') else if (member->vehicle[0] != '\0')
{ {
sprintf(text, "%s / %s", member->vehicle, member->color); sprintf(text, "%s - %s",
RPL4FrontEnd_VehicleNameForKey(member->vehicle),
member->color);
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE); DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
} }
} }
@@ -403,6 +574,13 @@ namespace
: "Waiting for the host to launch...", : "Waiting for the host to launch...",
-1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE); -1, &hint, DT_CENTER | DT_VCENTER | DT_SINGLELINE);
// back to the room font for the buttons, which have their own room
if (squeezed != NULL)
{
SelectObject(mem, previous);
DeleteObject(squeezed);
}
HBRUSH frame = CreateSolidBrush(kGreenBright); HBRUSH frame = CreateSolidBrush(kGreenBright);
// //
@@ -742,6 +920,18 @@ Logical
return SteamNetTransport_GetFakeAddressString()[0] != '\0'; 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 Logical
RPL4Lobby_InRoom() RPL4Lobby_InRoom()
{ {
+9
View File
@@ -33,6 +33,15 @@ enum RPL4LobbyOutcome
Logical Logical
RPL4Lobby_Available(); 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). // True while we sit in a lobby (races return to the room).
Logical Logical
RPL4Lobby_InRoom(); RPL4Lobby_InRoom();
+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 ## Rebinding
`bindings.txt` sits beside the exe, written with the full default layout `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 Addresses: `0x00``0x47` are the lamp buttons above, `0x50``0x6F` the
keypads. Axis names: `Throttle`, `LeftPedal`, `RightPedal`, `JoystickX`, 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`, for the digit row, `F1``F12`, `NumPad0``NumPad9`, `Up`, `Down`,
`Left`, `Right`, `Space`, `Return`, `Shift`, `Ctrl`, `Alt`, `Left`, `Right`, `Space`, `Return`, `Shift`, `Ctrl`, `Alt`,
`OemMinus`, `Oemplus`, `Oemcomma`, `OemPeriod`, `OemOpenBrackets`, `OemMinus`, `Oemplus`, `Oemcomma`, `OemPeriod`, `OemOpenBrackets`,
File diff suppressed because one or more lines are too long
+103 -4
View File
@@ -76,6 +76,28 @@ foreach ($pair in @(
} }
Set-Content -Path "$dist\CONTROLS.txt" -Encoding ascii -Value $controls Set-Content -Path "$dist\CONTROLS.txt" -Encoding ascii -Value $controls
# The same map as a page, for anyone who would rather look at the
# diagrams than read them. Its source is the published artifact, which is
# a fragment - the publisher supplies the document shell - so wrap it to
# stand alone: without a doctype the browser drops into quirks mode, and
# without a charset the typography arrives as mojibake. Written without a
# BOM so the charset declaration is the only thing speaking.
$controlsPage = Get-Content (Join-Path $root 'docs\rp412-controls.html') -Raw -Encoding UTF8
$page = @"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
$controlsPage
</body>
</html>
"@
[System.IO.File]::WriteAllText(
"$dist\CONTROLS.html", $page, (New-Object System.Text.UTF8Encoding $false))
# --- OpenAL runtime -------------------------------------------------------- # --- OpenAL runtime --------------------------------------------------------
# The exe links OpenAL32.dll (32-bit). Prefer shipping the already-installed # The exe links OpenAL32.dll (32-bit). Prefer shipping the already-installed
# runtime beside the exe; oalinst.exe covers machines where that misses. # runtime beside the exe; oalinst.exe covers machines where that misses.
@@ -144,6 +166,28 @@ L4PLASMA=SCREEN
# 480x640 - decoded exactly as the pod's VDB split them, no downscale). # 480x640 - decoded exactly as the pod's VDB split them, no downscale).
L4MFDSPLIT=1 L4MFDSPLIT=1
# The game window - and in the exploded view (L4MFDSPLIT=2) each display
# window - is placed fresh every launch, so moving one somewhere useful
# never survived the menu-race-menu loop. This remembers where you put
# them, in mfd_layout.cfg beside this file:
# off / 0 / unset computed placement only, no file (default)
# load put the windows back where they were saved
# save the same, and re-save on every finished drag
# The game window gets its size back too, so you can size the cockpit to
# suit your monitor once and keep it. The display windows get position
# only: their size follows their content and their button banks, so an
# old one is never restored over them. Arrange everything once with
# save, then leave it on load.
#
# The plasma display window takes part too, under "Plasma Display".
#
# Each line in mfd_layout.cfg reads <title>=<x>,<y>,<w>,<h>, and you can
# append ,noframe to take that window's title bar and border off - a
# cockpit that fills the monitor edge to edge without -fit taking the
# whole screen. Put the window where you want it first: a bare window
# has nothing to drag by. Delete the flag to get the frame back.
#RP412MFDLAYOUT=off
# Size of the six secondary displays in the glass cockpit, as a # 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; # 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 # on a big panel there is room to trade viewscreen for instrument, so
@@ -194,6 +238,36 @@ L4RADARSCALE=100
# both of them, and it grows from the middle in both directions). # both of them, and it grows from the middle in both directions).
L4RADARPOS=CENTER 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 # Simulation/render frame rate, integer frames/second. The desktop
# default is 60; the arcade pods shipped at 25. # default is 60; the arcade pods shipped at 25.
TARGETFPS=60 TARGETFPS=60
@@ -304,14 +378,36 @@ cd /d "%~dp0"
start rpl4opt.exe -fit 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 @" Set-Content -Path "$dist\README.txt" -Encoding ascii -Value @"
Red Planet 4.12.3 Red Planet 4.12.7
================= =================
Run start-fullscreen.bat for borderless over the whole monitor, or Run start-fullscreen.bat for borderless over the whole monitor, or
start-windowed.bat to keep a title bar. No cockpit hardware needed. start-windowed.bat to keep a title bar. No cockpit hardware needed.
If there is no sound, run oalinst.exe once. 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 Controls (XInput controller and/or keyboard) - EVERY input is
rebindable: edit bindings.txt beside the exe (written with the full rebindable: edit bindings.txt beside the exe (written with the full
documented default layout on first run; delete it to restore). documented default layout on first run; delete it to restore).
@@ -352,8 +448,11 @@ pod's real button banks: click them with the mouse, and they light up
as the game commands their lamps. as the game commands their lamps.
environ.ini is self-documenting: every option ships in the file with environ.ini is self-documenting: every option ships in the file with
a comment (Steam networking, keyboard lighting, stick inversion, LAN a comment (Steam networking, keyboard lighting, stick inversion, LAN
hosting, developer keys, and more). CONTROLS.txt has the full controls hosting, developer keys, display scaling and radar placement, and more).
map with pad and keyboard diagrams.
CONTROLS.html is the full controls map - open it in a browser for the
pad, keyboard and pod-panel diagrams. CONTROLS.txt is the same thing as
plain text.
Known prototype notes: pods race untextured (the player1-8 skins come Known prototype notes: pods race untextured (the player1-8 skins come
from the presets system, not shipped data), and text drawn on the plasma from the presets system, not shipped data), and text drawn on the plasma
@@ -367,7 +466,7 @@ $size = (Get-ChildItem $dist -Recurse | Measure-Object Length -Sum).Sum
Write-Host ("dist ready: {0:N1} MB" -f ($size / 1MB)) Write-Host ("dist ready: {0:N1} MB" -f ($size / 1MB))
if ($Zip) { if ($Zip) {
$zipPath = Join-Path $root 'RedPlanet-4.12.3.zip' $zipPath = Join-Path $root 'RedPlanet-4.12.7.zip'
Write-Host "zipping to $zipPath..." Write-Host "zipping to $zipPath..."
# Everything lives under a single RP412\ folder inside the zip, so # Everything lives under a single RP412\ folder inside the zip, so