Files
RP412/RP_L4/RPL4.CPP
T
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

551 lines
18 KiB
C++

////===========================================================================//
//// File: rp4lbe4.cc //
//// Project: MUNGA Brick: Red Planet LBE Application //
//// Contents: //
////---------------------------------------------------------------------------//
//// Date Who Modification //
//// -------- --- ---------------------------------------------------------- //
//// //
////---------------------------------------------------------------------------//
//// Copyright (C) 1994, Virtual World Entertainment, Inc. //
//// All Rights reserved worldwide //
//// This unpublished sourcecode is PROPRIETARY and CONFIDENTIAL //
////===========================================================================//
//
#define WIN32_LEAN_AND_MEAN
#define _WIN32_WINNT 0x0500
#define WINVER 0x0500
#include <tchar.h>
#include "rpl4.h"
#pragma hdrstop
#include "rpl4pb.h"
#include "rpl4fe.h"
#include "rpl4console.h"
#include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h"
#include "..\munga_l4\l4splr.h"
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h"
#include "..\munga\resver.h"
#include "..\munga\resource.h"
// added for game status drawing support
#include "..\munga\player.h"
#include "..\munga\mission.h"
#include "..\munga_l4\l4ctrl.h"
// end add
#include <DbgHelp.h>
#include <strsafe.h>
#include <direct.h>
#include <shellapi.h>
#define SPOOL_SIZE 0x600000
//#define SCREEN_WIDTH 1024
//#define SCREEN_HEIGHT 768
Byte version[3] = {MAJOR_DATA_VERSION, RELEASE_VERSION, MINOR_DATA_VERSION};
char* names[] = {"InitializingState",
"WaitingForEgg",
"LoadingMission",
"WaitingForLaunch",
"LaunchingMission",
"RunningMission",
"EndingMission",
"StoppingMission",
"SuspendingMission",
"ResumingMission",
"AbortingMission",
"CreatingMission",
"ApplicationStateCount"};
//##########################################################################
//################## RP4L4Application Globals ########################
//##########################################################################
extern const char* const ProgName;
const char* const ProgName = "rpl4";
Application *rpl4App;
HWND hWnd;
//
// Unhandled-exception minidump: rpl4crash.dmp lands beside the exe so
// a crash on a test machine hands back a stack. dbghelp loads lazily -
// no link-time dependency.
//
static LONG WINAPI RPL4CrashDumpFilter(EXCEPTION_POINTERS *exception)
{
HMODULE dbghelp = LoadLibraryA("dbghelp.dll");
if (dbghelp != NULL)
{
typedef BOOL (WINAPI *MiniDumpWriteDumpFn)(
HANDLE, DWORD, HANDLE, MINIDUMP_TYPE,
PMINIDUMP_EXCEPTION_INFORMATION,
PMINIDUMP_USER_STREAM_INFORMATION,
PMINIDUMP_CALLBACK_INFORMATION);
MiniDumpWriteDumpFn write_dump =
(MiniDumpWriteDumpFn) GetProcAddress(dbghelp, "MiniDumpWriteDump");
if (write_dump != NULL)
{
HANDLE file = CreateFileA("rpl4crash.dmp", GENERIC_WRITE, 0,
NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (file != INVALID_HANDLE_VALUE)
{
MINIDUMP_EXCEPTION_INFORMATION dump_info;
dump_info.ThreadId = GetCurrentThreadId();
dump_info.ExceptionPointers = exception;
dump_info.ClientPointers = FALSE;
write_dump(GetCurrentProcess(), GetCurrentProcessId(), file,
MiniDumpNormal, &dump_info, NULL, NULL);
CloseHandle(file);
}
}
}
return EXCEPTION_CONTINUE_SEARCH;
}
LRESULT CALLBACK WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CLOSE:
DestroyWindow(hWnd);
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
case WM_SYSCOMMAND:
// Alt is a flight control (reverse thrust) - releasing it must
// not pop the window menu and steal keyboard focus
if ((wParam & 0xFFF0) == SC_KEYMENU)
{
return 0;
}
break;
case WM_EXITSIZEMOVE:
//
// A drag or resize just finished. Here rather than in the
// cockpit's subclass so it also covers the console screen, which
// is where the window gets moved before there is any cockpit to
// subclass. No-op unless RP412MFDLAYOUT is save.
//
RPWindowLayout_Save();
return 0;
}
return DefWindowProc(hWnd, uMsg, wParam, lParam);
}
int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
char filename[50];
strcpy_s(filename, 50, "rpl4.log");
std::ofstream logfile;
logfile.open(filename);
std::cout.rdbuf(logfile.rdbuf());
SetUnhandledExceptionFilter(RPL4CrashDumpFilter);
// load up our environment variables
//controls
if(getenv("L4CONTROLS") == NULL)
putenv("L4CONTROLS=KEYBOARD");
if(getenv("DPLARG") == NULL)
putenv("DPLARG=1");
if(getenv("L4DPLCFG") == NULL)
putenv("L4DPLCFG=RPDPL.INI");
if(getenv("MULTISAMPLE") == NULL)
putenv("MULTISAMPLE=0");
if(getenv("TARGETFPS") == NULL)
putenv("TARGETFPS=60");
if(getenv("MAXPARTICLES") == NULL)
putenv("MAXPARTICLES=8192");
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
{
while (!feof(file))
{
if (fgets(line, sizeof(line), file))
{
for (int i = strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r')
line[i] = 0;
// the file is self-documenting: skip comments, blanks,
// and anything that is not KEY=VALUE
char *setting = line;
while (*setting == ' ' || *setting == '\t')
++setting;
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
continue;
putenv(setting);
}
}
fclose(file);
}
DEBUG_STREAM << "Red Planet 4.12.6" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
#ifdef RP412_STEAM
//
// RP412STEAM=1 (environ.ini or a Steam launch) swaps the wire to
// ISteamNetworkingSockets. Failure logs the reason and the game
// carries on over TCP - dev boxes without Steam are unaffected.
//
{
const char *steam_switch = getenv("RP412STEAM");
if (steam_switch != NULL && atoi(steam_switch) != 0)
{
SteamNetTransport_Install();
}
}
#endif
// GetProcessAffinityMask(GetCurrentProcess(),
DWORD_PTR res = SetThreadAffinityMask(GetCurrentThread(), (DWORD_PTR)1);
if (res == 0)
{
DWORD err = GetLastError();
DEBUG_STREAM << "Could not set thread affinity. Err Code = " << err << std::endl << std::flush;
}
//
//----------------------------------------------------------------------
// Parse command line for an egg notation file name
//----------------------------------------------------------------------
//
int argc;
LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc);
Logical run_application = RPL4Application::ParseCommandLine(argc, argv);
if (!run_application)
{
return 1;
}
else
{
Start_Registering();
}
//
//-------------------------------------------------------------------------
// Create our window. This is only temporary because this will move after
// the graphics are moved over.
//-------------------------------------------------------------------------
//
WNDCLASS wc;
if (!hPrevInstance)
{
wc.style = 0;
wc.lpfnWndProc = (WNDPROC) WndProc;
wc.cbClsExtra = 0;
wc.cbWndExtra = 0;
wc.hInstance = hInstance;
wc.hIcon = LoadIcon((HINSTANCE) NULL, IDI_APPLICATION);
wc.hCursor = LoadCursor((HINSTANCE) NULL, IDC_ARROW);
wc.hbrBackground = (HBRUSH)GetStockObject(BLACK_BRUSH);
wc.lpszMenuName = NULL;
wc.lpszClassName = L"MainWndClass";
if (!RegisterClass(&wc))
return FALSE;
}
DWORD wsStyle = WS_OVERLAPPED | WS_SYSMENU;
if (L4Application::GetFullscreen())
wsStyle = WS_POPUP;
hWnd = CreateWindowEx(0, L"MainWndClass", L"RPL4", WS_OVERLAPPEDWINDOW, 0, 0, L4Application::GetScreenWidth(), L4Application::GetScreenHeight(), (HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd)
return FALSE;
ShowWindow(hWnd, nShowCmd);
//
// Sticky placement, from the first frame the player sees. SVGA16 also
// registers and reloads when it builds the cockpit, but that is not
// until a mission starts - without this the console screen would come
// up at the default rect with its title bar still on, and only jump
// to the saved placement once a race began. -fit takes the whole
// monitor and has no placement of the player's to restore.
//
if (!L4Application::GetFitDisplay() && !L4Application::GetFullscreen())
{
RPWindowLayout_Register(hWnd, "RPL4", True);
RPWindowLayout_Load();
}
//
//-------------------------------------------------------------------------
// RP412JOYCONFIG=1 (joyconfig.bat): the joystick setup wizard, before
// anything else claims the screen. It asks the player to move each
// control on their stick, HOTAS or pedals and writes the joy* rows of
// bindings.txt, then falls through into the game so they can try them
// straight away.
//
// One shot: the variable is cleared from this process so a rebuilt
// PadRIO later in the session cannot run the wizard a second time.
//-------------------------------------------------------------------------
//
{
const char *joyconfig = getenv("RP412JOYCONFIG");
if (joyconfig != NULL && *joyconfig != '\0' && atoi(joyconfig) != 0)
{
RPJoyConfigWizard();
SetEnvironmentVariableA("RP412JOYCONFIG", NULL);
_putenv("RP412JOYCONFIG=");
}
}
#if !_DEBUG
// Arcade pods have no mouse - but desktop/windowed play needs the
// cursor for the on-screen cockpit buttons, so hide it only when
// running fullscreen.
if (L4Application::GetFullscreen())
{
ShowCursor(FALSE);
}
#endif
//
//-------------------------------------------------------------------------
// Front-end mode: started without an egg, a network, or mission review.
// The race loop below keeps everything in ONE process - setup screen,
// mission, teardown, and back to the setup screen - with the local
// console marshaling each race. (-egg / -net / -mr run a single pass,
// exactly as before.)
//-------------------------------------------------------------------------
//
Logical front_end_mode =
L4Application::GetMissionReviewMode() == 0 &&
L4Application::GetNetworkCommonFlatAddress() == 0 &&
!L4Application::GetEggNotationFileName(); // no egg on the command line
int last_launch_mode = FELaunchSingle;
for (;;)
{
if (front_end_mode)
{
if (!IsWindow(hWnd))
{
break; // the cockpit window is gone (closed mid-race)
}
// drain any WM_QUIT left over from the previous mission's
// teardown so it cannot instantly close the setup screen
MSG leftover;
while (PeekMessage(&leftover, NULL, WM_QUIT, WM_QUIT, PM_REMOVE))
{
}
// lobby races: the owner publishes the score sheet, members
// pull it - the same results screen shows on every machine
RPL4Lobby_PushRaceResults();
RPL4Lobby_PullRaceResults();
// last race's final scores first (no-op on first boot)
if (!RPL4FrontEnd_ShowResults(hInstance, hWnd))
{
break;
}
static char frontend_egg[MAX_PATH];
if (!RPL4FrontEnd_Run(hInstance, hWnd, frontend_egg, sizeof(frontend_egg)))
{
break; // player closed the menu without launching
}
last_launch_mode = RPL4FrontEnd_LastLaunchMode();
Exit_Code = 0;
if (last_launch_mode == FELaunchMember)
{
//
// Lobby member: enter the race as a network pod. The
// owner's console connects over the wire, feeds the egg,
// and marshals us - no local egg, no local console. If
// the owner leaves mid-race the mission ends (a race
// with no console never stops on its own).
//
L4Application::SetNetworkCommonFlatAddress(1501);
gConsoleLossEndsMission = True;
}
else
{
gConsoleLossEndsMission = False;
L4Application::SetEggNotationFileName(frontend_egg);
// Front-end games are marshaled by the in-process console:
// it ends the race when the selected time expires.
// (Hand-fed -egg runs stay unmarshaled - the developer
// shortcut.)
//
// RP412HOSTPODS turns the launch into a hosted network
// race: this pod switches to network mode (it meshes like
// any pod) and the console also marshals the listed member
// pods over the wire. The Steam lobby primes the same path.
const char *host_pods = getenv("RP412HOSTPODS");
if (host_pods != NULL && host_pods[0] != '\0')
{
int host_port = 1501;
const char *port_override = getenv("RP412HOSTPORT");
if (port_override != NULL && atoi(port_override) > 0)
{
host_port = atoi(port_override);
}
L4Application::SetNetworkCommonFlatAddress(host_port);
if (!RPL4LocalConsole_InstallNetworkRace(
RPL4FrontEnd_LastMissionSeconds(), frontend_egg,
host_pods, RPL4FrontEnd_LastPilotNames()))
{
DEBUG_STREAM << "Network race setup failed - back to the menu\n" << std::flush;
L4Application::SetNetworkCommonFlatAddress(0);
continue;
}
}
else
{
L4Application::SetNetworkCommonFlatAddress(0);
RPL4LocalConsole_Install(RPL4FrontEnd_LastMissionSeconds());
}
}
}
//
//-------------------------------------------------------------------------
// Open the resource file. The brace will help correct the scoping problem
// of destruction the resource file
//-------------------------------------------------------------------------
//
{
StreamableResourceFile resources("rpl4.res", version);
Check(&resources);
//
//----------------------------------------------
// Create and initialize the application manager
//----------------------------------------------
//
ApplicationManager* app_manager;
Application *new_app;
int review_mode = RPL4Application::GetMissionReviewMode();
if (review_mode > 0)
{
MissionReviewApplicationManager *spool_mgr = new MissionReviewApplicationManager(hInstance, hWnd, atoi(getenv("TARGETFPS")), (review_mode == 2) ? 1 : 2, SPOOL_SIZE);
app_manager = spool_mgr;
Register_Object(app_manager);
//
//-------------------------------------------------------------------
// Create and initialize the playback application if there is a spool
// file
//-------------------------------------------------------------------
//
#if 1
CString spoolFileName = L4Application::GetSpoolFileName();
if (!spoolFileName)
spoolFileName = "last.spl";
SpoolFile *spool = spool_mgr->GetStoredSpoolFile(spoolFileName);
if (spool)
{
new_app = new RPL4PlaybackApplication(hInstance, hWnd, &resources, spool);
} else
{
new_app = new RPL4IdleApplication(hInstance, hWnd, &resources);
}
Register_Object(new_app);
app_manager->StartApplication(new_app);
#else
new_app = new RPL4PlaybackApplication(hInstance, hWnd, &resources, NULL);
Register_Object(new_app);
app_manager->StartApplication(new_app);
#endif
//
//------------------------------------
// Initialize the spooling application
//------------------------------------
//
if (review_mode == 1)
{
new_app = new L4SpoolingApplication(hInstance, hWnd, &resources, RPL4);
Register_Object(new_app);
app_manager->StartApplication(new_app);
}
}
else
{
app_manager = new ApplicationManager(hInstance, hWnd, atoi(getenv("TARGETFPS")));
Register_Object(app_manager);
//
//--------------------------------------
// Create and initialize the application
//--------------------------------------
//
Application *new_app = new RPL4Application(hInstance, hWnd, &resources);
rpl4App = new_app;
Register_Object(new_app);
SetCursorPos(1680 / 2, 1050 / 2);
app_manager->StartApplication(new_app);
}
//
//--------------------------
// Run missions till we done
//--------------------------
//
app_manager->RunMissions();
rpl4App = NULL;
Unregister_Object(app_manager);
delete app_manager;
}
//
//-------------------------------------------------------------------------
// Single-binary race loop: if the local console ended this mission
// (or we raced as a lobby member under the owner's console), go
// back to the setup screen / lobby room for the next one; anything
// else (user quit, -egg / -net / -mr single pass) falls out.
//-------------------------------------------------------------------------
//
if (!front_end_mode ||
(last_launch_mode != FELaunchMember && !RPL4LocalConsole_MissionCompleted()))
{
break;
}
}
//
// Last word on the window's placement. Every finished drag has already
// written it and SVGA16 writes again as the cockpit comes down, but a
// session that never started a race has neither - and quitting from
// the console screen is exactly how somebody would leave after moving
// the window to where they want it. No-op unless the mode is save.
//
if (IsWindow(hWnd))
{
RPWindowLayout_Save();
}
RPWindowLayout_Forget(hWnd);
#if !_DEBUG
// symmetric with the fullscreen-only hide at startup
if (L4Application::GetFullscreen())
{
ShowCursor(TRUE);
}
#endif
Stop_Registering();
return Exit_Code;
}