4410febd4c1c4e8e37f8b27d900d6fe550985e9d
72
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4410febd4c |
The particle engine hands the device back at the end of the race
A fresh renderer is built per mission, and the particle engine's vertex buffer is D3DPOOL_DEFAULT with a texture to match - both bound to the device that made them. Initialize overwrote the two pointers with the new device's resources without releasing the old ones, so the old device kept a reference from resources nothing could reach any more. ~DPLRenderer's SAFE_RELEASE(mDevice) therefore never took it to zero. Every race left a whole live device behind it - back buffer, depth buffer and all, at whatever the render target is, which on the tester's machine is 2560x1440. The next race's Initialize was the only thing that ever let one go, so quitting from the front end let it go never. Measured rather than assumed, with a standalone test using the same pool and usage: release the device with the buffer outstanding and it reports 1 reference left, still alive. Release the buffer first and it reports 0. Three parts to it: - Destroy is null-safe now, and clears what it drops. It was neither, and it runs on the device-lost path AHEAD OF A RESET - so a texture that never loaded, which a missing VIDEO\particles.png is enough to cause, took the Reset down with it. A released pointer left in place is a dangling one the moment anything looks again. - Initialize calls it first. The device-lost path already released before re-initialising; this is the same contract for the case where the device is not lost but REPLACED, which is what a new race is. - ~DPLRenderer calls it before releasing the device, next to the texture cache flush that is there for exactly this reason and had missed this one. The device now dies with the mission that made it. Destroy clearing mDevice is what makes the gap between it and the next Initialize safe: the paint paths already test that pointer before they touch anything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3482de5147 |
The renderer stops when it cannot get a device
PostQuitMessage is a message, not a return. The fallback CreateDevice posted one and then carried straight on into the Clear below it, so a machine that could not give us the mode we asked for dereferenced a device that was never created and died on an access violation instead of saying what had happened. The quit message it had just posted would not be read until someone pumped the queue, which by then nobody would. Both attempts are now judged once, and the line names the size that was REFUSED. That is the question this failure raises rather than an incidental detail: the back buffer is the requested size windowed as well as full-screen since the render target went back to being the size that was asked for, so a request the adapter will not meet is the first thing to look at. DPLRenderer: no D3D device for a 2560x1440 windowed back buffer (hr=0x8876086c) - giving up mDevice was also never in the initialiser list, so until CreateDevice wrote it the member held whatever was on the stack - and the mPrimaryIndex bail-out above has always returned through that into the destructor's SAFE_RELEASE. It is nulled before either exit can be taken. Found while reading the constructor for an unrelated crash, which turned out to be on another thread. This one is latent - no report of it yet - but it is the difference between a tester sending a dump and a tester sending a line that says what to fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
de9a163f37 |
The lamp worker clears its factory cache before the apartment goes
A tester's second race died on an access violation with nothing in the
log after the monitor setup, which is only where the MAIN thread had got
to - the fault was on another thread entirely, and the crash filter
writes no line of its own, so the truncation named the wrong suspect.
The dump named the right one. Thread 19, inside the Dynamic Lighting
worker, calling through a vtable at an address that lm shows falling in
the GAP between two loaded modules - an unloaded DLL, not corruption:
rpl4opt!...ILampArrayStatics::GetDeviceSelector+0x23
[inlined in rpl4opt!`anonymous namespace'::Worker+0x121]
call dword ptr [eax+18h] ds:002b:6fd72eb8=????????
C++/WinRT caches an activation factory the first time a type is used and
that cache is PROCESS-wide. The apartment is not: the worker init'd one,
asked LampArray for its device selector, and exited without clearing the
cache, so COM tore the apartment down at thread detach and unloaded the
Lights server with it - nothing else in the process held a reference.
The cached pointer stayed, aimed at an address range that no longer had
a module in it. The next race started a fresh worker, which found the
cache populated, did not re-activate, and called straight through it.
So this could only ever fire on the second race, and only because the
worker is started per race - KeyLight_Start() runs from the PadRIO
constructor. A machine with no Dynamic Lighting keyboard is not spared:
asking for the device selector is enough to populate the cache, and the
tester's log says plainly that nothing was found.
The guard is RAII and declared BEFORE the DeviceWatcher, so it runs LAST
- the watcher's COM release still happens inside a live apartment. It
also covers the early return when Dynamic Lighting is unavailable, which
was the other way out of the function.
Confirmed both directions with a standalone reproducer of the same
pattern - worker thread, init_apartment, GetDeviceSelector, exit, thrice.
As shipped it dies on pass 2 with 0xC0000005, matching the dump. With
this, three passes clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
aa294071f9 |
The render target is the size that was asked for
A crosshair off-centre on the second race, and underneath it every race after the first was a different race. Windowed, BackBufferWidth/Height were left at zero, so D3D sized the back buffer to the device window's client area at the moment the device was created. Everything downstream is built from the size we ASKED for instead - the projection matrix takes its aspect from it, the reticle is centred on it - so a window that was not exactly that size rendered at the wrong shape and got rescaled on the way to the viewscreen pane. -fit decided which window that was, and it decided differently for the first mission than for the rest. Its borderless full-monitor placement lived only in SVGA16's cockpit build, which does not run until a mission starts - just after that mission has built its device. So race one was set up against a still-bordered client and every race after it against the borderless monitor. On a 3440x1440 panel that is a 1.778 image drawn across a 2.389 target, against 1.816 the first time. That is not a cosmetic difference. The simulation advances on wall-clock deltas, so frame cost is physics: two render targets that size and scale differently are two different races from one lobby and one set of settings. A racing sim does not get to do that. So: the back buffer is the requested size windowed as well as full-screen, and -fit takes its shape at startup rather than four screens later. SVGA16 still applies the same rect when it builds the cockpit - that call is now a no-op instead of a change, which is the point. The first lobby also stops being the only one with a title bar. The reticle keeps its own share of the blame and is fixed on its own terms, so it cannot drift again if a target ever does move: - It is measured against the viewport at draw time and rebuilt when that changes, rather than baked once in the constructor from the renderer's requested size. One GetViewport a frame, no rewrite until it moves. - The arms are quads, not lines. D3D9 line rasterisation follows the diamond-exit rule and is free to differ between drivers on a segment running along a pixel boundary, which is how a crosshair loses one pair of arms and keeps the other - and full-screen, where both dimensions are usually even and both pairs sit on boundaries, how it can lose the lot. - Arm thickness follows the target rather than being one pixel whatever the resolution. One pixel is a width the presentation can throw away in a downscale, and it was a hairline at 1440 next to the pod's line at 480. The log names the viewport, the requested size and where the crosshair landed, and says TARGET DISAGREES with both aspects when the first two do not match - so the next report of this arrives with its own diagnosis. Window creation cleaned up while in there: it computed a style and then handed CreateWindowEx a literal WS_OVERLAPPEDWINDOW regardless, so the full-screen path never got the WS_POPUP it thought it was asking for. Borderless modes are now born borderless instead of being restyled a moment after. The requested size also goes through AdjustWindowRect, because -res is a render size and was being used as the OUTER rectangle with the chrome taken out of the middle - which is how -res 640 480 came to present into a 624x441 client and started all of this. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d35af59136 |
The joystick wizard works out the shape of your pedals
You are never asked what you own. Two controls cannot simply be watched, so they are asked for differently. Yaw is asked for twice, right then left, and which axis answers is the measurement. The same axis both times is one control covering both directions - a twist grip, a rudder bar, pedals the driver has already mixed - and binds to the signed Pedals axis. Two different axes are two real pedals, one per foot, which is what the pod had, so they bind to the pod's own LeftPedal/RightPedal pair and the game does the mixing: both at once then does what both at once did in the pod. The throttle is zeroed first. A lever sits wherever it was last left, possibly hard against the stop that reads +1, so watching it move says nothing about which end means power. Close it, press SPACE, then open it, and the direction it travels from a known idle is the direction that means throttle. CONTROLS.md, the handbook and the packaged README say all of this, and joyconfig.bat's own header no longer promises "rudder-pedal setup" when racing pedals work too. |
||
|
|
12f9ebefab |
A quiet sound at the wrong distance no longer kills the game
From Nathan's crash dump: an access violation reading 8093e920, fourteen minutes into a session, on 4.12.115. rpl4opt!PatchLevelOfDetail::SetupPatch+0xbb rpl4opt!Static3DPatchSource::StartImplementation+0x50 rpl4opt!AudioRenderer::ExecuteBackground+0x9e The faulting instruction is g_buffers[index] with index = 0x20000000 - 536 million - and the array base in eax at 0093e920, which is exactly the address it died on. So the index was garbage, and the dump says where the garbage came from: the stack slot holding info.bufferIndex. PRESET_getSampleInfo builds a SAMPLEINFO to return when it is asked for a zone the preset does not have. It sets chan, file, implemented and loop - and not bufferIndex. Every caller tests bufferIndex >= 0 before using it, so "no such zone" was meant to be rejected there; instead the test read whatever was on the stack, and passed whenever that happened to be positive. AL_getBuffer then indexed the array with it, unchecked. Why it asked for a zone that is not there: the loop runs to sourceSet.count, which was fixed when the audio source was built, from whichever level of detail was selected at the time. SetDistance re-picks the level of detail by distance on the line immediately before SetupPatch runs, and the zone counts across the recovered banks are nothing like uniform - of 200 presets, 46 have no zones at all, and the rest run 1 to 4. So a sound that moved far enough to drop to a quieter patch could ask that patch for a zone it never had. In the dump: count 3, died asking for zone 2. Fixed at all three levels, because any one of them alone would have held: the default carries bufferIndex = -1 so the existing guard works, AL_getBuffer returns AL_NONE rather than reading past its array, and SetupPatch asks for no more zones than the patch it is actually using has. Verified: the dump's own numbers reproduce arithmetically, and two full races run clean. The distance-dependent trigger itself was reasoned from the dump rather than reproduced here - it needs a sound to cross a level of detail boundary into a shorter patch - so the belt-and-braces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
943a15cef4 |
A second race no longer takes the stack with it
Reported by a tester and reproduced here: finish a race, come back to the
lobby, start another, and the game dies a few seconds in.
It is a stack overflow, from CockpitShellProc calling itself. The cockpit
subclasses the game window to catch WM_SIZE and re-fit the canvas, and
kept SetWindowLongPtr's return as the proc to chain on to. But the game
window is not the cockpit's - it outlives it, and carries the console
screen from one race to the next - and nothing ever unsubclassed it. So
the second race subclassed an already-subclassed window, SetWindowLongPtr
handed back CockpitShellProc itself as the "original", and from the next
message onwards the proc chained to itself until the stack ran out.
Nothing in the log, because nothing in the game had gone wrong yet.
So the destructor puts the window's own proc back, and the install site
will not subclass the same window twice even if it could not.
While there: the destructor also left activeCockpit pointing at the
object it had just freed, so GetCockpit() handed CockpitShellProc a dead
cockpit to lay out. Harmless until someone resized or maximised the
window at the lobby between races, which is not a hard thing to do. Now
cleared.
This came in with the cockpit resize work in
|
||
|
|
25e25260b1 |
Home and End are the bass knob
The volume keys wanted a partner, and the bass trim could not be one as it stood: it scaled the sample data as it loaded, so by the time anyone pressed a key the audio was already sitting in OpenAL buffers and nothing short of a restart would move it. So the trim is now a per-zone gain applied in the mix instead. Each buffer's depth - how much of the low band it occupies - is still worked out once at load from its playback rate, but the trim itself is read every frame, which is what lets Home and End move it while sounds are playing. It is the better form regardless: no rewriting of sample data, and no quantisation on top of audio that has already been through one gain stage. Home raises, End lowers, in steps of 0.05, and the setting is written to bass.cfg beside the exe exactly as the volume writes volume.cfg. Together with PageUp and PageDown that is the amplifier and the crossover the cabinets had in hardware and a desktop does not. Builds clean, runs, and neither knob fires unprompted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4e8392fcfb |
PageUp and PageDown are the volume knob
The cabinets had no volume control - they ran at unity and left level to an external amplifier - so a player without that hardware had nowhere to turn it down but environ.ini and a restart. PageUp and PageDown now step the master volume by 0.05 while you play, from silent to double, and whatever you leave it on is written to volume.cfg beside the exe and used from then on. The environ.ini figure decides where a machine that has never been touched starts out; the keys are the knob, and a knob stays where it was left. Page keys because they produce no typed character, so they cannot collide with the character-keyed commands the engine already answers to, nothing else in RP binds them, and they are on every keyboard including tenkeyless. They are polled rather than read off the key-message path, which is worth recording because the message path looked like the obvious home for them and was tried first. RP's keyboard pump only takes WM_KEYUP, WM_SYSKEYUP and WM_CHAR off the front of the queue, and the front end runs message loops of its own, so key messages get raced for and lost: six deliberate, well-spaced presses arrived as two. Fine for the abort chord, useless for something you tap repeatedly to find a level. Reading key state directly costs nothing and cannot be dropped. That losses figure is a pre-existing property of the input path, not something this change introduced, and is worth knowing before anything else gets bound there. Builds clean, runs, and does not fire unprompted. The step function itself is proven - it was driven end to end through the message path before the switch, stepping the right way, clamping, and persisting. What I could not test from here is the polling trigger, because Windows would not hand the game foreground and injecting keys without it would have sprayed them across whatever else was open. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
523f713a30 |
Volume and bass knobs, for players without an amplifier
The cabinets ran the game at unity and shaped volume and tone outside it, in an external amplifier and a 3-way crossover. That is why there is no master volume anywhere in the original code and none in AUDIO.INI - an operator turned a knob on an amp. A desktop player has no amp and no crossover, and the recovered soundbanks are a good deal livelier than what 4.12 shipped with, so the game has to offer the two controls the pod got from hardware. RP412AUDIOVOLUME, 0.0 to 4.0, is the amplifier: a listener gain, which the port had never set at all. RP412AUDIOBASS, 0.0 to 1.0, is the crossover's low band. Both default to leaving the mix exactly as the pod played it, so neither changes anything for anyone who does not go looking. The bass trim is not a filter, and the reason is worth writing down: the OpenAL we ship is Creative's, not OpenAL Soft, and it implements only AL_FILTER_LOWPASS. It rejects highpass and bandpass outright. A bandpass would have been the tidy answer, carrying the authored brightness model on GAINHF and the trim on GAINLF across the single direct filter a source gets. It is not on offer. So the trim scales sample data as it loads, which suits how this low end is actually built: the weight lives in discrete deep layer zones whose per-zone tuning bakes out to a very low playback rate - thirteen zones below 8kHz, three to five octaves under their recorded pitch, against four fifths of the set at 22kHz and up. Baked rate is a dependable proxy for band, so pulling down the low-rate zones is a real low-band trim and not a blunt cut. It eases in below 22kHz and reaches full depth at 5.5kHz. Caught while building this, and the reason for the probe: EFX_Initialize checks alGetError after configuring the scratch filter, so asking for a filter type the driver refuses leaves an error pending and takes the entire bridge down - reverb included. The bandpass attempt did precisely that and would have silently killed the reverb and brightness work. Initialize now survives losing the filter and says so. Builds clean, runs with both knobs set and with neither. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e133d4c993 |
Sounds recycle their voices instead of churning through them
Recovering the soundbanks took voice demand per sound from about one zone to about two and a half, and the audio path allocated an OpenAL source for every sound event and destroyed it again on release. Sources are a hard per-context resource - this driver grants 256 - so that churn doubled at exactly the moment it got more expensive. Sources are now generated once and recycled through a free list: measured, three sources generated across twelve thousand acquisitions. The BT tree reached the same conclusion the expensive way, from field logs full of failed acquisitions: raising the source budget is not the fix, because the ceiling also acts as a governor and more voices mixing is real CPU during exactly the busiest moments. Recycling is the fix, and it costs nothing. Two older bugs were sitting underneath, both reproduced against the driver rather than assumed: Releasing a set leaked it. alDeleteSources is atomic - one bad name in the array and nothing at all is deleted. ReleaseSourceSet handed it the whole fixed-size array and then parked the slots at -1, so any partial set, and any double release, leaked every source it held. Sources are now handed back one at a time and slots park at 0, which is never a valid name. A source set began life uninitialised. The constructor set only the count, and the acquire path decided whether a slot was already filled by asking OpenAL about uninitialised stack garbage. Garbage that happened to match a live name meant two sounds silently sharing one source. Pooling would have made that more likely, not less, since it keeps small names in circulation. Recycled sources are scrubbed before parking - stopped, buffer detached, looping, gain, pitch, relative flag, position and velocity reset, and the EFX filter and reverb send dropped. Without that last part a dry cockpit sound inherits the wet send of whatever 3D source held the name before it. Verified: a deliberately dirtied source comes back clean. Builds clean. Runs with memory and handle count flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
ce1b0ab9c3 |
Sounds fade, dull and doppler with distance again
The OpenAL port kept the whole authored audio model and then threw most of
its output away. Every frame the engine computed a distance-attenuation
curve, a high-frequency rolloff, doppler cents, a reverb level and a
front/rear placement, and every one of those consumers had been commented
out when the two AWE32 cards were replaced. What reached the speakers was
OpenAL's own defaults instead: a straight-line fade to silence, no
filtering, doppler at the wrong constants with an inverted velocity, no
reverb, and every cockpit sound dead centre.
Restored, per AUDIO.INI, which is byte-identical to the file that shipped
in August 1995:
- the authored knee/rolloff distance curve, replacing AL_LINEAR_DISTANCE.
This also un-blinds the transient cull, the voice-steal weighting and
the mix ducking, which all key off it and were treating far sources as
full presence
- the CC7 squared volume law; writing the scale linearly ran everything
about 6 dB hot at mid-scale
- brightness and distance muffling, and the wet-exterior/dry-cockpit
reverb split, both through a new OpenAL EFX bridge
- doppler on the moving-source path only, as the original had it
- front/rear placement from the authored position enum
The larger find is that AL_PITCH was never called anywhere in the tree, so
the entire pitch chain was inert - not only doppler but pitch_mix_offset,
which our own sequences author 97 times. Doppler alone would have changed
nothing audible.
Note pitch is applied for parity with the BT engine but is identity here:
our content predates NoteAudioControlID, so every source runs at note 60.
Builds clean on VS2022 Release|Win32. Smoke-tested against vRIO on COM1 -
reaches gameplay and holds a steady frame loop. ALC_EXT_EFX is present on
the build machine with all nine entry points, so the filter and reverb work
is live rather than inert. Not yet listened to on the pod, which is the
real test: the volume law changes the level of everything.
docs/SOUND.md documents the original two-card quadraphonic design, where
the surviving original assets are, and what remains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
6ce729bab5 |
Lit cockpit buttons keep up with the sim
BT411's f99003c, brought across. Its playtesters reported the cockpit lighting going slow or stopping altogether while the 3D view stayed smooth, and RP412 has the same structure exactly: the on-screen vRIO buttons light themselves from PadRIO::GetLampState, but what FILLS that store is lampManager->Update() in GaugeRenderer::ExecuteForeground - once per full gauge cycle. Which is the cycle the previous commit was about. Measured on a starved frame budget it now completes 3.1 times a second, and completed 0.7 times a second before that; either way far too slow to carry a flashing lamp. So sweep the lamps once per frame from the main render instead, which runs regardless of how little frame is left over. It is cheap, and AssertNewLampValue already drops anything unchanged, so this pushes no extra traffic - it only stops changes arriving late. Only when a PadRIO is active, i.e. cockpit-less play, and only while a mission is actually running. With real serial hardware selected the pod keeps its authentic bandwidth-paced cadence, untouched. RP412LAMPSWEEP=0 restores the once-per-cycle behaviour. BT411's other half, 02ce9f5, does not apply. That one is about Windows throttling WM_TIMER and paint messages for background windows, which made the glass panels' flash crawl whenever they did not have focus. RP412 has no timer-driven repaint anywhere - the MFD windows are D3D devices presented from SVGA16::Update, and the panel strips repaint from there too - so there is no throttled message path to bypass. That path was starved rather than throttled, and the previous commit is the fix. Verified: no regression at either budget, 20.0 display sweeps/s at a normal frame budget and 3.1/s starved, both unchanged by this commit; mission runs clean. The lamp win itself is structural - the sweep is now an unconditional per-frame call - and would want a busy multiplayer mission to see directly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f4fef29428 |
The map keeps drawing when the view gets busy
Two testers reported the map and the countdown clock freezing, one of them only on larger, more complex maps, and one of them until a death. Both details point at the same place. The gauges and the cockpit displays are redrawn in whatever time is left after the 3D view. The background loop is guaranteed a single pass per frame and gets more only while time remains before the frame is due, and one pass drew exactly one gauge. So a full sweep of ninety-odd gauges needed ninety-odd passes - free when there is spare frame, but on a busy map the 3D view eats all of it, the loop drops to its one guaranteed pass, and a sweep takes ninety-odd FRAMES. Seconds. A death makes the renderer skip every static object, the budget frees up, and the backlog drains at once: the display appears to come back to life. Worse, the copy phase that follows ended after a SINGLE display, so the map - one of three - came round only every third sweep. So: draw gauges to a 2ms slice rather than one per pass, which ties the refresh rate to elapsed time instead of to how much spare frame there happened to be; and copy every display before reporting the sweep done. Measured on a deliberately starved frame budget, which reproduces the reported symptom: 0.7 sweeps/s before, 3.1 after. At a normal budget 20/s, against 18-19 before - no cost to the healthy case. RP412GAUGESLICE tunes the slice and 0 restores the old behaviour, which reproduces the 0.7 exactly. RP412GAUGEDIAG=1 logs the rate; watching the screen cannot tell a display that has stopped refreshing from one whose picture simply is not changing, which is what made this hard to see. Also fixes the constructor calling Update() three lines before it initialised mDisplayToUpdate, so the first pass indexed the D3D device and surface arrays with whatever was on the stack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
ea9491d2d5 |
Add the cockpit glass-sizing declarations missed by the last commit
L4VB16.h carries GlassSize and CockpitGlassSizes, which L4VB16.cpp uses
in both the constructor and LayoutCockpit -
|
||
|
|
8dc6605a07 |
Cockpit: buttons under the glass, -fit, and player display layout
Button banks The exploded diagnostic view was still display-only - it predates the button work - so it now builds the same banks as the cockpit, with the pod arrangement laid out from the panes measured sizes rather than a hardcoded 640/480 grid (the banks make each window bigger than its glass, and the bottom row hung off the work area otherwise). The map side columns were spread height/6 from the top, but the maps own legend grid is not sixths: measured off the bitmap it starts 13 rows down with six 102-tall cells on a 105 pitch. Every button sat high of its label, worst at the bottom. Each buttons top and bottom now come off that grid separately and are subtracted - scaling a height directly would let rounding drift them back out of step on a resized cockpit. Depth 100 to 240: against the 480 glass the two banks meet in the middle bar the strips, so practically the whole display is a press target. This mattered most in the cockpit, where the panes are small enough that the halfway clamp governs - at 100 the MFDs had a 110px dead band straight through the middle of the glass. -fit (also spelled -windowed-fullscreen) Borderless over the whole monitor, with the render size chosen to match. The cockpit presents the 3D into a viewscreen that fills its canvas, so the right -res is that canvas at the scale the cockpit will settle on; computing it with identical arithmetic makes the stretch a copy. On the 3440x1440 panel that is 133% and -res 2553 1436, against 125% for the windowed path that pays for the taskbar. The pick runs after the whole command line, so an explicit -res wins from either side of -fit. Capped at 3840x2160. Cockpit mode only - mode 0 has to stay playable on real pod hardware and mode 2 is a dev view - so those get the resolution and keep their windows. Display layout, in environ.ini L4MFDSCALE sizes all five MFDs, L4MFDSCALE_UL and friends override any one of them, L4RADARSCALE the radar, and L4RADARPOS puts the radar bottom centre, in either bottom corner, or halfway up either side. Scaling is applied in canvas units before the canvas is fitted to the window, so a number means the same thing on every monitor. Sizing each display separately let the clamps become exact rather than one conservative rule for all five: what limits a display is its actual neighbour. Which neighbour that is depends on the radar, so the clamps follow it - on the bottom edge it clears the one MFD above its column, but centred on a side it has one above AND below and grows from the middle both ways, so it must clear the taller twice over. Clamping shrinks uniformly; these are photographs of real instruments and a one-axis clamp would squash them. Verified on the ultrawide: all five radar positions, per-display and group scaling with the clamps biting, -fit with and without an explicit -res, and the button geometry measured back off the screen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6b43971d2d |
Cockpit scales both ways and re-fits on resize, aspect locked
The canvas was capped at 100%, so a bigger monitor got a 1920x1080 cockpit in the corner of the screen and maximising did nothing - the layout only ran once at startup. Now the fit is one uniform scale with no ceiling, recomputed whenever the window changes size (maximise, restore, drag), and the canvas is centred in whatever client area it gets. Uniform scale is what locks the aspect: a wider-than-16:9 desktop letterboxes with even black bars instead of stretching the cockpit. Panes gained Resize() so the glass and its button banks re-scale with the canvas; the pixel buffers keep their native source resolution. Scaling up is free quality on the MFDs - their glass is a downscale of a native 640x480 channel until about 200%. Verified on the 3440x1392 ultrawide: opens at 125% (2400x1350) instead of 100%, maximises to 126% centred with even bars, a 1884x661 window fits 61% letterboxed left/right, 1084x961 fits 56% letterboxed top/ bottom, and a 2584x1461 client scales up to 134%. The 3D still renders at -res and D3D stretches it to the canvas, so raise -res to match a large screen for 1:1 pixels; start-windowed.bat and the README say so. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b6045f3c94 |
Glass cockpit: radar columns reach under the map too
The amber Secondary/Screen columns get the same treatment as the red MFD banks, turned on its side: each button reaches 100px in behind the map with a 10px indicator clearing the edge, so the lamp reads as a slim column and the radar picture is the click target. The shared buttonDepth/indicatorStrip constants now drive both banks, and the columns are clamped so they can never meet behind a narrow map. The map pane narrows from 404 to 344 for a 324-wide glass, handing the difference back to the viewscreen. Verified live: clicking 60px inside the radar from either edge presses the column button behind it, with feedback in that side's strip (720 and 1350 pixels). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0bec0f9640 |
Glass cockpit: MFD buttons reach under the display
The red banks were thin strips sitting outside the glass, so the click target was only as tall as the strip. They are now 100px buttons that extend BEHIND the MFD picture with just a 10px indicator clearing the edge: the lamp still reads as a slim strip along the bezel, but the region of the display above or below it is what you press. Paint order flipped to match - buttons first, glass over them. The banks are clamped so they can never meet in the middle on a scaled-down cockpit, and the panes lost 40px of height each (260 vs 300 for a 240-tall glass), which hands that space back to the viewscreen. Verified live: clicking 50px inside the picture presses the button behind it and the press shows in the indicator strip (770 pixels changed, all of them within the strip). Amber map columns are untouched for now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8fb4b72f7a |
Pad Start and Back ship unbound
They were mapped to the config buttons (0x37/0x36), which the 9 and 0 keys already reach from the Upper Right MFD bank. Freeing them gives players two pad buttons to assign; bindings.txt carries the mapping as commented example lines so the way to re-enable it is in the file. Docs and the dist README follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1bd6dd83e2 |
MFD rendering: HALFTONE downscale, plus the exploded diagnostic view
Two pieces of MFD work that had been sitting in the tree (already in every build and dist since). The compact cockpit glass shows the full 640x480 gauge canvas at about half size, and COLORONCOLOR did that by dropping every other row and column - which shredded the 1-bit vector strokes and small text. HALFTONE area-averages instead (with the brush origin set, as MSDN requires) so the downscaled MFDs stay legible. L4MFDSPLIT=2 adds an exploded diagnostic view: every display in its own full-size desktop window at native resolution, decoded exactly as the pod VDB split them from the single gauge canvas, with no cockpit compositing and no downscale. It makes an individual MFD readable and screenshottable at full resolution for comparison against the emulator per-channel reference windows. L4MFDSPLIT=1 keeps the composited glass cockpit and stays the default. Also: .gitignore now covers the packaged RedPlanet-*.zip releases, which live on the Gitea release page rather than in the tree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f1604213ad |
Cockpit: radar trimmed to 1.35x (1.5x was a touch too large)
324x432 glass on the 1080p canvas, per playtest feedback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d6f4f16a24 |
Cockpit: radar 50 percent larger
The map/radar glass grows from 240x320 to 360x480 on the 1920x1080 canvas (user request - the radar reads best big). The flanking Secondary/Screen columns, bottom-center placement, and canvas scaling all follow from the measured size automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0ca6b5b01f |
RGB keyboard lamp mirror: vRIO Dynamic Lighting port, live in-game
The polish-backlog item, implemented from vRIO KeyboardLampMirror: game-commanded RIO lamp states paint per-key RGB keyboards through Windows Dynamic Lighting (WinRT LampArray). Keys bound to lamp addresses in the active bindings profile glow with the panel palette (red banks, yellow Secondary/Screen columns), flash modes use the exact L4MFDVIEW formula so keyboard and on-screen buttons blink in step, unbound keys are blacked out so the board reads as the button field, and zone-lit keyboards fall back to a board-wide mirror of the strongest lamp. Advantage over vRIO: Dynamic Lighting grants LEDs to the FOREGROUND app - which is the game - so no Windows settings dance. Isolation: L4KEYLIGHT.cpp compiles /std:c++17 + DEFAULT packing + conformance (per-file vcxproj settings; the engine /Zp1 would break the WinRT ABI) with a scalars-only interface, and all WinRT work runs on a private worker thread (watcher, claiming, 100ms paint loop). On by default with a bindings map present; RP412KEYLIGHT=0 opts out; missing Dynamic Lighting logs once and stays dormant. Verified live on the dev laptop: claimed its 24-zone keyboard (board-wide mirror) during a race; race cycling with per-race start/stop of the mirror thread stays green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
97adff22b3 |
Default layout: flight on the numpad, modifiers for throttle/reverse
Per the user: the game never reads the pilot keypad, so the numpad becomes the flight cluster - 8/2/4/6 stick, 7/9 pedals, 0 trigger - with Shift/Ctrl as throttle up/down and Alt as reverse thrust. That frees the entire letter board: W/A/S/D/Q/E return to their printed MFD bank positions and B goes back to being the gap key, so the default profile now carries the complete unmodified vRIO bank layout. The keypad addresses stay bindable (arcade key events) but ship unbound. Alt as a held flight control meant every release popped the window menu and stole focus - WndProc now eats SC_KEYMENU. Shift/Ctrl/Alt key-name aliases added to the parser alongside the .NET names. Profile parses clean (59 key buttons, 8 key axes); single-player cycle and key-bomb tests green (Alt+Q abort unaffected). Machines with an existing bindings.txt keep their old map - delete the file to take the new defaults. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
b0def79de2 |
Rebindable input: vRIO bindings profile ported, full board coverage
PadRIO now loads bindings.txt (vRIO profile grammar: key/pad/padaxis lines with toggle, deflect/rate, invert/deadzone options) written self-documenting with the full default layout on first run. The default is vRIO board-complete map - number and letter rows are the MFD banks as printed on the panel, F-keys the secondary/screen columns, numpad the pilot keypad (0x50-0x5F delivered as arcade RIO KeyEvents, a new PadRIO capability), Space/arrows the joystick column - with the desktop driving keys carved out: WASD stick, Q/E pedals, PgUp/PgDn throttle, B reverse (vRIO gap key; R returns to its bank). Pad bindings unchanged in spirit, plus Panic on LB and config on Start/Back; axis signs are encoded in the profile now, so L4PADFLIP flips on top of it. Default profile parses with zero rejected lines (68 key buttons, 8 key axes, 12 pad buttons, 5 pad axes); single-player cycle and the key-bomb tests stay green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fbd23ff1ca |
Key-command audit: the up arrow was the abort key; bombs disarmed
Full input-surface audit of the PC keyboard channel. The arcade abort was the typed ampersand character (0x26), but the Win32 port feeds WM_KEYUP VIRTUAL-KEY codes into the same channel - and VK_UP is also 0x26. The hat look-up key aborted the mission: that was every mystery abort across the multiplayer test rounds. Likewise the E key (0x45, the right pedal!) dumped the event queue on every release. Disarmed: plain 0x26 and E are swallowed at the L4 layer; the abort answers only to the deliberate Alt+Q chord (translated to the legacy engine code, so APP.cpp is untouched); the debug toggles (wireframe Alt+W, predator vision Alt+V, frame dump Alt+F, perf stats, event queue on Alt+E now) arm only with RP412DEVKEYS=1. The cheat-string manager has no PC-keyboard strings and the trace-log keys are compiled out of release - both inert. Verified live: a volley of VK_UP releases mid-race leaves the mission running; Alt+Q aborts on demand. Docs and the dist README updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3f691cacb3 |
Departed-pod resilience: collision guard + console loss ends the race
Round six raced all three machines (staging fix confirmed) and then exposed what happens when a pod leaves mid-mission - which arcade pods never did. The B crash dump named it exactly: VTV::TakeDamageMessageHandler resolved message->inflictingEntity to NULL (the entity belonged to the departed owner) and dereferenced it - Verify is compiled out in release. Collision damage from an entity that no longer exists is now ignored. And the race B and C were left in was a zombie: the owner (console) had aborted, so the mission clock would count up forever and the death/respawn flow hung with nobody to arbitrate. Lobby-member races now set gConsoleLossEndsMission: losing the console mid-mission posts StopMission locally, the pod tears down, and lands back in the lobby room. Arcade -net pods keep the re-listen-and-wait behavior. Loopback hosted race still green. For the drivers: the ampersand key is the arcade mission-abort - that was every crash-on-keypress so far; and a sleeping Bluetooth pad wakes on the Xbox button and hot-connects within 3 seconds (PadRIO re-probes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7ad53e03b5 |
Steam transport: identity-resolved accepts + load-proof timeout
Round four reached one step from the race: eggs delivered, both members ACKed with complete meshes, one member entered LoadingMission. Two failures remained, both now fixed. One: Steam reports incoming callers under locally-allocated ALIAS FakeIPs, not their global ones - the owner accepted both mesh legs but its identity check compared the alias against the egg address and never counted the connections (no Connected to GameMachineHost on the owner). The peer table now carries each member SteamID (lobby go roster gained a field) and Accept resolves the caller identity back to the global FakeIP the egg promised. Two: mission load stalls the game thread for 10-30s with nothing pumping, and Steam default 10s connected-timeout sheared every connection mid-load (end reason 4001, rx ages 11.5-20.5s - right at load duration). Connected timeout is now 90s; TCP never timed out an idle arcade link and races pump every frame once running. Self-test still green (ping, survives listener close). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
974cc6e120 |
Steam transport: accepted connections survive listener close
Round-three diagnosis from the three-machine logs: the console channel connected first try on SDR both ways - then died seconds later with end reason 5010 (PeerSentNoConnection). Cause: the arcade engine closes its console LISTENER the moment the console connects (correct under TCP, where accepted sockets outlive the listener), but Steam''s CloseListenSocket closes all accepted connections ungracefully. The members silently killed their console connection at accept; the owner''s next packet got no-connection back, no eggs were ever fed, and all three pods sat waiting. The transport now bridges the semantics: an engine close only marks the listener (new callers are rejected, pending queue dropped); the Steam socket is destroyed in Cleanup at mission teardown. Re-listening on the same engine port reopens the marked listener. The loopback self-test now covers exactly this: accept, close the listener, then push data both ways over the accepted connection - PASSED (ping 1, survives listener close 1). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7eb765696d |
Steam transport: status callbacks now dispatch (CCallback, not config ptr)
Root cause of the three-machine failure found and fixed. The connection-status callback was registered as a per-connection config value pointer - which the steam_api flavor of the library never dispatches (only the standalone lib does). So members never saw the incoming connection request, never accepted, and the owner timed out after 120s of state-5 retries. Valve''s own SpaceWar example registers SteamNetConnectionStatusChangedCallback_t with STEAM_CALLBACK; the transport now does the same through a CCallback listener constructed after SteamAPI_Init. Proven with the new RP412STEAMSELFTEST=1 loopback: one machine listens on its fake console port and dials its own FakeIP - the log now shows incoming/accept/connected on both ends and a verified data round trip, connect succeeded on attempt 1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d2836d195f |
Steam transport: full connection diagnostics + polled connect state
The three-machine test failed at exactly one step: the owner''s ConnectByIPAddress to a member''s fake console port retried silently for 120s while the member WAS listening on that port. The transport threw away Steam''s reason for killing each attempt. Now every connection state change logs the connection description, and drops log the end reason plus Steam''s debug string - the next run will say exactly which subsystem refused (cert, FakeIP directory, routing, accept). Connect also polls GetConnectionInfo directly instead of trusting only the callback flags, logs each attempt''s final state, no longer holds a pointer into the connection table across mutations, and paces retries at 1s. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
5892af318e |
Steam lobby: host, join, room, and launch into a marshaled race
RPL4LOBBY implements the multiplayer front door on ISteamMatchmaking. The setup menu grows HOST STEAM RACE / JOIN STEAM RACE buttons when the Steam wire is live; hosting creates a tagged public lobby, joining finds one. Every member publishes FakeIP + fake ports + persona + loadout as member data; the room screen lists members (host marked) and gives the owner a launch button. Launching writes a nonced go-roster into lobby data. Each pod registers every peer with the Steam transport (two-port peer table: engine console/game ports map to Steam fake ports on connect) and enters the race: the owner through the hosted-race path - it builds the multi-pilot egg from real personas and loadouts and its console marshals everyone - and members as network pods that boot straight into WaitingForEgg for the owner to feed over the wire. The lobby outlives races: members loop back through WinMain into the room (no local console needed - MissionCompleted is waived for member races), and the owner returns to the room after its results screen. Leaving the lobby clears the hosted-race priming. Verified on this box: menu buttons appear under RP412STEAM=1, hosting creates a lobby on the Steam backend, the room runs and leaves back to the menu; single-player cycling and the LAN hosted race both still pass. Full three-account mesh test is next, on real hardware. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
c66cb00a7e |
Network races: the local console marshals remote pods over the wire
The lobby-owner-as-console architecture, in-engine. RPL4CONSOLE gains RPL4LocalConsole_InstallNetworkRace: the owner pod switches to network mode and meshes like any pod (egg fed locally via FeedLocalEgg, which now opens the ConsoleOnly state gate), while the console tick also marshals REMOTE pods over NetTransport speaking the exact arcade protocol - egg chunks with 5s-retry-until-ACK, 1Hz state polling, RunMission once every pod stages at WaitingForLaunch, StopMission at expiry (remotes first, local pod holds until their EndMission scores land), score intake labeled with [pilots]-order names on the results screen. The front end builds the multi-pilot egg: RP412HOSTPODS lists member console channels (lobby stand-in; the Steam lobby feeds the same path), RP412HOSTPORT/RP412HOSTADDR set the owner side. Winsock Connect now redials with a fresh socket per attempt (a refused TCP socket is dead; the old loop reused it) bounded at 120s - needed whenever a peer boots after the caller, which is the normal Steam lobby launch order. Verified on loopback: member pod in -net, owner hosting from its menu; mesh completed both sides, 30s race, remote score collected over the wire (host 3), local stop after the drain, results screen shows both pilots by name in one process that returns to the menu. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ff6ec8c56a |
SteamNetTransport: the Steam wire is implemented and live
Steamworks SDK 1.64 vendored at extern/steamworks_sdk_164 (headers + win32 redistributables only; .gitignore trims the rest). Both projects build with RP412_STEAM; activation stays behind the RP412STEAM=1 environment switch, so plain desktop runs never touch Steam. L4STEAMTRANSPORT.cpp implements NetTransport on ISteamNetworkingSockets with FakeIP: SteamNetTransport_Install brings up SteamAPI, relay network access, and a two-port FakeIP identity (fake port 0 = console channel, 1 = game mesh), then swaps the process wire; any failure logs the reason and the game carries on over TCP. Addressing keeps the engine untouched: all pods share the -net port convention, eggs carry fakeip:engineport, and the transport alone translates engine ports to Steam fake ports via the lobby-fed peer table (RegisterPeer). Connect mirrors the TCP retry-while-refused loop; Receive normalizes message lanes back into the stream semantics CheckBuffers expects. Runtime verified on this box: RP412STEAM=1 under AppID 480 came up as 169.254.59.52 (fake ports 32256/32257); without Steam credentials it falls back to TCP cleanly; default boot logs no Steam lines at all. steam_api.dll ships in the dist. Next: the lobby layer (ISteamMatchmaking member data -> RegisterPeer + egg build + RPL4CONSOLE marshal), which needs a second account to test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22421fb418 |
NetTransport seam: the wire moves behind an interface
The Steam-multiplayer prerequisite from the design doc: L4NET keeps hosts, message queues, and the deterministic mesh ordering, while connect/listen/accept/close, send/receive, startup/cleanup, the local interface list, and ip[:port] parsing move behind NetTransport (L4NETTRANSPORT.h). WinsockNetTransport carries the existing TCP behavior over verbatim - including the connect retry-while-refused loop the egg-ACK ordering relies on - and is the process default; NetTransport_Set installs a replacement before the network manager comes up. L4STEAMTRANSPORT.h documents the ISteamNetworkingSockets mapping per method (FakeIP keeps [pilots] entries as IPv4 strings) behind RP412_STEAM until the Steamworks SDK lands. Also fixed in passing: the ExclusiveBroadcast path sent sizeof(network_packet) - four bytes of pointer - instead of the message size, which would have sheared the stream framing had it ever fired. Verified: single-player race cycle (menu, race, results, menu, race) green; -net 8000 boots, listens for a console through the transport, and idles stable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
570eb3aceb |
Single-binary race loop: menu -> race -> menu in one process
WinMain now wraps the engine block in a loop: when a front-end-launched mission ends under the local console, the setup screen comes back in the same process instead of exiting (the arcade relaunch-per-mission model). Replaces the CreateProcess self-respawn - required for Steam, where the lobby and sockets must survive across races. Second-cycle re-init crash fixed: d3d_OBJECT kept a static texture cache keyed by filename, so race 2 got IDirect3DTexture9 pointers created on race 1 destroyed device and died at first draw (DrawMesh AV). The cache is now flushed in ~DPLRenderer before the device is released, and ParticleEngine::Initialize drops particles left over from the previous mission. Verified: three consecutive 30s races in one PID, each stopped on time by the console with final scores collected. Also: L4CONSOLELEN env override for test-length races, and the console exposes MissionCompleted() for the loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |