136 Commits
Author SHA1 Message Date
CydandClaude Opus 5 1e0c412102 Leaving early keeps the points earned
From the playtest: a member who Alt+Q's mid-race vanishes from the host's
score sheet, as though they had not raced at all. A pod that leaves never
sends its EndMission score - that message belongs to the buzzer - and the
sheet only ever held what EndMission delivered.

The pods have been telling the console their score all along:
ConsolePlayerVTVScoreUpdate telemetry fed the arcade console's status
board, and this console skipped it ('IDs 2-6 skip through for now'). It
now keeps the last score each pod reported, together with the host ID the
pod itself put in the message - no arithmetic about egg order to get
wrong - and at the buzzer any pod that is not there to answer has its
last telemetry score stood in and named in the log. Leaving early keeps
what was earned; it just stops earning.

Also on the leaver's own machine: RPL4Lobby_PullRaceResults gave every
member eight seconds of frozen window waiting for a score sheet - but an
aborter leaves a race still running everywhere else, whose sheet will not
exist for minutes. Exit_Code carries the abort at that moment (the race
loop resets it after the menu), so the wait becomes one free look. The
sheet's nonce keeps it eligible to show once the race really ends.

The member's drop-to-desktop after Alt+Q is NOT fixed here - it is not
yet diagnosed, and this session has spent enough confident guesses. The
next piece of evidence that names it is the member machine's rpl4.log
tail from right after an abort.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:27:44 -05:00
CydandClaude Opus 5 bdc5df087e The roster shows what everyone is bringing, all of it
The setup-page roster listed each player's name and vehicle; Cyd wants
the loadout: colour and badge for a race, team and position for
football. It is what the host is about to commit everyone AS, and the
host should see it before pressing the button - a member who left their
team unset in football reads '(no team)' right where the commit decision
is being made.

All of it was already on the wire - colour, badge, team and position
ride each member's lobby row for the egg builder's sake - the roster
line just never printed them. A camera member still reads LIVE CAM
alone: it brings none of the above, the pick replaces the lot. A 'None'
badge prints as nothing rather than as the word None.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:17:21 -05:00
CydandClaude Opus 5 50399e016e The board repeats what the tick decided
Cyd's screenshot: the commit board's LAUNCH button lit - which requires
the local pod to be staged - while the host's own row read 'connecting',
which is the word for a state of -1. Two reads of the same fact from two
places, disagreeing: the tick read the application state and armed on it,
and the paint handler read it AGAIN, separately, and got something else.

Why the paint's read returned -1 is not proven, and it does not need to
be: re-deriving state at paint time was the mistake, the same one that
broke five instruments in one night of the render-tick hunt. The tick
already stores every state it acted on in gShownStates, for change
detection - the paint now draws those values and reads nothing else. The
board and the arming can no longer disagree, because they are the same
read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 23:12:35 -05:00
CydandClaude Opus 5 2d70ae7075 An abort during prep goes back to the menu, not the desktop
Cyd Alt+Q'ed out of a mission and landed on the desktop. From a RUNNING
race that was never the intent - the single-binary loop returns to the
setup screen whenever the console says the mission completed - but the
console only learned that from PhaseRunning. A mission that died while
still PREPPING - Alt+Q during loading, or during the commit hold - left
the console in PhaseWaiting forever, MissionCompleted() answered False,
and WinMain fell out of the race loop to the desktop.

The hole is as old as the race loop, but it had no traffic until tonight:
the commit hold makes the prep window somewhere players actually stand,
and changing your mind there is exactly what Alt+Q is for.

The console now counts a prep death as a completed mission: back to the
menu, lobby intact - a host lands on the setup page still hosting, the
commit board comes down, and the remote console channels close, which
ends the members' prep too (their consoles vanishing already means the
mission cannot start; now it means it promptly).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:57:00 -05:00
CydandClaude Opus 5 bc309f7919 COMMIT readies the mission; LAUNCH belongs to the operator
The pod bay has a ritual: the mission is committed, every pod preps up to
- but not past - the launch, and then the operator presses the button.
The prep hold is not dead time. The MFDs and the map buttons are alive
before the mission starts, so pilots use the window to set their maps and
presets, and the wait itself is part of the game's social fabric. Cyd
asked for the ritual back, and the engine turned out to have kept most of
it: the console already feeds the eggs, watches every pod climb through
loading to WaitingForLaunch, and held the run until the room was staged -
it just fired RunMission itself the instant that was true.

Now it arms instead of firing. The host's menu button reads COMMIT while
hosting (a plain single-player launch is still a launch, and commits with
no remote pods still start themselves - a lone pilot has nobody to wait
for). Committing marshals everyone exactly as before, and a small commit
board comes up over the 3D view: one row per pod, the host first, each
saying what its machine is doing - connecting, waiting for egg, loading,
READY - bright when ready, dim while not, so who the room is waiting on
reads at a glance. When every system is ready the board's LAUNCH button
lights, the operator presses it, the run goes out over the same wire and
local delivery as always, and the board gets out of the way - the host
has a race to fly. If a pod falls back out of readiness before the press,
the button disarms rather than launching a room no longer whole.

The board lives entirely on the game thread, created and clicked inside
ConsoleTick, so the game's own message pump delivers its input - the same
pump that keeps the MFDs alive through the hold is what makes the button
work, which is fitting. WS_EX_NOACTIVATE keeps the game window focused
throughout: PadRIO controls answer only while it is, and launching the
race must not cost the operator their controls. The board repaints only
when a state actually changes, and every teardown path destroys it -
InstallCommon, the run transition, and the mission-ended-otherwise exit -
so no board survives into a race or out of one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:48:39 -05:00
CydandClaude Opus 5 bc56ab5d48 Joining stays on the page too, and there is one host at a time
The other half of the setup-page-as-room work, both halves Cyd's ask.

JOIN STEAM GAME now sits down where it stands. The mission column -
scenario buttons and the track, time, weather and length drop-downs -
greys out and MIRRORS the host: the owner's published setup is mapped
from display names back to catalog indices on every heartbeat, so a
member watches the host change the track from inside their own setup
screen. The right column stays theirs - callsign, role, vehicle, colour,
badge, recording - and republishes as they change it, so the host's
roster tracks them the same way. LAUNCH GAME reads WAITING FOR THE HOST
and answers to nobody; the go arrives through the same timer, registers
the peers, and leaves the menu as a member launch. JOIN becomes LEAVE
LOBBY while seated. The room screen is now unused by both roles - kept
compiled, but nobody's flow reaches it.

A member's launch also now sets the RECORDING flag from its own pick.
Nothing on the member path ever set it before - the same
published-but-never-acted-on seam as the member cam - so a member who
ticked RECORDING recorded nothing, all the way back to the feature
landing.

One host at a time, while the flow is young: HOST STEAM GAME first runs
the same worldwide search joining uses, and if an RP412 lobby is already
open anywhere it refuses with A LOBBY IS ALREADY OPEN - JOIN IT rather
than opening a second room nobody can tell from the first.
Check-then-create is not airtight against two people clicking in the
same breath, but for a playtest group on voice it is the rule that was
asked for. If the host leaves, ownership would migrate silently and turn
some member's page into a host's - the member poll treats inheriting
ownership as THE LOBBY CLOSED and steps out instead.

The old modal exits are gone with it: steamAction, the post-loop
RPL4Lobby_Join call, and the member's room re-entry path. The menu's
modal loop now ends only at launch or close, whoever you are.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 22:08:32 -05:00
CydandClaude Opus 5 ae939bcea7 A camera brings no vehicle, so the row should not show one
Cyd's catch on the new roster: a member who picked Live Cam was listed as
"name  vehicle  cam", vehicle first, as though the pick were a garnish.
The pick REPLACES the vehicle - that is exactly what it does in the egg -
so it replaces it on the line too: "name  LIVE CAM", in the menu roster
and in the members' room alike.

The room's row also still gated LIVE CAM to the owner, from the era when
only the host's pick was acted on - the comment beside it even explained
that showing it for a member "would be the room screen lying about the
grid". True then, backwards now: any row shows LIVE CAM, except in a
football room, where the egg builder strips every cam pick and the team
sheet is the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:46:04 -05:00
CydandClaude Opus 5 47817ca6f5 The setup page is the host's room
Two player requests from the six-player night, one cause between them:
the lobby treated hosting as somewhere you GO, and a role you could only
have by being the machine that went there.

Hosting no longer leaves the setup page. HOST STEAM GAME claims the lobby
and stays put: the button becomes CLOSE LOBBY, joiners list under GAME
LENGTH as they arrive (callsign, vehicle, a cam tag, and WRONG BUILD where
launch would refuse - the reasons launch might say no, standing on the
same screen as the launch button), and the track, weather and length stay
changeable the whole time, republished to the members' rooms as they
change. LAUNCH GAME launches the whole room from right there, and after
the race the host lands back on the same page, lobby still open, roster
still live. The room screen still exists - for members, whose flow is
untouched.

Mechanically: RPL4Lobby_Host's create-then-run-the-room split into
HostOpen (create, publish, return) plus Pump/PublishSetup/RosterLines/
HostLaunch/Leave, with the owner-launch and leave blocks extracted from
the room's message loop so both screens call the same code. The menu gets
a timer that pumps Steam callbacks - nothing else pumps them with no room
running - and republishes dirty picks at most every 1.5s, because Steam
throttles chatty writers. One wrinkle: the lobby publishes through the
RPL4FrontEnd_* accessors, which read globals the menu only wrote on the
way OUT, so publishing from a live menu syncs them first or every publish
would carry the previous visit's picks.

And a member's Live Cam pick now works. It was host-only by design - the
pick published as member data and the room displayed it, but the egg
builder never read it, so a member who chose Live Cam raced their default
loadout instead (the "default Quark - red" Cyd was listed as). The pick
now rides the whole chain - lobby row, hosted-pilot table, egg entry as
hostType=1 / vehicle=camera - and the member machine sets its own cockpit
from its own pick at launch, before the renderers build, exactly as the
host and playback already did. One rule holds it together: SOMEBODY has
to race. If every pilot picks cam, every pick is stripped and the log
says so - a grid of cameras has nothing to point at, and is also the
shape known to hang the map load.

The playtest launch failures while a member had cam picked are not
directly explained - no logs were kept from those attempts - but the
half-implemented state they ran in (flag published, egg ignoring it) is
exactly the seam this closes, and the failure cannot recur in that form:
the pick is now either honoured everywhere or stripped everywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:34:12 -05:00
CydandClaude Opus 5 e2bcb29a53 The throttle a pod respawns under is nobody's order
Player request from the six-player night: a pod that dies at full throttle
reappears under a hand still holding full throttle, and launches on it.
The pod should respawn with the throttle at zero and stay there until the
player has brought the control back to zero themselves. The arcade never
met the problem in this form - its throttle was a physical lever that kept
its position regardless - but the same idea protects a real lever too,
which matters now that the pods want this code.

The latch lives in VTVControlsMapper, the one place every control source -
RIO lever, pad trigger, keyboard axis, input script - funnels through, per
simulation step, so it is deterministic and local to the owning machine by
construction (the mapper only runs on MasterInstance).

VTV::Reset arms it through DeathReset, with one wrinkle: the controls
mapper is subsystem ZERO, below BasicSubsystemCount, so Reset's subsystem
loop has never reached it. It is called explicitly now. Only RegularReset
latches - that is the death respawn and the first spawn (so a throttle
held through the countdown no longer buys a flying start). Football's
repositioning reset does not latch, and mission review has no controls.

While latched the mapper computes as though the throttle were zero, but
only for the pass: the true lever position is restored at the bottom of
the function, so the cockpit gauge and the watchers keep showing the
player the hand they need to bring down. The restore also keeps the
release test honest - RIO analog events arrive on CHANGE, so a zeroed
attribute would otherwise sit at zero, release the latch by itself, and
hand back a live throttle the moment the hand twitched.

Release is at 5% of travel: wide enough for a resting trigger or a real
lever's potentiometer sitting a few counts off its stop, narrow enough
that easing off does not satisfy it.

Proven with the input-script harness, both directions. Throttle held at
1.0 from the green light: the pod moves 1.3cm in 30 seconds, all of it
hover settle. Throttle at zero for two seconds then 1.0: the latch
releases in the quiet window and the pod is 665m down the track twenty
seconds later. The second run is also the normal-play case - a control
already at zero releases the latch on the first step, invisibly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 21:08:16 -05:00
CydandClaude Opus 5 13a2109918 What the first six-player night found
Playtest of 2026-08-11: sixteen logs, three crash dumps, six players. The
great races were most of the evening; this commit is about the rest.

Two of the three dumps are the same crash on the same instruction:
L4NetworkManager::Send at cmp [ebx+24h],3 with ebx NULL - GetConnectStatus
called on the nothing GetRemoteHost returned. Both machines show the same
scene: podium up, a peer just disconnected, and
DropZone::AssignDropZoneMessageHandler dispatching through Entity::Dispatch
to a host with no row in the table. Rows never leave the table (disconnect
only marks a host offline), so the ID was never adopted - and host -1 is
sitting in every log as 'Entity -1:106 class42', the ownerless map
entities, with GetRemoteHost's Verify(host_ID >= FirstLegalHostID)
compiled out in release. Send and SendMessageToNetnub now treat an absent
host exactly like an offline one - message dropped - and Send logs the ID,
client and message so the next occurrence names the actual sender instead
of needing a debugger.

The sync-to-start failures are one badly-NATed machine plus a deadline of
mine. Steam's first attempt at that peer goes direct, burns about ten
seconds, and dies (reasons 5003/5008); the second attempt comes up through
the relay and succeeds WHENEVER IT IS GIVEN TIME. RP412CONNECTWAIT was a
single budget across all attempts, so the relay attempt inherited the
scraps - the logs show 'attempt 2 ended in state 1', still connecting, at
the 20s cutoff. The deadline is per attempt now, three attempts at most:
the awkward router connects in about half a minute, a truly absent peer
costs under a minute, the title bar names the try, ESC still works.

The third dump is different: a DirectSound teardown race on an audio
worker thread (CEngineRendererConnection releasing audio-session
interfaces at exit). One occurrence, filed, not chased.

Also in every log, benign but noisy: 'Entity -1:106 class42 couldn't
figure out how to MakeEntityRenderables' twice per mission - drop zones
have no renderables and nothing to draw them with. Left alone tonight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 20:36:52 -05:00
CydandClaude Opus 5 056ee0bdad The per-packet trace goes behind RP412CAMLOG
Playback is confirmed good - Cyd on the replay: "it looks as good as the
Live cam view" - so the scaffolding that got it there can come down.

DispatchPacket's line per packet earned its keep: one of them identified
the spool's own header being parsed as packet one, which was the whole
crash. But a station in a pod bay has no business writing two dozen log
lines at the start of every replay, and the pods are where this is headed.
Gated on RP412CAMLOG, kept rather than deleted, because the next thing to
go wrong in this path will want it back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:23:42 -05:00
CydandClaude Opus 5 3de30a14d7 The cockpit is decided before it is built
Playback was laying out a pod's cockpit over a camera's recording - five
instrument panes and the map back in the middle - and it already knew
better. StartConnecting works the answer out from the local host's type and
sets it, but StartConnecting runs after the mission is created and the
cockpit is built before that. The log said so in order: the egg read at
line 9, SVGA16 fitting the cockpit at line 20, and the Live Cam line
arriving at 25. The answer was right and five lines late.

The egg carries it, so read it where the egg is already open: the first
entry in [pilots] is the station that owned the race, and its own section
gives the hostType. That is early enough, and StartConnecting still
confirms the same fact from the host table afterwards.

    SVGA16: secondary displays ... map 100%
    SVGA16: map on the bottom left (L4MAPPOS)

where it had been radar, centred, on L4RADARPOS.

That is the loop closed: a Live Cam records a race, and the recording
replays with the camera's own cockpit over it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:21:00 -05:00
CydandClaude Opus 5 06d4c91dad Playback plays
Two faults, and playback runs.

The spooler task was reading before the header came off the stream. The
header's length depends on the egg's host count, so it can only be taken
off once the mission exists - which is
L4PlaybackNetworkManager::StartConnecting - and until then the cursor sits
on the header. The task parsed it as packet one: our application ID is 0,
which is NetworkManagerClientID exactly, and the two host IDs behind it
read as a message length of 1 and a message ID of 3. Message 3 on the
network manager is ReceiveEggFile, so it built a Mission from the header
and died inside it. The task now waits for SpoolHeaderConsumed, set only
after the header has been read AND passed its sanity check, so a spool that
does not add up is never played at all.

The arcade's WaitingForEgg case, which replays an egg carried in the spool
as network manager packets, cannot work with this format in any case:
reading those packets means passing the header first, and passing the
header means already knowing the egg. A recording made by this build keeps
its egg beside it, so there is no such circle.

And last.egg had three other writers, not one. Every network manager dumps
the egg it loaded to that name as a debug aid -
networkEggNotationFile->WriteFile - and it writes a notation image, 101,920
bytes of it, straight over the 9,470 byte text egg saved with the
recording. Playback then read that as its egg, found no map entry, and
Mission::Mission carried on past its own PostQuitMessage with an
uninitialised map name to dereference a NULL resource. All three dumps are
last-loaded.egg now, and last.egg belongs to the recording alone.

Worth noting the copy was never wrong: SPOOLS\<stamp>.egg is byte-identical
to the frontend.egg it came from. Only the last.egg convenience copy was
being overwritten, which is the sort of thing that looks like a corrupt
recording and is not.

Where it stands: -pb loads the spool, takes the egg saved beside it, lays
the cockpit out as the Live Cam it was recorded from, consumes the header,
and dispatches the race's packets to the interest manager with sane
lengths, without crashing. Whether the pods MOVE on screen is the next
thing to look at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 15:18:45 -05:00
CydandClaude Opus 5 d00e03ae20 Playback reads its own header as the first packet
The heap corruption is explained, and it was never a memory bug - it was a
mis-parse that corrupted the heap downstream.

One line of instrumentation in DispatchPacket did it:

    Playback: packet client=0 message=3 length=1 -> the NETWORK MANAGER

Against the spool's header - appID 0, major 3, (remote 0, id 2),
(remote 1, id 3) - every field lines up:

    clientID      0   <- appID
    gameID        3   <- major version
    fromHost      0   <- host 1 remote
    timeStamp     2   <- host 1 id
    messageLength 1   <- host 2 remote
    messageID     3   <- host 2 id

So the spooler task is parsing the 24 byte header as packet one. Client 0
message 3 is ReceiveEggFile, which builds a Mission - a SECOND one, from a
packet - and that is the crash in Mission::Mission.

The cursor is at byte zero because L4PlaybackNetworkManager::StartConnecting,
which is what consumes the header, had not run yet. Its own log line for a
camera station never appeared, and Application::CreateMission shows up in
the stack UNDER SpoolerTask::Execute rather than under the egg message
posted from the constructor. The task begins dispatching before the mission
exists, and nothing stops it.

That also explains why the wrong-egg guard stayed silent: it inspects the
first packet in StartConnecting, which is correct when it runs, and it
simply never got the chance.

Left in: DispatchPacket names the client ID, message ID, length and the
client each packet resolved to, for the first two dozen packets. Client IDs
and message IDs are both small integers counted from the same base -
NetworkClient::NextMessageID is 3, so the interest manager's
NewDynamicEntity and the network manager's ReceiveEggFile are BOTH message
3 - which is exactly why a mis-routed packet looked valid to whoever
received it. A NULL client is now returned from rather than called through,
since Check() is a no-op in release.

Next: the spooler task must not dispatch until the mission is loaded and
the header consumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:58:30 -05:00
CydandClaude Opus 5 48a595b244 Two egg hazards closed, and a heap overrun still to find
-pb died with exit 0xC0000374, heap corruption, nothing in the log. Two
causes found, one fixed hazard behind them, and the crash itself still
open.

The first cause was mine. L4NetworkManager has always dumped whatever egg
it loaded to "last.egg" as a debug aid, and the egg-beside-the-spool work
put a recording's companion egg at exactly that name. Playback's fallback
then picked up an unrelated egg as though it belonged to last.spl. That
matters more than a wrong filename: the spool header holds one
(remote, hostID) pair per host named in the EGG, so an egg with a
different host count makes the reader consume the wrong number of pairs,
leave the read pointer mid-header, and parse the first packet out of
garbage. The debug dump is now last-loaded.egg.

The second is that nothing checked. A wrong egg could only announce itself
by corrupting memory, which is the least useful signal a program can give.
Playback now looks at the first packet after the host table and refuses a
length that cannot be right, naming the cause: play it back with the .egg
saved beside it.

Neither fixed the crash. With the guard in place and an egg it accepts,
playback still corrupts the heap - and the guard did NOT fire, so the
header was consumed consistently and the fault is later, in dispatching
the packets themselves. The stack at the failure is inside ntdll's
allocator, which is where corruption is DETECTED rather than where it is
caused, so the next step is page heap to find the write rather than more
staring.

Where MR stands after today: it loads the world from the egg, lays the
cockpit out as whatever the station was, accepts the spool's header, and
gets as far as playing packets into the simulation. That is a good deal
further than "crashes on the first thing it touches" this morning, and the
remaining fault is a single memory overrun in a code path that has not run
since 2007.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:30:24 -05:00
CydandClaude Opus 5 ef81b75058 A spool and its egg are one artifact
Cyd's question, and the answer is yes. A spool records what MOVED and
never the track it moved through, so it cannot be replayed without the egg
the race was run on - and frontend.egg is rewritten by the next race set
up on the machine. A recording kept on its own therefore stops being
playable the moment somebody picks a different track, silently, and by
then the egg that would have opened it is gone.

So the egg is saved with it: SPOOLS\<timestamp>.egg beside
SPOOLS\<timestamp>.spl, and last.egg beside last.spl. The console sets the
path, because the console is where the egg's name is actually known.

And playback looks for it. Given no -egg it takes the spool's name, swaps
the extension, and uses that if it is there. Naming the egg by hand is not
just tedious, it is dangerous: a spool played against a DIFFERENT track
loads perfectly happily and shows nonsense, and frontend.egg is exactly
the wrong egg by default because it belongs to whatever was set up last.

Verified on the way here: the header this build writes reads back exactly
as playback expects it - major version 3, host 2 local (the camera), host
3 remote (the racer, matching every packet's fromHost), 8263 packets after
a 24 byte header ending precisely at EOF. Playback then loaded it with no
complaint about application ID or version, which is the check that failed
before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 14:14:26 -05:00
CydandClaude Opus 5 6f63770f22 A spool needs a header, and a world to play into
Two more blockers down, found by watching it rather than reasoning about
it - Cyd reported a black screen with a full pod cockpit over it, and both
halves of that turned out to be real and separate.

The black screen: playback had no mission. L4NetworkManager reads the -egg
file and posts a ReceiveEggFileMessage in single user mode, which is what
ends in CreateMission and builds the world - but that is the POD's network
manager. L4PlaybackNetworkManager descends from NetworkManager and
inherited none of it, so the playback application came up with a cockpit,
nothing behind it, and not a word in the log. A spool records what MOVED,
never the track it moved through, so the egg is not optional.

The cockpit: Application::SetCameraStation is the front end's answer to a
question asked on the setup screen, and playback never sees the setup
screen, so a Live Cam recording replayed as a pod - five instrument panes
over the view, map back in the middle. The egg knows what the station was;
it is the same egg the race ran on. Read it from the host type instead.

Then playback got far enough to reject the spool outright:

    Error - Not a spool file for this application!
    Error - Spool file major data version should be 3, not 0!

Correct of it. A spool opens with the application ID, the resource major
version, and one (remote, hostID) pair per egg host, and SpoolRecorder was
writing packets and nothing else - so playback read a zero where the
application ID belonged. The header is written now, in
L4NetworkManager::StartConnecting, because every field in it is
network-layer knowledge and that is the first moment all of it exists.

NOTE the recordings made before this cannot be played back. They have no
header, and there is nothing in the file to reconstruct one from - the
host table describes machines that were on the wire at the time. A race
recorded from here on will have one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:28:11 -05:00
CydandClaude Opus 5 ae23c040ce Mission review gets far enough to load the race
Starting the MR port, since the pods want it too. Four changes, and one
correction of my own making.

A release Fail now says what failed. It expanded to a bare abort() that
threw the message away, and because the compiler merges identical cold
paths, every Fail in a function became one anonymous stub - a crash named
the function and nothing else. It now prints message, file and line.

It also writes that line to rpl4-fail.log and closes the file. rpl4.log is
std::cout with an ofstream's streambuf swapped in, and the text written
there did NOT survive the exit however it was flushed; rather than guess
where it stopped, the one line that matters goes to its own file, and
fclose is a promise it reached the disk. Its own file because rpl4.log is
already open for writing and Windows will not share it.

-pb plays a spool back without recording a new one. Mode 2 has been in
RPL4.CPP all along - one spool file instead of two, no spooling
application - with no way to ask for it. -mr starts a recorder alongside
the playback and that recorder is the half that cannot cope away from a
pod bay.

Which is the third fix: L4SpoolingNetworkManager::StartConnecting walks
the hosts named in the egg, and called host->GetHostID() on whatever
FindHost returned. FindHost answers NULL for a host that is in the egg but
not connected - impossible in an arcade, ordinary everywhere else - and it
crashed before the mission could start. The table is read back one pair
per egg host in egg order, so a missing host cannot be skipped without
shifting every entry after it; it writes the pair, says so, and carries on.

And RPL4.CPP chose between the playback application and an idle one in
silence. For the first few seconds those look identical from outside, so a
spool that failed to load was indistinguishable from one that had not
started yet. It now says which, and why not.

The correction: I reported a failure in L4AudioRenderer::Initialize on
audiomr.ini's clipping_radius. That was mine. I was running cdb from the
source tree, so the game looked for audio\audiomr.ini beside the debugger
rather than beside itself and found nothing. With the working directory
right, audio initialises fine and always did.

Where it stands: -pb loads last.spl - "2973012 bytes to play" - creates
RPL4PlaybackApplication and runs without crashing. Whether it puts the
race on the screen is the next thing to look at, and that wants eyes
rather than a log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 13:15:20 -05:00
CydandClaude Opus 5 46418ebb5c The console speaks to the room it is standing in
Cyd's point: a real pod bay console is its own machine, and every station
- pod, Live Cam, mission review - hears it over the wire. That is why the
spooler expects LoadMission and RunMission to arrive as packets. Ours is
colocated, and the network stack is quite right not to push bytes through
a socket to reach a client in the same process.

But the console had gone further than that. It called
application->Dispatch, stepping past the client's receive entry
altogether, so nothing watching packets ever saw the console speak. The
first Live Cam recording is the evidence: 14,342 packets, every one of
them from the racer via the interest manager, and not one LoadMission,
RunMission or StopMission - because those came from inside the house.

The tee was not in the wrong layer for the wire; it was in the right place
and the console was walking around it.

So build the packet SendWire would have built and hand it to the client's
own front door. NetworkClient::ReceiveNetworkPacket is Dispatch plus
whatever is watching, and Application IS the client for
ApplicationClientID, so this is the same delivery arriving where deliveries
arrive - no socket, no loopback, no second copy of the protocol.

Correcting myself twice over. RunMission does NOT have to be the first
packet in the spool: playback reads spool->GetPointer(), the current
cursor, by which time the task has consumed what came before, so it checks
that the cursor has REACHED RunMission rather than that the file starts
with it. And the ordering is therefore not "the whole job" as I claimed -
the recorded timings already show a normal shape, fifteen entity creations
inside the first twenty milliseconds and updates running from 0.110s to
308.940s of a five minute race.

Still open: the egg feed is local too (FeedLocalEgg), so a spool carries
no egg. Whether that matters depends on how the playback application loads
its mission, which is the next thing to find out rather than guess at.

WATCH ON NEXT LAUNCH: this changes how the local station is told to start
and stop a race. It should be identical - ReceiveNetworkPacket dispatches
the same message to the same object - but it is the launch path, so a race
that does not start is this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:26:04 -05:00
CydandClaude Opus 5 1596feb636 The recording is sound; it is missing its first packet
First live recording, from a Live Cam watching one racer: 2,937,892
bytes, 14,342 packets. Verified independently of the game by walking the
file - the declared payload matches the bytes present exactly, and the
packet chain walks to the exact end of the file at exactly 14,342
packets, which is the count the log reported. The timestamps span 308.9
seconds, which is the five minute race. Every packet carries fromHost 3,
the one racer, and the mix is 15 entity creations against 14,326 updates.
The motion is all there and the format is right.

What is missing is the frame around it. Every packet came from clientID 3,
the interest manager, and none from the network manager - so there is no
LoadMission, no RunMission, no StopMission in the spool. On a camera host
the console is LOCAL: it posts those messages straight into the
application rather than sending them over the wire, so the tee, which sits
on the receive path, never sees them. The review build got them because
its console was a remote machine.

That is exactly one blocker for playback, and a specific one:

    NetworkPacket *packet = (NetworkPacket*)spool->GetPointer();
    Verify(packet->messageData.messageID == RunMissionMessageID);

Playback requires the FIRST packet in the spool to be RunMission, and
ours is an entity update.

So the remaining work is not "capture more" - the pod motion is complete -
it is to synthesise the handful of control packets the local console never
sends, with RunMission at the head of the file. Small and well defined,
but the ordering is the whole of it and it wants doing carefully rather
than quickly.

Also fixed: the size in the log read GetBytesUsed AFTER SaveAs, which
rewinds the stream, so a 2.8MB recording reported "0KB".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:18:02 -05:00
CydandClaude Opus 5 127b8077f5 A solo race has no packets to keep
The SPOOLS folder was empty after a solo run because there was nothing to
put in it, and the code said so nowhere.

Recording captures the packets this station RECEIVES. A race with no other
machines in it neither sends nor receives any -
L4NetworkManager::ExclusiveBroadcast walks the remote host list and a solo
race has none - so the tee is never called, the recorder never arms, and
Save returned in silence. Correct behaviour, invisible reasoning.

The comment I put in the front end claimed the opposite, that "a
single-player run records as readily as a lobby one". It does not, and the
claim is now the truth instead.

The same gap has a consequence I had not drawn out either: a RACER's
recording is not the whole race, because its own pod is simulated locally
and never arrives as a packet. A Live Cam races nothing, so every pod
reaches it over the wire - it is the only station that hears the lot,
which is a better argument for the feature than the one I started with.

Say all of this where it will be read: the log now explains an empty
recording instead of leaving the folder to be puzzled over, and the front
end explains why the row is offered on races that cannot use it (hiding it
conditionally would read as a bug of its own).

Not fixed here: capturing locally simulated entities, which would make
solo recordable and a racer's spool complete. It is feasible -
Entity::Execute already produces each local update in wire form every
frame whether or not anyone is listening, and NetworkPacketHeader is four
fields, all of them available locally - but it means synthesising packets
that were never sent, and that wants proving against playback rather than
landing on the evening of a test with players.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 12:11:06 -05:00
CydandClaude Opus 5 f5ad3036cc Keeping the race, chosen under YOUR ROLE
The recording tee, which is what the Live Cam was for.

L4SpoolingApplication turned out not to be the obstacle it looked like.
The review build already records by teeing - it spools each packet and
then hands it on - and what tied that to a review build was never the
recording but where the buffer came from.
MissionReviewApplicationManager is only a pool allocator, and SpoolFile
takes whatever buffer it is handed, so SpoolRecorder owns one buffer and
needs none of it.

One hook covers what the review build taps in two places. Both
InterestManager and NetworkManager derive from NetworkClient, so
NetworkClient::ReceiveNetworkPacket sees entity updates and mission
control alike, and it sits before Dispatch so a packet is kept whether or
not anything downstream wants it. The recorder arms on the first packet
rather than at the green light, because playback rebuilds the world from
the LoadMission and RunMission packets and a spool that starts at the flag
cannot be replayed.

Two things a live recorder must do that the review one did not.

It must not touch the packet. The spooler restamps in place with local
arrival time, which is right in itself - playback paces off those stamps
and packets from different senders carry different clock origins - but the
sender's timestamp is what Simulation::ReadUpdateRecord hands to
RP412NETCLOCK and from there to the projection. Overwriting it live would
feed arrival jitter into where remote pods are drawn, which is the tick
just fixed. So the write position is taken first and the COPY is stamped,
in the spool, afterwards.

And it must not take the race down. SpoolFile::SpoolPacket answers a full
buffer with PostQuitMessage - a fair end to a replay, and killing the race
being recorded on a live host. The recorder checks the room first and
stops, and says so.

RP412RECORDSIZE defaults to 100MB rather than the review build's 6, on
Cyd's call: a full grid sends around 17KB a second, so six megabytes is
six minutes and a hundred is an hour and a half, which costs nothing on
any machine that can run this.

Saved at the buzzer - the first of the two StopMissions, the end of the
race rather than the fade timer - into SPOOLS\<timestamp>.spl and copied
to last.spl, matching where the review build looks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:54:53 -05:00
CydandClaude Opus 5 94e1cf2cf0 A window that answers while it waits
Launching a network race connects to each machine in turn and retries
while one is not listening yet, because they finish loading at different
moments. That part is deliberate and stays. What was not deliberate is
that the wait slept without pumping messages, so Windows saw a process
that had stopped answering and painted the whole thing "Not responding" -
for up to two minutes, with no indication of which peer was missing, how
long remained, or any way out. It runs before the engine block, so there
is no render loop keeping the window alive either.

Three changes, both transports:

Pump while waiting. Every sleep on the connect path goes through
NetTransport_PumpAndSleep, so the window keeps painting and can be moved.
It is re-entrancy guarded, because dispatching a message can run
application code that reaches a connect of its own, and nested pumping
would deliver messages twice and let an inner wait swallow the escape
meant for the outer one.

Shorten the deadline. Two minutes suited the arcade, where a pod that was
still booting would always answer eventually on a LAN with nothing else to
go wrong. Over the internet a machine silent for twenty seconds is not
coming. RP412CONNECTWAIT, 2 to 300, default 20, documented in environ.ini.

Say what is happening. The title bar names the peer and counts down, and
ESC gives up at once - the title being the one surface guaranteed to exist
this early, since there is no renderer yet to draw a progress screen with.
The cancel latch is cleared when a connect sequence begins so that an
escape pressed during one race cannot cancel the next.

The Winsock path is only partly fixed and the code now says so: connect()
there is still blocking, since the socket is only made nonblocking after
it succeeds, so an unreachable host - filtered rather than refused - still
sits in the OS SYN retry for around twenty seconds. Fixing that needs
FIONBIO before connect() and a select() on our own timeout. Left for when
LAN play comes up; a Steam host goes through SteamNetTransport::Connect,
which is fully covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:36:46 -05:00
CydandClaude Opus 5 b0b40559d5 A fraction of zero is a place, not an absence
The drawn pod stalled about fifteen times in 293 frames while the
simulation stepped perfectly smoothly through the same window - and 15 of
293 is 5%, which is exactly the count of frames the earlier trace found
sitting at a render fraction of zero. That was the whole clue.

GetRenderToWorld tested the fraction to decide whether to interpolate at
all:

    if (renderStepFraction <= 0 || !RenderInterpolationEnabled())
        *out = localToWorld;

Drawing at fraction f means drawing at the start of the step plus f of it,
so f = 0 means the START of the step. localToWorld is its END. The two are
a whole step apart, about a metre at racing speed.

behind is a whole number of milliseconds against a 20ms step, so it lands
on exactly zero roughly one frame in twenty. On those frames the pod was
drawn a full step ahead of itself and snapped back on the next one. Three
times a second at 59fps, regular because the beat between frame rate and
step rate is regular, and worst when a pod crosses the view quickly -
which is the symptom as it was first described, and it took this long to
find because every simulation trace was right. Only the drawing was wrong.

Ask renderStepTaken instead, which is what the condition meant all along.
Interpolating at f = 0 is continuous with its neighbours: each frame
advances the drawn position by frame_time / step whether or not a step
boundary falls between the two, which is the entire point.

The same mistake was in DPLEyeRenderable's rebuild gate, using the
fraction as a proxy for whether interpolation was running. Same fix.

renderStepTaken is cleared where localOrigin is assigned outside the step
loop, so a stale snapshot is never blended from.

Render path only - localOrigin is untouched, so physics, collisions and
determinism are unaffected.

The foreign-eye rejection added in the previous build turned out not to be
the cause: it rejected between zero and three samples per window against
stall counts in the twenties. Keeping it, since sampling one viewpoint
against another was still wrong, but it was not this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:15:53 -05:00
CydandClaude Opus 5 d680cce5a2 Twenty frames exactly is a render schedule, not a race
The anchor worked where it was aimed. Per-step stalls in the replicant
went from 11, 16 and 36 in a window to 0, 0, 0, 0 - four consecutive
windows clean - and the single capture that remains shows a pod braking,
steps rising 0.214 to 0.261 and then shortening, which is a pod slowing
down rather than a target jumping.

The on-screen count did not follow, and its own numbers say why. The
period came back as 0.34s with minimum and maximum identical to six
figures: twenty frames, exactly, every time. Nothing in a network or a
simulation keeps time that well. A render schedule does.

A camera station draws the map on the gauge wheel as well as the world,
and that pass runs the eye renderable too, so gEyeFrame ticked for it and
the trace was comparing the map viewpoint against the main one. The stall
and lurch counts converging on the same number said it too, since a stray
viewpoint yields one short step going out and one long one coming back -
in pairs, which is what the counts became.

Reject a sample whose eye has jumped more than twenty metres since the
last one, and count the rejections rather than hiding them. A real camera
at racing speed moves under a metre between frames, so the threshold is
far outside anything legitimate while still tolerating a genuine cut from
one trackside camera to another.

That is the fifth time in this hunt the instrument rather than the game
turned out to be at fault, and all five were the same mistake: sampling
across two frames of reference that were never the same one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:06:41 -05:00
CydandClaude Opus 5 331bc10365 The deadline belongs on the sender's clock
The prediction test answered clearly, though not through the verdict
label - that compared two noise floors with no absolute threshold and so
cried TIMING over errors of a millimetre. Read the magnitudes instead:
extrapolating the sender's own position and velocity across the gap
between two of the sender's own timestamps lands within 0.0005 to 0.011m.
Constant velocity holds to MILLIMETRES over one interval.

Against corrections of 0.25 to 0.66m that is a factor of five hundred, so
the two cannot be the same quantity. The corrections are not prediction
failure at all - they are the latency offset, which is what a dead
reckoner is supposed to carry.

That leaves the target, and the fault is mine. The dead reckoner projects
to updateOrigin + velocity * (nextUpdate - lastUpdate), so that difference
becomes a DISTANCE once multiplied by speed. lastUpdate is the sampling
moment RP412NETCLOCK computes, on the sender's clock. The median predictor
I added set nextUpdate from Now(), ours - so the subtraction spanned two
different timelines and yielded the interval plus however late that
particular packet ran.

At 52 m/s each millisecond of that is 52mm. Fifteen milliseconds of
ordinary jitter is three quarters of a metre of target error, enough to
collapse a one metre step to a third, and only on the packets that ran
late. An intermittent tick, worst when a pod is close and fast - which is
the symptom as it was reported.

Anchor nextUpdate to lastUpdate and the difference is the predicted
interval exactly. The target then depends on what the sender said and how
fast it is going, and not at all on the route the packet took.

NetClock confirmed live in the log, offset 52735ms, which is these two
machines' launch times differing now that the clock counts from launch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 11:01:17 -05:00
CydandClaude Opus 5 d147c093b2 Score the sender against itself
The tick is now fully accounted for. Corrections arrive about 26 times a
second per pod, mean 0.25 to 0.66m against a step of roughly a metre.
localOrigin is never snapped during a mission - only updateOrigin is
replaced - so the pod position stays continuous and its TARGET jumps. The
lerp then translates a target discontinuity into a step-size one: fifteen
steps smooth to a tenth of a percent, then a single step at 20-33%, then
recovery. Every capture has that shape and the ratios match the correction
size arithmetically.

Position is C0-continuous, so no amount of position interpolation can hide
it; the discontinuity is in the rate.

Before smoothing anything, ask whether the correction is even real. Half a
metre at 52 m/s is ten milliseconds of travel, and a dead reckoner tracking
constant velocity across a 38ms gap should be right to within centimetres.
That smells like evaluating the projection at the wrong instant rather
than like a prediction that genuinely failed.

Settle it without involving any clock we do not trust. Take the position
and velocity the sender reported last time, carry them forward by the gap
between the two SENDER timestamps, and compare against the position the
sender reports now. Both stamps come from one machine, so latency, clock
offset and RP412NETCLOCK play no part whatsoever.

Split the error along the path and across it. Along is time: divided by
speed it IS the milliseconds the window is out by, and its sign says which
way. Across cannot be a timing fault at all - that is a pod turning, and
no clock fix would touch it.

TIMING says fix the extrapolation window and the tick shrinks at the
source with no smoothing and no lag. MANOEUVRE says the corrections are
honest, the pods really are cornering, and smoothing is the only remaining
answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:50:53 -05:00
CydandClaude Opus 5 da7c35cac1 Sample the stall, not the calm before it
The raw step lengths came back immaculate - 1.02918, 1.03069, 1.03186,
monotonic to a tenth of a percent - in the very window that counted
sixteen stalls. Both readings are correct. The twelve printed steps were
the FIRST twelve of the window and the sixteen stalls were among the other
two hundred and thirty nine, so the trace sampled a calm quarter second
and said nothing whatever about the tick.

That also disposes of the alternation theory it was built to test: where
the pod moves steadily the steps are steady, and no high-low beat exists
to find.

Keep the last sixteen steps rolling instead, and freeze a copy the instant
a stall is seen, along with the ratio that triggered it and the dead
reckoner blend fraction at that moment. What prints is then the run-up to
an actual tick with the tick last in the list - the shape at the event
rather than the shape near it.

The stationary-pod windows remain ratio noise and stay discounted: steps
of a few tenths of a millimetre make every ratio meaningless, which is why
the capture requires a full sixteen-step history behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:38:42 -05:00
CydandClaude Opus 5 3c49bc4fd1 A running mean is blind to alternation
The split is unanimous and one detail settles it: in two windows the eye
travelled 0.0001m - a trackside camera standing perfectly still - and the
pod stalled 15 and 29 times anyway. CameraShip::FollowGoal is exonerated,
and so is the pan. It is the pod motion itself.

That contradicts this trace only in appearance. It has been comparing each
step against a RUNNING MEAN, and a running mean cannot see an alternating
pattern: high, low, high, low averages to the mean and nothing ever looks
anomalous. The renderer compares each frame against the PREVIOUS one,
which catches exactly that, and counted 15 to 46 stalls in the same motion
this trace called clean. So the mean test never ruled out uneven motion.
It only ever ruled out drift.

Apply the same consecutive test one level down, and keep twelve
consecutive step lengths verbatim so the shape can be read rather than
inferred from counters. Twelve steps is a quarter second at 50Hz - long
enough to show a beat, short enough to fit on one line.

The clock fix confirmed itself in passing: the reported intervals are now
multiples of 17ms, the frame time, which is the honest resolution for a
per-frame detector. The 1/32s artifact is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:33:57 -05:00
CydandClaude Opus 5 2bd824e16e The clock counts from launch, not from boot
Chasing the tick turned up why its period looked quantised: every interval
the trace reported was a multiple of 1/32s, which is the spacing between
representable float32 values near 474196 - this machine's uptime in
seconds. GetRTC returned QueryPerformanceCounter scaled to milliseconds
since BOOT, so (Scalar) Now() was a number near half a million and had
lost resolution accordingly.

That is not only a measurement problem. Scalar is a 32-bit float, so any
absolute time held in one degrades as the number grows: 3.9ms apart after
nine hours of uptime, 15.6ms after a day and a half, 31.25ms after three
days - past which the clock cannot resolve a single 20ms physics step.

Two places subtract absolute times in float and inherit it. L4CTRL polls
the joystick when (Scalar)Now() - lastJoystickUpdate exceeds 50ms, and
lastJoystickUpdate is a Scalar, so that test becomes 62.5ms after three
days of uptime and 125ms after twelve: a player's controls get less
responsive the longer their machine has been switched on, with nothing on
screen to explain it. The smoke emitter in L4VIDRND compares myLastSmoke
plus an interval against now, and once the interval falls under the
spacing the addition rounds to no change at all.

Separately, GetRTC returns a long, and milliseconds since boot overflow
one after 24.8 days.

Counting from launch fixes the whole class at the source. Every Time
arithmetic path is untouched, because those subtract ticks as integers and
were always exact - which is also why the simulation itself was never
affected, and why the render fraction measured clean. The origin is taken
in Startup rather than on first use, so it is fixed before anything reads
the clock and no two threads can race to set it.

Peer machines already disagreed about this origin, having booted at
different moments, so the network is no worse off; reconciling that is
what RP412NETCLOCK does.

The fix is self-checking: the trace's interval readings should stop being
multiples of 0.03125.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 10:25:35 -05:00
CydandClaude Opus 5 5cd9783d38 Ask whether the pod stalled or the pan did
Two things are now ruled out with evidence rather than argument.

Render interpolation is healthy. behind is computed by Time::operator-,
which subtracts ticks and only then converts to float, so it carries the
clock full millisecond precision. With a 20ms step that gives the
fraction twenty possible values, and the measured 17 frames of 293 sitting
at zero is 5.8% against the 5% a sawtooth crossing zero would produce by
itself. Mean 0.474, never once pinned at 1: it is sweeping correctly.

And the trace period was never a period. Every interval it reported -
0.03125, 0.125, 0.375, 1.90625 - is a multiple of 1/32s, which is the
spacing of a float32 near 474196. That is this machine uptime in seconds,
because GetRTC returns QueryPerformanceCounter scaled to milliseconds
since boot, so (Scalar) Now() is a number near half a million and its
resolution has decayed to 31ms - coarser than the physics step it is being
used to time. The stall COUNT is unaffected and real, at three to five a
second; the interval between them was measurement noise. Logged separately
as its own defect.

What is left is that the angle is measured BETWEEN the pod and the eye,
so a hitch in the pan reads exactly like a hitch in the pod - and the
symptom is a pod moving PAST, which is when the pan rate peaks. Measure
each one on its own, by the same consecutive-frame ratio, and let the
trace say which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:24:09 -05:00
CydandClaude Opus 5 9c886efd8d A running mean cannot keep up with a pod that is closing
The period trace came back with no period - intervals scattered from 0 to
2.5s - so the twenty-step renormalisation is not the culprit and neither
is any other fixed cadence. That is a real answer, and it also exposed a
fault in the instrument that produced it.

The angular step varies six hundredfold across the samples, 0.12 mrad
with the pod at 119m against 77 mrad at 5.4m. Judging each frame against
a long running mean therefore reports the mean own lag as a lurch
whenever the pod is closing, which is exactly what the window matching
the reported symptom was doing: 26 lurches at 5.4m range, almost all of
them measurement artifact.

Compare each frame against the frame before it instead. Consecutive
frames of a smooth pass are nearly equal however fast the sweep, so the
ratio is immune to range, and a tick is specifically one frame that
barely moves followed by one that catches up - so time the stalls, not
the lurches.

Also read renderStepFraction directly, per drawn frame, which should have
been the first measurement taken. It is the number the interpolation
actually uses. Sweeping smoothly from 0 to 1 means interpolation is
working; pinned at 1 means the simulation is behind and every frame is
drawing the same latest step, which is stepping at the physics rate no
matter how clean the packets were. The camera station rasters the map and
the gauges as well as the world, so falling behind is entirely plausible
and would show here and nowhere else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:09:07 -05:00
CydandClaude Opus 5 8d372ec067 The tick has a period, and the period is the evidence
Rejecting the sender-stall theory was right: a gap of over a second is a
death pause, not a tick. The remaining symptom is described precisely - a
RHYTHMIC tick as a pod moves past the camera - and rhythm is the clue.
A fixed period points at a cadence in our own code, because the network
has no period.

There are at least four candidates and they are only distinguishable by
their interval: the 20ms physics step, the 30ms update rate, the sawtooth
in the dead reckoner blend (percent climbs from 0.29 to 0.87 across each
update interval as lastPerformance approaches nextUpdate, then resets),
and the twenty-step quaternion renormalisation in Mover::BeginStep, which
falls at 0.4s - a few times a second.

So measure the interval rather than guess among them. Per frame, take the
angle the traced pod subtends at the eye, flag the frames whose angular
step is far above the running mean, and report the time BETWEEN those
events. Angle rather than distance because a pod crossing the view moves
far across the screen while barely changing range, which is the geometry
the tick was reported in.

Both samples come from one frame, marked by the eye's own frame counter
rather than assumed from draw order, and the renderer reports on the same
pod the mover trace describes - MoverTracedEntity now exposes that latch.
Two traces about two different pods, or two different frames, would
compare nothing; that mistake has already cost this investigation three
wrong answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 09:01:55 -05:00
CydandClaude Opus 5 9107fb4cd9 A quiet sender and a stalled loop are not the same silence
With the clamp in, spikes now occur only in windows where updates stopped
arriving. Steady state is clean - zero spikes, 250 of 250 steps blending,
the prediction within 18ms - while the one window holding a 1.236s gap
carried all four spikes. The clamp is doing exactly what it was built to
do: 0.01976 / (0.25 + 0.01976) is 0.0733, matching the logged blend floor
of 0.0732601 against the old 0.0097.

So what is left is not jitter. It is the absence of data for over a
second, and no predictor can invent motion it was never told about.

The useful question is whose silence it is, and the answer is already in
the arrival pattern. A long gap followed by ordinary 30ms gaps means the
sender went quiet - their machine or the connection. A long gap followed
by a burst of near-zero gaps means the packets were sitting in the queue
while OUR loop was busy elsewhere, and we read them all at once the
moment it came back. From inside the dead reckoner the two are
indistinguishable, and they want opposite fixes.

Record the widest gap, the count over 200ms and the count under 5ms, per
entity, and let the trace name which pattern it saw. This matters more
than it might: the camera station only recently began rastering the map,
which is exactly the kind of work that stalls a main loop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:55:32 -05:00
CydandClaude Opus 5 178714198a The warmup path was the one that skipped the clamp
Live trace of the median predictor: in steady state the tick is gone -
zero spikes over five seconds where there were seven, every step blending
instead of 182 in 250, the blend fraction floored at 0.294 instead of
0.014, and the prediction within 13 to 20 ms of the gap that followed it.

But the log also read "predicting 2.054s", above the 1.0s clamp, which
should not have been reachable. It was: the fewer-than-three-samples path
returned the raw gap without clamping it. The arithmetic identifies it
exactly - 0.0201 / (2.054 + 0.0201) is 0.009685, against a logged blend
fraction of 0.00968523.

So the old near-stall survived, confined to the first three updates after
an entity appears. That is every respawn, and a pod is being watched
closely at exactly that moment.

Route every path through one ClampPredictedInterval, and tighten the
bounds now that the real send rate is known to be about 30ms: nothing
slower than half a second enters the sample window, and no prediction
reaches beyond 250ms. The second of those puts a floor under the blend
fraction itself - at a 20ms step the worst case is 0.02/(0.25+0.02),
roughly 7% of the gap per step, so a pod converges on its projection in a
dozen steps rather than crawling toward it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:46:31 -05:00
CydandClaude Opus 5 ec815b6216 One late packet is not the new rate
Another player's pod is moved between updates by dead reckoning, which
advances it toward the projected position by a fraction of the remaining
gap each step:

    percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)

That fraction is decided by nextUpdate, so predicting when the next
packet lands is not cosmetic - it sets how far every step moves. The
prediction was labelled HACK in the original source and deserved it: the
next gap was assumed to equal the previous gap. On a LAN the gaps were
all alike and it held. Over Steam a single late packet doubles the
expected gap, percent collapses, the pod barely advances for one step and
then catches up on the next - a visible tick.

Measured on a live connection, in the replicant's own step grid: 7 spikes
in 5 seconds, about 1.4 a second, matching the reported symptom, with
percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.

Predict from the median of the last eight gaps instead. A median has a
breakdown point of half its samples, so one straggler - or three - moves
it not at all, while a genuine change in the send rate still carries it
within a few updates. Gaps that are non-positive (duplicate or reordered)
or multi-second (a join or a stall) never enter the window, and the
window is reset outright when the stream is interrupted.

Against the measured jitter pattern the blend fraction holds 0.282..0.286
where it previously swung 0.095..0.294 - a spread fifty times smaller.

This changes how remote pods MOVE, not merely how they are drawn, so it
feeds collisions with them as well. RP412NETPREDICT=0 restores the old
prediction for comparison on the same build, and the environ.ini entry
says to keep the setting alike on every machine in a race.

The RP412CAMLOG trace now also reports the interval being predicted and
the worst one-step-ahead miss, scored per entity rather than through the
file-scope statics the percent readings use - those are written by
whichever mover ran last, which is exactly the crossed frame of reference
this whole investigation kept tripping over.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:30:01 -05:00
CydandClaude Opus 5 e029113ade A replicant is measured in its own step
Three previous attempts at "is the watched pod's motion uniform" all
sampled it from somewhere else - the camera's step grid, or an arriving
packet's timestamp - and two independent clocks alias against each other
whatever the game is doing. Those numbers could never separate a real
hitch from the measurement's own beat, and each of them cost a deploy to
find that out.

This one has a single frame of reference: consecutive steps of the entity
being asked about, taken inside Mover::DeadReckon, which IS a replicant's
own per-step performance.

It also reports the mechanism rather than only the symptom. percent is how
far each step moves toward the projected position:

    percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)

so it depends on nextUpdate being a decent guess at when the next packet
lands. A poor guess makes the fraction swing, and a swinging fraction is
uneven motion however clean the packets were. The trace reports its range,
how many steps blended rather than snapped, and spikes and stalls against
the entity's own running mean.

One entity only, the first replicant seen, because the counters are shared
and a full grid would blend into noise.

What confirms: spikes or stalls in the entity's OWN steps, or a percent
range that swings. Either is real, because there is no second clock here to
blame. What refutes: uniform steps and a steady percent - then a replicant's
motion is fine and the tick is in presentation, and the remaining suspects
are frame delivery and the map raster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 08:07:46 -05:00
CydandClaude Opus 5 9a674e9241 The camera measures its own smoothness
The last instrument was mis-specified: it compared an arriving update
against our current position, but those are at different times, so most of
the 0.40 m it reported was latency times speed rather than prediction
error. It could not have shown a visual jump even if one existed. The
follow trace agreed - per-step motion stayed tight at 1.36 to 1.68 m and
tracked speed, with no outliers riding on top.

It did settle one thing: corrections arrive 43 times a second, near the
50 Hz step rate, so whatever ticks a few times a second is not one per
correction.

So measure the symptom instead of a theory about its cause. A visible tick
IS a step that moves much further, or much less, than the steps around it,
and that is now counted directly: spikes above 2.5x a short running mean,
stalls below 0.4x, judged against the mean rather than an absolute distance
because a pod at 75 m/s moves 1.5 m per step and one against a wall moves
nothing. Respawns are counted and excluded - they teleport hundreds of
metres and are meant to be discontinuities.

Stated in advance, so the result cannot be read to taste: spikes at a few
per second confirms the tick is in entity motion and gives its rate; spikes
and stalls near zero refutes it, and points at frame delivery or the
newly-active map raster instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 07:53:44 -05:00
CydandClaude Opus 5 5c7e218c98 A room cannot mix builds, and the log stops hiding things
Three guards, all of them paid for by an actual wasted afternoon.

THE BUILD GUARD. kNetRevision is hand-maintained and only bumped when
someone judges that a change alters the simulation, so two different builds
normally carry the same revision and will race each other happily - which
is how a 4.12.184 machine and a 4.12.187 machine sat in the same room all
day. The exact build now travels as lobby data and member data, and it is
checked in two places:

  - on JOIN, before the room is ever entered, with a dialog naming both
    builds. The owner's launch check would have caught it eventually, but
    only after everyone had picked a loadout and pressed go, and all it can
    do then is decline to start - which reads as the host's button being
    broken.
  - at LAUNCH, beside the revision check, so a member seated before the
    guard existed still cannot race.

The room screen shows BUILD <version> against an offending row, because a
host is owed the reason as well as the refusal. An absent value counts as a
mismatch: a build older than the key cannot be trusted to match. Both
guards stay - the revision still refuses a mix known to simulate
differently even where the build strings agree.

THE DUPLICATE KEY WARNING. environ.ini is applied line by line, so a second
copy of a key silently beats the first. A TARGETFPS added at the top of the
file was overridden by the one the template ships further down, and the test
it was written for looked as though it had failed rather than never having
run. Now: "TARGETFPS is set twice - line 1 and line 8; the LATER one wins",
naming the key and both lines, because which and where is the whole value.

THE MISSING FACTS. TARGETFPS appeared nowhere in the log, so no run could
be checked afterwards against what it was actually asked for, and
interpolation only announced itself when switched off - there was no way to
confirm from a log that it was on. Both are now stated outright, with a
warning when a frame target differs from the physics rate AND interpolation
is off, which is the combination that steps.

Verified: the duplicate warning names lines 1 and 8 of a file carrying both,
the frame line reads "144 fps, drawing on exact physics steps" with the
mismatch note when interpolation is off, and "60 fps, drawing interpolated
across the physics step" with no note when it is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:58:27 -05:00
CydandClaude Opus 5 46aef0691e The replicant says how hard each update moves it
A tick a few times a second survived render interpolation, and it would:
interpolation smooths WITHIN a fixed step, and this is a discontinuity in
the stepped values themselves. Between updates a replicant is dead-reckoned
from updateOrigin over (lastPerformance - lastUpdate); when the next update
lands the basis is replaced and the position jumps by however far the
prediction had drifted. A few updates a second is a tick a few times a
second.

So Entity::ReadUpdateRecord now measures the correction before applying it:
how many arrived in the last five seconds, and the mean and worst distance
between where we had the entity and where the update says it was. Replicants
only, behind RP412CAMLOG, on a clock so a busy race cannot bury the log.

If that reads a few per second at tens of centimetres, the tick is named
and the fix is to damp the correction in rather than apply it instantly -
which is a real piece of work and worth sizing on evidence. If the
corrections are tiny, the tick is something else and this rules it out.

Also worth recording: RP412GAUGEDIAG is blind on a camera station. It only
reports when a full display sweep completes, and the missing-MFD bail
resets that counter every cycle, so the gauge theory could not be tested
that way. The camera's own symptom description did the work instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:49:17 -05:00
CydandClaude Opus 5 adcb81e7fc Replicants interpolate too
The first cut hung the snapshot off Mover::BeginStep, which is inside
Entity::PerformAndWatch's fixed-step interleave - and that interleave sits
entirely inside "if (GetInstance() != ReplicantInstance)". A replicant
never runs it; it reaches the step loop through Simulation::PerformAndWatch
instead. Every remote pod is a replicant, so on a Live Cam the camera was
being interpolated while the car it was watching still stepped. Smoother,
and most of the way to nowhere - which is exactly what "still some
hitching" was.

So the hooks move to Simulation::PerformTo, where both paths meet:
SnapshotRenderOrigin before each Perform, SetRenderStepFraction after the
loop, two virtuals that do nothing by default and are overridden by Entity
because Entity owns the origin. Mover::BeginStep goes back to what it was,
so there is now one mechanism instead of two.

Taking the snapshot inside the step loop is also strictly better placed
than BeginStep was: it lands immediately before the integration, and still
after any BeginStep teleport, so a VTV's scheduled respawn stays a cut.

Entity::PerformAndWatch keeps computing the fraction itself after its
interleave, because there PerformTo is called once per step with a till
one step ahead and so sees no leftover at all - it needs the FRAME's till,
which only the interleave has.

Determinism re-proved, and more thoroughly than the first time. The
scripted lap at 240 fps, interpolation on and off, on both the old build
and this one: all four runs agree to the last decimal at the same
simulation time - pos -15.06739 3.01541 388.02603 at t=15.260. The one
"differing" sample in the raw comparison was the trace sampling at t=1.260
in one run and t=1.280 in the other and then realigning, not divergence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:36:44 -05:00
CydandClaude Opus 5 9e8a94c436 RP412INTERP is in the documented file
Every knob the code reads belongs in environ.ini's template, and the
interpolation switch was missing from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:15:18 -05:00
CydandClaude Opus 5 449ed5d297 Drawing interpolates across the fixed step
The simulation advances in whole 50 Hz steps and the renderer draws
whenever it can, so the drawn position only changed fifty times a second
and was held for however many frames fell inside a step. That is visible
as stepping, and it got WORSE the faster the machine: at 240 fps each
position is held for nearly five frames, which is why a fast PC looked
like a rabbit on crack while TARGETFPS=50 looked perfect. Matching the two
rates hid it, but locking the frame rate to the physics rate throws away
the entire point of having a fixed step.

So drawing now blends. Entity keeps renderPreviousOrigin - its origin at
the start of the step it is in, snapshotted by Mover::BeginStep - and
renderStepFraction, how far through that step the frame falls, which is
the leftover Entity::PerformAndWatch deliberately does not simulate.
GetRenderToWorld blends the two with Origin::Lerp, which already did
position and shortest-arc quaternion with normalisation.

It is RENDER ONLY. localOrigin and localToWorld are untouched, so physics,
collision, scoring, the nav map's queries and the network update records
all still see exact stepped values.

Three call sites. RootRenderable::Execute, which was the single place a
vehicle's transform reached the matrix stack - the renderable already ran
per frame and simply re-read a value that changed at the physics rate. The
eye needed its gate widened as well: it rebuilt the view only when
localToWorld CHANGED, so the world would have glided while the camera went
on stepping and the judder would have moved rather than gone. And a
teleport must stay a cut - that falls out free, because VTV::BeginStep
applies a scheduled respawn and THEN calls Mover::BeginStep, so the
snapshot lands post-teleport and the blend has nothing to travel.

The picture trails the simulation by up to one step, 20 ms at 50 Hz. That
is the standard price of interpolating rather than extrapolating, and much
the lesser evil: guessing forward overshoots and shimmers every time the
guess is corrected.

RP412INTERP=0 turns it off so the stepping can be seen again without a
rebuild.

Determinism proved rather than asserted: a scripted lap (RP412INPUTSCRIPT,
throttle and steering and pitch) at 240 fps with interpolation on and off,
90 PHYSTRACE samples over 22 seconds of driving, zero differ. Two earlier
attempts at that comparison were invalid and both were my method - the
first did not pin RP412SPAWNZONE so the runs began on different pads, and
the second had no input script, so a joystick sitting on the desk drove
the two runs differently. The template warns about the first of those in
as many words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 23:14:39 -05:00
CydandClaude Opus 5 55e751648f The camera says whether it is tracking a staircase
Janky tracking on a Live Cam, and the rotation is a spring-damper on the
fixed simulation step, so it should glide. Which leaves the input rather
than the smoothing: the camera follows a REMOTE pod, whose position only
changes on this host when an update lands. If the watched point sits still
for most steps and then leaps, the camera is tracking a staircase
faithfully and nothing in the rotation can hide it.

So FollowGoal now reports, every five seconds: how many simulation steps
it ran, how many of those saw the target move at all, the largest single
jump in metres, and how many times the trackside camera was cut to a
different one. At 50 Hz that is about 250 steps per report, so:

  moved near 250      the target moves every step - look elsewhere for
                      the jank, most likely frame pacing
  moved near 50       the target changes about ten times a second and the
                      camera is stepping between arrivals
  biggest jump large  confirms leaps rather than drift
  several cuts        the trackside camera is flip-flopping, which snaps
                      rather than glides and is its own kind of jank

The cut count is worth having because timeOnCamera is 0 for a race - the
director sets it to 0 outside football - so the closest-camera choice is
re-evaluated every step and only hysteresis stops it oscillating.

Not baselined locally: FollowGoal only runs on a camera station with a
peer, so unlike the nav and copy traces this one goes out unverified
against real numbers. The counters are simple enough to trust; the
interpretation above is what to hold it to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:26:15 -05:00
CydandClaude Opus 5 7a3b117f61 The sweep steps past a display it cannot draw
A Live Cam's map was blank and its score frozen at 1000 while the gauge
canvas underneath was being drawn perfectly - 50 static entities and one
mover, every sample, centre tracking the camera. The trace that settled it
found zero "map copy running" lines against that: the canvas was alive and
simply never reached the pane.

SVGA16::Update services one display per call and steps mDisplayToUpdate at
the END of the function. Both of its early bails returned before ever
getting there. A camera's cameraInit page configures the secondary port
and nothing else - no auxUL2, auxC, auxUR2, auxLL or auxLR - so the MFD
branch could never be serviced, and the first time the counter landed on
an MFD slot it stopped dead. Display 0's copy is the map, so it ran once,
early, and never again. The pane kept that one frame for the whole race: a
blank map, because nothing had registered with the renderer that early,
and a score showing its opening value. "It had name and score at the start
of the mission" was the tell, and it was accurate.

Both bails now step the rotation on the way out, so the sweep moves past a
display it cannot service instead of parking on it. A station with no MFDs
therefore copies its secondary every third call, which is the same cadence
a pod gets.

Pod regression: the map copy still runs every pass at the same ~9900 lit
pixels as before. A pod has all five MFD ports and never takes either
bail, so that path is untouched.

Three wrong diagnoses preceded this one - the gauge page, the renderer
link, and a snapshot theory - and each died to a measurement rather than
an argument. The trace that found it was worth more than any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 22:05:49 -05:00
CydandClaude Opus 5 d08404bf50 The room calls you what you typed
The lobby published SteamFriends()->GetPersonaName() as each member's
name, so the callsign box on the setup screen changed nothing anybody
could see - the room and the race both showed Steam personas instead. And
because the name never came from a file, wiping the install directory did
not shake it loose either, which is what made it look like stale data
being read from somewhere.

It now publishes the callsign. The persona stays as the fallback for a
player who has never set one, on the grounds that appearing as yourself
beats appearing as "Pilot".

This fixes the race as well as the room: a member's name in the egg comes
from the same published field, so the owner was building eggs full of
Steam personas too. The owner's own entry already used the typed callsign,
so the two were inconsistent in the same race.

RPL4FrontEnd_Callsign exposes what the front end already keeps and
persists in pilot.cfg. Verified the accessor reads it: a pilot.cfg
carrying callsign=TESTCALL logs FrontEnd: callsign "TESTCALL". The lobby
publish itself needs a room with a member in it, so that part rides on the
next two-machine run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 20:52:15 -05:00
CydandClaude Opus 5 0371544b88 The map copy says whether the canvas went dark
A Live Cam's secondary display carries its name and score at mission start
and is black later on. Placement is not the cause - a POD with
L4RADARPOS=LEFT fills the same corner properly, track lines, labels,
mini-map and all - so the pane and its position are fine and something
stops.

Three faults wear that one symptom: the per-frame copy stopping, the
source gauge canvas going blank underneath it, or the pane not repainting
what it was given. The copy now reports, every five seconds, that it ran
and how many non-zero pixels the source canvas holds (one row in sixteen
sampled, enough to tell blank from not). So:

  no line at all          the copy stopped
  line, lit falls to ~0   the gauge canvas went blank
  line, lit stays high    the pane is not showing what it was handed

Baseline from a pod, whose map demonstrably works: a steady 9900 or so lit
pixels every pass, 640x480 source, mask 0xff. Verified before shipping
this time, rather than after drawing a conclusion from it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 19:32:04 -05:00
CydandClaude Opus 5 2adbeba6c4 The nav trace was measuring the wrong five seconds
It sampled the first five sweeps, which run while the mission is still
coming up and nothing has registered with the renderer yet. So it reported
"0 static, 0 moving" on a POD, whose map demonstrably works - and I took
that reading at face value and concluded the map was broken for everyone.
It was the instrument, not the game.

Now sampled on a clock like the ranking trace, every five seconds, and the
bounds line repeats too so the centre can be seen tracking. The real
baseline from a pod race:

  CamLog: nav in bounds - 0 static, 0 moving      (first sample, loading)
  CamLog: nav in bounds - 44 static, 1 moving     (running, and stays)

44 static is the track, 1 moving is the player, and the centre walks with
the vehicle. That is what a working nav display looks like, so a camera
station can now be compared against something real rather than against a
startup artefact.

Found by putting the pod's map on screen next to its own trace, which is
what should have happened before the first conclusion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:43:07 -05:00
CydandClaude Opus 5 04f72d4e3f A camera's map is placed by its own knob
The host picks Racer or Live Cam from the setup screen, so the two roles
cannot share one placement setting - switching would mean editing
environ.ini every time, which is not a thing to ask of anyone. L4MAPPOS
places the camera's map and L4MAPSCALE sizes it, taking the same values as
L4RADARPOS and L4RADARSCALE and defaulting to the bottom-left corner. A
pod keeps its own pair and its dead-centre default, which is where the
cabinet had it. Each role remembers its own; one file serves both.

The scale is split for the same reason as the position: a host who wanted
a bigger map while camming would otherwise have hit exactly the same
problem the next time out.

The log now names which variable it read - "map on the bottom left
(L4MAPPOS)" - because a setting that silently loses to another one is how
this went unnoticed in the first place. Both new keys are in the shipped
environ.ini template, so they are discoverable without reading the source.

Nothing to edit on an existing install: a file with L4RADARPOS set and no
L4MAPPOS gives the pod its centre and the camera its corner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:39:01 -05:00
CydandClaude Opus 5 1cd8911df0 The nav display says what it can see
A Live Cam's map came up as furniture with nothing in it, and two
confident explanations for that turned out to be wrong in a row - the
cameraInit gauge page exists, and the gauge renderer IS linked to the
camera ship, so both the page and its centre were fine all along. Two
falsified guesses is the signal to measure instead, so RP412CAMLOG now
traces the nav display: the scale and centre it resolves, the bounds it
asks about, how many static and moving entities come back, and whether the
sweep reaches the phase that actually draws. The ranking widget reports
the players it can see with their rank and score, every five seconds so
movement shows without flooding the log.

It answered both questions on the first run, from a plain POD race:

  CamLog: nav scale 1000m across, 0.448 px/m, centre 12,416
  CamLog: nav in bounds - 0 static, 0 moving
  CamLog: nav drew (phase 3 reached)

A pod's nav map is as empty as a camera's. Sane scale, resolved centre,
drawing phase reached, and nothing registered to draw - so this is not a
Live Cam defect at all, it is a hole in whatever should be filling the
gauge renderer's staticEntities and movingEntities, and it has been there
for every station all along. A camera only made it obvious by having
nothing else on the glass.

The score, by contrast, tracks properly on a pod - 1000, 1005, 1148 across
half a minute - so a camera host frozen at 1000 is genuinely camera
shaped, and the ranking trace will say whether it sees the racer at all.

Also here: a camera station's map defaults to the bottom-left corner
rather than the pod's dead centre. Centre is where a cabinet wanted it and
the worst place to put a panel on a picture. L4RADARPOS still overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 17:16:29 -05:00
CydandClaude Opus 5 af82cac5d2 A Live Cam keeps the map and drops the panes
A Live Cam host raced with the full glass cockpit over its view: five
instrument MFD panes with no pod behind them, compositing as black
rectangles across the viewscreen, and the map stood on end. Both are
cabinet artefacts. The panes belong to a pod; the map is portrait because
that is how the glass was bolted into the cabinet. A camera has no
cabinet, so it keeps the map - landscape - and nothing else. The arcade
got the same result by running its camera cabinet with -lc.

The role is picked in the lobby, so there is no -lc on the command line to
carry it to the renderers. Application::IsCameraStation joins
suppressGauges, set by the front end where the egg settles
owner_is_camera - before the renderers exist, since the single-binary race
loop builds a fresh application per race after the menu. Set on every
launch either way: a stale True would strip the cockpit off the next pod.

In the compositor: the five MFD panes are not built, which LayoutCockpit
and FillSplitMFD already tolerate, so no layout surgery; the map's glass
swaps to 432x324 while still honouring L4RADARSCALE and L4RADARPOS; and
the per-frame copy runs straight through rather than rotating 90 degrees
clockwise. The pane's source dimensions swap to match, so the buffer the
copy fills is the size the pane allocated - checked both ways.

Untested on screen, unlike the rest of this work: it only engages with a
lobby member present and that needs the second machine. What is checked is
that it builds, that the buffer arithmetic agrees, and that every pane
access on the glass-cockpit path is NULL-guarded.

L4MFDSPLIT=2 and =0 are left alone. The exploded view still opens five
empty MFD windows for a camera; it is a diagnostic view where that is
arguably the point, and its layout derefs the panes unguarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:56:58 -05:00
CydandClaude Opus 5 aa2ca0376a The camera HUD only reads the slots it filled
A Live Cam host launched into a real race took the process down with an
access violation. WER put the fault at image offset 0x76239, which with
this build's fixed base resolves against the shipped PDB to
CameraShipHUDRenderable::Execute - the dereference in the ranking loop.

Two defects, both older than Live Cam. playerRank is SPARSE: it is sized
for the racing players plus the camera players and filled by
playerBitmapIndex - 1, exactly as the constructor's own comment says. But
it was allocated with new[], which does not zero, and Execute walked it
densely to playerCount and dereferenced every slot. Any bitmap index that
nobody claimed was therefore uninitialised heap read as an int*.

Live Cam is simply the first thing that can leave a gap at the front. The
host takes bitmap index 1 and, being the camera, is not in the "Players"
group, so slot 0 was never claimed while playerCount was still 1 and the
loop still ran. The arcade never hit it because its camera cabinets sat
after the pods in the egg, so slot 0 belonged to a real racer and the
dense walk was accidentally safe.

So: clear the array on allocation, remember its real length as rankCount,
and iterate that while skipping the unclaimed slots. The playerCount
bound was wrong on its own terms too - a race whose bitmap indices run
past it would have missed the tail.

Everything before the crash worked on the first try, which is the other
half of the news: with a live racing peer the camera director builds, the
camera ship comes up and starts directing. The map-load hang that stopped
a camera host before was an artefact of it having no peers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:34:22 -05:00
CydandClaude Opus 5 1a5a4a220c A Live Cam host brings no vehicle
Picking Live Cam and then hosting still showed the host in the room with a
VTV and a colour, because the room row draws whatever loadout the member
published and nothing on the wire said the host had given up its grid
slot. The lobby now publishes a cam key with the rest of the member data,
carries it in MemberInfo, and the owner's row reads LIVE CAM where a
loadout would go.

The loadout itself still goes out unchanged. Live Cam is a role, not a
vehicle, so vh/cl/bd keep carrying what was picked and switching back to
Racer finds it all still there.

Only the owner's row shows it. The host is the one that writes the egg, so
the host's pick is the only one acted on; a member who set Live Cam is
still going to race, and its row goes on saying so. Painting every cam=1
row as LIVE CAM would have the room screen lying about the grid. Letting
members spectate too is a real feature - the same hostType=1 on their egg
entry, plus a guard that one racer is left - but it is not this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:14:18 -05:00
CydandClaude Opus 5 487eaa3a36 The host can watch instead of racing
YOUR ROLE joins the setup screen at the head of the loadout column -
Racer or Live Cam - so the host still picks the track, the conditions and
the length, and still owns the lobby and marshals every pod, but can hand
its own grid slot back and watch. It rides the existing group machinery
(kRoles through GroupSize/GroupTitle/ItemName), so there is no new UI
code, and it persists in pilot.cfg beside the rest of the loadout.

Offered only when Steam is configured, the same condition the HOST and
JOIN buttons use: the role means nothing without a lobby to host.

In the egg the host's own entry becomes hostType=1 and vehicle=camera.
Those two are what RPRegistry::MakePlayer reads to build a camera
director rather than a racer, and they are all it takes - the arcade
selected its camera cabinet exactly this way, from egg data alone.

The pick is honoured only when other pods are actually in the race.
pilot.cfg remembers Live Cam, so someone who set it for a lobby race and
later launched a single-player one would otherwise hand the map load a
camera host with no peers, which is the one configuration known to hang
it. With no extras the choice is logged and ignored and the host races,
which is what pressing LAUNCH on a solo game meant anyway.

Still open: a camera host with real racing peers has never been tried, so
whether the map-load hang survives contact with a live race is the next
thing to find out; and nothing yet tells lobby members that the host is
spectating, so their room screen just shows one fewer car.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 15:01:10 -05:00
CydandClaude Opus 5 a5c1e0291c The camera bring-up says how far it got
A Live Cam station is selected entirely by egg data - hostType=1 on that
host's entry plus vehicle=camera - so no code path announces itself and a
station that fails to come up leaves a log that simply stops. RP412CAMLOG=1
traces the sequence: the stand-alone host and its type, the local player
node and game model, the interest-arena and interest-manager loads, the
registry choosing a director, the director making its camera ship, and the
launch handshake it waits on. Off by default.

What it found immediately: a camera host wedges inside
InterestManager::LoadMission - the map-entity load - and never returns.
Everything upstream is correct (the egg parses, hostType 1 is adopted,
gameModel reads 'camera'), and the same call with a racing egg passes
straight through to making the player and launching. Same map both times,
so it is the local host's TYPE that the map load cannot digest, not the
map. That is a defect to fix before a lobby has anything to switch on.

Also recorded while chasing it: a racing -egg run sits in application
state 11 with the low-priority queue never empty for its whole life, and
still simulates - so CheckLoad's no-console self-launch is not what
starts a stand-alone race. Worth knowing before trusting that path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 14:04:26 -05:00
CydandClaude Opus 5 ef4a5e501d The wireframe stands against black
Alt+W held the sky dome solid so the edges had something to read
against. Looking at it, the dome was the problem: lit, fogged and
filling the upper half, it washed out everything behind the near
geometry. A whole structure over the track in the middle distance was
invisible until the sky came away.

So the view now clears to BLACK under wireframe and the sky pass is
skipped entirely. Skipping costs nothing - the dome only ever covers
pixels the clear already owns - and it removes the two fill-mode
brackets that used to wrap the pass, so the frame is simpler than it
was. Both changes are conditional on gWireframe; the solid path clears
to the fog colour and draws its sky exactly as before.

Fog still applies to the edges, which is why the middle distance tints
toward the fog colour instead of staying bright. That is depth
information, so it stays.

environ.ini's template and BUILD.md both said the sky stayed solid.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 12:50:44 -05:00
CydandClaude Opus 5 0f4592c39a The wireframe key works and Steam is optional
Alt+W had been a no-op since the 2007 DPL->Direct3D port, which stubbed
DPLToggleWireframe along with every other dpl_ call. D3D9 has no global
wireframe property, so the toggle now records the intent in gWireframe
and ExecuteImplementation applies D3DRS_FILLMODE once per frame - which
also means it re-asserts itself after a device Reset. The sky dome and
the 2D pass are held solid: a wireframed dome buries the geometry you
turned the key on to look at, and the gunsight would otherwise come out
as bare diagonals.

Proving it needed a working copy, and that turned up a separate problem.
steam_api.dll was a hard import, so a machine without it died at load
time with 0xC0000135 - before a window, before a log line. It is now
delay-loaded, and because delay loading only moves that failure to the
first call, every Steam path is gated on
SteamNetTransport_ClientLibraryPresent(): Install and the two lobby
entries, with everything else downstream of one of them. Absent DLL
boots and races; absent DLL with RP412STEAM=1 logs the reason and stays
on TCP; DLL present brings the transport up exactly as before.

The documentation now says which debug keys are real. Five of the seven
are still 2007 stubs and always have been, so they are named as inert in
environ.ini's template and at the dispatch site - that beats letting the
next person debug a dead key, which is how this started. BUILD.md gains
a debug-key table, the delay-load contract for anyone adding a Steam
call site, and the environ.ini BOM trap that silently reverts
L4CONTROLS to KEYBOARD and then fail-fasts for want of a pod mapper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-10 11:15:11 -05:00
CydandClaude Fable 5 34abf40e7f Fifty hertz is the physics
RP412PHYSICSHZ defaults to 50: the simulation advances in fixed 20 ms
steps whatever the display does, and every machine plays the same race.
The proof preceded the promotion - a scripted lap with a crash, a burn,
a tumble and two respawns runs bit-identical at 30, 60 and 144 fps, and
identical runs reproduce exactly, neither of which was ever true of
this engine at any frame rate.

Fifty because it is exact on the engine's millisecond clock (a rate
like 60 quietly becomes 17 ms steps wearing the wrong name), and
because its settled hover ride height measured closest to the
frame-coupled physics the game has always run - the least change of
feel for the most change of correctness. The pods' 25 and the smoother
100 stay one line away for the play testers, and 0 keeps the original
frame-coupled behaviour for comparison, where the frame rate is part of
the simulation.

Carried-over environ files do not mention the option, so existing
testers get 50 on their next build and rpl4.log names both the option
they have not heard of and the mode every launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:05:45 -05:00
CydandClaude Fable 5 67d57452ba The podium hold asks whether there is a podium
RP412PODIUM=0 promises "straight to the results" and delivered eleven
seconds of black screen first: the winners' circle hold was applied
unconditionally at the buzzer, and the timer never asked whether there
was a stand to hold the mission open FOR. Found by the -egg harness,
which could reach the end of a race unattended and noticed the promise
not being kept.

With the podium off, the hold now stands aside and the base 3-second
race fade runs the show. With it on, RP412PODIUMHOLD tunes the length
(1-60 seconds, default the same 11 as always) - eleven seconds of one
parked pod is a long look in single player, and that is now a choice
rather than a constant. The decision point logs which path it took and
the value it applied, verified all three ways:

  podium off - the race fade stands (3s) and the results come straight up
  holding the mission open 5s for the stand
  holding the mission open 11s for the stand

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:04:18 -05:00
CydandClaude Fable 5 c479cd48e9 The death cycle is deterministic
A scripted lap - full throttle, a steer, a crash at speed, the burn,
the tumble, death, respawn, a second crash, a second respawn - now
plays out bit-identical between identical runs and across 30 and 144
fps under RP412PHYSICSHZ. Ninety of ninety samples exact in the repro
pair, sixty of sixty across frame rates, max difference 0.000000. The
crash was already deterministic; this makes the RECOVERY deterministic,
and it took five pieces, every one found by measurement:

- The respawn teleport moves onto the vehicle's own step grid.
  VTV::ScheduleRespawn stores it and BeginStep applies it at the first
  step whose clock reaches the due time, teleport and turn-toward-goal
  together, because the goal flip reads the POST-reset heading. The old
  path applied the Reset from the event queue, which runs on wall
  clock, and identical runs diverged on the first step after the pod
  stood back up.

- The handler keeps its Reset for the FIRST spawn of a mission, gated
  by a flag rather than by mode. A Mover is born in StasisState and the
  first Reset is what wakes it; gating on "is fixed stepping on" - the
  first attempt - skipped that wake-up and parked the pod frozen at its
  spawn point for an entire race. The scripted-lap harness caught it in
  one run.

- The vehicle stamps its own death clock, at the single site that sets
  BurningState - inside the step machinery, which is why the crash
  measured exact. The schedule anchors to the death, the last
  step-exact event in the chain.

- The due time is quantized to a half-second grid ANCHORED AT THE
  DEATH. The instrument showed the naive anchor was four seconds stale
  by scheduling time: the fry chain reposts itself at wall-clock
  Now()+2.0 and the drop-zone reply lands about five sim-seconds after
  death, jittered by a few steps of queue timing. Firing "next step"
  inherited that jitter whole. Rounding up to the next half-second
  after the death puts hundredths of jitter against tenths of headroom,
  so every run lands in the same cell - and the felt delay stays the
  six-ish seconds it has always been.

- The out-of-world tumble draws from a per-vehicle random stream seeded
  by creation order. The global Random is shared with the frame loop's
  consumers - particles, mostly - so its position at the moment a
  burning pod drew from it depended on how many frames had rendered,
  and the kick went straight into angular velocity. Last wall-clocked
  input in the whole death cycle.

The respawn scheduling and firing log under RP412PHYSTRACE in
run-comparable terms - pad identity, due offset, lateness - because
those lines are what cracked this: "due in -4.06 sim-s" said more in
one glance than three rounds of hypothesis.

Still outside the claim: multi-vehicle contact (DynamicBounce writes
the victim's state from the striker's step) and network play. That is
the lockstep frontier, and it now has a harness waiting for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:42:04 -05:00
CydandClaude Fable 5 af52476603 The pod can drive a scripted lap
RP412INPUTSCRIPT names a timeline file - one row per change, throttle,
stick X and Y, pedals, held until the next row's time - and the pod
drives it instead of listening to the controls. Times are SIMULATION
seconds from the green light, evaluated per step in the one place every
mapper funnels through (VTVControlsMapper::InterpretControls), so the
same script is the same lap at any frame rate. Rows hold rather than
interpolate on purpose: interpolation would sample differently at
different physics rates, and nothing on this path is allowed to.

The script shares the green-light anchor with RP412PHYSTRACE - its
clock starts at the instant the vehicle is stopped dead - because a
timeline that starts when the loader happens to finish is a different
lap every run.

A race is only deterministic if somebody DRIVES it, and a human cannot
drive the same lap twice. The first scripted lap - full throttle, a
steer, a crash at speed - earned the harness immediately:

- The drive, the crash, the death and the respawn teleport were all
  BIT-EXACT between identical runs, through t=13.5. Collisions with
  world geometry and the damage path are step-deterministic, which is
  better news than the code reading suggested.

- The first divergence is the step AFTER the respawn: the DropZoneReply
  that stands a dead pod back up is posted at wall-clock Now()+1.0
  (RPPLAYER.cpp), so the reset lands on a different sim step every run
  and everything after is time-shifted. The crash is deterministic; the
  RECOVERY is not. That is the next fix, and it is now a measurement,
  not a theory.

Values are clamped at load, once and visibly, so a script asking for
throttle 2.0 cannot trip the mapper's own range Verifies. Off unless
the environment names a file; it would be a cheat in a real race.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 21:03:03 -05:00
CydandClaude Fable 5 c2e2df1dce Every knob the code reads is in the documented file
A sweep of every getenv() in the tree against the environ.ini template
found five options the code answers to that the file never mentioned:
the physics trace, the spawn-zone pin, the gauge profiler, the renderer
diagnostic and the joystick-scan log. They were deliberately env-only
once - scaffolding, not settings - but scaffolding that cannot be found
is scaffolding that gets rebuilt, and RP412RENDERDIAG had already been
forgotten thoroughly enough that this sweep is what rediscovered it.

They get their own section, between the shipped configuration and the
optional extras, with the header saying what they are for: making a
claim about the game testable instead of arguable. All five ship
commented out, cost nothing when off, and none belongs in a real race.

The sweep now closes empty - there is no environment variable the game
reads that the file does not document - and the mention-check keeps it
honest from here: a build that grows a new option names it in rpl4.log
for every carried-over file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 20:36:14 -05:00
CydandClaude Fable 5 9643e02198 The physics rate is the play testers' question now
RP412PHYSICSHZ documented in environ.ini, under TARGETFPS where it
belongs, with the three rates worth testing: 25, the arcade pods' rate
and the step the original handling was tuned against; 50, the middle
road; 100, the smoothest contact response. All three divide the
engine's millisecond clock exactly and all three are verified
bit-identical across frame rates.

The entry says what to feel for - hover bounce, wall hits, how the pod
takes a hill crest - and asks for the rate alongside the verdict,
because whichever one the testers pick becomes the canonical physics
for PC and pods alike. It ships commented out: the default stays the
frame-coupled game everyone knows until that decision is made on
purpose.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:20:03 -05:00
CydandClaude Fable 5 2a679ab571 A race can be replayed exactly, so physics claims can be tested
Three pieces of harness, all env-gated and inert in normal play, that
turn "do two frame rates play the same race?" from an argument into a
number:

- RP412PHYSTRACE=1 samples the player vehicle's position on the
  SIMULATION's own clock - the vehicle's lastPerformance, which advances
  in whole fixed steps - so two runs sample at identical step counts and
  their traces compare exactly. Frame-time sampling compares different
  instants and calls the difference physics; an earlier version of this
  trace did exactly that, and its noise was chased as if it were drift.
  At the green light it stops the vehicle dead, because the pod
  simulates on its pad while the mission loads and a load is never the
  same length twice: two runs reached the start 776 and 599 steps in,
  same position, different velocity.

- RP412SPAWNZONE pins which drop zone is tried first. The pick is
  Random(), and Random() is seeded - but a seed only repeats a run if
  the same NUMBER of draws precedes the pick, and that count rides on
  load timing. Same seed, different pad, incomparable traces. Pinned,
  the zone is tried first and falls back to the random walk if taken,
  so it cannot wedge and changes nothing unless set.

- The trace prints the global step counter, which is what caught the
  force-accumulator bug: the position columns can look plausible while
  the step column says the physics ran a different number of times.

With these three and RANDOM= (which already existed), a race is
repeatable to the bit, and the determinism matrix - rates by frame
rates by repeats, run as parallel sandboxed instances - is a regression
suite: any mismatch in any cell is a real bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:20:03 -05:00
CydandClaude Fable 5 5e47987508 The simulation steps at a fixed rate
RP412PHYSICSHZ names a rate and the simulation advances in whole steps
of exactly that size on every machine, whatever the display does. 0 -
the default, and the shipped behaviour until the play testers have
spoken - is the game as it has always run: the step is however long the
last frame took, which makes the frame rate part of the physics.
Measured over two seconds of free fall, a 30 fps machine's pod fell
three times further than a 144 fps machine's. Two players on the same
track were not in the same gravity.

With a rate set, the same race is bit-identical across frame rates:
30, 60 and 144 fps produce the same trajectory to the last printed
digit, and identical runs reproduce exactly - which was never true of
this engine before, at any frame rate.

It took three pieces, and every one was found by measuring, not by
reading:

- Simulation::PerformTo turns lastPerformance into the accumulator it
  always secretly was: whole steps while time remains, the remainder
  carried to the next frame. Watchers and update records stay once per
  frame - stepping is physics, watching is I/O.

- Entity::PerformAndWatch interleaves subsystems and entity per STEP.
  The frame loop ran all subsystems to the frame boundary and then the
  entity, indistinguishable from correct at one step per frame - which
  is why thirty years of code never noticed - and wrong at two: the
  thrusters raycast twice from a vehicle that had not moved, and the
  hover spring fired twice on one stale height sample. The subsystems
  are also snapped onto their entity's step grid; each Simulation
  anchors its grid at its own creation time, a per-run phase no seed
  could pin.

- Mover::BeginStep clears the force accumulator per step. It was
  cleared once per frame while the thrusters ADD per step, so step two
  of a frame integrated step one's thrust again - and how many steps a
  frame holds rides on wall-clock jitter, which is why identical
  configs measured a quarter-metre apart. The quaternion renormalise
  counts steps now too, for the same reason.

The catch-up clamp is a quarter second of simulation whatever the rate,
so a machine that cannot keep up slows down rather than seizing, and
does so identically everywhere. The engine's clock counts milliseconds,
so rates that do not divide 1000 - 60 among them - quietly run at the
neighbouring millisecond step; the log now says so and names the exact
ones. 25, 50 and 100 are exact, and all three are verified bit-identical
across frame rates and across runs.

Verified for a single vehicle settling under gravity and hover. Driving,
collisions and the network are the next frontiers, in that order: the
collision path writes the victim's state with wall-clock stamps and a
hard-coded 0.1 s bounce, which single-player survives and lockstep will
not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:19:43 -05:00
CydandClaude Opus 5 74aa5ae98d A hand-fed egg can run a whole race
'-egg' skips the menu and drops straight into a mission, which is the
developer shortcut - and it installs no console, so nothing ever ends the
race. Everything after the chequered flag was therefore unreachable from
the command line: the buzzer, the fade, the winners' circle, the
teardown. All of it could only be exercised by hand through the menu.

RP412MISSIONSECONDS now marshals a hand-fed run as well, so a whole race
plays out unattended. That is the difference between a shortcut that can
be watched and one that can be TESTED, and it immediately earned itself:
it caught RP412PODIUM=0 holding the mission open for its full eleven
seconds with the podium switched off. The environ file promises "straight
to the results"; the hold is applied without asking whether the podium is
on, so what you actually get is the same wait against a black screen.
That one is not fixed here - it wants a decision about the hold's length
as well - but it is now reproducible in one command.

pack-dist keeps frontend.egg for the same reason. It is written on launch
and holds the menu's last selection, so it is what lets '-egg
frontend.egg' drop back into the track under test - and a repack was
wiping it, which turns the next run into a zero-byte file and an abort on
"no map in egg".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:13:16 -05:00
CydandClaude Opus 5 1dd40be0a3 The map draws on every step of the rate wheel
The renderer walks a sixteen-step rate wheel: one step per full pass over
the gauge list, shifted right each pass and reset at the bottom. A gauge
redraws only on the step its configured rate names, so the map - on one
step - waited a whole turn of the wheel however cheap its redraw was.

With the frame budget fixed the wheel turns about fifty times a second
and one-in-sixteen would be tolerable. It is still the wrong shape for
the map: the thing a pilot reads to navigate should not be the display
that updates least often, and RP412MAPRATE says how many of the sixteen
steps it draws on. Sixteen by default, one for the old data-driven
behaviour. Each extra step costs one gauge's redraw against a pass that
runs ninety of them, which measured as nothing.

The write has to be QUALIFIED, and that is worth recording because it
cost hours. GPS's constructor takes its rate as a parameter also called
'rate', which shadows the inherited Gauge::rate for the whole body - so a
bare assignment sets the parameter and leaves the member holding whatever
the gauge data asked for. oldRate is not shadowed, so it took the value,
and the pair then disagreed: rate=2000, old=ffff. That looked exactly
like something writing the member from outside, and there is no such
writer - Gauge touches rate in three places, none of which can produce
that pair. A hardware write-watch on the member settled it by reporting
an address on the STACK.

Also here, the terrain-arrival work on the map background. It draws one
placement into the cached picture when the static bounds are unchanged,
and rebuilds the whole thing only when they move - the bounds set the
scale, and the scale is what everything already on the picture was drawn
at. It is honest to say this fires rarely: the logs show terrain arriving
in one burst at mission load, not streaming in as you drive, so the
incremental path is mostly insurance. What it does close is real, though
- departures now order a rebuild. Nothing listened for those before, and
they had been swept up by the rebuild the next ARRIVAL ordered, which on
a track whose terrain all arrives at load is a rebuild that never comes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:13:03 -05:00
CydandClaude Opus 5 b7b2c3b148 The GPU transforms the vertices
The cockpit displays were updating every two to three seconds while the
3D view held a perfectly smooth 55 fps. This is why, and it is one line.

Every device was created D3DCREATE_SOFTWARE_VERTEXPROCESSING - every
vertex on the track transformed and lit on the CPU, on the one core this
game uses for everything. That was not a choice when the engine was
written; there was no hardware to hand it to. The error message beneath
the call still says "Couldn't create HARDWARE_VERTEXPROCESSING device",
so the flag was changed at some point and the message left behind.

Measured on the biggest track, 1920x1080:

  software   foreground 17.2 ms   background 1.2 ms   2.4 gauge passes/s
  hardware   foreground  0.2 ms   background 17.9 ms  50.0 gauge passes/s

The frame loop runs the foreground and then spends whatever is LEFT on
the background gauge work. A foreground costing 17.2 ms of an 18 ms frame
leaves nothing, so the gauge loop got the single pass it is guaranteed
and no more. A pass needs about twenty steps - eighteen gauges and three
display copies - so the cockpit ran at two passes a second, and since the
renderer walks a sixteen-step rate wheel, a gauge on one step redrew once
per SIXTEEN of those. Three seconds. The map, the clock, the boost gauge
and the sim still running after the fade to black were all that one
number.

Hardware T&L is now the default and sw is the way back. Fixed-function
lighting and fog are not bit-identical between the old software path and
a driver, so the escape hatch stays - but the picture was checked against
both and the difference is not the one worth defending. A cockpit whose
instruments update twice a second is. It falls back to software by itself
if the adapter has no hardware T&L.

The instruments that found it stay in, because nothing about this was
visible from outside:

- FrameSplit, under RP412GAUGEDIAG, reports foreground against background
  against whole frame. APPMGR has computed those four timestamps every
  frame since forever and never reported one of them; it would have
  pointed here on the first day.
- FrameDiag reports frames per second on the same window, so the gauge
  sweep rate can be read against the frame rate rather than guessed at.
- ProfileReport, which already existed and was only reachable through F11
  on the RIO controls mapper - not the mapper a desktop player runs, so
  in practice unreachable - now runs on a timer under RP412GAUGEPROFILE.
  Its per-gauge line gains the rate mask and tier, which is what names a
  display as one-in-sixteen rather than merely slow.
- The winners' circle logs what its exterior and name-plate rebuilds
  cost, since nothing else runs while they do.

RP412VSYNC is here too, and it is honest about itself: presenting
IMMEDIATE was measured and made no difference to the frame budget,
because the frame was full of work rather than waiting. It stays as a
latency-against-tearing preference, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 13:12:34 -05:00
CydandClaude Opus 5 827c5b295b The controls answer only while the game is the window in front
Testers taking notes in another window were flying the pod while they
typed. RP412INPUTFOCUS=1 is the new default; 0 restores the old
behaviour.

The pod was the only thing running on its cabinet, so the virtual RIO
reads the key state directly rather than waiting on the message pump.
That is the right call for latency and it is why the pedals feel like
pedals - but a direct read is a read of the WHOLE keyboard, whatever has
focus. On a cabinet that distinction did not exist. On a desktop it is
the difference between writing a bug report and steering into a wall
while you write it.

One choke point does the whole job: PadRIO::PollInputs is where the
keyboard, the XInput pad and the DirectInput stick are all read, so a
single flag covers the three of them. The joystick needs no change of
its own - unfocused the resolve block is skipped, every device slot
stays at -1, and the button, hat and axis loops find no device and read
released on their own. It is opened DISCL_BACKGROUND on purpose, or it
would stop answering the moment a cockpit pane took focus, so declining
to poll it is what makes it go quiet.

Each source reads as RELEASED rather than the poll returning early, and
that is the part worth keeping: bail out instead and whatever was held
at the moment you switched away stays held until you come back, which is
the stuck throttle this is meant to prevent rather than cause. Reading
released lets the diffs already in there turn it into proper release
events.

The throttle accumulator is the deliberate exception. It is the pod's
one sticky axis and it integrates what the controls ask for, so controls
asking for nothing simply stop moving it - you come back to the speed
you left rather than to a dead stop.

Focus is tested per PROCESS, not against one window handle. The cockpit
is a shell full of child panes, the exploded view is six windows of its
own and the plasma glass another; matching a single HWND would drop the
controls the moment somebody clicked an MFD.

Real RIO cockpit hardware is untouched - this is the keyboard, pad and
joystick path only. The volume and bass keys in L4CTRL were already
gated this way, unconditionally, which is where the idiom comes from.

On by default because the alternative is every tester editing a file
before the fix reaches them: an environ.ini written by an older build
does not carry the line, so the built-in default is what they get. The
log says which way it is set, and the option-mention check names it as
one they have not heard of.

Verified against the built exe both ways: a fresh run writes the
documented default and applies 14 settings where it applied 13, and a
file with the line removed reports exactly one unknown option and falls
back to focus-gated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 20:24:40 -05:00
CydandClaude Opus 5 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>
2026-08-08 19:56:13 -05:00
CydandClaude Opus 5 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>
2026-08-08 19:55:52 -05:00
CydandClaude Opus 5 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>
2026-08-08 19:55:21 -05:00
CydandClaude Opus 5 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>
2026-08-07 22:19:39 -05:00
CydandClaude Opus 5 769407ca24 The mode lamps follow the mode
Selecting NOV, STD, VET or EXP on the Upper Right MFD lit nothing and
dimmed nothing. Two separate faults had to line up for that.

SetControlsMode announced the change as
L4VTVControlsMapper::NotifyOfControlModeChange - explicitly qualified,
which suppresses the virtual call and lands on the base class no-op. The
code that drives the four lamps is VTVRIOMapper's override, so a mode
change never reached it. Its neighbour has always gone out unqualified
from VTVControlsMapper::SetConfigurationState, which is why the
configuration lamps behaved and these did not.

previousControlMode is the lamp the next change dims, and nothing wrote
it after construction set it to -1. Even once the call arrived, the dim
step would have matched nothing and the panel would have accumulated
lamps rather than following the selection.

The one call that did dispatch is the one in VTVRIOMapper's own
constructor, where the vtable is already the derived one - which is why
NOV lit at the start and then nothing ever moved.

B / S / V / M are gone from the Thrustmaster mapper's key handler. The
driving mode is a panel decision, four buttons carrying the lamps that
say which one you are in, and a bare letter key changing it behind the
player's back is not that. It reads worse in 4.12 than it ever did in
the pod: the whole letter board is the MFD banks now, so on that path
those four letters would have fired their bank button and silently
changed the driving mode as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 21:21:00 -05:00
Cyd 28790de901 Every VTV card carries its performance
Acceleration, top speed, impact speed, armor, boosts, chutes and each
tool's charges, decoded from the resource file rather than transcribed.

GameModel is a fixed 180-byte block: mass at +0, drag at +36, acceleration
at +64, impact speed at +100. Top speed is NOT stored - it is terminal
velocity, acceleration over drag, which is why it lands on the round
numbers the arcade quoted: 6.0/0.060 is Mule's 360 kph, 5.5/0.060 is
Bull's 330. Armor is a float in the DamageZones record past the "dz_vtv"
name, at +35.

Boosts and chutes come from the subsystem stream, which is now walked
properly: a record is name[32], a type id, its own length, and the charge
count sixteen bytes on. That replaces a regex that hunted for printable
names in the float tails and guessed where each one started - the new walk
matches every vehicle's declared subsystem count exactly.

Neutrino's two derived figures are withheld and the card says why. Its
drag is 0.008 against 0.052 on every other Lepton and its impact speed is
uninitialised, so the engine would give it a 2880 kph top speed. The data
is wrong, not the reading.
2026-08-07 21:04:08 -05:00
Cyd 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.
2026-08-07 16:31:40 -05:00
CydandClaude Opus 5 6e829f815e Doors run on the mission clock instead of being replicated
A door's position was an integrated countdown owned by whichever machine
the map-entity round-robin happened to deal it to. That left doors one
one-way-latency behind on every other machine, re-acquired at each state
change; drifting permanently on any frame hitch over a second, which the
old code dropped outright rather than clamping; and frozen mid-cycle,
collision volumes included, when their owning peer left, since ownership
transfer is not implemented.

Doors are clockwork with no inputs, and door/VTV physics is already local
pointer access - VTV::ProcessCollision reads door->currentVelocity off
the local object and the crush test is local VTV state - so a door does
not need an owner at all. Door::SlideDoor is now a phase function of
Application::GetMissionElapsed(), anchored so phase zero reproduces the
original DefaultState entry: fully open, starting to close. Every host
builds its own doorframe out of the map stream as a HermitInstance, the
instance kind DynamicEntityCreation does not broadcast, so nothing is
sent, nothing is received, and a peer leaving takes no doors with it.

Verified against a copy of the old integrator at 25fps with the real 10s
travel / 3s dead timings: identical 26s cycle, a constant one-frame
offset, and no drift across a 3s stall that leaves the old code
permanently 3 seconds out of phase.

Also fixes a latent bug found on the way: UpdateManager iterates the
dynamic master socket, which holds Independant and Hermit instances as
well as masters, and handed all of them to EntityUpdateReplicants, which
asserts MasterInstance.

Doorframes no longer consume a slot in the map-entity ownership cursor,
which shifts who owns every map entity dealt after them, so this cannot
share a session with an older build. The lobby publishes a simulation
revision and refuses to launch a mixed room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 16:25:42 -05:00
Cyd 0e39075a20 The track plans are the map screen's drawing again
With the models resolving properly there is nothing left to infer, so the
inference goes. Out: the gate tracing, the route/field test, the collapse
of each wall bar to a centreline, the dropping of "isolated" placements.
Every one of those existed to make sense of a track that appeared to be
one model repeated, and it is not.

What is left is what the map screen does. Every placement, its model's
GaugeImage looked up by name, laid down rotated and positioned, at the
LOD the engine would pick for that scale, in the palette the display is
configured with, on the display's own black. So the walls are grey
because index 51 is grey and the score zones are amber because sc50 and
sc500a are drawn in 56 - nothing on the page is a styling choice.

Also right way round now: +X runs right and +Z up, matching the engine and
the map viewer. The nine console pictures are still here, below each
drawing where they exist, captioned as mirrored - they are illustrations
rather than screenshots, and the page no longer quietly adopts their
handedness for everything else.
2026-08-07 15:07:36 -05:00
Cyd 9f0a77cc16 The tracks were never built from one model
A map record is an Entity::MakeMessage (MUNGA/ENTITY3.h): classToCreate,
owningPlayerID, resourceID, instanceFlags, localOrigin. The origin ends
the 76-byte case, which puts classToCreate at +28, resourceID at +40 and
instanceFlags at +44.

I had been reading +44. That is instanceFlags, and it is 524 on every
scenery record - and 524 happens to be cn3's GaugeImage. So every track
resolved to cn3 repeated a few hundred times, consistently and wrongly,
and every conclusion drawn from that followed: the "one wall bar with a
gate", the ticks, the claim that the LOD machinery is never exercised.

The id at +40 varies per placement. The tracks use cn1, cn3, cn4, cn5,
cn7, br1, br3, ft1, cq1, cq2, md3, md4, the pits, and the score zones
sc50/sc50a/sc500a that gave the amber boxes at each end. A record names
the model's Model List; the GaugeImage is filed under the same model
name, so the name is the join - and models with no gauge image (oao,
snAwork, pz1) are skipped here exactly as DrawStatic skips them.

Found by following the map loader: InterestManager::LoadMapStream reads
the stream as MakeMessages and names the map entity classes, one of which
is 95 in these records - CulturalIconClassID.
2026-08-07 14:45:20 -05:00
Cyd 86bf6a934b Map instance records carry their own length
They are not a fixed 76 bytes. The first int of each record is its length,
and while most placements are 76, every track also has eight of 140, two
of 80 and one of 336 - 560 on Paingod's. Striding a fixed 76 landed
mid-record on those, and hunting forward for the next plausible
quaternion then locked onto arbitrary bytes: that is where the impossible
class ids came from, and the "resource id" 1065353216, which is
0x3F800000 - float 1.0.

Reading the length instead, all eighteen tracks parse to exactly their
declared instance count with no bytes left over. That is the check that
was missing before.

It does not change what gets drawn, because the extra records were never
drawable anyway. It does mean the parse is no longer guessing.
2026-08-07 13:23:37 -05:00
Cyd 4979193528 The viewer uses the engine's projection, not the console's
It was drawing the map mirrored. The X flip came from the setup console's
picture of Brewer's Bane, which is an illustration and not a screenshot -
the game does not draw it that way round.

What the game does: L4GaugeImagePrimitive::Draw plots
MoveToAbsolute(dest->x, dest->z), so the screen axes are view-space X and
Z, and the graphics view's origin is bottom left with Y increasing upward
- BackgroundLine draws endpoints.bottomLeft to endpoints.topRight, and
the port's zero-degree blit is documented with the origin in the bottom
left corner. So +X runs right and +Z runs up.

Also added the heading the real display has. NavDisplay centres on the
vehicle and turns with it, inverting the viewer's transform and taking
yaw only unless rockAndRoll is set; the viewer defaults to heading 0,
which is the north-up case, and Q/E/R turn it. Panning and dragging now
work in what you see rather than in world axes, so up stays up when the
map is turned.

TRACKS.html is left following the console pictures on purpose: nine of
those cards are the console pictures, so the reconstructed nine have to
sit beside them consistently. The two disagree by a mirror and each is
right for what it is, which both READMEs now say.
2026-08-07 13:12:57 -05:00
Cyd dc74fb3867 Refresh the tracked bytecode cache
Rewritten because build_mapview.py imports navmap. This is what tracking
a .pyc costs - it will churn whenever a tool that imports navmap runs,
without ever being a source change. One .gitignore line ends it.
2026-08-07 12:41:16 -05:00
Cyd b1b82d5da1 A map viewer that draws tracks the way the map screen does
Pan with the arrows, zoom with plus and minus, [ and ] for the next
track. Self-contained HTML with the track data and palette embedded;
nothing here ships, and pack-dist.ps1 does not look at it.

It follows the engine rather than approximating it. NavDisplay derives
metersPerPixel from the zoom and sets LODIndex to it; L4GaugeImage::Draw
takes the first LOD whose scale is at least that value and draws nothing
once the value runs past the largest, so objects vanish rather than
simplify. Both map gauges are here because they disagree - nav is the
448x416 radar screen with LOD following zoom, gps the 125x203 panel whose
config pins LOD at 1.0. The HUD reports what is dropped, and is honest
that this content barely exercises it: every placement in every track is
cn3 with one LOD at scale 1000.

The map is not a phosphor screen. Primitives carry palette indices and
the palette is whichever the port was configured with - for the pod's
secondary port, configure(0,sec,270,0x00ff,clut0,rgb,secpal.pcc). PCC is
PCX, so the palette is the last 769 bytes. Walls are grey because index
51 is #4b4b4b; background is index 0, black; a primitive with colour 0
keeps the display's staticColor, 0x3C. Per-file palettes, not a global
one - 39 of 40 gauge PCCs differ - so the port's configured palette is
the one that counts.
2026-08-07 12:40:52 -05:00
Cyd 53c4eac3fd Merge restore-cut-vtvs: the cut vehicles, and the reference to go with them
Started as a question about eleven vehicles and seven maps missing from
our resource file. They were in a community 4.11 build; verifying that
file as a strict superset and promoting it brought them back, and most of
what follows came out of having to prove things about the file rather
than guess.

  * The cut VTVs restored, and the content pipeline (RPL4TOOL -b) made to
    work in this tree for the first time.
  * The front end fits 800x600, uses dropdowns, and no longer paints
    Windows grey over its own green.
  * VTV-PRESETS.html: 38 vehicles, loadouts and six-preset tables decoded
    by resource id, tabbed by hull.
  * TRACKS.html: all 18 tracks. Nine now show the setup console's own
    maps, recovered rather than reconstructed; the other nine are the
    course traced through their gates.
  * The airlock archive tracked whole, so those promotions stay
    checkable, plus the console's track, vehicle and pod art.

Two corrections worth carrying: the vehicles were cut BY 4.11 rather than
never shipped - ALPHA_1/REL410 is a cockpit of the ALPHA wing and its
RPL4.RES is the retail file - and the top-down projection was mirrored,
which the console's picture of Brewer's Bane caught.
2026-08-07 12:30:04 -05:00
Cyd 911345703f Commit the working tree as it stands
Everything outstanding, uncurated: the bytecode cache Python wrote beside
navmap.py while building the pages. It is derived from a tracked source
and tagged to one interpreter (cpython-314), so it will go stale rather
than break anything - one .gitignore line drops it again if it becomes
noise.
2026-08-07 12:18:03 -05:00
Cyd 1b603045d2 Track the airlock archive whole
A community build of 4.11 from another site. Three things have already
been taken out of it - RPL4.RES and GAUGE/L4GAUGE.CFG promoted into
assets/RP411 in f7c7000, and the console configs copied to
tools/console-config - and with the archive untracked none of that was
checkable. Keep it whole so it is.

Nothing here is read at build time or ships in a release; a README says
so, says what was promoted and why, and warns that rpl4opt.exe in this
folder is the community's 4.11 binary and not what BUILD.md produces.

It costs much less than the 111 MB on disk suggests: 767 of the 998 files
are byte-identical to assets/RP411 and git stores a blob once. The real
additions are the 223 WAVs, every one of which differs from ours, and
four audio files with no counterpart at all. Whether those WAVs are
better masters or just different renderings is unestablished, so they
stay unpromoted. Thumbs.db was already covered by .gitignore.
2026-08-07 12:13:08 -05:00
Cyd 5ad55aa1eb Nine tracks get the console's own map, and the projection is corrected
RPConfig.xml has always named a picture for nine of the eighteen tracks.
The pictures exist after all, so the page reads the mapping straight out
of the config and uses them: score zones, drop zone, the chambers drawn
properly and labelled. No reconstruction beats the real thing. The other
nine keep the course traced from their gates, and each card now says
which of the two it is showing.

The pictures also check the reconstruction. Brewer's Bane is the one
track shaped distinctively enough to be obviously wrong, and it matches
the console picture turn for turn - long leg up one side to Score Zone 1,
the corner, the run out to Score Zone 2, junction chambers spaced along
it. It matched MIRRORED. Seen from above with +Z up the page the engine's
+X runs to the left, and every plan here had been drawn the other way
round. Fixed, so the nine tracks without a picture are drawn the same way
round as the nine with one.
2026-08-07 12:08:11 -05:00
Cyd 8dcf738593 The console's reference art
Three sets, added to assets/: the setup console's nine track maps, its
nine vehicle hull pictures, and twenty-four pieces of pod art. The track
maps are the ones RPConfig.xml has always named and pointed at - the
pictures the console showed - and they had been presumed lost.
2026-08-07 12:07:59 -05:00
Cyd 56b2af5208 The track plans are the course, not the wall markers
Drawing what the map screen draws never was going to give a map. Nearly
every placement in every track is one piece, cn3, and its gauge image is
two 25x5 bars at x 19.5..44.5 and -44.5..-19.5 - not a wall along the
route but a wall across it with a 39 unit gate in the middle. The
collision solid agrees exactly. A few hundred of those is a row of ticks.

The gate is the point: cn3's origin sits in the opening, so every
placement marks somewhere the race passes through. Walking the gates
nearest to nearest, from the end furthest out, draws the track itself -
Brewer's Bane comes out as its L with the junction chambers, Zaxxis as a
circuit, and the small arena as the maze it always was.

Guarded, because chaining nearest neighbours across a regular grid
invents a maze-like path out of nothing but visit order. Each track is
tested first on how many neighbours a gate has within 1.6x the typical
spacing: a corridor gives 2, a floor of obstacles gives 4 or more. The
separation is not close - seventeen tracks score 1 or 2, the demolition
arena scores 8 on an exact 100 unit grid and keeps its wall blocks.

Most of the arcade tracks really are near-straight canyon runs, a few
hundred units wide and several thousand long. The plans say so now
rather than implying otherwise.
2026-08-07 11:31:25 -05:00
Cyd b12eaa8bb2 The blacker VTVs sort to the end, and Blacker Broccoli loses a note
They are palette variants of machines already in the list, so interleaved
they read as duplicates. The console's own name decides it - eleven of
the thirty-eight are called Black or Blacker something - so they sort
last within a hull tab as well as overall.

Blacker Broccoli's console picture is a Bug, but the game draws it on the
Mule hull like every other Broccoli: the console entry is simply wrong,
and saying so on the card raised a question the card could not answer.
Blacker Tarantula keeps its note, where the console and the game really
do disagree about the hull.
2026-08-07 11:19:27 -05:00
Cyd 4dc4f86702 The README describes the track plans as they are drawn
It promised higher ground shown brighter, which the flat masks never
did, and it predated the change that draws each wall bar as one line
rather than a box.
2026-08-07 11:08:14 -05:00
Cyd f32494f0b7 The VTV page tabs by hull
Thirty-eight cards in one run is a lot to read. The first letter of a
vehicle's two-letter art code is the hull the game actually draws it
with, and the console's class names are subdivisions of those five -
Bull and Roadblock are both 'b', Bug and Skeeter both 's' - so grouping
by the letter keeps kin together whatever the console called them.

Particles 6, Bugs 10, Mules 9, Bulls 8, Police 5, which is all of them.
The build prints a warning if any vehicle falls outside the five, so a
new hull cannot go missing from the page quietly. The tabs compose with
the armament filter and the search box rather than replacing them.
2026-08-07 11:05:25 -05:00
Cyd 5135476088 Track plans read as outlines, not hatching
Nearly every track is one model repeated: cn3, a wall bar whose gauge
image is two closed 25x5 rectangles. Five metres of wall thickness is
finer than the plan can resolve, so each bar was landing as two parallel
lines plus two end caps - several hundred times over, which is the
hatching that swamped the arenas. Collapse a thin closed quad to the
centreline between its short edges: one stroke for one wall.

Walls stacked to build height coincide seen from above, so draw each
distinct wall once. And drop placements standing alone more than 200
units from any other - fourteen tracks park a single bar at (1200,0,0)
well off the course, and that one placement stretched the frame to
twelve times the width of the track. The four tracks without it are
exactly the four that always framed correctly. A real branch keeps its
neighbours and stays: Paingod's second canyon is sixty bars out at
x=-400.
2026-08-07 11:05:16 -05:00
CydandClaude Opus 5 99030e4aac The track plans are the map screen's own drawing
The first version of these plans was a scatter of scenery positions - an
impression of a track rather than a picture of one. The game already draws
the real thing: the map screen in the pod renders the track from above every
race, so the plans now reconstruct that instead of approximating it.

NavDisplay::DrawStatic walks the static entities, looks up each one's
L4GaugeImage by resource id, and draws it through localToWorld x
worldToView. navmap.py does the same offline. The pieces that made it
possible:

  - a map instance carries its model's GaugeImage id at +44, beside the
    position at +48 and the quaternion at +60;
  - a GaugeImage is a vertex array plus per-LOD polylines through it, in
    world units - cn3 is an 89x5 wall segment, pit1 a 500x300 pit;
  - a placement whose model has no gauge image is skipped here exactly as
    DrawStatic skips it, which is why a card can report fewer placements
    carrying map art than the track contains.

The difference is not subtle. Wiseguy's Wake and Paingod's Passage resolve
into twin canyon walls running their length, Brewer's Bane into an L-shaped
route through junction chambers, and both arenas into a lattice of obstacles
inside a boundary wall. What is still missing is the driving surface: the
map draws what lines the route, never the tarmac.

tools/pages/navmap.py carries the reader, and the README documents both the
instance record and the gauge image stream. Regenerating from the committed
generators reproduces the committed page byte for byte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:42:03 -05:00
CydandClaude Opus 5 6c3127a94d Every track, seen from above
docs/tracks.html joins the roster page: all 18 tracks with a plan view, what
the console calls them, which scenarios offer them, and how big they are.

There are no track maps in the game's files. The console had pictures of
them and those pictures did not survive - RPConfig.xml still points at
"images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the
plans are drawn from the tracks themselves. A map's instance stream places
its scenery: 76-byte records carrying a position at +48 and a unit
quaternion at +60, a few of them longer, so the reader resyncs on an
unexpected class id rather than trusting the stride. The quaternion doubles
as a checksum - a mis-read almost never yields a unit one - and 17 of the 18
decode every instance the header promises. Trough gives up 631 of 633 and
the card says so.

Seen this way the tracks have obvious shapes: Brewer's Bane turns two
corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular
lattice of obstacles rather than a route at all.

The eras come from the resource-file archaeology rather than a guess: 9
tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11,
and 7 were built by the community afterwards. Scenario legality is read out
of the front end's own kMaps and kFootballMaps, so the page cannot claim a
track is offered when the menu does not offer it.

tools/pages carries the generators for both reference pages, with a README
covering the two formats they read and the id-alignment the listing is
needed for. They were scratch scripts until now, which made a committed
page harder to regenerate than to rebuild by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:27:26 -05:00
CydandClaude Opus 5 6df309e9c4 Vehicles and hulls get the names the console gave them
The roster page called vehicles by their resource key and hulls by their
two-letter art code - PUCK, "Hull PA". TeslaConsole's RPConfig.xml is the
only place either is named in words, so it now supplies both: the card says
ARMADILLO with the key beneath it, and the hull says "Armadillo hull" rather
than "Hull PA".

The class comes from the picture the console showed for each machine, so
vehicles sharing a picture share a class, and each hull is named by majority
vote of the vehicles drawn with it - one mis-set picture cannot rename a
whole class. Which matters, because two are mis-set: the console shows a Bug
picture for Blacker Broccoli and Blacker Tarantula, while the game draws
them on the Mule and Bull hulls. The page says so on those two cards instead
of quietly picking a side. Every other vehicle agrees.

RPConfig.xml and RPStrings.xml are copied into tools/console-config: the
front end's catalogs already came from them by hand, the roster page reads
them now, and they were only living inside the airlock archive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:18:55 -05:00
CydandClaude Opus 5 17bbcb2049 Every VTV on the roster page, resolved by id
The page listed 34 of the 38 vehicles in the resource file. The four
missing were community variants, and I had written down that they shared
another vehicle's mapping streams. They do not - they have their own, of 17
to 33 records. The decoder just could not find them.

Streams are stored as resources named plainly L4 and Thrustmaster, so
nothing in a stream says whose it is. The old decoder guessed by taking the
nearest preceding vehicle name in the file, which works while vehicles are
laid out one after another and fails quietly when they are not: it lost vole
outright and mis-attributed four blkr variants.

The file answers exactly if asked properly. A vehicle's ControlsMappings
List holds the resource ids of its two streams. Ids are not quite positional
- this file leaves 53 and 56 unassigned - so the directory walk is aligned
against RPL4TOOL -l, skipping the ids the listing marks Not Used. That gives
1077 ids with zero size mismatches, and every stream lands on its owner.
Subsystem names now come from each vehicle's own Stream of N Subsystems,
checked against the count in its header instead of being pattern-matched out
of the bytes.

Nothing already verified moved: lepton, dark, blkspk and neut decode exactly
as they did when checked against the 4.10 retail file, blkspk still putting
its third booster on the thumb-high in preset 4 and dark still spending
preset 5's HORN slot on its second demo pack.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 10:11:00 -05:00
CydandClaude Opus 5 b3ed7bc141 The drop-down boxes stop being Windows-coloured
CBS_OWNERDRAWFIXED only hands over the item area, so while the list rows
came out green on black, the closed box kept the system's frame and drop
arrow - a white/grey Windows control sitting in the middle of a black panel.

The closed box is painted here now: black field, dim green border, bright
green text, and a plain green triangle instead of a themed button. The
control keeps doing everything else, including dropping its list, so this is
a subclass over WM_PAINT rather than a reimplementation.

Still system-drawn: the scrollbar inside a dropped list, which only appears
on the two lists longer than twelve rows - vehicle and track.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:55:10 -05:00
CydandClaude Opus 5 44f5a2c6fd The pilot name heads the loadout column
It was tucked under the vehicle and colour boxes, which put the one
field you type into below three you only click. It now sits at the top
of the second column: who you are, then what you are driving.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:51:47 -05:00
CydandClaude Opus 5 3ed6498183 The setup menu picks from drop-downs
Every list group is a combo box now: track, time, weather, length, vehicle,
and colour/badge or team/position. Scenario stays as visible buttons because
it decides what the other lists contain, so it should not be hidden behind
one of them.

This ends a problem I had been solving the wrong way. The menu was flat
lists of everything, which was fine when the content was short enough to see
at once - the quality that made it feel like the pod panel. The promoted
resource file roughly doubled it, and I answered with two columns, then
better margins, then a general column flow, each time keeping an idiom whose
justification had already gone. Eight controls replace ninety-odd rows.

At 800x600 the columns go from 131px to 323px, so nothing ellipsizes any
more - the longest name wants 158. The whole menu now needs 310px of the 492
above the buttons there, and 529 of 900 at 1080p, so adding vehicles or maps
cannot crowd it again.

The boxes are owner-drawn - green on black, highlight inverted rather than
tinted - so they read as part of the panel instead of arriving in system
colours. They are rebuilt rather than moved when the scenario changes, since
it swaps two of them outright and reshuffles the track list.

Built and run at 640x480, 800x600, 1280x720 and 1920x1080.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:48:10 -05:00
CydandClaude Opus 5 da7f675cca The setup menu flows into as many columns as it needs
I fixed the wrong column twice. The overflow was never the vehicle list: it
was the settings column - scenario, map, time, weather, length - which sat
within 11px of the bottom at 800x600 before any of this, and went 115px past
it once seven maps were added. At 1080p it wanted 1174 of 1080.

Fixed columns cannot hold this menu any more, so the groups flow: they fill
a column, start the next, and the layout takes as many as the content needs,
sizing them to share the width. Two passes - one to count the columns, one
to place the items - so nothing has to know the count in advance. Adding a
map or a vehicle can no longer push anything off screen, which is the actual
property that was missing.

Every column starts two rows down so the pilot name box has the same home
whichever column ends up last, and the bottom is reserved for LAUNCH and the
lobby buttons. AddGroupItems is gone; the flow places items directly.

Verified by arithmetic at 640x480, 800x600, 1024x768, 1280x720 and 1920x1080
in both scenarios - nothing exceeds its width or its bottom - and by running
the front end at four of those plus a mission at 800x600.

800x600 is honestly dense: the content genuinely needs five columns there,
and the longest few names ellipsize. Lowering the row-height floor does not
buy a column back, so the rows stay at 18px and legible. Dropdowns would end
this class of problem outright and are worth considering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:29:03 -05:00
CydandClaude Opus 5 ff0e98a7a5 Four columns get margins that suit four columns
The wrapped layout inherited the three-column fractions, which are generous
for three and wrong for four. At 800x600 that put the last column hard
against the frame - 19px of slack across the whole right edge, with the
longest vehicle name needing all but 2px of its 160px column.

The wrapped case now spreads its four columns evenly on its own margins,
and the unwrapped case keeps the proportions it always had. At 800x600 the
columns go to 168px with 40px of slack; 1024x768 and 1280x720 land
comfortably too.

640x480 cannot be made to fit: four columns of long names want more width
than there is, so names there are drawn with an ellipsis rather than sliced
through a glyph. That applies everywhere, so any window too narrow for its
content degrades the same readable way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 09:03:14 -05:00
CydandClaude Opus 5 cf90698159 The vehicle list wraps instead of running off the bottom
Adding eleven vehicles to the setup menu made the column longer than the
window. At 1920x1080 it used to end at 994 against a 1080 client and now
wanted 1324, so the bottom of the list was simply off screen - and the rows
cannot shrink to absorb it, being already at the 18px floor that keeps them
legible.

The list now wraps across two columns when it does not fit, split evenly
rather than filled-then-spilled, and the loadout column - colours and
badges, or team and position - moves one place right along with the pilot
name box and the launch, host and join buttons. A roster short enough for a
single column lays out exactly as it did before, so this only changes the
screen when it has to.

Four columns still fit the width everywhere we ship: the right edge lands at
623 of 640, 1250 of 1280 and 1877 of 1920. The tallest column is 19 rows,
ending at 428, 502 and 754 against those clients.

AddGroupItems grows a 'first' argument so a group can start partway through
its own list. The items stay contiguous in fe->items, so the header still
draws once above the first of them, and each item keeps its true index -
selection and hit-testing already work off item->index rather than position.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 08:25:41 -05:00
CydandClaude Opus 5 3ba79476fc The setup menu offers the vehicles and maps that came back
The front end's catalogs are hand-written from the console's RPConfig.xml,
and the promoted resource file brought content they did not know about.
Eleven vehicles added - dark, blktrn and neut, which the console names
Blacker Puck, Black Tarantula and Neutrino, plus the eight community Blacker
variants - and seven maps, keeping the console's own convention of bracketing
non-arcade tracks in dashes.

The football map list is deliberately untouched. The console config has its
per-scenario invalid lists commented out, so it says nothing about whether
the new tracks are football-legal, and guessing would put players on a map
with no scoring zones.

A note where the catalog is declared, not a check: validating the keys
against RPL4.RES at menu time crashes, because the front end runs before the
resource file is opened and GetResourceFile has nothing to search yet. That
drift is real - this menu offered blkspk for a while before any vehicle
resource backed it - but the place to catch it is offline against the built
file, not in the boot path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:12:30 -05:00
CydandClaude Opus 5 f7c7000694 The cut vehicles come back, and eight more besides
assets/RP411/RPL4.RES is now the 1.25MB resource from the airlock archive, a
2014 community build, replacing the 785KB file RP412 inherited. It verifies
as a strict superset of ours: nothing is lost but two unnamed Not Used
placeholders, the format version matches, all 26 base vehicles' L4 and
Thrustmaster mapping streams are byte-identical to the ones we shipped, vole
matches resource for resource, and it boots against our own GAUGE, VIDEO and
AUDIO with a log identical to the baseline.

Its L4GAUGE.CFG comes with it. That file is ours plus the new vehicles'
blocks and one fix: dragonInit gains twoBoosterInit, so the dragon's two
boosters finally have gauges - it always had them in its subsystem list and
the panel simply never drew them.

dark, blkspk and blktrn are back with the tables they shipped with in 4.10,
checked against the retail file: blkspk still puts its third booster on the
thumb-high in preset 4 alone, dark still spends preset 5's HORN slot on its
second demo pack. The archive also brings neut, a four-booster Lepton class
with an Eject subsystem, eight community Blacker variants and seven maps.

The black mystery turned out to be a renaming bug rather than a vehicle that
never existed. RP411's gauge config carried a blackInit block nothing could
select, because the lookup is <model>Init and no model is named black. The
airlock config calls the same block - byte-identical body - blktrnInit,
which is the model's real name. disk is now the only genuine orphan: a panel
layout with no vehicle behind it in any resource file, and no entry in the
console's own config either.

The roster page regenerates to 34 vehicles. Four of the eight community
variants have a ControlsMappings List but no streams of their own, pointing
at another vehicle's by id, so they carry no preset table and do not appear.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 22:05:57 -05:00
CydandClaude Opus 5 8e2e00d8d3 The resource pipeline builds again
RPL4.RES is compiled from authored sources by RPL4TOOL -b, the original 1996
resource tool, which still builds in this tree. That pipeline did not work:
the tool aborted on the very first model, so the resource file could only be
consumed, never regenerated. Content could not be changed at all.

It died in PlugStream_FindEntryAndWriteObjectID resolving resource=
Translocation01 from AUDIO/PLYINT.SCP, a name defined in no file under
CONTENT/RP. The obvious suspect was the AWE32 soundbank path, since
AudioCard::LoadSBK is stubbed to return 1 by the Win32 port - but that is a
red herring. Supplying the banks changes nothing because nothing reads them
at build time. CreateStaticAudioStreamResource opens audio\static.scp, which
declares all 154 patch resources as plain text mapping each name to a bank
and patch number, and CONTENT/RP simply does not have that file. The sda4
developer drive does. Two more scripts included by 27 vehicles, VTVINT.SCP
and VTVEXT.SCP, were missing the same way. All three are kept in recovered/
because they are the keystone and are small.

No engine change was needed. The pipeline was missing content, not code.

build-res.ps1 assembles a build tree from the 4.10 content, those three
scripts, and the soundbanks and ~547 VIDEO files that RP412 ships complete
and the content tree does not. It never overwrites an authored file with a
shipped one, so archival content stays authoritative where it exists.

A model missing a skeleton is dropped SILENTLY - the tool logs and carries
on, producing a resource file with fewer models rather than failing. Check
the model count, which is why the script reports unresolved inputs.

-RestoreCutVehicles uncomments dark, blkspk and blktrn, three vehicles taken
out of the .bld after 4.10 shipped with their model ids left in place. All
three build clean and take the count from 42 to 45, exactly retail's, each
with its full subsystems, segments, damage zones and control mappings.

What this cannot do yet: eleven maps have no source. Five survive only on the
sda4 drive in a 1996 state older than retail, and otto, frstrm, burnt,
brewers, headoff and headmf are gone entirely - the .CAM cameras and .XST
existence boxes are here but the .MAP files are not. So a build from these
sources yields 45 models and zero maps against RP411's nine, and is not yet a
drop-in replacement for assets/RP411/RPL4.RES.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 17:12:00 -05:00
CydandClaude Opus 5 cf59ac9f4b Every VTV and its presets, in a page that ships with the game
VTV-PRESETS.html joins the handbook in the dist: all 26 racable vehicles,
each with its hull plan view, what it carries, and the six control presets
on its stick. The data is decoded from RPL4.RES rather than transcribed -
each vehicle's own subsystem list names its systems, and
VTV::BasicSubsystemCount fixes list index i as subsystem 9+i, so every
subsystem id in all 26 mapping streams resolves with nothing left over.
The gauge config could not have been used for this: it is only artwork,
and it lies. The dragon has boosters and a chute with no art declared,
and the burro's chute sits in a different panel slot than sequence would
suggest. Both come out right this way.

The plan views are each vehicle's own damage-gauge silhouette, redrawn
from the three-colour original as an alpha mask so it takes the page's
colour in either theme. There is no per-vehicle art to use instead:
vehicles are grouped into hull families that share both the silhouette
and the mesh in VIDEO/, which is keyed by the same two-letter code. Each
card says which hull it is and who else races the same one.

The page does not mention the intercom PTT. Its hardware never went past
prototype cockpits, so naming a control nobody can press would only raise
questions; those cells are simply blank. CONTROL-PRESETS.md carries the
full account, because anyone re-decoding the resource will find message
ID 13 on the pinky and needs to know why the tables show it empty. Two
things in the shipped assets settle it: the tool panel's fourth quadrant
is bare where every other system has a legend, and the two finished
intercom station screens are referenced by nothing at all. The edge
strips are referenced - but gated on ModeIntercom, which nothing ever
sets, so they have never been on screen either. The block that would have
wired the buttons is inside #if 0 and still names L4ModeManager, a class
that no longer exists: cut before the RPL4ModeManager rename and never
revisited.

The doc also now explains the mode-mask gate itself, since that is what
makes the dead intercom legible: one 32-bit word, seeded 0x201, read by
both the controls dispatch and the gauge renderer's active/inactive
sorting. A drawable whose bit is never set is parked in inactiveList for
the life of the process and never complains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 13:10:53 -05:00
CydandClaude Opus 5 05f993b9aa Picking a preset no longer blinds the mode lamps
The six PRESET switches down the map's right flank stored their lamps in
modeLamp[], which holds four. Indices 4 and 5 ran off the end into
presetLamp[0..1], so the whole thing stayed self-consistent by memory
layout and nobody noticed - but it overwrote the four control-mode lamps
made moments earlier, and BASIC/STANDARD/VETERAN/MASTER on the upper-right
MFD were never lit again. presetLamp[], meanwhile, went unused.

The preset pass now fills the array it was always meant to, and the lamp
work moves out of the switch handler into a virtual NotifyOfPresetChange
that PresetEnable announces itself. That closes the second gap in passing:
keyboard 1-6 changed the mappings without touching the lamps, leaving the
flank showing a preset that was no longer in force. Both routes now go
through one place. The lamp arrays are also cleared in the constructor -
only the mapping loops ever filled them, and NOMODES skips those.

Verified by dumping the commanded RIO lamp states out of the running game
(PadRIO, TEST.EGG, at rest in Basic mode). Before and after are identical
except lamp 0x33, BASIC, which goes from 14 dim to 3c lit. The preset
lamps are unchanged: they worked by accident, and now work by
construction.

docs/CONTROL-PRESETS.md is the research behind it. The presets are not a
map feature at all - each is a complete factory layout for the four
mappable stick buttons, one mode-mask bit apiece, with all 26 vehicles
carrying their own six-preset table in RPL4.RES for both the pod RIO and
the Thrustmaster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 11:43:02 -05:00
CydandClaude Opus 5 f83f56e14d Stop sending expired testers to an empty page
Both the README and the dialog an expired build puts up pointed at the
Gitea releases page for the next build. That page is empty now - releases
come through another channel - so in a fortnight's time the one message a
tester is guaranteed to read would have sent them somewhere with nothing
on it.

No address in its place: whoever handed them the build is who to ask, and
a URL that goes stale again is worse than no URL. The Source: line stays,
because the repository it names is still there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:38:29 -05:00
CydandClaude Opus 5 f44cdbacca How a hit becomes lost armor and lost score
The damage model, end to end, read off the authentic surviving RP source
(VTV.cpp, WEAPSYS.cpp, RIVET.cpp, DEMOPACK.cpp, RPPLAYER.cpp) rather than
inferred. Companion to the BT doc of the same name; the shared engine
layers are the same and only summarised here.

The finding it is built around: RP's damage model is a physics and score
economy, not a subsystem-failure simulation. One armor pool at zone 0, no
criticals, no per-zone cascade. Collision armor is not authored at all -
it is calibrated from the vehicle's own mass and MaxImpactSpeed so that a
full-speed hit spends exactly the whole budget - and deathConstant
converts damage to score at that same exchange rate. Every point of
damage is simultaneously a transaction between two players, through a
two-second revenge window. Martian football, not a mech duel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:38:16 -05:00
CydandClaude Opus 5 303758cb52 Testers' crash dumps stay out of the history
Crashdmp\ is where a dump sent in by a tester lands. Read it with cdb
against the matching Release\rpl4opt.pdb - the PE timestamp recorded in
the dump says which build it came from, and the symbols mean nothing
unless it matches.

Not tracked, because a minidump is not ours to keep: it carries process
memory and the sender's own file paths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 09:37:58 -05:00
CydandClaude Opus 5 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>
2026-08-06 09:08:20 -05:00
CydandClaude Opus 5 a417175da8 The handbook explains why the sound changed
The volume and bass keys were not on the keyboard diagram, and nothing told
a returning player why the game suddenly sounds different.

Adds a short section on what came back out of the original soundbanks -
pitch, the missing layers, distance, reverb, doppler - written for someone
who wants to know why their collisions have weight now, not for someone
reading the source. It closes on the knobs, because "it is too much" is a
fair reaction and the answer should be next to the explanation.

PgUp, PgDn, Home and End now light up on the keyboard diagram in their own
colour, with a legend entry, rather than sitting there as dead keys. And
volume.cfg and bass.cfg join the list of files in the folder that belong to
the player.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:54:32 -05:00
CydandClaude Opus 5 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 6b43971, so every build
since has had it.

Verified under cdb: before, the crash is a c00000fd stack overflow with
CockpitShellProc / CallWindowProcA repeating the whole way down. After,
four consecutive races - launch, race, results, CONTINUE, lobby, launch
again - complete with no exception at all, and the process exits only
when asked to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:36:35 -05:00
CydandClaude Opus 5 cdccb16251 Keep AUDIO.RES; a Windows ? matches nothing too
The bank exclusion used AUDIO?.RES, which also swallowed AUDIO.RES - the
one-byte stub that has been in the audio folder since 1995. A '?' in a
Windows wildcard will match zero characters, not just one. Name the two
banks instead of pattern-matching them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:24:36 -05:00
CydandClaude Opus 5 a5faa6cf9b Stop shipping seven megabytes nobody reads
The original AWE32 soundbanks are the source the shipped sound effects are
generated from, and they belong in the repo for that, but the game has no
use for them at run time - the LoadSBK path died with the sound cards and
AUDIO.INI's [AudioResources] section is commented out alongside it. They
were going into every download regardless.

Also drops four wav files that nothing references: two leftovers named temp,
one of them empty, and two stale zone files orphaned when the preset table
was regenerated from the banks.

Together about 8MB off the package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 00:22:39 -05:00
CydandClaude Opus 5 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>
2026-08-06 00:08:55 -05:00
CydandClaude Opus 5 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>
2026-08-06 00:00:36 -05:00
CydandClaude Opus 5 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>
2026-08-05 23:45:29 -05:00
CydandClaude Opus 5 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>
2026-08-05 23:33:45 -05:00
CydandClaude Opus 5 d361a0b8be The sound effects play at the pitch they were written at
Red Planet's original AWE32 soundbanks are back in the tree, and the game's
sound effects are now generated from them instead of from an incomplete
one-off extraction.

AUDIO1.RES and AUDIO2.RES come from the 1996 release in the TeslaRel410
archive, hash-identical. AUDIO.INI has named them all along - they were
simply never carried into the port. tools/rp_sf2extract.py reads them and
regenerates both the WAV set and RP_L4/WTPresets.cpp, so the assets are
reproducible from the banks rather than hand-maintained.

Two things were wrong with the old set:

Pitch. Every shipped WAV was flat 44100 Hz with the banks' tuning discarded,
so 202 of the 219 zones played at the wrong speed - the worst by nine
semitones. The EMU8000's per-zone root key and tuning are now baked into
each file's declared sample rate, which is exact and needs no engine change.
Layers that were meant to be deep now are: a collision sub-thud that lasted
18 milliseconds at the wrong rate is a 0.66 second one at 1228 Hz.

Missing layers. 93 presets were short of zones and 176 were missing outright,
219 of 395. Nothing was lost recovering them - the 46 preset slots that
disappeared were all empty placeholders. The old files were also over-read,
running past the end of their sample into whatever PCM came next;
WellheadDrill02a was six seconds where the bank says eight hundred
milliseconds. Every one of the 395 files now matches its bank record exactly.

Also baked in: per-zone layer attenuation, and the static resonant low-pass
the EMU8000 applied in hardware.

Measured while doing it, and worth knowing: RP's banks contain no key-splits
at all - every multi-zone preset is a pure layer stack - and no preset has
more than four zones, which is what the engine's own "AWE appears to only
play 1st 4 voices" warning has been asserting since 1995.

Still to do: loop regions and the release fades, which 349 zones ask for and
which need new SAMPLEINFO fields. And voice demand per sound has gone from
about one zone to about two and a half, so the per-event alGenSources and
alDeleteSources churn roughly doubles - the BT tree measured pooling as the
fix for that, and a CPU win besides.

Builds clean. The extreme baked rates, 1228 Hz up to 88200, were checked
through the real path - libsndfile, alBufferData, alSourcePlay - and all
load. Not yet listened to on the pod.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:16:13 -05:00
CydandClaude Opus 5 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>
2026-08-05 23:00:37 -05:00
CydandClaude Opus 5 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>
2026-08-05 22:45:23 -05:00
CydandClaude Opus 5 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>
2026-08-05 22:36:45 -05:00
CydandClaude Opus 5 eb17220dd5 Test builds go stale after a fortnight
A tester still racing a two-week-old binary reports things that were
fixed a week ago, and the afternoon spent chasing them is gone. An
expired build now says so and stops: a dialog naming its version and
expiry date, pointing at the releases page, and an exit before anything
else runs. The log carries the same line, so a report from an expired
build identifies itself.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    sample = ourNow - theirStamp = trueOffset + oneWayLatency

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two things the wiring turned up:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 10:06:30 -05:00
1567 changed files with 375198 additions and 1505 deletions
+30
View File
@@ -45,6 +45,15 @@ rpl4.log
# on somebody's desk.
mfd_layout.cfg
# The pilot's callsign, remembered between sessions. Whoever is sitting
# at this machine, which is not the repo's business.
pilot.cfg
# Generated by stamp-version.ps1 as RP_L4's pre-build step. The patch
# number in it IS this repository's commit count, so a committed copy
# would be stale the moment it was committed.
/RP_L4/rpl4build.h
# Build-output static libs that land in lib/ (the two committed dependency
# libs, OpenAL32.lib and libsndfile-1.lib, stay tracked).
/lib/Munga_L4.lib
@@ -75,3 +84,24 @@ assets/**/last.spl
# packaged releases (attached to Gitea releases, not tracked)
RedPlanet-*.zip
# Crash dumps sent in by testers. Read them with cdb against the matching
# Release\rpl4opt.pdb - the PE timestamp in the dump says which build, and
# the symbols only mean anything if it matches. They are not ours to keep
# in the history: a minidump carries process memory and the sender's own
# file paths.
/Crashdmp/
# Runtime files the game writes beside itself when run from the tree.
# environ.ini in particular is GENERATED from the template in
# RP_L4/RPL4ENVIRON.cpp - a copy committed here would be a second source
# of truth that nothing updates.
bindings.txt
environ.ini
rpl4.log
rpl4-fail.log
last.spl
SPOOLS/
# Playtest evidence - dumps, logs, symbol snapshots. Kept on disk, not in history.
playtestlogs/
+112 -3
View File
@@ -50,10 +50,57 @@ The solution is [WinTesla.sln](WinTesla.sln) with four v143 projects:
Build order is resolved by `ProjectReference` (RP_L4 and RPL4TOOL both reference
Munga_L4).
**Versioning:** the patch number *is* the repository's commit count, so a
build always names the commit it came from and there is no question about
which changes a given binary contains.
[stamp-version.ps1](stamp-version.ps1) runs as RP_L4's pre-build step and
writes the generated, uncommitted `RP_L4\rpl4build.h`:
```
#define RP412_VERSION "4.12.96"
#define RP412_VERSION_LONG "4.12.96 (a1b2c3d)"
```
The game logs the long form on its first line. A trailing `+` on the hash
means the tree had uncommitted changes to tracked files when it was built —
useful when a test machine reports something a clean build cannot reproduce.
Only the `4.12` product line is set by hand, at the top of the script.
**Test builds expire.** `$expireDays` at the top of the same script is the
shelf life in days (currently **14**, counted from the day it was *built*,
not the day the code was written — rebuilding an old commit gives a usable
binary rather than one born stale). An expired build says so in a dialog,
names its version and expiry date, points at the releases page, and exits
without running. It stops a tester spending an afternoon on something that
was fixed a week ago.
> ⚠️ **Set `$expireDays = 0` for a real release.** A shipped build that
> expires is a catastrophe, and that one line is what decides it.
`RP412NOEXPIRY=1` waives the check when an old build has to be run on
purpose, and says so in the log so a waived build is never mistaken for a
current one. It is deliberately **not** listed in `environ.ini` — a bypass
every tester can see is a bypass every tester will use. Negative
`$expireDays` backdates the expiry, which is how the refusal gets tested
without touching the machine's clock.
The header is deliberately not committed: the commit that recorded a
hardcoded number would itself change the count, so the file would be stale
the moment it landed. It is rewritten only when the stamp actually changes,
so ordinary rebuilds do not recompile `RPL4.CPP` for nothing. Building
outside a git checkout stamps `4.12.x (no repository)` rather than inventing
a number that would sort against real ones.
**Packaging:** [pack-dist.ps1](pack-dist.ps1) assembles a runnable game into
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, desktop
`environ.ini`, `start-windowed.bat`, README). Pass `-Zip` to also produce
`dist\RedPlanet412-prototype.zip` for handing to someone else.
`dist\` (exe + PDB, game data, OpenAL/libsndfile runtimes, launch scripts,
HANDBOOK.html, README). It deliberately does **not** write `environ.ini`
the exe carries that template and writes it on first run
([RP_L4/RPL4ENVIRON.cpp](RP_L4/RPL4ENVIRON.cpp)), so a tester can drop a new
build over an old folder without losing their settings. Pass `-Zip` to also produce
`RedPlanet-<version>.zip` for handing to someone else. It reads the version
from `rpl4build.h` rather than asking git again, so the package and the
binary inside it cannot disagree, and it warns if the build it is packing
came from a modified tree.
## 3. VS2022 migration notes (what changed and why)
@@ -97,10 +144,72 @@ textures now log `L4D3D.cpp couldn't load texture …` and render untextured;
the game boots to a running window with `-windowed -res 640 480 -egg TEST.EGG`
from a working copy like `assets/RP411/`.
**`steam_api.dll` is optional.** It is **delay-loaded**
(`DelayLoadDLLs` in [RP_L4/RP_L4.vcxproj](RP_L4/RP_L4.vcxproj), with
`delayimp.lib` supplying the helper), so a working copy that has never seen
Steam — like `assets/RP411/`, which predates the Steam work — boots and races
normally. Only Steam itself is switched off, logged as
`Steam: steam_api.dll not found beside the exe - Steam features off, staying
on TCP`.
Delay loading alone would only move the failure: the first call into a
delay-loaded DLL that cannot be found raises the helper's fatal exception
rather than returning an error. So every path that reaches a Steam symbol
first asks `SteamNetTransport_ClientLibraryPresent()`
([MUNGA_L4/L4STEAMTRANSPORT.cpp](MUNGA_L4/L4STEAMTRANSPORT.cpp)) — a cached
`LoadLibrary` probe using the same plain-name lookup the helper does. The
gates are `SteamNetTransport_Install` and the two lobby entries
`RPL4Lobby_Host`/`_Join`; everything else in the transport and lobby is
downstream of one of those. **Adding a new Steam call site means checking
which gate covers it.**
For Steam features you need the DLL from
[extern/steamworks_sdk_164/sdk/redistributable_bin/steam_api.dll](extern/steamworks_sdk_164/sdk/redistributable_bin/steam_api.dll)
(the 32-bit one, not `win64\`) beside the exe, plus `steam_appid.txt` — with
the DLL but no appid file `SteamAPI_Init` fails and the game falls back to TCP.
`environ.ini` must be written **without a BOM**. The parser matches key names
from the start of the line, so a leading UTF-8 BOM silently invalidates the
first key in the file — put `L4CONTROLS` there and it reverts to the built-in
`KEYBOARD` default, which then fail-fasts on `0xC0000409` with
`*****VTV has no controls mapping!*****` in the log (there is no keyboard-only
pod mapper). PowerShell's `Set-Content -Encoding utf8` writes a BOM in 5.1;
use `[System.IO.File]::WriteAllText` with an `ASCIIEncoding`. The log line
`Environ: environ.ini does not mention N option(s)` naming a key that is
plainly in the file is the tell.
For runtime debugging the v143 build produces full PDBs — run
`cdb -g -G -lines -y Release rpl4opt.exe ...` from the working directory
(cdb ships in this machine's Windows Kits).
### Debug keys
`RP412DEVKEYS=1` arms them, and they arrive through the engine keyboard
handler, so `L4CONTROLS` must include `KEYBOARD`. That handler takes one key
per frame off the front of the message queue and genuinely drops presses, so
press again before concluding a key is broken.
| Key | State |
|-----|-------|
| **Alt+W** wireframe | **Live.** Reimplemented on D3D9 as a per-frame `D3DRS_FILLMODE` (`gWireframe`, [MUNGA_L4/L4VIDEO.cpp](MUNGA_L4/L4VIDEO.cpp)). The view clears to black and the sky pass is skipped, so the edges stand on their own — with the lit dome in place the far half of the scene is unreadable. The 2D pass (gunsight, cam-ship HUD) is held solid; particles are wireframed with everything else. Fog still applies, so distant edges tint toward the fog colour rather than staying white. |
| **Alt+E** event-queue dump | **Live.** Reaches `GeneralEventQueue::DumpEventQueue`. |
| Alt+V predator vision | Inert. |
| Alt+F frame dump | Inert. |
| Alt+/ perf stats | Inert. |
| Alt+K free memory | Inert, silent. |
| Alt+R dither, Alt+P eyepoint | Inert; they log "Function net yet enabled." |
| Alt+Q abort | Always live, no `RP412DEVKEYS` needed. |
The inert ones call `DPLRenderer` methods whose bodies were commented out
with the rest of the DPL calls in the 2007 port (`STUBBED: DPL RB 1/14/07`).
Reviving one means writing it against D3D9 rather than un-commenting
anything: the `dpl_*` types those bodies used are empty placeholder classes
now ([DPLSTUB.h](DPLSTUB.h)), and `libDPL/` is reference headers that are not
compiled. Alt+V is the worst of them — the DPL renderer implemented predator
vision internally, reached by passing an out-of-band explosion effect type
(`-1` on, `-2` off) with a NULL DCS, and nothing in this tree records what it
actually looked like.
### Running without the cockpit (Workstream A prototype)
Two new environment options remove the hardware dependency entirely:
+242 -2
View File
@@ -16,6 +16,7 @@
#include "console.h"
#include "appmsg.h"
#include "evtstat.h"
#include "inputscript.h"
#if defined(TRACE_FOREGROUND_PROCESSING)
BitTrace Foreground_Processing("Foreground Processing");
@@ -32,6 +33,25 @@
Application *application = NULL;
int Exit_Code = 0;
Logical Application::suppressGauges = False;
Logical Application::cameraStation = False;
Logical Application::recordMission = False;
//
// RP412CAMLOG - see app.h. Cached: the waiting trace asks once a second
// for as long as a station sits unlaunched.
//
Logical
RPCameraLog()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412CAMLOG");
enabled = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
return enabled ? True : False;
}
//#############################################################################
//########################### Application ###############################
@@ -273,6 +293,35 @@ Scalar
return mgr->GetFrameRate();
}
//
//#############################################################################
// GetMissionElapsed
//#############################################################################
//
Scalar
Application::GetMissionElapsed()
{
Check(this);
//
//--------------------------------------------------------------------------
// gameStarted is only ever stamped by RunMissionMessageHandler, so before
// the race it is uninitialized - and entities that are pre-runnable do get
// performed before then. Answer zero until the clock actually exists.
//--------------------------------------------------------------------------
//
if (
GetApplicationState() != RunningMission
&& GetApplicationState() != EndingMission
)
{
return 0.0f;
}
Scalar elapsed = Now() - gameStarted;
return (elapsed > 0.0f) ? elapsed : 0.0f;
}
//
//#############################################################################
// Initialize
@@ -577,6 +626,147 @@ Time startUpdate = Now();
updateManager->Execute(start_of_frame);
Time endUpdate = Now();
//
//--------------------------------------------------------------------------
// RP412PHYSTRACE=1: the player's position, sampled on the SIMULATION's
// own clock rather than per frame.
//
// This is the acceptance test for decoupling physics from frame rate.
// Run the same egg at two frame rates and diff the traces: today they
// diverge, because the simulation advances by whatever the last frame
// happened to cost (SIMULATE.cpp, slice = till - lastPerformance), so a
// 30 fps machine integrates in 33 ms steps and a 144 fps machine in 7 ms
// ones and they are not the same race. Fixed-step them and the two
// traces have to agree.
//
// Sampled every 0.25 s of SIM time on purpose - sampling per frame would
// compare different instants and prove nothing.
//--------------------------------------------------------------------------
//
{
static int physTrace = -1;
if (physTrace < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
physTrace = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
//
// The scripted-input harness shares this anchor: its clock has to
// start at the same instant the vehicle is stopped, or the script
// timeline shifts against the settling transient by however long
// the load happened to take.
//
if ((physTrace || RPInputScript_Active()) &&
GetApplicationState() == RunningMission)
{
static Logical traceStarted = False;
static Time traceOrigin;
static Scalar traceDue = (Scalar) 0;
if (!traceStarted)
{
traceStarted = True;
traceOrigin = start_of_frame;
traceDue = (Scalar) 0;
//
//----------------------------------------------------------
// Start the measurement from a known state, not merely a
// known place.
//
// The pod sits on its pad simulating while the mission
// loads, and a load is not the same length twice - two runs
// of the same egg reached the green light 776 steps in and
// 599 steps in. Same pad, same position, different VELOCITY,
// and a trajectory compared from there measures the loader,
// not the physics.
//
// So: stop the vehicle dead and put its clock on the same
// mark. Every run then starts from rest at the same instant
// and any difference that follows belongs to the simulation.
//
// Test scaffolding, and it only runs with the trace asked
// for - it would be a cheat in a real race.
//----------------------------------------------------------
//
Player *reset_player = GetMissionPlayer();
Entity *reset_vehicle =
(reset_player != NULL)
? reset_player->GetPlayerVehicle() : NULL;
if (reset_vehicle != NULL &&
reset_vehicle->IsDerivedFrom(*Mover::GetClassDerivations()))
{
Mover *reset_mover = (Mover *) reset_vehicle;
reset_mover->localVelocity = Motion::Identity;
reset_mover->localAcceleration = Motion::Identity;
//
// The clock is NOT touched. lastPerformance sits on the
// vehicle's own step grid and the trace reads that grid
// instead. The first version forced it to the frame
// timestamp, which knocked the vehicle off its grid by
// a random fraction of a step per run - and that read
// as physics drift when it was only ever measurement.
//
traceOrigin = reset_mover->GetLastPerformance();
// the script's t=0 is this same instant
RPInputScript_Arm(traceOrigin);
DEBUG_STREAM << "PhysTrace: vehicle stopped "
<< "at the green light\n" << std::flush;
}
}
if (physTrace)
{
//
// Sampled on the SIMULATION's clock - the vehicle's own
// lastPerformance, which advances in whole fixed steps - so two
// runs sample at identical step counts and their traces compare
// exactly. Frame time samples mid-step at whatever phase the
// frame happened to land on, which compares different instants
// and calls the difference physics.
//
Player *clock_player = GetMissionPlayer();
Entity *clock_vehicle =
(clock_player != NULL) ? clock_player->GetPlayerVehicle() : NULL;
Scalar elapsed =
(clock_vehicle != NULL)
? (Scalar)(clock_vehicle->GetLastPerformance() - traceOrigin)
: (Scalar)(start_of_frame - traceOrigin);
if (elapsed >= traceDue)
{
traceDue += (Scalar) 0.25;
Player *trace_player = GetMissionPlayer();
Entity *trace_vehicle =
(trace_player != NULL) ? trace_player->GetPlayerVehicle() : NULL;
if (trace_vehicle != NULL)
{
extern long gPhysicsStepsTaken;
char buffer[160];
sprintf(buffer,
"PhysTrace: t=%7.3f steps=%6ld pos %12.5f %12.5f %12.5f\n",
(double) elapsed,
gPhysicsStepsTaken,
(double) trace_vehicle->localOrigin.linearPosition.x,
(double) trace_vehicle->localOrigin.linearPosition.y,
(double) trace_vehicle->localOrigin.linearPosition.z);
DEBUG_STREAM << buffer << std::flush;
}
}
}
}
}
CLEAR_UPDATE_MANAGER();
//
@@ -649,8 +839,23 @@ Time endIntercom = Now();
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
//
// Ask the console first: it owns the clock that actually ends the
// race, so this is the countdown the buzzer will agree with. Its
// own reckoning is the fallback for everything with no console of
// its own - see gMissionClockHook in APPMGR.h.
//
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
routePacketFinished = False;
@@ -1125,6 +1330,12 @@ void
Check(this);
Check(egg_notation_file);
//
// Forget every peer's clock offset: the hosts in the next race are not
// the hosts in the last one, and a HostID gets reused.
//
NetClock_Reset();
//
//--------------------------------------------------------------------------
// Create mission from egg notation file
@@ -1148,7 +1359,15 @@ void
InterestManager *interest_mgr = GetInterestManager();
Check(interest_mgr);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: player data set - loading interest arenas\n" << std::flush;
}
interest_mgr->LoadInterestArenas(currentMission);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: interest arenas loaded - mission created\n" << std::flush;
}
}
//
@@ -1184,7 +1403,16 @@ void
trace_manager.StartTimingAnalysis();
#endif
Check(interestManager);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: LoadMission handler - loading interest manager\n" << std::flush;
}
interestManager->LoadMission(currentMission);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: interest manager loaded - about to make the player\n"
<< std::flush;
}
//
//--------------------------------------------------------------------------
@@ -1354,6 +1582,13 @@ void
case CreatingMission:
case LoadingMission:
case WaitingForLaunch:
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: CheckLoad state=" << (int) applicationState.GetState()
<< " minPriorityQueueEmpty="
<< (int) (eventQueue->IsPriorityEmpty(MinEventPriority) ? 1 : 0)
<< "\n" << std::flush;
}
if (eventQueue->IsPriorityEmpty(MinEventPriority))
{
Host *console_host;
@@ -1406,6 +1641,11 @@ void
RunMissionMessage run_mission_message;
Post(DefaultEventPriority, this, &run_mission_message);
Tell("Sent ready message to ourselves\n");
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: no console - posted RunMission to ourselves\n"
<< std::flush;
}
}
}
+51
View File
@@ -56,6 +56,20 @@ class GeneralEventQueue;
class Entity__MakeMessage;
class ResourceFile;
//##########################################################################
//######################### Live Cam tracing #############################
//##########################################################################
//
// RP412CAMLOG=1 traces the camera-station bring-up: the registry choosing
// a director, the director creating its camera ship, and the launch
// handshake it waits on. The Live Cam path is selected purely by egg data
// (hostType=1 plus vehicle=camera), so when it does not come up there is
// nothing in the log to say how far it got - which is exactly the hole
// this fills. Off by default; it prints once per second while waiting.
//
Logical
RPCameraLog();
//##########################################################################
//######################### Application ##############################
//##########################################################################
@@ -318,6 +332,15 @@ public:
Scalar
GetSecondsRemainingInGame()
{return secondsRemainingInGame;}
//
// Seconds since the console's RunMission started the race, counting up.
// Every machine anchors this on the same message, so anything derived
// from it agrees across the mesh without being replicated - see the
// clockwork doors in DOOR.cpp. Reads 0 outside a running mission
// (gameStarted holds garbage until RunMission stamps it).
//
Scalar
GetMissionElapsed();
ApplicationID
GetApplicationID()
{return applicationID;}
@@ -431,6 +454,32 @@ public:
static Logical DoSuppressGauges() { return suppressGauges; }
//
// A Live Cam station. It has no pod, so none of the five instrument
// MFDs have anything to put on them - the panes would just composite
// as black holes over the view. It DOES keep the map, but landscape:
// the pod's is portrait because that is how the glass was physically
// mounted in the cabinet, and a camera has no cabinet.
//
// Settable because the role is picked in the lobby rather than passed
// as -lc on the command line, so the front end has to hand it over.
// That happens before the renderers are built - the single-binary race
// loop makes a fresh application per race, after the menu.
//
static Logical IsCameraStation() { return cameraStation; }
static void SetCameraStation(Logical state) { cameraStation = state; }
//
// Whether to keep a spool of this race. Picked on the setup screen
// under the role, and set the same way and for the same reason: it
// has to be known before the network manager is built, which happens
// after the menu, and it must be set either way because the same
// process races again and a stale True would quietly record a session
// nobody asked to keep.
//
static Logical IsRecording() { return recordMission; }
static void SetRecording(Logical state) { recordMission = state; }
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Modules
//
@@ -466,6 +515,8 @@ protected:
*modeManager;
static Logical suppressGauges;
static Logical cameraStation;
static Logical recordMission;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Module Creation
+71
View File
@@ -15,6 +15,9 @@ Logical gConsoleMarshalsLaunch = False;
// losing the console mid-mission ends it (lobby-member races)
Logical gConsoleLossEndsMission = False;
// the console's countdown, when a console is marshalling (see APPMGR.h)
Logical (*gMissionClockHook)(Scalar *seconds_remaining) = NULL;
ApplicationManager* ApplicationManager::CurrentAppManager = NULL;
ApplicationManager::ApplicationManager(HINSTANCE hInstance, HWND hWnd, Scalar frame_rate) : Node(ApplicationManagerClassID), runningApplications(this)
@@ -231,6 +234,74 @@ Background_Loop:
}
Time endBackground = Now();
//
//---------------------------------------------------------------------
// RP412GAUGEDIAG=1: where the frame actually goes.
//
// These four timestamps have been computed every frame since forever
// and never reported. The whole cockpit problem is a question about
// this split - the background loop only gets what the foreground
// leaves - and it has been measurable all along.
//---------------------------------------------------------------------
//
{
static int
frameSplitDiag = -1;
if (frameSplitDiag < 0)
{
const char
*setting = getenv("RP412GAUGEDIAG");
frameSplitDiag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (frameSplitDiag)
{
static Scalar
foregroundSum = (Scalar) 0,
backgroundSum = (Scalar) 0,
frameSum = (Scalar) 0;
static int
splitFrames = 0;
static Logical
splitStarted = False;
static Time
splitWindowStart;
Time
splitNow = Now();
foregroundSum += (Scalar)(endForeground - startForeground);
backgroundSum += (Scalar)(endBackground - startBackground);
frameSum += (Scalar)(splitNow - beginFrameTimestamp);
++splitFrames;
if (!splitStarted)
{
splitStarted = True;
splitWindowStart = splitNow;
}
else if ((Scalar)(splitNow - splitWindowStart) >= (Scalar) 2)
{
char
buffer[200];
sprintf(buffer,
"FrameSplit: %d frames | foreground %.2f ms | "
"background %.2f ms | whole frame %.2f ms\n",
splitFrames,
(double)(foregroundSum * 1000.0f / splitFrames),
(double)(backgroundSum * 1000.0f / splitFrames),
(double)(frameSum * 1000.0f / splitFrames));
DEBUG_STREAM << buffer << std::flush;
foregroundSum = backgroundSum = frameSum = (Scalar) 0;
splitFrames = 0;
splitWindowStart = splitNow;
}
}
}
//char str[256];
//Scalar lastFrameLength = Now() - beginFrameTimestamp;
//sprintf(str, "RPL4 - %.2f FPS", 1.0f / lastFrameLength);
+22
View File
@@ -20,6 +20,28 @@ extern Logical gConsoleMarshalsLaunch;
// console to return, exactly as always.
extern Logical gConsoleLossEndsMission;
//
// The console's own countdown, when there is a console to ask.
//
// A mission ends when the console says so, but secondsRemainingInGame was
// computed here from the engine clock and its own idea of when the race
// started - a different clock, from a different epoch, than the one that
// actually fires the buzzer. The two agree to within a frame or so, which
// is why nobody noticed, but they are not the same number: the cockpit
// clock could read 0:00 with the race still running, and the camera
// directors' "last 30 seconds" behaviour switched on the engine's reading
// rather than on the real remaining time.
//
// Set by the console when it is marshalling; NULL restores the engine's
// own reckoning, which is what the arcade -net pods, lobby members and
// mission review all use (none of them run a console locally, and their
// clock is anchored by the console's RunMission arriving anyway).
//
// Returns False when it has no answer yet - the window between the
// application reaching RunningMission and the console noticing.
//
extern Logical (*gMissionClockHook)(Scalar *seconds_remaining);
class ApplicationManager : public Node
{
public:
+10 -2
View File
@@ -94,8 +94,16 @@ void
}
headEntitySocket.Add(entity);
alDistanceModel(AL_LINEAR_DISTANCE);
alDopplerFactor(0.3f);
// FIDELITY (docs/SOUND.md F3/F10): the engine computes the AUTHORED distance
// attenuation curve (AUDIO.INI amplitude_rolloff knee/exponent ->
// AudioLocation::distanceVolumeScale) and the AUTHORED doppler-cents model
// (doppler_range=600 / speed_of_sound=250) on every spatial update. Disable
// OpenAL's own models so they cannot double-apply or fight them:
// AL_LINEAR_DISTANCE faded distant audio to zero on a straight line where the
// authored curve still sits near 44% at the clip edge, and AL doppler ran at
// the wrong constants with a sign-inverted velocity feed.
alDistanceModel(AL_NONE);
alDopplerFactor(0.0f);
#if 0
//
+128
View File
@@ -12,6 +12,12 @@
//############################# CameraShip ################################
//##########################################################################
//
// Trackside camera cuts since the last RP412CAMLOG report - counted where
// the cut happens and drained where it is reported, both in FollowGoal.
//
static int gCameraCuts = 0;
//#############################################################################
// Shared Data Support
//
@@ -320,6 +326,127 @@ void
Point3D target;
target.Multiply(focusOffset, goalEntity->localToWorld);
//
// RP412CAMLOG: is the thing we are following actually MOVING every
// step, or arriving in jumps?
//
// This runs on the fixed simulation step, so it is called at a steady
// rate whatever the frame rate. The entity it follows is a REMOTE pod,
// whose position only changes when an update lands - so if the watched
// point is identical on most steps and then leaps, the camera is
// tracking a staircase and no amount of smoothing here will hide it.
// Reported as: steps counted, how many of them saw any movement at
// all, the largest single jump, and how often the trackside camera was
// cut to a different one (a cut is a snap, not a glide, and would look
// like jank of a different kind).
//
if (RPCameraLog())
{
static Scalar next_say = 0.0f;
static Point3D last_target(0.0f, 0.0f, 0.0f);
static Logical have_last = False;
static int steps = 0;
static int moved = 0;
static Scalar biggest = 0.0f;
//
// A visible tick IS a step that moves much further, or much less,
// than the steps around it. Measuring that directly beats measuring
// anything about where the number came from: it does not care
// whether the cause is the network, the catch-up rule, or something
// nobody has thought of yet.
//
// Judged against a short running mean rather than an absolute
// distance, because a pod at 75 m/s moves 1.5 m per step and one
// sitting against a wall moves nothing - a fixed threshold would
// call every acceleration a spike.
//
// What confirms: spikes running at a few per second. That is the
// reported tick, and its rate is in the count.
// What refutes: spikes and stalls near zero. Then entity motion is
// smooth and the tick is not in the simulation at all, which points
// at frame delivery or the map raster instead.
//
static Scalar mean_step = 0.0f;
static int spikes = 0;
static int stalls = 0;
static int respawns = 0;
static Scalar worst_ratio = 0.0f;
++steps;
if (have_last)
{
Vector3D step_delta;
step_delta.Subtract(target, last_target);
Scalar distance = step_delta.Length();
if (distance > 0.0f)
{
++moved;
}
if (distance > biggest)
{
biggest = distance;
}
//
// A respawn teleports hundreds of metres and is MEANT to be a
// discontinuity. Counted, excluded, and the mean restarted so
// one does not brand the following steps as stalls.
//
if (distance > 50.0f)
{
++respawns;
mean_step = 0.0f;
}
else if (mean_step > 0.01f)
{
Scalar ratio = distance / mean_step;
if (ratio > 2.5f)
{
++spikes;
if (ratio > worst_ratio)
{
worst_ratio = ratio;
}
}
else if (ratio < 0.4f)
{
++stalls;
}
mean_step = mean_step * 0.9f + distance * 0.1f;
}
else
{
mean_step = distance;
}
}
last_target = target;
have_last = True;
if ((Scalar) Now() >= next_say)
{
if (next_say > 0.0f)
{
DEBUG_STREAM << "CamLog: follow - " << steps << " steps, "
<< moved << " moved, biggest jump " << biggest
<< "m, " << gCameraCuts << " cut(s)\n" << std::flush;
DEBUG_STREAM << "CamLog: smoothness - " << spikes
<< " spike(s), " << stalls << " stall(s), "
<< respawns << " respawn(s), worst " << worst_ratio
<< "x the running mean of " << mean_step << "m\n" << std::flush;
}
next_say = ((Scalar) Now()) + 5.0f;
steps = 0;
moved = 0;
biggest = 0.0f;
spikes = 0;
stalls = 0;
respawns = 0;
worst_ratio = 0.0f;
gCameraCuts = 0;
}
}
//
//------------------------------------------------------------------------
// If time has not yet expired on this camera, keep with it if can see the
@@ -348,6 +475,7 @@ void
//
if (currentCamera != old_camera)
{
++gCameraCuts;
AimCameraAtPoint(target);
lastSwitch = lastPerformance;
}
+19 -2
View File
@@ -58,6 +58,23 @@
#define Check(p)
#define Check_Signature(p)
//#define Fail(m) Fail_To_Debugger(m,__FILE__,__LINE__)
#define Fail(m) abort();
//
// A release build used to answer Fail("clipping_radius not defined") with a
// bare abort(), throwing the message away - and because the compiler merges
// identical cold paths, every Fail in a function became the same anonymous
// stub. A crash then told you the function and nothing else, which cost an
// afternoon of disassembly to learn that a mission review build stops in
// L4AudioRenderer::Initialize without ever saying which of its nine checks
// was the one that tripped.
//
// The debug build has always routed this to a function that prints the
// message with its file and line. Release now does too. The termination is
// unchanged - still abort(), still exit code 0xC0000409 - so nothing
// downstream sees anything different; it just says what happened on the way
// out.
//
extern void Fail_With_Message(const char *message, const char *file, int line);
#define Fail(m) Fail_With_Message(m, __FILE__, __LINE__)
#define Cast_Object(type, ptr) ((type)(ptr))
+65 -140
View File
@@ -72,172 +72,96 @@ Door::AttributeIndexSet& Door::GetAttributeIndex()
//#############################################################################
// Model Support
//
void
Door::ReadUpdateRecord(Simulation::UpdateRecord *message)
{
Check(this);
Check_Pointer(message);
Subsystem::ReadUpdateRecord(message);
UpdateRecord* record = (UpdateRecord*) message;
percentOpen = record->percentOpen;
switch (GetSimulationState())
{
case Opening:
case Closing:
phaseTimeRemaining = travelTime;
break;
case Opened:
case Closed:
phaseTimeRemaining = deadTime;
break;
}
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door updated to state "
// << GetSimulationState() << " @ "
// << application->GetSecondsRemainingInGame() << endl;
MoveCollisionVolume(percentOpen);
Check_Fpu();
}
// There is no ReadUpdateRecord/WriteUpdateRecord pair here on purpose. Doors
// are Hermit instances built independently on every host, so no door state is
// ever sent or received - the phase function below is the only thing that
// decides where a door is, and it reaches the same answer everywhere.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::WriteUpdateRecord(Simulation::UpdateRecord *record, int update_model)
{
Check(this);
Check_Pointer(record);
Subsystem::WriteUpdateRecord(record, update_model);
UpdateRecord *update = (UpdateRecord*)record;
update->percentOpen = percentOpen;
update->recordLength = sizeof(*update);
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::SlideDoor(Scalar time_slice)
Door::SlideDoor(Scalar)
{
Check(this);
//
//------------------------------------------------------------
// Advance the clock, then branch based upon our current state
//------------------------------------------------------------
//--------------------------------------------------------------------------
// The door is clockwork. Its position is a function of how long the race
// has been running, not of a countdown integrated frame by frame, so:
//
int new_state;
if (time_slice > 1.0f)
// - every machine puts this door in the same place from the same mission
// clock, without a byte crossing the wire,
// - a frame hitch of any length costs nothing, because there is no
// accumulated state left to fall behind (the old code dropped any slice
// over a second outright and never got that time back).
//
// Phase zero is the instant the door starts to close, fully open, which is
// where the original state machine began from DefaultState:
//
// [0, travel) Closing 1 -> 0
// [travel, travel+dead) Closed 0
// [travel+dead, 2travel+dead) Opening 0 -> 1
// [2travel+dead, cycle) Opened 1
//--------------------------------------------------------------------------
//
if (cycleTime <= 0.0f)
{
MoveCollisionVolume(0.0f);
SetSimulationState(Closed);
Check_Fpu();
return;
}
phaseTimeRemaining -= time_slice;
Scalar percent_open;
switch (GetSimulationState())
Check(application);
Scalar phase = fmod(application->GetMissionElapsed() - phaseOffset, cycleTime);
if (phase < 0.0f)
{
phase += cycleTime;
}
//
//------------------------------------------------------------------------
// If the door is not done opening, set its new position, otherwise branch
// to the opened state
//------------------------------------------------------------------------
//--------------------------------------------------------------------------
// Pick the band. Each division below is guarded by the comparison that
// selected the branch, so a door with a zero travelTime or deadTime simply
// loses that band rather than dividing by zero.
//--------------------------------------------------------------------------
//
case Opening:
Door_Opening:
new_state = Opening;
if (phaseTimeRemaining > 0.0f)
{
percent_open = 1.0f - phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opened @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opened;
}
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
Check_Fpu();
break;
Scalar open_start = travelTime + deadTime;
int new_state;
Scalar percent_open;
//
//-------------------------------------------------------------
// If the door is ready to start closing, jump to closing state
//-------------------------------------------------------------
//
case Opened:
Door_Opened:
new_state = Opened;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closing @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closing;
}
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
//
//------------------------------------------------------------------------
// If the door is not done closing, set its new position, otherwise branch
// to the closed state
//------------------------------------------------------------------------
//
case DefaultState:
phaseTimeRemaining = travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door default @ "
// << application->GetSecondsRemainingInGame() << endl;
case Closing:
Door_Closing:
if (phase < travelTime)
{
new_state = Closing;
if (phaseTimeRemaining > 0.0f)
{
percent_open = phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closed @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closed;
}
percent_open = 1.0f - phase/travelTime;
currentVelocity.Subtract(
GetEntity()->localOrigin.linearPosition,
worldExtent
);
currentVelocity /= travelTime;
Check_Fpu();
break;
//
//-------------------------------------------------------------
// If the door is ready to start opening, jump to opening state
//-------------------------------------------------------------
//
case Closed:
Door_Closed:
}
else if (phase < open_start)
{
new_state = Closed;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opening @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opening;
}
percent_open = 0.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
}
else if (phase < open_start + travelTime)
{
new_state = Opening;
percent_open = (phase - open_start)/travelTime;
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
}
else
{
new_state = Opened;
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
}
//
@@ -344,7 +268,8 @@ Door::Door(
//
// Initialize variables
//
phaseTimeRemaining = 0.0f;
phaseOffset = 0.0f;
cycleTime = 2.0f*(travelTime + deadTime);
currentPosition = Point3D::Identity;
SetPerformance(&Door::SlideDoor);
+16 -19
View File
@@ -20,16 +20,11 @@ struct Door__SubsystemResource:
collisionID;
};
//##########################################################################
//##################### Chute::UpdateRecord #####################
//##########################################################################
struct Door__UpdateRecord :
public Subsystem::UpdateRecord
{
Scalar
percentOpen;
};
//
// A door has no update record. It is Hermit clockwork - every host builds
// its own out of the map stream and derives the position from the mission
// clock, so there is nothing to publish and nothing to receive.
//
//##########################################################################
//######################### CLASS Door ########################
@@ -91,7 +86,6 @@ public:
typedef void
(Door::*Performance)(Scalar time_slice);
typedef Door__UpdateRecord UpdateRecord;
void
SetPerformance(Performance performance)
@@ -109,12 +103,6 @@ public:
GetFirstBoxedSolid()
{Check(this); return collisionVolumes;}
protected:
void
WriteUpdateRecord(Simulation::UpdateRecord *message, int update_model);
void
ReadUpdateRecord(Simulation::UpdateRecord *message);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
//
@@ -152,9 +140,18 @@ private:
worldExtent;
Scalar
phaseTimeRemaining,
travelTime,
deadTime;
deadTime,
//
// Where in the cycle this door sits at mission time zero, and the
// length of one full open-close-open cycle. phaseOffset is not in
// the subsystem resource yet: every door in the game is in lockstep,
// and adding a field to Door__SubsystemResource changes its sizeof,
// which invalidates every prebuilt .res. Wire it to a "PhaseOffset"
// notation entry when there is a reason to rebuild resources.
//
phaseOffset,
cycleTime;
int collisionVolumeCount;
+8 -1
View File
@@ -126,8 +126,15 @@ Logical
}
creation_message->classToCreate = RegisteredClass::DoorFrameClassID;
//
// Hermit, not Master: every host builds its own doorframe out of the map
// stream (see the DoorFrameClassID exemption in LoadMapStream) and runs it
// off the mission clock. Hermit is the instance kind DynamicEntityCreation
// does NOT broadcast, which is what stops N machines each announcing the
// same doorframe and producing N-squared of them.
//
creation_message->instanceFlags =
MasterInstance|DynamicFlag|MapFlag|TrappedFlag;
HermitInstance|DynamicFlag|MapFlag|TrappedFlag;
return true;
}
+39 -1
View File
@@ -224,9 +224,47 @@ void
//-----------------------------------------------------
//
highest = highest - lowest + 1;
//
//------------------------------------------------------------------------
// RP412SPAWNZONE pins which drop zone is tried first, so a test run can
// be repeated.
//
// The pick below is Random(), and Random() is seeded - but the seed only
// makes a run repeatable if the same NUMBER of draws happens first, and
// that depends on how many frames the mission load took. So two runs of
// the same egg with the same RANDOM= still start on different pads, over
// different ground, and no two traces can be compared. That is not a
// game bug, but it makes the physics unmeasurable.
//
// Pinned, the zone is tried first and the loop falls back to the random
// walk if it is taken - so this can never wedge, and it changes nothing
// unless it is set.
//------------------------------------------------------------------------
//
static int
pinnedZone = -2;
if (pinnedZone == -2)
{
const char *setting = getenv("RP412SPAWNZONE");
pinnedZone = (setting != NULL) ? atoi(setting) : -1;
}
Logical
tryPinnedZone = (pinnedZone >= 0) ? True : False;
while (remaining)
{
i = lowest + Random(highest);
if (tryPinnedZone)
{
tryPinnedZone = False;
i = lowest + (pinnedZone % highest);
}
else
{
i = lowest + Random(highest);
}
Verify(i < dropZoneCount && i >= 0);
if (IsAvailable(i))
{
+296
View File
@@ -377,8 +377,28 @@ void
//------------------------------------------------------------------------
// Step through each block until there are no more remaining, and send the
// update out the the simulation indicated by the subsystemID
//
// This is the only point on the receive path that knows WHOSE update
// this is - the records themselves carry a timestamp but not an owner -
// so the sender is published here for the net clock to align against.
// Every record in the message, and the damage zones nested inside them,
// came from the same machine in the same frame.
//------------------------------------------------------------------------
//
//
// Only for an entity somebody else owns. Our own clock needs no
// aligning, and an update we somehow handed ourselves would otherwise
// drag lastUpdate back by a frame for no reason.
//
Check(application);
Check(application->GetHostManager());
Logical remote_owner =
GetOwnerID() != application->GetHostManager()->GetLocalHostID();
if (remote_owner)
{
NetClock_BeginUpdate(GetOwnerID());
}
while (stream.GetBytesRemaining())
{
Simulation::UpdateRecord *update =
@@ -389,6 +409,11 @@ void
simulation->ReadUpdateRecord(update);
stream.AdvancePointer(update->recordLength);
}
if (remote_owner)
{
NetClock_EndUpdate();
}
Check_Fpu();
}
@@ -410,6 +435,61 @@ void
//-----------------------
//
UpdateRecord *update = (UpdateRecord*)record;
//
// RP412CAMLOG: how hard does an arriving update MOVE this
// entity, and how often?
//
// Between updates a replicant is dead-reckoned from
// updateOrigin over (lastPerformance - lastUpdate). When the
// next one lands the basis is replaced, so the drawn position
// jumps by however far the prediction had drifted. Render
// interpolation cannot hide that: it smooths within a step,
// and this is a discontinuity in the stepped values
// themselves. If the interval is a few per second and the
// correction is tens of centimetres, that is a tick a few
// times a second - which is the symptom being chased.
//
// Reported per entity, on a five second clock, so a busy race
// does not bury the log.
//
if (RPCameraLog() && GetInstance() == ReplicantInstance)
{
static Scalar next_say = 0.0f;
static int corrections = 0;
static Scalar worst = 0.0f;
static Scalar total = 0.0f;
Vector3D drift;
drift.Subtract(
update->localOrigin.linearPosition,
localOrigin.linearPosition
);
Scalar distance = drift.Length();
++corrections;
total += distance;
if (distance > worst)
{
worst = distance;
}
if ((Scalar) Now() >= next_say)
{
if (next_say > 0.0f && corrections > 0)
{
DEBUG_STREAM << "CamLog: replicant corrections - "
<< corrections << " in 5s ("
<< (corrections / 5) << "/s), mean "
<< (total / corrections) << "m, worst "
<< worst << "m\n" << std::flush;
}
next_say = ((Scalar) Now()) + 5.0f;
corrections = 0;
worst = 0.0f;
total = 0.0f;
}
}
updateOrigin = update->localOrigin;
//
@@ -426,6 +506,13 @@ void
{
localOrigin = updateOrigin;
localToWorld = localOrigin;
//
// Set outside the step loop, so renderPreviousOrigin now
// describes a step that never happened. Draw plainly until
// a real one does.
//
renderStepTaken = False;
}
Simulation::ReadUpdateRecord(record);
}
@@ -720,6 +807,118 @@ void
//
if (GetInstance() != ReplicantInstance)
{
//
//----------------------------------------------------------------
// Fixed-step: the subsystems and the entity advance TOGETHER,
// one step at a time, because they read each other mid-flight.
// The VTV's hover spring is computed from its thrusters'
// measured heights, and each thruster measures from where the
// vehicle IS - so thrusters stepped twice against a vehicle
// that has not moved yet hand back two identical height
// samples, and the spring fires twice on stale data. Measured,
// that pod climbs at 30 fps and flies level at 144.
//
// So the step loop lives HERE, above both: everyone is walked
// to the same sub-frame instant before anyone takes the next
// step. Watchers and the update stream still run once per
// frame, after the loop - stepping is physics, watching is
// I/O, and only the first belongs inside.
//
// The interleave keys off the ENTITY's own clock so a
// subsystem created mid-flight (they are made alongside their
// owner) can never wedge the loop.
//----------------------------------------------------------------
//
Scalar fixed_step = Simulation::FixedStep();
if (fixed_step > (Scalar) 0)
{
//
// One grid for the whole vehicle. Every Simulation anchors
// its own lastPerformance at its creation time, so an
// entity and its subsystems were stepping on grids offset
// by a random fraction of a step - deterministic within a
// run, DIFFERENT between runs, because creation times ride
// on load timing. The thrusters' measurements then landed
// a different sub-step distance from the vehicle's
// integration every launch, which is physics drift no seed
// can pin. Snap the subsystems onto the entity's grid; the
// interleave below then keeps everyone in lockstep by
// construction, and once aligned this assignment is a
// no-op every frame after.
//
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->SetLastPerformance(
GetLastPerformance());
}
}
Time step_till = GetLastPerformance();
step_till += fixed_step;
while (step_till <= till)
{
//
// BeginStep on the ENTITY comes before the subsystems
// perform: the Mover's force accumulator is cleared
// here, and the thrusters then ADD this step's forces
// into a clean slate. The first version left that
// clear on the per-frame path, so a two-step frame
// integrated step one's thrust twice - and since how
// many steps land in a frame rides on wall-clock
// jitter, no two runs saw the same force history.
// Identical configs measured 0.23 apart because of it.
//
BeginStep();
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->BeginStep();
subsystemArray[i]->PerformTo(step_till);
}
}
Simulation::PerformTo(step_till);
step_till += fixed_step;
}
//
// How far past the last completed step the frame we are
// about to draw falls, as a fraction of one step. This is
// the leftover the fixed-step loop deliberately does not
// simulate - see renderPreviousOrigin in entity.h.
//
{
Scalar leftover = till - GetLastPerformance();
Scalar fraction = (fixed_step > (Scalar) 0)
? (leftover / fixed_step) : (Scalar) 0;
if (fraction < (Scalar) 0) fraction = (Scalar) 0;
if (fraction > (Scalar) 1) fraction = (Scalar) 1;
renderStepFraction = fraction;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i] &&
subsystemArray[i]->IsNonReplicantExecutable())
{
subsystemArray[i]->WatchAndWrite(update_stream);
}
}
SET_PERFORM_ENTITY();
Simulation::WatchAndWrite(update_stream);
Check_Fpu();
CLEAR_PERFORM_ENTITY();
CLEAR_PERFORM_SUBSYSTEMS();
return;
}
for (int i=0; i<subsystemCount; ++i)
{
if (subsystemArray[i])
@@ -937,6 +1136,16 @@ Entity::Entity(
updateOrigin = localOrigin;
localToWorld = localOrigin;
//
// Render interpolation starts with nothing to blend: the previous
// origin IS the current one and no fraction of a step is outstanding,
// so GetRenderToWorld hands back localToWorld until the first step has
// actually been taken.
//
renderPreviousOrigin = localOrigin;
renderStepFraction = (Scalar) 0;
renderStepTaken = False;
// initialize camera stuff
cameraOffset = Origin::Identity;
@@ -1255,6 +1464,93 @@ Logical
return IsDerivedFrom(*GetClassDerivations());
}
//##########################################################################
// Render interpolation - see entity.h for why, and for why it is render
// only. RP412INTERP=0 turns it off so the stepping it removes can be seen
// again on a test machine without a rebuild.
//##########################################################################
//
static Logical
RenderInterpolationEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412INTERP");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "Render: interpolation off (RP412INTERP=0) - "
<< "drawn motion steps at the physics rate\n" << std::flush;
}
}
return enabled ? True : False;
}
void
Entity::SnapshotRenderOrigin()
{
Check(this);
renderPreviousOrigin = localOrigin;
renderStepTaken = True;
}
void
Entity::SetRenderStepFraction(Scalar fraction)
{
Check(this);
renderStepFraction = fraction;
}
void
Entity::GetRenderToWorld(LinearMatrix *out)
{
Check(this);
Check_Pointer(out);
//
// No fixed step at all, interpolation switched off, or this entity has
// not taken a step yet. Hand back exactly what every caller used before
// this existed.
//
// The test used to be on the fraction rather than on renderStepTaken,
// and that was wrong in a way that showed. Drawing at fraction f means
// drawing at the start of the step plus f of it, so f = 0 means the
// START of the step - while this early return hands back localToWorld,
// which is its END. The two are a whole step apart, about a metre at
// racing speed.
//
// behind is a whole number of milliseconds against a 20ms step, so it
// lands on exactly zero about one frame in twenty. On those frames a
// pod was drawn a full step ahead of itself and then snapped back on
// the next one: a jump three times a second at 59fps, regular because
// the beat between frame rate and step rate is regular, and worst when
// a pod crosses the view quickly. It never appeared in any simulation
// trace because the simulation was right - only the drawing was wrong.
//
// Interpolating at f = 0 is continuous with everything either side of
// it. Each frame advances the drawn position by frame_time / step
// whether or not a step boundary falls between the two, which is the
// entire point of doing it.
//
if (!RenderInterpolationEnabled() || !renderStepTaken)
{
*out = localToWorld;
return;
}
//
// Origin::Lerp does the position and the shortest-arc quaternion, and
// normalises - which over a 20 ms step is indistinguishable from a
// true slerp and a good deal cheaper.
//
Origin blended;
blended.Lerp(renderPreviousOrigin, localOrigin, renderStepFraction);
*out = blended;
Check_Fpu();
}
//##########################################################################
// Renderer Support
//
+63
View File
@@ -141,6 +141,69 @@ public:
int damageZoneCount;
DamageZone **damageZones;
//######################################################################
//################### Render interpolation #########################
//######################################################################
//
// The simulation advances in whole fixed steps (RP412PHYSICSHZ) and the
// renderer draws whenever it can, so at any frame rate that is not the
// step rate the drawn position only changes 50 times a second and is
// held for however many frames fall inside a step. That is visible as
// stepping, and it gets WORSE the faster the machine: at 240 fps each
// position is held for nearly five frames.
//
// So drawing interpolates. renderPreviousOrigin is this entity's origin
// at the START of the step it is currently in, snapshotted by
// Mover::BeginStep, and renderStepFraction is how far through that step
// the frame being drawn falls. GetRenderToWorld blends the two.
//
// RENDER ONLY. localOrigin and localToWorld are untouched, so physics,
// collision, scoring, the nav map's entity queries and the network
// update records all keep seeing exact stepped values - which is what
// keeps the simulation identical on every machine at every frame rate.
// RP412PHYSTRACE is the proof of that and must not move.
//
// The picture therefore trails the simulation by up to one step (20 ms
// at 50 Hz), which is the standard price and much the lesser evil:
// extrapolating FORWARD instead has to guess, and overshoots into
// shimmer every time the guess is corrected.
//
// A teleport must not be smoothed - sliding a pod 300 metres across the
// map over 20 ms would be far worse than the cut it replaces. That
// falls out for free: VTV::BeginStep applies a scheduled respawn and
// THEN calls Mover::BeginStep, so the snapshot is taken after the
// teleport and the blend has nothing to travel.
//
Origin renderPreviousOrigin;
Scalar renderStepFraction;
//
// Whether renderPreviousOrigin describes a step this entity actually
// took. It has to be asked separately from the fraction, because a
// fraction of zero is a perfectly ordinary place to be drawing - see
// GetRenderToWorld for the one-step jump that testing the fraction
// instead used to produce.
//
Logical renderStepTaken;
//
// The transform to DRAW with. Falls back to localToWorld verbatim when
// interpolation is off, when there is no fixed step to interpolate
// within, or before the first snapshot exists - so the unfixed-step
// path behaves exactly as it always did.
//
void
GetRenderToWorld(LinearMatrix *out);
//
// Filled by Simulation::PerformTo around each fixed step, for locally
// simulated entities and replicants alike.
//
void
SnapshotRenderOrigin();
void
SetRenderStepFraction(Scalar fraction);
int
GetDamageZoneIndex(const CString &damage_zone_name) const;
+17
View File
@@ -690,6 +690,23 @@ void
DEBUG_STREAM << "." << std::flush;
}
//
// The rate this gauge runs at, and which tier that puts it in. The
// renderer walks a sixteen-step wheel and a gauge draws only on the
// steps its rate names, so tier 4 is one turn of the wheel between
// redraws - seconds, once a race has the passes down to a handful a
// second. Without this the profile says how EXPENSIVE each gauge is
// but not how RARELY it runs, and the second one is what makes a
// display look stuck.
//
{
char
rate_buffer[32];
sprintf(rate_buffer, "%04x/t%d ", (unsigned) rate, DiscernTier());
DEBUG_STREAM << rate_buffer << std::flush;
}
if (profileCycles > 0)
{
Scalar
+100 -1
View File
@@ -21,6 +21,31 @@
BitTrace Gauge_Renderer("Gauge Renderer");
#endif
//
// How long a single background pass may spend drawing gauges, in
// milliseconds. RP412GAUGESLICE tunes it; 0 restores the original
// behaviour of exactly one gauge per pass.
//
static long
GaugeSliceMs()
{
static long
slice = -1L;
if (slice < 0L)
{
const char
*setting = getenv("RP412GAUGESLICE");
slice = (setting != NULL) ? atol(setting) : 2L;
if (slice < 0L)
{
slice = 0L;
}
}
return slice;
}
//#######################################################################
// Miscellaneous utilities
//#######################################################################
@@ -3672,6 +3697,60 @@ Logical
Logical
result;
//
// RP412GAUGEPROFILE=<seconds> - dump the gauge profile on that
// cadence. Off unless set.
//
// ProfileReport already exists and PROFILE_GAUGES is already on, so
// the numbers are being collected whether anyone looks or not. It was
// only reachable from F11 through the RIO controls mapper, which is
// not the mapper a desktop player is running - so on PAD;KEYBOARD it
// could not be reached at all. This gives it a way out.
//
// It reports every gauge with its rate, its tier, how many times it
// ran and what it cost, then clears - so each dump covers the
// interval since the last one rather than all of history.
//
{
static long
profileInterval = -1L;
if (profileInterval < 0L)
{
const char
*setting = getenv("RP412GAUGEPROFILE");
profileInterval = (setting != NULL) ? atol(setting) : 0L;
if (profileInterval < 0L)
{
profileInterval = 0L;
}
}
if (profileInterval > 0L)
{
static Logical
profileScheduled = False;
static Time
profileDue;
Time
profileNow = Now();
if (!profileScheduled)
{
profileScheduled = True;
profileDue = profileNow;
profileDue += profileInterval * 1000L;
}
else if (profileDue < profileNow)
{
profileDue = profileNow;
profileDue += profileInterval * 1000L;
ProfileReport();
}
}
}
Time start, end;
int oldTaskMode = taskMode;
@@ -3683,7 +3762,27 @@ Logical
case background:
{
result = ProcessOneActiveGauge();
//-----------------------------------------------------------
// Draw gauges until the slice is spent, rather than exactly
// one per pass.
//
// The background loop is only guaranteed a single pass per
// frame; it gets more only while time remains before the
// frame is due. On a busy map the 3D foreground eats the
// whole budget, so a cycle of ninety-odd gauges takes
// ninety-odd frames to come round and the displays sit
// frozen for seconds. Working to a slice makes the refresh
// rate depend on elapsed time instead of on how much spare
// frame there happened to be.
//-----------------------------------------------------------
Time slice_end = Now();
slice_end += GaugeSliceMs();
do
{
result = ProcessOneActiveGauge();
}
while (result && taskMode == background && Now() < slice_end);
break;
}
+182
View File
@@ -0,0 +1,182 @@
#include "munga.h"
#pragma hdrstop
#include "inputscript.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//##########################################################################
// RP412INPUTSCRIPT - see the header for what and why. This file is the
// how: a timeline of rows parsed once, held in a fixed array, evaluated
// by walking to the last row at or before the asked-for time.
//##########################################################################
namespace
{
enum { inputScriptMaxRows = 256 };
struct InputScriptRow
{
float t;
float throttle;
float stickX;
float stickY;
float pedals;
};
InputScriptRow gRows[inputScriptMaxRows];
int gRowCount = 0;
int gLoaded = -1; // -1 not tried, 0 no script, 1 loaded
Logical gArmed = False;
Time gOrigin;
float ClampInto(float value, float low, float high)
{
if (value < low) return low;
if (value > high) return high;
return value;
}
void Load()
{
gLoaded = 0;
const char *path = getenv("RP412INPUTSCRIPT");
if (path == NULL || *path == '\0')
{
return;
}
FILE *file = fopen(path, "rt");
if (file == NULL)
{
DEBUG_STREAM << "InputScript: cannot read '" << path
<< "' - driving unscripted\n" << std::flush;
return;
}
char line[256];
float last_t = -1.0f;
while (fgets(line, sizeof(line), file) != NULL &&
gRowCount < inputScriptMaxRows)
{
InputScriptRow row;
if (sscanf(line, " %f %f %f %f %f",
&row.t, &row.throttle, &row.stickX,
&row.stickY, &row.pedals) != 5)
{
continue; // comments, blanks, ragged lines
}
//
// Clamped HERE, not at sample time, so a script asking for
// throttle 2.0 is corrected once and visibly rather than
// silently every step - and the mapper's own Verify range
// checks can never trip on scripted input.
//
row.throttle = ClampInto(row.throttle, 0.0f, 1.0f);
row.stickX = ClampInto(row.stickX, -1.0f, 1.0f);
row.stickY = ClampInto(row.stickY, -1.0f, 1.0f);
row.pedals = ClampInto(row.pedals, -1.0f, 1.0f);
if (row.t < last_t)
{
DEBUG_STREAM << "InputScript: row at t=" << row.t
<< " is out of order - dropped\n" << std::flush;
continue;
}
last_t = row.t;
gRows[gRowCount++] = row;
}
fclose(file);
if (gRowCount > 0)
{
gLoaded = 1;
DEBUG_STREAM << "InputScript: '" << path << "', " << gRowCount
<< " row(s), last at t=" << gRows[gRowCount - 1].t
<< "s\n" << std::flush;
}
else
{
DEBUG_STREAM << "InputScript: '" << path
<< "' held no usable rows - driving unscripted\n" << std::flush;
}
}
}
int
RPInputScript_Active()
{
if (gLoaded < 0)
{
Load();
}
return (gLoaded == 1) ? 1 : 0;
}
void
RPInputScript_Arm(const Time &origin)
{
if (!RPInputScript_Active())
{
return;
}
gOrigin = origin;
gArmed = True;
DEBUG_STREAM << "InputScript: armed at the green light\n" << std::flush;
}
int
RPInputScript_Sample(
const Time &now,
float *throttle_out,
float *stick_x_out,
float *stick_y_out,
float *pedals_out
)
{
if (!gArmed || gLoaded != 1)
{
return 0;
}
Scalar t = now - gOrigin;
if (t < (Scalar) 0)
{
t = (Scalar) 0;
}
//
// The last row at or before t holds; before the first row, neutral.
// A linear walk, but the list is tiny and already ordered.
//
const InputScriptRow *current = NULL;
for (int i = 0; i < gRowCount; ++i)
{
if (gRows[i].t <= (float) t)
{
current = &gRows[i];
}
else
{
break;
}
}
if (current == NULL)
{
*throttle_out = 0.0f;
*stick_x_out = 0.0f;
*stick_y_out = 0.0f;
*pedals_out = 0.0f;
}
else
{
*throttle_out = current->throttle;
*stick_x_out = current->stickX;
*stick_y_out = current->stickY;
*pedals_out = current->pedals;
}
return 1;
}
+52
View File
@@ -0,0 +1,52 @@
#pragma once
//##########################################################################
// RP412INPUTSCRIPT - scripted analog input, on the simulation's clock.
//
// A race cannot be called deterministic until somebody DRIVES it, and a
// human cannot drive the same lap twice. This feeds the four analog
// channels the controls mapper interprets - throttle, stick X/Y, pedals -
// from a timeline file instead, evaluated against the mapper's own step
// clock, so the same script produces the same race at any frame rate.
//
// The file named by RP412INPUTSCRIPT= holds one row per change:
//
// # t throttle stickX stickY pedals
// 0.0 0.0 0 0 0
// 2.0 1.0 0 0 0
// 6.0 1.0 0.5 0 0
//
// Times are seconds of SIMULATION time from the green light. Each row
// HOLDS until the next row's time - a step function, no interpolation,
// because interpolation would sample differently at different physics
// rates and the whole point is that nothing does.
//
// Armed by the green-light anchor in Application::ExecuteForeground (the
// same instant RP412PHYSTRACE stops the pod dead), so the script clock,
// the trace clock and the vehicle's state all start together.
//
// Test harness: off unless the environment names a file, costs nothing
// when off, and it would be a cheat in a real race.
//##########################################################################
class Time;
// is a script named and readable? (parsed once, on first ask)
int
RPInputScript_Active();
// the green light: script time zero is this instant
void
RPInputScript_Arm(const Time &origin);
// evaluate at 'now' (a simulation clock, normally GetLastPerformance()).
// Returns 0 before Arm or with no script - callers leave their own
// values alone. Outputs are clamped to the mapper's legal ranges.
int
RPInputScript_Sample(
const Time &now,
float *throttle_out, // 0..1
float *stick_x_out, // -1..1
float *stick_y_out, // -1..1
float *pedals_out // -1..1
);
+13 -1
View File
@@ -411,8 +411,20 @@ void
// supposed to
//---------------------------------------------------------------------
//
//
// Doorframes are exempt: they are clockwork, computed identically on
// every machine from the mission clock, so each host builds its own
// Hermit copy instead of one host owning it and replicating. That
// also means they survive a peer dropping, which owned doors do not -
// ownership transfer is not implemented. Note this changes how many
// times the cursor below is advanced, so old and new builds deal the
// remaining map entities differently: they cannot share a session.
//
Logical post_make_message = True;
if (Entity::EntityFlagsIsMap(message->instanceFlags))
if (
Entity::EntityFlagsIsMap(message->instanceFlags)
&& message->classToCreate != DoorFrameClassID
)
{
Check(application);
HostManager *host_manager = application->GetHostManager();
+653 -5
View File
@@ -12,6 +12,117 @@
#include "app.h"
#include "notation.h"
//
// The blend fraction the last dead-reckoned step used, and whether it
// blended at all rather than snapping. Only read by the RP412CAMLOG trace
// in Mover::DeadReckon, which needs them from the branch that computes
// them a few lines earlier.
//
static Logical gLastLerpUsed = False;
static Scalar gLastPercent = 0.0f;
//
// The one replicant the RP412CAMLOG traces describe. Latched here because
// the renderer reports on the same entity from the other end - what its
// motion looks like on screen - and two traces about two different pods
// would compare nothing.
//
static EntityID gTracedEntity = EntityID::Null;
static Logical gTracedLatched = False;
EntityID
MoverTracedEntity()
{
return gTracedEntity;
}
//
// Prediction-error totals for the RP412CAMLOG trace. Shared across
// replicants deliberately: the question - does constant-velocity
// extrapolation hold over one send interval - is about the model, not
// about any one pod, so a whole grid contributing samples is a better
// answer rather than a muddled one.
//
static int gPredictSamples = 0;
static Scalar gPredictAlong = 0.0f;
static Scalar gPredictAlongAbs = 0.0f;
static Scalar gPredictAcross = 0.0f;
static Scalar gPredictMilliseconds = 0.0f;
static Scalar gPredictNextSay = 0.0f;
//
// Bounds on the replication interval estimate, in seconds.
//
// The first pair decide what is allowed into the sample window at all: a
// non-positive gap is a duplicate or a reordered packet, and a multi-second
// one is a join, a pause or a stall. Neither says anything about the rate
// the sender is actually keeping.
//
// The second pair are a backstop on the answer, set deliberately wide so
// that in every sane case the median decides it and these never bind.
//
// Measured send rate on a live connection is about 30ms, so half a second
// is already sixteen times slower than anything healthy.
//
static const Scalar kMinimumUpdateInterval = 0.001f;
static const Scalar kOutlierUpdateInterval = 0.5f;
static const Scalar kMinimumPredictedInterval = 0.010f;
//
// Never predict further ahead than this, which puts a floor under the
// dead reckoner's blend fraction: at a 20ms step the worst case becomes
// 0.02/(0.25+0.02), near enough 7% of the gap per step, so a pod still
// converges on its projection in a dozen steps instead of crawling.
//
static const Scalar kMaximumPredictedInterval = 0.25f;
//
// A gap this long is a stall, not jitter - six times the observed rate.
// A gap this short cannot be a sender keeping to 30ms, so it is a packet
// that was already waiting when we finally got round to reading it.
//
static const Scalar kLongGapThreshold = 0.200f;
static const Scalar kQueuedGapThreshold = 0.005f;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// RP412NETPREDICT=0 restores the original single-sample prediction, so the
// two can be compared on the same build and the same connection.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Every path out of PredictUpdateInterval goes through here. It used not
// to, and the one that skipped it was the bug.
//
static Scalar
ClampPredictedInterval(Scalar interval)
{
if (interval < kMinimumPredictedInterval)
{
return kMinimumPredictedInterval;
}
if (interval > kMaximumPredictedInterval)
{
return kMaximumPredictedInterval;
}
return interval;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
static Logical
UseMedianPrediction()
{
static int cached = -1;
if (cached < 0)
{
const char *setting = getenv("RP412NETPREDICT");
cached = (setting && *setting == '0') ? 0 : 1;
}
return cached ? True : False;
}
//#############################################################################
//############################### Mover #################################
//#############################################################################
@@ -513,6 +624,10 @@ void
Scalar percent =
time_slice / ((nextUpdate - lastPerformance) + time_slice);
// for the RP412CAMLOG trace at the end of this function
gLastLerpUsed = True;
gLastPercent = percent;
//
//------------------------------------------
// Do a spherical lerp on the angular motion
@@ -562,6 +677,7 @@ void
}
else
{
gLastLerpUsed = False; // snapped, not blended
localOrigin = projectedOrigin;
worldLinearVelocity = projectedVelocity.linearMotion;
localVelocity.angularMotion = projectedVelocity.angularMotion;
@@ -581,6 +697,219 @@ void
localToWorld = localOrigin;
}
UpdateLocalMotion();
//
// RP412CAMLOG: is a replicant's motion actually uniform?
//
// Measured HERE, in the replicant's own step, and nowhere else.
// Every previous attempt at this question sampled from another
// clock - the camera's step grid, or an arriving packet's
// timestamp - and two independent clocks alias against each other
// whatever the game is doing, so those numbers could never
// separate a real hitch from the measurement's own beat. This one
// has a single frame of reference: consecutive steps of the entity
// being asked about.
//
// percent is the whole mechanism above: it is how far this step
// moves toward the projected position, and it depends on
// nextUpdate being a decent guess at when the next packet lands.
// If that guess is poor the fraction swings, and swinging fraction
// is uneven motion no matter how clean the packets were.
//
// One entity only - the first replicant seen - because these
// counters are shared and a grid full of pods would blend into
// noise.
//
if (RPCameraLog())
{
if (!gTracedLatched)
{
gTracedLatched = True;
gTracedEntity = GetEntityID();
}
if (gTracedEntity == GetEntityID())
{
static Scalar next_say = 0.0f;
static Point3D last_pos(0.0f, 0.0f, 0.0f);
static Logical have_last = False;
static int steps = 0;
static int spikes = 0;
static int stalls = 0;
static int lerped = 0;
static Scalar mean_step = 0.0f;
static Scalar min_percent = 1.0f;
static Scalar max_percent = 0.0f;
static Scalar worst_error = 0.0f;
static Scalar last_distance = 0.0f;
static Scalar recent[16];
static Scalar frozen[16];
static int recent_next = 0;
static int recent_count = 0;
static Logical captured = False;
static Scalar captured_ratio = 0.0f;
static Scalar captured_percent = 0.0f;
static int seq_stalls = 0;
static int seq_spikes = 0;
++steps;
if (have_last)
{
Vector3D moved;
moved.Subtract(localOrigin.linearPosition, last_pos);
Scalar distance = moved.Length();
//
// The same test the renderer applies to drawn frames:
// this step against the one before it, not against a
// running mean.
//
// A running mean is blind to an alternating pattern -
// high, low, high, low averages to the mean and nothing
// ever looks anomalous - which is why this trace has
// been reporting zero spikes and zero stalls while the
// renderer, comparing consecutive frames, counted
// fifteen to forty-six stalls in the same motion. The
// mean test only ever ruled out DRIFT.
//
if (distance < 50.0f)
{
//
// Keep the last sixteen steps rolling, and freeze a
// copy the moment a stall is seen.
//
// The first version of this printed the first twelve
// steps of each window and they came back immaculate
// - 1.029, 1.031, 1.032, monotonic to a tenth of a
// percent - while the same window counted sixteen
// stalls among the other two hundred and thirty
// nine. Sampling a calm quarter second says nothing
// about a tick that happens elsewhere. The sample
// has to be triggered BY the event.
//
recent[recent_next] = distance;
recent_next = (recent_next + 1) % 16;
if (recent_count < 16) { recent_count++; }
if (last_distance > 0.001f)
{
Scalar sequential = distance / last_distance;
if (sequential < 0.4f)
{
++seq_stalls;
if (!captured && recent_count == 16)
{
captured = True;
captured_ratio = sequential;
captured_percent = gLastPercent;
for (int c = 0; c < 16; c++)
{
frozen[c] = recent[(recent_next + c) % 16];
}
}
}
else if (sequential > 2.5f) { ++seq_spikes; }
}
last_distance = distance;
}
if (distance > 50.0f)
{
mean_step = 0.0f; // respawn, not motion
}
else if (mean_step > 0.01f)
{
Scalar ratio = distance / mean_step;
if (ratio > 2.5f) { ++spikes; }
else if (ratio < 0.4f) { ++stalls; }
mean_step = mean_step * 0.9f + distance * 0.1f;
}
else
{
mean_step = distance;
}
}
last_pos = localOrigin.linearPosition;
have_last = True;
if ((Scalar) Now() >= next_say)
{
if (next_say > 0.0f)
{
DEBUG_STREAM << "CamLog: replicant motion - " << steps
<< " own steps, " << spikes << " spike(s), "
<< stalls << " stall(s), " << lerped
<< " lerped, percent " << min_percent << ".."
<< max_percent << ", mean step " << mean_step
<< "m, predicting " << predictedInterval
<< "s worst miss " << worst_error << "s\n"
<< std::flush;
DEBUG_STREAM << "CamLog: replicant sequence - "
<< seq_stalls << " stall(s), " << seq_spikes
<< " spike(s) against the PREVIOUS step";
if (captured)
{
//
// The fifteen steps leading into a stall and the
// stall itself, last in the list.
//
DEBUG_STREAM << "; at a stall (ratio "
<< captured_ratio << ", percent "
<< captured_percent << "):";
for (int s = 0; s < 16; s++)
{
DEBUG_STREAM << " " << frozen[s];
}
}
else
{
DEBUG_STREAM << "; no stall caught this window";
}
DEBUG_STREAM << "\n" << std::flush;
DEBUG_STREAM << "CamLog: replicant arrivals - widest gap "
<< widestGap << "s, " << longGapCount
<< " long, " << queuedGapCount
<< " queued ("
<< ((longGapCount > 0 && queuedGapCount > 0)
? "our loop stalled"
: (longGapCount > 0
? "sender went quiet"
: "clean"))
<< ")\n" << std::flush;
}
widestGap = 0.0f;
longGapCount = 0;
queuedGapCount = 0;
next_say = ((Scalar) Now()) + 5.0f;
steps = 0;
spikes = 0;
stalls = 0;
lerped = 0;
min_percent = 1.0f;
max_percent = 0.0f;
worst_error = 0.0f;
captured = False;
seq_stalls = 0;
seq_spikes = 0;
}
{
Scalar missed =
(predictionError < 0.0f) ? -predictionError : predictionError;
if (missed > worst_error) { worst_error = missed; }
}
if (gLastLerpUsed)
{
++lerped;
if (gLastPercent < min_percent) { min_percent = gLastPercent; }
if (gLastPercent > max_percent) { max_percent = gLastPercent; }
}
}
}
}
Check_Fpu();
}
@@ -658,9 +987,14 @@ Bye_Bye:
//
//-----------------------------------------------
// Make sure the position quaternion stays stable
//
// Frame-counting, so it only runs on the frame-coupled path - fixed
// steps do the same thing in BeginStep, counted in STEPS, because
// "every 20 frames" lands at a different point of the step sequence
// on every machine and rounding at different points is drift.
//-----------------------------------------------
//
if (++normalizeCount == 20)
if (Simulation::FixedStep() <= (Scalar) 0 && ++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;
@@ -668,6 +1002,133 @@ Bye_Bye:
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The per-STEP set-up. This is the same work Mover::PerformAndWatch does
// once per frame above - and once per frame is exactly wrong under fixed
// stepping: the thrusters ADD their forces into localAcceleration every
// step, so an accumulator cleared per frame carries step one's thrust
// into step two whenever a frame holds two steps. How many steps a frame
// holds depends on wall-clock jitter, which made identical runs diverge
// by a quarter of a metre while sitting still on the pad.
//
// Idempotent on purpose: the frame-level copy still runs first on every
// path, and repeating this at each step start is a recompute from
// current state, not an accumulation.
//
void
Mover::BeginStep()
{
Check(this);
localVelocity.linearMotion.MultiplyByInverse(
worldLinearVelocity,
localToWorld
);
localAcceleration = Motion::Identity;
previousOrigin = localOrigin;
if (++normalizeCount >= 20)
{
localOrigin.angularPosition.Normalize();
normalizeCount = 0;
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Mover::ResetUpdateIntervals()
{
Check(this);
updateIntervalCount = 0;
updateIntervalWrite = 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Estimate how long until the next update for this entity arrives.
//
// This is not a cosmetic guess. DeadReckon blends toward the projected
// origin by
//
// percent = time_slice / ((nextUpdate - lastPerformance) + time_slice)
//
// so the prediction sets how far every single step moves. The original code
// predicted the next gap from the one previous gap. On a LAN that was fine,
// because the gaps were all alike. Over the internet a late packet doubles
// the prediction, percent collapses toward zero, the entity barely advances
// for a step and then catches up on the following ones - which is a visible
// tick. Measured on a live Steam connection at about 1.4 a second, with
// percent bottoming out at 0.014 against a normal range of 0.27 to 0.95.
//
// A median has a breakdown point of half its samples, so one straggler - or
// three - moves it not at all, while a real change in the send rate still
// carries it within a few updates. That is the whole trick: ignore the
// outlier, follow the trend.
//
Scalar
Mover::PredictUpdateInterval(Scalar latest)
{
Check(this);
//
// Only plausible gaps go into the window. Letting a join or a stall in
// would poison the estimate for the next eight updates - precisely when
// the entity is most conspicuous, just after it appears.
//
if (latest > kMinimumUpdateInterval && latest < kOutlierUpdateInterval)
{
updateIntervals[updateIntervalWrite] = latest;
updateIntervalWrite = (updateIntervalWrite + 1) % UpdateIntervalSamples;
if (updateIntervalCount < UpdateIntervalSamples)
{
updateIntervalCount++;
}
}
//
// Too few samples to hold an opinion. Fall back to the gap we just saw
// rather than inventing a rate we have no evidence for - but clamp it
// like any other answer. Leaving this path unclamped let a 2.05s gap
// through in the first updates after an entity appeared, which drove
// the blend fraction to 0.0097 and stalled the step. That is every
// respawn, and it is exactly when the pod is being watched.
//
if (updateIntervalCount < 3)
{
return ClampPredictedInterval(latest);
}
//
// Insertion sort - the window is eight samples, and this runs once per
// arriving packet per entity.
//
Scalar sorted[UpdateIntervalSamples];
int i;
for (i = 0; i < updateIntervalCount; i++)
{
sorted[i] = updateIntervals[i];
}
for (i = 1; i < updateIntervalCount; i++)
{
Scalar value = sorted[i];
int j = i - 1;
while (j >= 0 && sorted[j] > value)
{
sorted[j + 1] = sorted[j];
j--;
}
sorted[j + 1] = value;
}
return ClampPredictedInterval(sorted[updateIntervalCount / 2]);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -681,15 +1142,160 @@ void
{
//
//-------------------------------------------
// HACK - precalculation for next update time
//-------------------------------------------
//---------------------------------------
// Precalculation for next update time
//---------------------------------------
//
nextUpdate = Now();
Scalar diff = nextUpdate - lastUpdate;
Scalar anchorInterval = (Scalar) 0;
if (diff < 10.0f)
{
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
if (UseMedianPrediction())
{
Scalar predicted = PredictUpdateInterval(diff);
//
// Anchor the projection to the SENDER's timeline, below,
// once Entity::ReadUpdateRecord has moved lastUpdate to
// the sampling moment RP412NETCLOCK worked out.
//
anchorInterval = predicted;
//
// Score the previous prediction against the gap that
// actually just elapsed - a true one-step-ahead error,
// kept per entity so a trace reads the entity it is
// watching and not whichever one updated last.
//
if (predictedInterval > 0.0f)
{
predictionError = predictedInterval - diff;
}
predictedInterval = predicted;
//
// Arrival statistics, for telling a quiet sender from
// our own stalled loop. See the members.
//
if (diff > widestGap) { widestGap = diff; }
if (diff > kLongGapThreshold) { longGapCount++; }
if (diff < kQueuedGapThreshold) { queuedGapCount++; }
}
else
{
nextUpdate.ticks += nextUpdate.ticks - lastUpdate.ticks;
}
}
else
{
//
// The stream was interrupted - a join, a pause, a long
// stall. Nothing recorded before it describes the rate
// now, so start the window over.
//
ResetUpdateIntervals();
}
//
// RP412CAMLOG: is constant-velocity extrapolation actually
// accurate over one interval, or is the pod manoeuvring?
//
// The corrections measured 0.25 to 0.66m against a step of
// about a metre, which is what collapses one step to a third
// and shows as the tick. At 52 m/s half a metre is ten
// milliseconds of travel, so the question is whether we are
// evaluating the projection at the wrong INSTANT or whether the
// pod simply is not going in a straight line.
//
// This settles it without involving any clock we do not trust:
// take the position and velocity the sender reported last time,
// carry them forward by the difference between the two SENDER
// timestamps, and compare against the position the sender
// reports now. Both stamps come from the same machine, so
// latency, clock offset and RP412NETCLOCK play no part - it
// measures the prediction and nothing else.
//
// Split the error along the direction of travel and across it.
// Error ALONG the path is time: divided by speed it IS the
// number of milliseconds the window is out by, and its sign
// says which way. Error ACROSS the path cannot be a timing
// problem at all - that is a pod turning, and no clock fix
// would touch it.
//
if (RPCameraLog())
{
UpdateRecord *sample = (UpdateRecord*)record;
if (haveSenderSample)
{
Scalar dt = sample->timeStamp - senderStamp;
Scalar speed = senderVelocity.Length();
if (dt > 0.001f && dt < 1.0f && speed > 1.0f)
{
Vector3D error;
error.x = sample->localOrigin.linearPosition.x
- (senderPosition.x + senderVelocity.x * dt);
error.y = sample->localOrigin.linearPosition.y
- (senderPosition.y + senderVelocity.y * dt);
error.z = sample->localOrigin.linearPosition.z
- (senderPosition.z + senderVelocity.z * dt);
Scalar along =
(error.x * senderVelocity.x
+ error.y * senderVelocity.y
+ error.z * senderVelocity.z) / speed;
Vector3D across;
across.x = error.x - (senderVelocity.x / speed) * along;
across.y = error.y - (senderVelocity.y / speed) * along;
across.z = error.z - (senderVelocity.z / speed) * along;
gPredictSamples++;
gPredictAlong += along;
gPredictAlongAbs += (along < 0.0f) ? -along : along;
gPredictAcross += across.Length();
gPredictMilliseconds += (along / speed) * 1000.0f;
Scalar now_say = (Scalar) Now();
if (now_say >= gPredictNextSay)
{
if (gPredictNextSay > 0.0f && gPredictSamples > 0)
{
Scalar mean_along = gPredictAlong / gPredictSamples;
Scalar mean_across = gPredictAcross / gPredictSamples;
Scalar mean_ms =
gPredictMilliseconds / gPredictSamples;
DEBUG_STREAM << "CamLog: prediction error - "
<< gPredictSamples << " intervals, along "
<< mean_along << "m (" << mean_ms
<< "ms of travel), across " << mean_across
<< "m, verdict "
<< (((mean_along < 0.0f ? -mean_along : mean_along)
> mean_across * 2.0f)
? "TIMING - the window is off"
: ((mean_across
> (mean_along < 0.0f ? -mean_along : mean_along) * 2.0f)
? "MANOEUVRE - the pod is turning"
: "mixed"))
<< "\n" << std::flush;
}
gPredictNextSay = now_say + 5.0f;
gPredictSamples = 0;
gPredictAlong = 0.0f;
gPredictAlongAbs = 0.0f;
gPredictAcross = 0.0f;
gPredictMilliseconds = 0.0f;
}
}
}
senderStamp = sample->timeStamp;
senderPosition = sample->localOrigin.linearPosition;
senderVelocity = sample->worldLinearVelocity;
haveSenderSample = True;
}
//
@@ -699,6 +1305,35 @@ void
//
Entity::ReadUpdateRecord(record);
//
// Put the projection deadline on the SENDER's timeline.
//
// The dead reckoner projects to updateOrigin + velocity *
// (nextUpdate - lastUpdate), so that difference is a DISTANCE
// once multiplied by speed - and a pod at 52 m/s turns every
// millisecond in it into 52mm of target.
//
// lastUpdate is the sampling moment RP412NETCLOCK computed, on
// the sender's clock. Setting nextUpdate from Now() measured the
// gap between two different timelines, so it came out as the
// interval PLUS however late this particular packet happened to
// be. Fifteen milliseconds of ordinary jitter became three
// quarters of a metre of target error, which is enough to
// collapse a one metre step to a third - and only on the packets
// that ran late, which is exactly the intermittent tick that was
// reported.
//
// Anchored to lastUpdate the difference is the predicted
// interval exactly, so the target depends on what the sender
// said and how fast it is going, and not at all on the route the
// packet took to reach us.
//
if (anchorInterval > (Scalar) 0)
{
nextUpdate = lastUpdate;
nextUpdate += anchorInterval;
}
//
//-----------------------
// Update the motion data
@@ -1708,6 +2343,19 @@ Mover::Mover(
updateAcceleration.angularMotion = localAcceleration.linearMotion;
nextUpdate = lastUpdate;
ResetUpdateIntervals();
predictedInterval = 0.0f;
predictionError = 0.0f;
widestGap = 0.0f;
longGapCount = 0;
queuedGapCount = 0;
haveSenderSample = False;
senderStamp = lastUpdate;
senderPosition = localOrigin.linearPosition;
senderVelocity.x = 0.0f;
senderVelocity.y = 0.0f;
senderVelocity.z = 0.0f;
normalizeCount = 0;
if (IsInitialStasis())
{
+71
View File
@@ -265,6 +265,14 @@ protected:
MemoryStream *update_stream
);
//
// Per-step set-up under fixed stepping: clears the force accumulator
// the thrusters add into, so each step integrates only its own
// forces. See the definition for the frame-jitter bug this closes.
//
void
BeginStep();
int
normalizeCount;
Environment
@@ -281,6 +289,61 @@ protected:
Time
nextUpdate;
//
// Recent gaps between replication updates for this entity, as a ring,
// and the running estimate drawn from them. The original code predicted
// the next gap from the single previous gap; see PredictUpdateInterval
// for why that stalls a step every time a packet runs late.
//
enum {UpdateIntervalSamples = 8};
Scalar
updateIntervals[UpdateIntervalSamples];
int
updateIntervalCount,
updateIntervalWrite;
//
// The interval last predicted, and how wrong the prediction before it
// proved to be once the gap it described actually elapsed. Per entity,
// so a trace reads the entity it is watching.
//
Scalar
predictedInterval,
predictionError;
//
// Arrival statistics for the window a trace reports over. A long gap
// followed by normal gaps means the sender went quiet; a long gap
// followed by a burst of near-zero ones means OUR loop stalled and the
// packets queued up behind it. The two look identical from inside the
// dead reckoner and want opposite fixes.
//
Scalar
widestGap;
int
longGapCount,
queuedGapCount;
//
// The last position, velocity and timestamp the SENDER reported, kept
// so an arriving update can be scored against what the one before it
// predicted. All three come from the same machine, so the comparison
// owes nothing to latency or to clock alignment.
//
Time
senderStamp;
Point3D
senderPosition;
Vector3D
senderVelocity;
Logical
haveSenderSample;
Scalar
PredictUpdateInterval(Scalar latest);
void
ResetUpdateIntervals();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Collision support
//
@@ -461,3 +524,11 @@ public:
Logical
TestInstance() const;
};
//
// The replicant the RP412CAMLOG traces are describing, so the renderer can
// report on the same pod from the other end - what its motion looks like on
// screen. Null until a replicant has stepped at least once.
//
EntityID
MoverTracedEntity();
+34 -2
View File
@@ -5,6 +5,7 @@
#include "interest.h"
#include "icom.h"
#include "app.h"
#include "spooler.h"
#include "notation.h"
#include "nttmgr.h"
@@ -72,10 +73,41 @@ Logical
//
void
NetworkClient::ReceiveNetworkPacket(
NetworkPacket*,
NetworkPacket *packet,
Receiver::Message *packet_message
)
{
//
// The recording tee.
//
// Every network client comes through here - the interest manager
// carrying entity updates and the network manager carrying mission
// control - which are the two places the review build spools
// separately. One hook covers both, and it sits before Dispatch so a
// packet is kept whether or not anything downstream makes use of it.
//
// The recorder arms itself on the first packet rather than at the
// green light: playback rebuilds the world from the LoadMission and
// RunMission packets, so a spool that starts at the flag cannot be
// replayed.
//
if (Application::IsRecording())
{
SpoolRecorder *recorder = SpoolRecorder_Get();
if (!recorder->IsArmed())
{
if (!recorder->Arm())
{
//
// Could not reserve the buffer - say so once, by turning
// the request off, rather than asking again every packet.
//
Application::SetRecording(False);
}
}
recorder->Record(packet);
}
Dispatch(packet_message);
}
@@ -255,7 +287,7 @@ void
networkEggNotationFile = new NotationFile();
Register_Object(networkEggNotationFile);
networkEggNotationFile->ReadText(eggTempBuffer, eggTempNext);
networkEggNotationFile->WriteFile("last.egg");
networkEggNotationFile->WriteFile("last-loaded.egg");
//
// Now turn the notation file into a mission
+10
View File
@@ -172,6 +172,11 @@ void
// Create any MUNGA level vehicles
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: CreatePlayerVehicle, cameraShipPlayer="
<< (int) (IsCameraShipPlayer() ? 1 : 0) << "\n" << std::flush;
}
if (IsCameraShipPlayer())
{
Check(application);
@@ -186,6 +191,11 @@ void
playerMission->GetGameModel(),
ResourceDescription::ModelListResourceType
);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: model '" << playerMission->GetGameModel()
<< "' resource " << (camera_res ? "FOUND" : "MISSING") << "\n" << std::flush;
}
Check(camera_res);
CameraShip::MakeMessage
create_camera(
+412 -12
View File
@@ -264,6 +264,109 @@ Simulation::SharedData
// Model support
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//##########################################################################
// Net clock - see SIMULATE.h for why the sender's timestamp is estimated
// rather than used as it stands.
//##########################################################################
namespace
{
enum
{
netClockMaxPeers = 16,
// Samples per rolling minimum. A peer sends one record per
// simulation per frame, so at eight vehicles and 60 fps this is
// well under a second - fast enough to follow a route change,
// long enough that the minimum means something.
netClockWindow = 128,
// The furthest back we will believe a timestamp. Beyond this the
// packet is stale or the estimate is wrong, and extrapolating a
// vehicle half a second forward does more harm than the lag we
// are correcting.
netClockMaxLagTicks = 500
};
struct PeerClock
{
HostID host;
Logical inUse;
Logical settled;
long offsetTicks; // our clock - their clock
long windowMinTicks;
int windowCount;
};
PeerClock gPeerClocks[netClockMaxPeers];
HostID gUpdateSender = 0;
Logical gUpdateSenderValid = False;
Logical NetClockEnabled()
{
static int enabled = -1;
if (enabled < 0)
{
const char *setting = getenv("RP412NETCLOCK");
enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1;
if (!enabled)
{
DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - "
<< "replicants dead-reckon from arrival time\n" << std::flush;
}
}
return enabled ? True : False;
}
PeerClock *FindPeer(HostID host)
{
PeerClock *free_slot = NULL;
for (int i = 0; i < netClockMaxPeers; ++i)
{
if (gPeerClocks[i].inUse)
{
if (gPeerClocks[i].host == host)
{
return &gPeerClocks[i];
}
}
else if (free_slot == NULL)
{
free_slot = &gPeerClocks[i];
}
}
if (free_slot != NULL)
{
free_slot->inUse = True;
free_slot->host = host;
free_slot->settled = False;
free_slot->offsetTicks = 0;
free_slot->windowMinTicks = 0;
free_slot->windowCount = 0;
}
return free_slot;
}
}
void NetClock_BeginUpdate(HostID sender)
{
gUpdateSender = sender;
gUpdateSenderValid = True;
}
void NetClock_EndUpdate()
{
gUpdateSenderValid = False;
}
void NetClock_Reset()
{
memset(gPeerClocks, 0, sizeof(gPeerClocks));
gUpdateSenderValid = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -272,7 +375,84 @@ void
Check(this);
Check_Pointer(message);
lastUpdate = Now(); // HACK - should be based upon message->timeStamp
//
//------------------------------------------------------------------
// When this update arrived is not when it was taken. Put lastUpdate
// at the sender's sampling moment, expressed in our clock, so the
// dead reckoner extrapolates over the network latency instead of
// starting from scratch once it has already elapsed.
//------------------------------------------------------------------
//
long now_ticks = Now().ticks;
long local_ticks = now_ticks;
PeerClock *peer = gUpdateSenderValid && NetClockEnabled()
? FindPeer(gUpdateSender) : NULL;
if (peer != NULL)
{
//
// sample = trueOffset + oneWayLatency, so the running minimum
// converges on the offset from above.
//
long sample = now_ticks - message->timeStamp.ticks;
if (!peer->settled)
{
peer->settled = True;
peer->offsetTicks = sample;
peer->windowMinTicks = sample;
peer->windowCount = 0;
DEBUG_STREAM << "NetClock: host " << peer->host
<< " first sample, offset " << sample << " ms\n" << std::flush;
}
else
{
if (sample < peer->windowMinTicks)
{
peer->windowMinTicks = sample;
}
if (sample < peer->offsetTicks)
{
peer->offsetTicks = sample; // a shorter path: believe it now
}
if (++peer->windowCount >= netClockWindow)
{
//
// Close the window: adopt its minimum even if it is
// LARGER than the running estimate, which is how the
// figure follows clock drift and a route that got
// slower rather than staying pinned to one old packet.
//
long moved = peer->windowMinTicks - peer->offsetTicks;
if (moved > 50 || moved < -50)
{
DEBUG_STREAM << "NetClock: host " << peer->host
<< " offset " << peer->offsetTicks << " -> "
<< peer->windowMinTicks << " ms\n" << std::flush;
}
peer->offsetTicks = peer->windowMinTicks;
peer->windowMinTicks = sample;
peer->windowCount = 0;
}
}
local_ticks = message->timeStamp.ticks + peer->offsetTicks;
//
// Never ahead of our own clock, and never further back than we
// are willing to extrapolate.
//
if (local_ticks > now_ticks)
{
local_ticks = now_ticks;
}
else if (now_ticks - local_ticks > netClockMaxLagTicks)
{
local_ticks = now_ticks - netClockMaxLagTicks;
}
}
lastUpdate.ticks = local_ticks;
SetSimulationState(message->simulationState);
Check_Fpu();
}
@@ -441,9 +621,238 @@ void*
}
}
//#############################################################################
// RP412PHYSICSHZ - the size of one simulation step, as a rate in hertz.
//
// The engine simulates TO a timestamp: every entity keeps a lastPerformance
// marking how far it has been simulated, and PerformAndWatch hands Perform()
// the difference. That difference used to be however long the last frame
// took, which made the frame rate part of the physics - explicitly so, since
// Mover scales its bounce and penetration thresholds by delta_t.
//
// Advancing lastPerformance in fixed steps instead makes it the accumulator
// a fixed-step loop needs, and every Perform() in the game gets an identical
// dt without one of them being touched.
//
// 0 restores the old behaviour for comparison. The RATE is a game-feel
// decision, not a technical one: thirty years of handling constants were
// tuned against the DOS build's 40 ms steps, and RP412 has been running
// ~18 ms variable ones, so the feel has already drifted. Whatever is chosen
// here becomes the canonical physics for pods and PCs alike.
//#############################################################################
static Scalar
FixedPhysicsStep()
{
static Scalar
step = (Scalar) -1;
if (step < (Scalar) 0)
{
const char
*setting = getenv("RP412PHYSICSHZ");
//
// 50 Hz is the default: a 20 ms step, exact on the millisecond
// clock, and the rate whose settled hover ride height measured
// closest to the frame-coupled physics the game has always run.
// Proven before it was defaulted - a scripted lap with a crash,
// a burn and two respawns runs bit-identical at 30, 60 and 144
// fps, and identical runs reproduce exactly. 0 restores the
// original frame-coupled behaviour, where the frame rate is
// part of the physics.
//
int rate = (setting != NULL) ? atoi(setting) : 50;
//
// Guard the arithmetic rather than the taste: a rate below the
// frame rate is a legitimate choice (the pods ran at 25), but a
// step of zero or a negative one is not a choice at all.
//
if (rate < 0)
{
rate = 0;
}
if (rate > 1000)
{
rate = 1000;
}
step = (rate > 0) ? ((Scalar) 1 / (Scalar) rate) : (Scalar) 0;
DEBUG_STREAM << "Physics: ";
if (rate > 0)
{
DEBUG_STREAM << "fixed step, " << rate << " Hz";
//
// The engine's clock counts MILLISECONDS, so a step is
// really round(1000/rate) ms. A rate that does not divide
// 1000 evenly therefore runs at a neighbouring rate wearing
// this one's name - 60 asks for 16.67 ms and gets 17, which
// is 58.8 Hz. Say so, and name the rates that mean what
// they say.
//
if ((1000 % rate) != 0)
{
int step_ms = (1000 + rate / 2) / rate;
DEBUG_STREAM << " - NOT millisecond-exact, steps will run "
<< step_ms << " ms (" << (1000.0f / (float) step_ms)
<< " Hz). 25, 50 and 100 are exact";
}
}
else
{
DEBUG_STREAM << "frame-coupled (RP412PHYSICSHZ=0)";
}
DEBUG_STREAM << "\n" << std::flush;
}
return step;
}
//
// How far behind one frame may catch up: a quarter second of simulation,
// whatever the rate - enough to ride out a texture load or an alt-tab,
// short of letting a stalled machine spiral. Counted in steps because the
// loop is, so 6 steps at 25 Hz, 12 at 50, 25 at 100.
//
static int
MaximumCatchUpSteps(Scalar step)
{
int steps = (int)((Scalar) 0.25 / step);
return (steps < 4) ? 4 : steps;
}
// how many fixed steps the whole simulation has taken - the trace prints it,
// so 'is the step actually fixed' is answered by measurement not by reading
long gPhysicsStepsTaken = 0;
//#############################################################################
// Simulation Support
//
Scalar
Simulation::FixedStep()
{
return FixedPhysicsStep();
}
void
Simulation::PerformTo(const Time& till)
{
Check(this);
Check(&till);
Scalar step = FixedPhysicsStep();
if (step > (Scalar) 0)
{
//
//------------------------------------------------------------------
// Fixed step. The simulation advances in whole steps of the same
// size on every machine, and whatever is left over waits for the
// next frame - lastPerformance is the accumulator, and always was.
//
// Before this, the slice was simply however long the last frame
// took, so a 30 fps machine integrated gravity in 33 ms steps and
// a 144 fps machine in 7 ms ones. Nothing in any Perform()
// changes: it is handed a dt it can rely on instead of one that
// depended on the graphics card.
//
// NOTE the caller decides the interleaving. An entity's spring
// forces are computed from its subsystems (the VTV reads its
// thrusters' measured heights), so the subsystems and the entity
// must advance TOGETHER, one step at a time -
// Entity::PerformAndWatch owns that loop and hands everyone the
// same sub-frame 'till'. Stepping a subsystem all the way to the
// frame boundary before its owner moves at all is how the first
// attempt at this produced a pod that climbed at 30 fps and flew
// level at 144: two spring impulses from one stale height sample.
//------------------------------------------------------------------
//
Scalar behind = till - lastPerformance;
int taken = 0;
int max_steps = MaximumCatchUpSteps(step);
while (behind >= step && taken < max_steps)
{
//
// Where this step STARTED, for drawing. Taken here rather than
// in BeginStep so it covers replicants too, and taken after any
// BeginStep teleport (a VTV's scheduled respawn) so a jump
// stays a cut instead of becoming a slide.
//
SnapshotRenderOrigin();
Perform(step);
++gPhysicsStepsTaken;
lastPerformance += step;
behind -= step;
++taken;
}
//
// How far past the last completed step the frame being drawn falls.
// Entity::PerformAndWatch computes this again from the FRAME's till
// after its interleave, because there this function is called once
// per step and sees no leftover at all.
//
{
Scalar fraction = behind / step;
if (fraction < (Scalar) 0) fraction = (Scalar) 0;
if (fraction > (Scalar) 1) fraction = (Scalar) 1;
SetRenderStepFraction(fraction);
}
//
// A machine that cannot keep up must not try to buy back the whole
// backlog next frame - that costs more time, which makes a bigger
// backlog. Drop what could not be run and carry on: the game slows
// down rather than seizing, and it does so identically everywhere.
//
if (taken >= max_steps && behind >= step)
{
lastPerformance = till;
}
}
else
{
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
}
Check_Fpu();
}
void
Simulation::BeginStep()
{
// nothing by default - see the header
}
void
Simulation::SnapshotRenderOrigin()
{
// nothing by default - only an Entity has an origin to snapshot
}
void
Simulation::SetRenderStepFraction(Scalar)
{
// nothing by default - see the header
}
void
Simulation::WatchAndWrite(MemoryStream *update_stream)
{
Check(this);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
}
void
Simulation::PerformAndWatch(
const Time& till,
@@ -453,17 +862,8 @@ void
Check(this);
Check(&till);
Scalar slice = till - lastPerformance;
lastPerformance = till;
Perform(slice);
if (!AreWatchersDelayed())
{
ExecuteWatchers();
}
WriteSimulationUpdate(update_stream);
Check_Fpu();
PerformTo(till);
WatchAndWrite(update_stream);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+85
View File
@@ -4,6 +4,41 @@
#include "receiver.h"
#include "time.h"
#include "resource.h"
#include "hostid.h"
//##########################################################################
//########################### Net clock ##############################
//##########################################################################
//
// Aligning a peer's clock with ours, so a replicant is dead-reckoned from
// when its update was SENT rather than when it happened to arrive.
//
// Every update record carries the sender's own timestamp. The receiver
// used to throw it away and stamp lastUpdate with its own Now() - the
// original code says so: "HACK - should be based upon message->timeStamp".
// The dead reckoner then extrapolates over (lastPerformance - lastUpdate),
// so starting that clock at ARRIVAL rather than at SEND leaves every
// remote vehicle exactly one network latency behind where it should be.
// On the 1 ms arcade LAN that was invisible. Over Steam Datagram Relay it
// is a constant 50-150 ms of positional lag - a bias, not jitter.
//
// The timestamp cannot be used raw: two machines' clocks share no epoch,
// both being QueryPerformanceCounter since their own boot. So we estimate
// the offset per peer. Each arriving record gives
//
// sample = ourNow - theirStamp = trueOffset + oneWayLatency
//
// and since latency is never negative, the SMALLEST sample seen is the
// closest to the true offset. Taking a minimum over a short rolling
// window tracks crystal drift and re-adapts when the route changes,
// instead of being pinned forever by one lucky packet.
//
// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time
// behaviour, so a test machine can A/B it without a rebuild.
//
void NetClock_BeginUpdate(HostID sender); // around one message's records
void NetClock_EndUpdate();
void NetClock_Reset(); // forget every peer (new mission)
class Simulation__SharedData;
class Simulation__IndexData;
@@ -112,6 +147,53 @@ public:
MemoryStream *update_stream
);
//
// The two halves of PerformAndWatch, so an ENTITY can interleave its
// subsystems' physics with its own, step by step, and still run the
// watchers and the update stream once per frame. PerformTo advances
// the simulation to the given time - in fixed steps when
// RP412PHYSICSHZ names a rate, in one variable slice otherwise.
//
void
PerformTo(const Time& till);
void
WatchAndWrite(MemoryStream *update_stream);
//
// Called by the entity interleave at the TOP of every fixed step,
// before any subsystem adds its forces for that step. Per-frame set-up
// work - clearing a force accumulator, deriving local velocity from
// world state - belongs here when the fixed step is on, because "once
// per frame" is a wall-clock cadence and the whole point is that wall
// clock no longer reaches the physics. Default: nothing.
//
virtual void
BeginStep();
//
// Render interpolation hooks, called by PerformTo around the fixed
// step. They live HERE rather than on the entity interleave because a
// REPLICANT never runs that interleave - it reaches PerformTo through
// Simulation::PerformAndWatch instead - and a replicant is exactly what
// every remote pod is. Hanging the snapshot off BeginStep left the
// watched car uninterpolated while the camera watching it was smooth,
// which is most of the way to nowhere.
//
// Defaults do nothing; Entity overrides them because it owns the
// origin. See Entity::GetRenderToWorld.
//
virtual void
SnapshotRenderOrigin();
virtual void
SetRenderStepFraction(Scalar fraction);
//
// The fixed step in seconds, 0 when frame-coupled. Global on purpose:
// a mixed-rate simulation would be a worse bug than either mode.
//
static Scalar
FixedStep();
void
DoNothingOnce(Scalar time_slice);
void
@@ -120,6 +202,9 @@ public:
void
SetLastPerformance(const Time& when)
{Check(this); Check(&when); lastPerformance = when;}
const Time&
GetLastPerformance() const
{Check(this); return lastPerformance;}
void
RequestEncore(Encore encore);
+304
View File
@@ -158,6 +158,310 @@ NetworkPacket*
return (NetworkPacket*)GetPointer();
}
//#############################################################################
//############################## SpoolRecorder ##############################
//#############################################################################
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// How much memory to give a recording, in megabytes.
//
// The review build's SPOOL_SIZE is 6MB, which was a sensible arcade
// number and is a silly desktop one: a full grid sends on the order of
// 17KB a second, so six megabytes is about six minutes and a long race
// would hit the end of it. A hundred megabytes is roughly an hour and a
// half and costs nothing on any machine that can run this.
//
static size_t
RecordSizeBytes()
{
static size_t cached = 0;
if (cached == 0)
{
const char *setting = getenv("RP412RECORDSIZE");
int megabytes = (setting != NULL) ? atoi(setting) : 100;
if (megabytes < 1) { megabytes = 1; }
if (megabytes > 512) { megabytes = 512; }
cached = (size_t) megabytes * 1024 * 1024;
}
return cached;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
SpoolRecorder::SpoolRecorder():
buffer(NULL),
spool(NULL),
bufferSize(0),
armed(False),
full(False),
headerWritten(False),
packetsRecorded(0)
{
eggPath[0] = '\0';
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
SpoolRecorder::SetEggPath(const char *path)
{
Check(this);
if (path == NULL)
{
eggPath[0] = '\0';
return;
}
strncpy(eggPath, path, sizeof(eggPath) - 1);
eggPath[sizeof(eggPath) - 1] = '\0';
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
SpoolRecorder::~SpoolRecorder()
{
Disarm();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
Logical
SpoolRecorder::Arm()
{
Check(this);
if (armed)
{
return True;
}
bufferSize = RecordSizeBytes();
buffer = new char[bufferSize];
if (buffer == NULL)
{
DEBUG_STREAM << "Record: could not reserve "
<< (bufferSize / (1024 * 1024))
<< "MB - recording disabled for this race\n" << std::flush;
bufferSize = 0;
return False;
}
spool = new SpoolFile(buffer, bufferSize);
spool->spoolState = SpoolFile::Spooling;
armed = True;
full = False;
headerWritten = False;
packetsRecorded = 0;
DEBUG_STREAM << "Record: armed, " << (bufferSize / (1024 * 1024))
<< "MB (RP412RECORDSIZE)\n" << std::flush;
return True;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
SpoolRecorder::Record(const NetworkPacket *packet)
{
Check(this);
if (!armed || full || packet == NULL)
{
return;
}
Check_Pointer(spool);
int length =
packet->messageData.messageLength + sizeof(NetworkPacketHeader);
//
// Ask before writing. SpoolFile::SpoolPacket answers a full buffer by
// quitting the process, which would end the race this is recording -
// so the recorder never lets it get that far, and stops instead.
//
if ((int) spool->GetBytesRemaining() < length)
{
full = True;
DEBUG_STREAM << "Record: buffer full after " << packetsRecorded
<< " packets - the race continues, the recording stops here."
<< " Raise RP412RECORDSIZE to keep more.\n" << std::flush;
return;
}
//
// Where the copy is about to land, taken BEFORE the write so the
// timestamp can be applied to the copy afterwards. The live packet is
// never modified: its timeStamp is the sender's sampling moment, which
// Simulation::ReadUpdateRecord hands to RP412NETCLOCK and from there to
// the dead reckoner's projection. Restamping it in place - which is
// what the review spooler does, harmlessly, having nothing else to
// serve - would put arrival jitter straight into where remote pods are
// drawn.
//
NetworkPacket *copy = (NetworkPacket*) spool->GetPointer();
spool->SpoolPacket((NetworkPacket*) packet);
//
// Playback paces from these, and packets from different senders carry
// different clock origins, so the spool needs them all on one clock:
// ours, at the moment of arrival.
//
copy->timeStamp = Now();
++packetsRecorded;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
SpoolRecorder::Save()
{
Check(this);
//
// Never armed means not one packet arrived all race.
//
// Recording captures the packets this station RECEIVES, and a race
// with no other machines in it sends and receives nothing at all -
// L4NetworkManager::ExclusiveBroadcast walks the remote hosts, and a
// solo race has none. So there is nothing to keep, and the reason is
// worth saying rather than leaving an empty folder to be puzzled over.
//
// The same gap is why a RACER's recording is not the whole race: its
// own pod is simulated locally and never arrives as a packet. A Live
// Cam races nothing, so every pod reaches it over the wire, which
// makes it the only station that hears the lot.
//
if (!armed || spool == NULL)
{
DEBUG_STREAM << "Record: nothing to write - no packets were received"
<< " this race. Recording keeps what arrives over the network,"
<< " so a single-player race has nothing to keep.\n" << std::flush;
return;
}
if (packetsRecorded == 0)
{
DEBUG_STREAM << "Record: nothing captured, no file written\n"
<< std::flush;
return;
}
//
// The review build writes here and so does this, so one folder holds
// every spool however it was made and the playback build finds them
// all in the same place.
//
CreateDirectoryA("SPOOLS", NULL);
struct tm newtime;
__int64 ltime;
_time64(&ltime);
_gmtime64_s(&newtime, &ltime);
char filename[MAX_PATH];
sprintf(
filename,
"SPOOLS\\%.4i_%.2i_%.2i_%.2i%.2i%.2i.spl",
newtime.tm_year + 1900, newtime.tm_mon + 1, newtime.tm_mday,
newtime.tm_hour, newtime.tm_min, newtime.tm_sec
);
//
// Read the size BEFORE saving: SaveAs rewinds the stream when it is
// done, so asking afterwards reports nothing written at all - which
// is what the first recording's log said, next to a 2.8MB file.
//
size_t written = spool->GetBytesUsed();
spool->SaveAs(filename);
CopyFileA(filename, "last.spl", FALSE);
//
// The egg beside it, under the same stem.
//
// A spool is only half a recording: it says what moved, never the
// track it moved through, and playback will not start without the egg
// the race was run on. frontend.egg is rewritten by the next race set
// up on this machine, so a recording kept on its own quietly stops
// being playable as soon as somebody picks another track. Kept
// together they stay one artifact for as long as the folder does.
//
if (eggPath[0] != '\0')
{
char egg_copy[MAX_PATH];
strncpy(egg_copy, filename, sizeof(egg_copy) - 1);
egg_copy[sizeof(egg_copy) - 1] = '\0';
size_t length = strlen(egg_copy);
if (length > 4)
{
strcpy(egg_copy + length - 4, ".egg");
if (CopyFileA(eggPath, egg_copy, FALSE))
{
CopyFileA(eggPath, "last.egg", FALSE);
DEBUG_STREAM << "Record: kept the egg beside it as "
<< egg_copy << "\n" << std::flush;
}
else
{
DEBUG_STREAM << "Record: could NOT copy the egg '" << eggPath
<< "' - the spool will not replay without it\n" << std::flush;
}
}
}
else
{
DEBUG_STREAM << "Record: no egg path known, so none kept - this spool"
<< " will need the matching egg supplied by hand to replay\n"
<< std::flush;
}
DEBUG_STREAM << "Record: wrote " << filename << " - "
<< packetsRecorded << " packets, " << (written / 1024) << "KB"
<< (full ? " (truncated - buffer filled)" : "")
<< "\n" << std::flush;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
SpoolRecorder::Disarm()
{
Check(this);
if (spool != NULL)
{
delete spool;
spool = NULL;
}
if (buffer != NULL)
{
delete [] buffer;
buffer = NULL;
}
bufferSize = 0;
armed = False;
full = False;
headerWritten = False;
packetsRecorded = 0;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
SpoolRecorder *
SpoolRecorder_Get()
{
static SpoolRecorder recorder;
return &recorder;
}
//#############################################################################
//################# MissionReviewApplicationManager #####################
//#############################################################################
+98
View File
@@ -38,6 +38,104 @@ public:
NextPacket();
};
//##########################################################################
//########################## SpoolRecorder #############################
//##########################################################################
//
// Keeping a race from a station that is also PLAYING one.
//
// The review build already records by teeing: L4SpoolingNetworkManager
// spools each packet and then hands it to the ordinary receive path. What
// tied that to a review build was never the recording - it was where the
// buffer came from. MissionReviewApplicationManager is only a pool
// allocator, and SpoolFile takes whatever buffer it is handed, so a
// recorder that owns one buffer needs none of it.
//
// Two things a live recorder must do that the review one did not:
//
// It must not touch the packet. The spooler restamps each packet with
// local arrival time, which is right - playback paces off those stamps and
// packets from different senders carry different clock origins, so they
// have to be put on one clock. But doing it in place would overwrite the
// sender's timestamp that Simulation::ReadUpdateRecord feeds to
// RP412NETCLOCK and the dead reckoner, which is exactly the input behind
// the projection tick. So it stamps the COPY, in the spool, after writing.
//
// And it must not take the race down with it. SpoolFile::SpoolPacket
// answers a full buffer with PostQuitMessage, which for a review is a fair
// end to a replay and for a live host is killing the race being recorded.
// The recorder checks the room first and simply stops.
//
class SpoolRecorder
{
public:
SpoolRecorder();
~SpoolRecorder();
// Allocate and begin. Call before the mission loads: playback rebuilds
// the world from the LoadMission and RunMission packets, so a spool
// armed at the green light cannot be replayed.
Logical
Arm();
// Tee one received packet. Safe to call unarmed or after the buffer
// has filled - both do nothing.
void
Record(const NetworkPacket *packet);
// Where the egg for this race lives, so it can be kept beside the
// recording. A spool records what MOVED and never the track it moved
// through, so the two are one artifact: without its egg a spool cannot
// be replayed, and frontend.egg is overwritten by the next race set up
// on this machine. Saving them together is what stops a recording
// going stale the moment somebody picks a different track.
void
SetEggPath(const char *path);
// Write SPOOLS\<timestamp>.spl, the matching .egg beside it, and copy
// the spool to last.spl.
void
Save();
void
Disarm();
Logical
IsArmed() const
{ return armed; }
//
// A spool opens with a header - the application ID, the resource
// major version, and one (remote, hostID) pair per host named in the
// egg - and playback refuses a spool without it. All of that is
// network-layer knowledge, so it is written by the network manager
// once the hosts exist, and these three are what let it.
//
SpoolFile*
GetSpool()
{ return spool; }
Logical
HeaderWritten() const
{ return headerWritten; }
void
MarkHeaderWritten()
{ headerWritten = True; }
protected:
char *buffer;
SpoolFile *spool;
size_t bufferSize;
Logical armed;
Logical full;
Logical headerWritten;
int packetsRecorded;
char eggPath[260];
};
// The process-wide recorder, made on first use.
SpoolRecorder *
SpoolRecorder_Get();
//##########################################################################
//############## MissionReviewApplicationManager #####################
//##########################################################################
+6
View File
@@ -32,6 +32,12 @@ protected:
static long ticksPerSecond;
static __int64 perfCounterFreq;
//
// The counter reading this process started at, so the clock counts from
// launch rather than from the machine's boot. See GetRTC.
//
static __int64 perfCounterOrigin;
static long GetRTC();
static double GetHiRes();
static __int64 GetHiResTicks();
+12 -2
View File
@@ -168,10 +168,20 @@ void
//
//-----------------------------------------------------------------------
// If update message is not null then send the change
// If update message is not null then send the change.
//
// The dynamic master socket holds Independant and Hermit instances as
// well as masters, and neither of those publishes: an Independant runs
// its own simulation on every host, and a Hermit is not replicated at
// all. EntityUpdateReplicants asserts MasterInstance, so the caller is
// the one that has to make that true - the clockwork doorframes are
// Hermits and would otherwise arrive there.
//-----------------------------------------------------------------------
//
if (update_message != NULL)
if (
update_message != NULL
&& entity->GetInstance() == Entity::MasterInstance
)
{
Check(update_message);
+64
View File
@@ -133,6 +133,70 @@ void Verify_Failed(char *Message, char *File, int Line)
}
}
//
//#############################################################################
//#############################################################################
//
//
// The release build's Fail. Says what went wrong before it goes.
//
// Terminates exactly as the old bare abort() did - same call, same
// 0xC0000409 - so no caller, script or crash handler sees a change. The
// only difference is the line in the log naming the check that failed and
// where it lives.
//
// The emergency shutdowns are worth doing before the process ends even
// though it is ending: the gauge renderer and the controls manager both
// hold hardware, and a display left mid-mode is a nuisance to whoever has
// to pick the machine up afterwards - which in a pod bay is not the
// person who was playing.
//
void
Fail_With_Message(const char *Message, const char *File, int Line)
{
if (!AlreadyFailed)
{
AlreadyFailed = True;
DEBUG_STREAM << "FAIL " << File << "(" << Line << "): " << Message
<< "\n" << std::flush;
//
// And again, somewhere that cannot be lost.
//
// rpl4.log is std::cout with its streambuf swapped for an ofstream's
// (see WinMain), and the text written above did NOT survive to disk
// across the exit - flushed as far as the stream and no further.
// Rather than guess at where it stops, write the one line that
// matters to its own file and close it: fclose is a promise the
// bytes are on the disk, and a file opened and closed inside this
// function cannot be left dangling by whatever happens next.
//
// Its own file rather than an append to rpl4.log because that one is
// already open for writing and Windows will not share it.
//
FILE *record = fopen("rpl4-fail.log", "w");
if (record != NULL)
{
fprintf(record, "FAIL %s(%d): %s\n", File, Line, Message);
fclose(record);
}
//
// Hardware last, and after the record is safely written: the gauge
// renderer and the controls manager both hold devices, and a display
// left mid-mode is a nuisance for whoever picks the machine up - in
// a pod bay, not the person who was playing. If one of these faults
// on the way down, the failure has already been recorded.
//
SystemClock::timer.Shutdown();
ControlsManager::Shutdown();
GaugeRenderer::EmergencyShutdown();
}
abort();
}
//
//#############################################################################
//#############################################################################
+4
View File
@@ -82,6 +82,10 @@ public:
// -fit: borderless window filling the monitor, with the render size
// chosen to match the cockpit canvas it will be presented into.
static bool GetFitDisplay() { return mFitDisplay; }
// -fit's borderless full-monitor placement. Applied once at startup so
// the window is in its final shape before ANY mission builds a device
// against it - see the definition for why the first race differed.
static void FitWindowToMonitor(HWND window);
static Logical GetSeeSolids() { return seeSolids; }
static unsigned long GetNetworkCommonFlatAddress() { return networkCommonFlatAddress; }
// The front end's multiplayer path turns network mode on at launch
+107 -6
View File
@@ -240,6 +240,9 @@ Logical
{
DEBUG_STREAM << "\n" << argv[0] <<
" -egg <filename> -net <memory_address> -solids -h -help\n"
" -mr mission review: play a spool back and\n"
" record a new one alongside it\n"
" -pb play a spool back only, no recording\n"
" -windowed windowed, title bar and all\n"
" -fit borderless over the whole monitor,\n"
" render size chosen to match\n"
@@ -302,6 +305,72 @@ void
<< monitor_w << "x" << monitor_h << " monitor\n" << std::flush;
}
//
//#############################################################################
// FitWindowToMonitor
//#############################################################################
//
// -fit's borderless full-monitor placement, applied to the shell window.
//
// This has to happen BEFORE the first race, not during it. SVGA16 does the
// same thing when it assembles the cockpit, but that is not until a mission
// starts - and the D3D device is created just ahead of it, against whatever
// the window is at that moment. So the first race got a device sized to a
// still-bordered window and every race after it got one sized to the
// borderless monitor: two different render targets, two different frame
// costs, from one lobby and one set of settings.
//
// A racing sim cannot have that. The window reaches its final shape while
// the front end is still up, so every mission of a session - the first one
// included - is set up against exactly the same client area.
//
// SVGA16 still applies it when it builds the cockpit. That call becomes a
// no-op rather than a change, which is the point.
//
void
L4Application::FitWindowToMonitor(HWND window)
{
if (window == NULL)
{
return;
}
RECT monitor_rect;
monitor_rect.left = 0;
monitor_rect.top = 0;
monitor_rect.right = GetSystemMetrics(SM_CXSCREEN);
monitor_rect.bottom = GetSystemMetrics(SM_CYSCREEN);
MONITORINFO monitor;
memset(&monitor, 0, sizeof(monitor));
monitor.cbSize = sizeof(monitor);
HMONITOR handle = MonitorFromWindow(window, MONITOR_DEFAULTTOPRIMARY);
if (GetMonitorInfoA(handle, &monitor))
{
monitor_rect = monitor.rcMonitor;
}
//
// Same style surgery SVGA16 performs, so the two agree exactly.
//
LONG_PTR style = GetWindowLongPtrA(window, GWL_STYLE);
style &= ~(WS_CAPTION | WS_THICKFRAME | WS_SYSMENU |
WS_MINIMIZEBOX | WS_MAXIMIZEBOX | WS_BORDER | WS_DLGFRAME);
style |= WS_POPUP | WS_CLIPCHILDREN;
SetWindowLongPtrA(window, GWL_STYLE, style);
SetWindowPos(window, NULL,
monitor_rect.left, monitor_rect.top,
monitor_rect.right - monitor_rect.left,
monitor_rect.bottom - monitor_rect.top,
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
DEBUG_STREAM << "L4Application: -fit placed the window borderless at "
<< (monitor_rect.right - monitor_rect.left) << "x"
<< (monitor_rect.bottom - monitor_rect.top)
<< " before the first mission\n" << std::flush;
}
//
//#############################################################################
// ParseCommandLine
@@ -337,6 +406,23 @@ Logical
{
suppressGauges = TRUE;
missionReviewMode = 1;
}
//
// Play a spool back without recording a new one.
//
// Mode 2 was always in RPL4.CPP - it takes one spool file
// instead of two and skips the spooling application - there was
// simply no way to ask for it. -mr starts a recorder alongside
// the playback, and that recorder is the half that falls over
// on a machine which is not a pod bay: it walks the hosts named
// in the egg expecting to find every one of them connected.
//
// Watching a race back does not need a recorder at all.
//
else if (!stricmp(W2A(argv[i]), "-pb"))
{
suppressGauges = TRUE;
missionReviewMode = 2;
} else if (!(*parser)(&i, argc, argv))
{
return False;
@@ -801,6 +887,13 @@ void
// The debug keys are for developers: RP412DEVKEYS=1 arms them.
// Players get exactly one chord - Alt+Q, the deliberate abort.
//
// Only Alt+W and Alt+E actually do anything. The rest call DPLRenderer
// methods whose bodies were commented out with the rest of the DPL
// calls in the 2007 Direct3D port and have been empty ever since; each
// is marked INERT below. Reviving one means writing it against D3D9,
// not un-commenting anything - the dpl_* types it used are empty
// placeholder classes now (DPLSTUB.h).
//
static int dev_keys = -1;
if (dev_keys < 0)
{
@@ -844,6 +937,7 @@ void
//--------------------------------------------
// FrameDump from Division card to Targa file
// INERT: DPLFrameDump and dump_frame_buffer are both stubs.
//--------------------------------------------
case PCK_ALT_F:
{
@@ -859,6 +953,7 @@ void
//------------------------------------
// Report current free memory in card
// INERT: the body below is commented out, so this key is silent.
//------------------------------------
case PCK_ALT_K:
{
@@ -886,6 +981,7 @@ void
//---------------------------------------
// Report performance statistics (Alt-?)
// INERT: DPLReportPerfStats is a stub - it read DPL's own counters.
//---------------------------------------
case PCK_ALT_SLASH:
{
@@ -898,9 +994,10 @@ void
}
break;
}
//--------------------------
// Toggle Wireframe display
//--------------------------
//--------------------------------------------------------------
// Toggle Wireframe display. Live: reimplemented on D3D9 as a
// per-frame D3DRS_FILLMODE (see gWireframe in L4VIDEO.cpp).
//--------------------------------------------------------------
case PCK_ALT_W:
{
if (!dev_keys) break;
@@ -912,9 +1009,13 @@ void
}
break;
}
//--------------------------
// Toggle "Predator-vision"
//--------------------------
//--------------------------------------------------------------
// Toggle "Predator-vision" - a global false-colour/thermal mode
// the DPL renderer implemented internally, reached by passing an
// out-of-band explosion effect type (-1 on, -2 off) with a NULL
// DCS. INERT: DPLTogglePVision is a stub, and what it looked
// like is not recorded anywhere in this tree.
//--------------------------------------------------------------
case PCK_ALT_V:
{
if (!dev_keys) break;
+177
View File
@@ -0,0 +1,177 @@
//###########################################################################
//
// L4AUDEFX.cpp -- OpenAL EFX bridge (docs/SOUND.md, findings F9 and F11).
// See L4AUDEFX.h for the fidelity rationale.
//
//###########################################################################
#include "mungal4.h"
#pragma hdrstop
#include "l4audefx.h"
#include "openal/alc.h"
#include "openal/efx.h"
#ifndef AL_EFFECT_EAXREVERB
#define AL_EFFECT_EAXREVERB 0x8000 // newer efx.h constant; OpenAL Soft supports it
#endif
namespace
{
bool s_available = false;
ALuint s_reverbSlot = 0;
ALuint s_reverbEffect = 0;
ALuint s_scratchFilter = 0;
LPALGENEFFECTS p_alGenEffects = 0;
LPALEFFECTI p_alEffecti = 0;
LPALEFFECTF p_alEffectf = 0;
LPALGENAUXILIARYEFFECTSLOTS p_alGenAuxiliaryEffectSlots = 0;
LPALAUXILIARYEFFECTSLOTI p_alAuxiliaryEffectSloti = 0;
LPALAUXILIARYEFFECTSLOTF p_alAuxiliaryEffectSlotf = 0;
LPALGENFILTERS p_alGenFilters = 0;
LPALFILTERI p_alFilteri = 0;
LPALFILTERF p_alFilterf = 0;
}
bool EFX_Available()
{
return s_available;
}
bool EFX_Initialize(float global_reverb_scale)
{
ALCcontext *context = alcGetCurrentContext();
if (context == 0)
{
return false;
}
ALCdevice *device = alcGetContextsDevice(context);
if (device == 0 || !alcIsExtensionPresent(device, "ALC_EXT_EFX"))
{
Tell("L4AUDEFX: ALC_EXT_EFX not present - filters and reverb inert\n");
return false;
}
p_alGenEffects = (LPALGENEFFECTS)alGetProcAddress("alGenEffects");
p_alEffecti = (LPALEFFECTI)alGetProcAddress("alEffecti");
p_alEffectf = (LPALEFFECTF)alGetProcAddress("alEffectf");
p_alGenAuxiliaryEffectSlots = (LPALGENAUXILIARYEFFECTSLOTS)alGetProcAddress("alGenAuxiliaryEffectSlots");
p_alAuxiliaryEffectSloti = (LPALAUXILIARYEFFECTSLOTI)alGetProcAddress("alAuxiliaryEffectSloti");
p_alAuxiliaryEffectSlotf = (LPALAUXILIARYEFFECTSLOTF)alGetProcAddress("alAuxiliaryEffectSlotf");
p_alGenFilters = (LPALGENFILTERS)alGetProcAddress("alGenFilters");
p_alFilteri = (LPALFILTERI)alGetProcAddress("alFilteri");
p_alFilterf = (LPALFILTERF)alGetProcAddress("alFilterf");
if (!p_alGenEffects || !p_alEffecti || !p_alEffectf
|| !p_alGenAuxiliaryEffectSlots || !p_alAuxiliaryEffectSloti || !p_alAuxiliaryEffectSlotf
|| !p_alGenFilters || !p_alFilteri || !p_alFilterf)
{
Tell("L4AUDEFX: EFX entry points missing - filters and reverb inert\n");
return false;
}
alGetError();
p_alGenAuxiliaryEffectSlots(1, &s_reverbSlot);
p_alGenEffects(1, &s_reverbEffect);
if (alGetError() != AL_NO_ERROR)
{
return false;
}
//
// EAXReverb where available (OpenAL Soft: yes), plain reverb otherwise.
//
p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_EAXREVERB);
if (alGetError() != AL_NO_ERROR)
{
p_alEffecti(s_reverbEffect, AL_EFFECT_TYPE, AL_EFFECT_REVERB);
}
p_alAuxiliaryEffectSloti(s_reverbSlot, AL_EFFECTSLOT_EFFECT, (ALint)s_reverbEffect);
//
// The authentic wet level: the original sent CC91 = global_reverb_scale on
// every 3D channel, so one global slot gain reproduces the same uniform
// send. RP authors 0.35 (AUDIO.INI); BT used 0.3.
//
p_alAuxiliaryEffectSlotf(s_reverbSlot, AL_EFFECTSLOT_GAIN,
(global_reverb_scale < 0.0f) ? 0.0f :
(global_reverb_scale > 1.0f) ? 1.0f : global_reverb_scale);
//
// LOWPASS only, deliberately. A bandpass would have been convenient -- one
// direct filter carrying both the authored brightness model and a bass trim
// -- but the OpenAL this game ships (Creative's, via oalinst.exe; renderer
// reports "Generic Software") implements ONLY AL_FILTER_LOWPASS. It rejects
// both HIGHPASS and BANDPASS, verified on the build machine. Asking for one
// leaves an error pending, which the check below would read as total EFX
// failure and silently take the reverb down with it.
//
p_alGenFilters(1, &s_scratchFilter);
p_alFilteri(s_scratchFilter, AL_FILTER_TYPE, AL_FILTER_LOWPASS);
if (alGetError() != AL_NO_ERROR)
{
//
// No usable direct filter. The reverb slot above is independent of it,
// so keep the bridge alive and just make the filter path a no-op rather
// than losing F11 as well.
//
s_scratchFilter = 0;
Tell("L4AUDEFX: no lowpass filter available - brightness path inert\n");
}
s_available = (alGetError() == AL_NO_ERROR);
Tell("L4AUDEFX: " << (s_available ? "ready" : "failed")
<< " (reverb slot gain " << global_reverb_scale << ")\n");
return s_available;
}
void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf)
{
if (!s_available || s_scratchFilter == 0)
{
return;
}
if (gainhf < 0.001f) gainhf = 0.001f;
if (gainhf > 1.0f) gainhf = 1.0f;
//
// Nothing to do at unity -- detach rather than attach a filter that would
// only cost mixing work to achieve nothing.
//
if (gainhf >= 0.999f)
{
alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL);
alGetError();
return;
}
//
// Filter parameters are COPIED at attach time, so one scratch filter object
// serves every source -- no per-source filter allocation is needed.
//
p_alFilterf(s_scratchFilter, AL_LOWPASS_GAIN, 1.0f);
p_alFilterf(s_scratchFilter, AL_LOWPASS_GAINHF, gainhf);
alSourcei(source, AL_DIRECT_FILTER, (ALint)s_scratchFilter);
alGetError();
}
void EFX_AttachReverbSend(ALuint source)
{
if (!s_available)
{
return;
}
alSource3i(source, AL_AUXILIARY_SEND_FILTER, (ALint)s_reverbSlot, 0, AL_FILTER_NULL);
}
void EFX_ClearSourceEffects(ALuint source)
{
if (!s_available)
{
return;
}
alSourcei(source, AL_DIRECT_FILTER, AL_FILTER_NULL);
alSource3i(source, AL_AUXILIARY_SEND_FILTER, AL_EFFECTSLOT_NULL, 0, AL_FILTER_NULL);
alGetError(); // swallow any property complaint
}
+73
View File
@@ -0,0 +1,73 @@
#pragma once
//###########################################################################
//
// L4AUDEFX.h -- OpenAL EFX bridge for the authored filter/reverb chains
// (docs/SOUND.md, findings F9 and F11).
//
// The original drove the AWE32's initial-filter-cutoff NRPN (21) every frame
// -- brightness x the distance high-frequency rolloff -- and sent CC91 reverb
// on the 3D channels (global_reverb_scale=0.35 in RP's AUDIO.INI) while
// keeping the cockpit DirectPatch channels dry. The OpenAL port computed
// both and applied neither: GetHighFreqCutoffScale() had no callers at all
// and every CC91 send site sat inside a comment block, so RP played
// spectrally full-bright at every distance and bone-dry everywhere.
//
// This bridge reproduces both through OpenAL Soft's EFX extension: one
// EAXReverb auxiliary slot plus a scratch AL_FILTER_LOWPASS whose parameters
// are copied at attach time. Without ALC_EXT_EFX it stays inert and every
// entry point below is a no-op, so the game still runs on a bare OpenAL.
//
//###########################################################################
#include "openal/al.h"
//
// Load the EFX entry points, create the reverb slot (gain = the authored
// global_reverb_scale) and the scratch lowpass. Call once, with the AL
// context current. Returns false (and stays inert) without ALC_EXT_EFX.
//
bool EFX_Initialize(float global_reverb_scale);
bool EFX_Available();
//
// Per-frame direct-path filter: gainhf is the linear high-frequency gain at the
// EFX 5 kHz reference, carrying the authored brightness x distance model.
// Callers map the AWE cutoff through EFX_CutoffScaleToGainHF below.
//
// At unity the filter is detached rather than attached at no-op settings.
//
// NOTE: this is a LOWPASS and can only ever be one. The OpenAL this game ships
// (Creative's) implements no other filter type -- see L4AUDEFX.cpp -- so the
// bass trim could not ride here as a bandpass GAINLF and lives in the resource
// loader instead (RPApplyBassTrim, L4AUDRES.cpp).
//
void EFX_SetSourceLowpassGainHF(ALuint source, float gainhf);
//
// AWE NRPN 21 curve -> EFX gainhf. cutoff_scale is [0,1] of the 100-8000 Hz
// span; approximated as the attenuation of a 2-pole lowpass at the 5 kHz
// reference. Curve shape is approximate, endpoints exact.
//
inline float EFX_CutoffScaleToGainHF(float cutoff_scale)
{
if (cutoff_scale < 0.0f) cutoff_scale = 0.0f;
if (cutoff_scale > 1.0f) cutoff_scale = 1.0f;
float cutoff_hz = 100.0f + cutoff_scale * 7900.0f;
float g = (cutoff_hz / 5000.0f) * (cutoff_hz / 5000.0f);
return (g > 1.0f) ? 1.0f : ((g < 0.001f) ? 0.001f : g);
}
//
// Wet-exterior routing: attach the source's auxiliary send to the reverb slot
// (Dynamic3D / Static3D). Direct cockpit sources stay dry.
//
void EFX_AttachReverbSend(ALuint source);
//
// Drop both the direct-path filter and the reverb send. Required when a source
// is recycled through the pool: without it a dry cockpit sound can inherit the
// wet send of the 3D source that used the name before it, and a full-bright
// source can inherit a distant source's lowpass.
//
void EFX_ClearSourceEffects(ALuint source);
+269 -32
View File
@@ -2,6 +2,7 @@
#pragma hdrstop
#include "l4audio.h"
#include "l4audefx.h"
#include "l4audlvl.h"
#include "l4app.h"
#include "l4audrnd.h"
@@ -9,6 +10,49 @@
#include "..\munga\player.h"
#include "..\rp\vtv.h"
//
// FIDELITY (docs/SOUND.md): the AWE32 played each patch at the requested MIDI
// note relative to the sample root (60). RP's authored 4.10 content predates
// NoteAudioControlID -- its AudioControlID enum stops at AttackTimeAudioControlID
// -- so every source runs at DEFAULT_NOTE and this factor is 1.0 today. It is
// applied anyway so the pitch path is complete if authored notes ever appear,
// and to keep the shared MUNGA engine in step with the BT tree.
//
static inline float RPNotePitchFactor(int note_value)
{
return (float)pow(2.0, ((double)note_value - 60.0) / 12.0);
}
//
// FIDELITY (docs/SOUND.md F12): the authored DirectPatchSource `position=`
// enum picked a SOUND CARD (front pair for Front/FrontLeft/FrontRight, rear
// pair for Rear/RearLeft/RearRight) and a MIDI pan (CC10 centre/left/right).
// The port read audioPosition from the stream and then discarded it -- every
// cockpit sound played dead centre because SetupPatch pins each source
// AL_SOURCE_RELATIVE at the origin.
//
// Sources are listener-relative and no AL_ORIENTATION is ever set, so OpenAL's
// default listener frame applies: facing -Z with +Y up. Front is therefore
// -Z, rear +Z, left -X, right +X; the corner values combine both at equal
// weight. RP's own content only ever authors Front (28 sites) and Rear (13),
// but the corners are mapped for completeness since the enum allows them.
//
static void RPGetDirectPatchPosition(DirectPatchPosition p, float *x, float *z)
{
const float diag = 0.7071068f; // unit vector split across both axes
switch (p)
{
case FrontDirectPatchPosition: *x = 0.0f; *z = -1.0f; break;
case RearDirectPatchPosition: *x = 0.0f; *z = 1.0f; break;
case FrontLeftDirectPatchPosition: *x = -diag; *z = -diag; break;
case FrontRightDirectPatchPosition: *x = diag; *z = -diag; break;
case RearLeftDirectPatchPosition: *x = -diag; *z = diag; break;
case RearRightDirectPatchPosition: *x = diag; *z = diag; break;
default: *x = 0.0f; *z = 0.0f; break;
}
}
//#############################################################################
//####################### L4AudioSpatialization #########################
//#############################################################################
@@ -658,6 +702,19 @@ L4AudioSource::L4AudioSource(
AudioSource(stream, entity)
{
channelSet.count = GetAudioVoiceCount();
//
// sources[] was left uninitialized here, and RequestAudioChannels decides
// whether a slot already holds a source by asking alIsSource about it.
// Garbage that happened to match a live name meant silently sharing another
// source -- a real hazard now that the pool recycles small integer names.
// 0 is never a valid AL name.
//
for (int i = 0; i < (int)(sizeof(channelSet.sources) / sizeof(channelSet.sources[0])); i++)
{
channelSet.sources[i] = 0;
}
L4AudioSourceX();
}
@@ -923,6 +980,22 @@ void
patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F12): place the source per the authored position
// enum. SetupPatch has just pinned it AL_SOURCE_RELATIVE at the origin, so
// this must run after it. With AL_NONE as the distance model the unit
// radius costs no attenuation -- it only supplies direction.
//
{
float pos_x, pos_z;
RPGetDirectPatchPosition(audioPosition, &pos_x, &pos_z);
for (int i = 0; i < channelSet.count; i++)
{
alSource3f(channelSet.sources[i], AL_POSITION, pos_x, 0.0f, pos_z);
}
}
//
// Set the channel to default control values
//
@@ -1039,6 +1112,8 @@ void
// Apply filter scale
//--------------------------------------------------------------------------
//
float direct_gainhf = 1.0f;
if (UseSourceBrightnessScale())
{
const MIDINRPNValue filter_resolution = 2;// HACK - should come from audio.ini
@@ -1058,6 +1133,32 @@ void
{
lastMIDIFilterCutoff = midi_filter_cutoff;
}
//
// FIDELITY (docs/SOUND.md F9): this block previously computed the AWE
// initial-filter-cutoff (NRPN 21) and then only updated its own
// bookkeeping member -- the cutoff was never applied to anything, so
// authored brightness (ctl 5) was inert. Route it through EFX instead.
// Direct sources take brightness alone; the distance rolloff belongs to
// the 3D paths.
//
direct_gainhf = EFX_CutoffScaleToGainHF(
(float)midi_filter_cutoff / (float)MIDI_MAX_CONTROL_VALUE
);
}
//
// Applied OUTSIDE the brightness gate: a source that does not use brightness
// still has to be told, because the same call carries the player's bass trim.
// At unity on both axes it detaches the filter, so this costs nothing in the
// default configuration.
//
if (EFX_Available())
{
for (int i = 0; i < channelSet.count; i++)
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], direct_gainhf);
}
}
//
@@ -1069,17 +1170,24 @@ void
const MIDIValue volume_resolution = 2; // HACK - should come from audio.ini
volume_scale = CalculateSourceVolumeScale();
L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation());
Check(application);
L4AudioRenderer *audio_renderer =
Cast_Object(L4AudioRenderer*, application->GetAudioRenderer());
Check(audio_renderer);
AudioHead *audio_head = audio_renderer->GetAudioHead();
Check(audio_head);
//
// FIDELITY (docs/SOUND.md F4): the original ended its volume path in MIDI
// CC7, whose GM/SoundFont curve is concave -- amplitude ~ (v/127)^2. Writing
// volume_scale linearly to AL_GAIN played every intermediate level about
// +6 dB hot at mid-scale and compressed the authored dynamic range.
//
// AL_MAX_DISTANCE is no longer written here: the distance model is AL_NONE
// (see MUNGA/AUDIO.cpp) so it has no effect, and DirectPatch is the
// non-positional cockpit path which never took distance attenuation anyway.
//
const float direct_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
PatchResource *direct_patch = Cast_Object(PatchResource*, GetAudioResource());
for (int i=0; i < channelSet.count; i++)
{
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale);
alSourcef(channelSet.sources[i], AL_GAIN,
volume_scale * volume_scale * direct_patch->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * direct_note_pitch);
}
}
@@ -1206,6 +1314,17 @@ void
patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F11): wet exterior. The original sent CC91 =
// global_reverb_scale on all four channels of a 3D source and CC91 = 0 on
// the cockpit DirectPatch channels -- a deliberate outside/inside contrast
// that the port lost when every send site was commented out.
//
for (int i = 0; i < channelSet.count; i++)
{
EFX_AttachReverbSend(channelSet.sources[i]);
}
/*patch_resource->SetDistance(GetDistanceToSource());
for (i = 0; i < AudioChannelSetSize; i++)
{
@@ -1405,15 +1524,71 @@ void
pitch_offset = CalculateSourcePitchOffset();
//
// FIDELITY (docs/SOUND.md F10): add the AUTHORED doppler. AUDIO.INI's
// doppler_range=600 / speed_of_sound=250 are computed into
// AudioLocation::dopplerCents on every spatial update, and the original
// applied it on this dynamic path only -- static and direct sources stayed
// doppler-free. GetDopplerCents() previously had no callers at all.
//
pitch_offset += GetAudioLocation()->GetDopplerCents();
double relativePitch = pow(2.0,pitch_offset/1200.0);
Clamp(relativePitch,0.5,2.0);
//
// FIDELITY (docs/SOUND.md): relativePitch was computed here and never
// applied -- there was no AL_PITCH call anywhere in the tree, so the whole
// authored pitch chain (pitch_mix_offset / PitchAudioControlID, authored 97
// times across RP's sequences) was inert along with doppler.
//
// AL_VELOCITY is still written for bookkeeping but is now inert: doppler
// factor is 0 (see MUNGA/AUDIO.cpp) because this feed is sign-inverted
// relative to the AL_POSITION frame and never subtracted head velocity.
// AL_MAX_DISTANCE is dropped -- the distance model is AL_NONE and the
// authored curve is applied in CalculateSourceVolumeScale instead.
//
//
// FIDELITY (docs/SOUND.md F9): the AUTHORED high-frequency rolloff. The
// original drove the AWE filter cutoff on this path from
// highFreqCutoffScale x brightnessScale, ungated, on all four quadrant
// channels -- every moving 3D sound got duller with distance. AUDIO.INI
// still computes highFreqCutoffScale each frame (rolloff 2.0, knee 60,
// scale 0.005) and GetHighFreqCutoffScale() previously had zero callers.
//
float dynamic_gainhf = 1.0f;
if (EFX_Available())
{
PatchResource *filter_patch =
Cast_Object(PatchResource*, GetAudioResource());
Check(filter_patch);
Scalar filter_scale =
GetAudioLocation()->GetHighFreqCutoffScale() *
CalculateSourceBrightnessScale();
Scalar max_cutoff = (Scalar)filter_patch->GetMaxMIDIFilterCutoff();
Scalar midi_cutoff = filter_scale * max_cutoff;
dynamic_gainhf = EFX_CutoffScaleToGainHF(
(float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE)
);
}
const float dynamic_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
PatchResource *dynamic_patch = Cast_Object(PatchResource*, GetAudioResource());
for (int i=0; i < channelSet.count; i++)
{
alSource3f(channelSet.sources[i],AL_POSITION,pos.x,pos.y,pos.z);
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale);
alSourcef(channelSet.sources[i], AL_GAIN,
volume_scale * volume_scale * dynamic_patch->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * dynamic_note_pitch);
alSource3f(channelSet.sources[i],AL_VELOCITY,-relative_velocity.x,-relative_velocity.y,-relative_velocity.z);
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
if (EFX_Available())
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], dynamic_gainhf);
}
}
}
@@ -1430,24 +1605,27 @@ AudioControlValue
//
// Call inherited method to calculate volume scale
//
Scalar
Scalar
volume_scale = L4AudioSource::CalculateSourceVolumeScale();
return volume_scale;
//
// Update the spatial model that will result in the value
// for distance related volume attenuation
//
/*Check(application);
Check(application->GetAudioRenderer());
UpdateSpatialModel(application->GetAudioRenderer()->GetAudioHead());
//
// Apply distance attenuation to the volume scale
//
// FIDELITY (docs/SOUND.md F3): apply the AUTHORED distance attenuation.
// AUDIO.INI's knee/rolloff curve (amplitude_rolloff=2.0, knee=60,
// distance_scale=0.003, clipping_radius=550) is computed into
// distanceVolumeScale on every spatial update; this multiply was commented
// out behind an early return and AL_LINEAR_DISTANCE substituted, which faded
// distant audio on a straight line to zero instead of the authored
// 1/(1+(k(d-knee))^2). Restoring it also un-blinds the volume-based
// transient cull, the AudioWeighting voice-steal, and the CalculateMix
// ducking chain, all of which key off this value and were treating far
// sources as full-presence.
//
// The spatial model is already refreshed each Execute, so the
// UpdateSpatialModel call the original comment carried is not needed here.
//
Check(GetAudioLocation());
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale();
return volume_scale;*/
return volume_scale;
}
//#############################################################################
@@ -1468,6 +1646,26 @@ Static3DPatchSource::Static3DPatchSource(
MemoryStream_Read(stream, &useInternalSpatialization);
}
//
//#############################################################################
//#############################################################################
//
AudioControlValue
Static3DPatchSource::CalculateSourceVolumeScale()
{
Check(this);
//
// FIDELITY (docs/SOUND.md F3): same authored distance attenuation as the
// dynamic path. The spatial model computes distanceVolumeScale on every
// execute; without this multiply statics were left to AL_LINEAR_DISTANCE.
//
Scalar volume_scale = L4AudioSource::CalculateSourceVolumeScale();
Check(GetAudioLocation());
volume_scale *= GetAudioLocation()->GetDistanceVolumeScale();
return volume_scale;
}
Logical Static3DPatchSource::IsAudioSourceClipped(AudioHead *audio_head)
{
if (AudioSource::IsAudioSourceClipped(audio_head) || l4_application->GetMissionPlayer()->GetPlayerVehicle()->GetSimulationState() == VTV::BurningState)
@@ -1694,6 +1892,16 @@ void
Check(patch_resource);
patch_resource->SetDistance(GetDistanceToSource());
patch_resource->SetupPatch(channelSet);
//
// FIDELITY (docs/SOUND.md F11): statics are exterior sources too, so they
// take the same wet send as the dynamic path.
//
for (int i = 0; i < channelSet.count; i++)
{
EFX_AttachReverbSend(channelSet.sources[i]);
}
/*for (i = 0; i < AudioChannelSetSize; i++)
{
if ((channel = channelSet.GetNth(i)) != NULL)
@@ -1909,12 +2117,6 @@ void
Scalar volume_scale = CalculateSourceVolumeScale();
L4AudioLocation *audio_location = Cast_Object(L4AudioLocation*, GetAudioLocation());
Check(application);
L4AudioRenderer *audio_renderer =
Cast_Object(L4AudioRenderer*, application->GetAudioRenderer());
Check(audio_renderer);
AudioHead *audio_head = audio_renderer->GetAudioHead();
Check(audio_head);
Scalar pitch_offset;
@@ -1933,12 +2135,47 @@ void
relative_position = audio_location->GetVectorToSource();
}
//
// FIDELITY (docs/SOUND.md F4 + pitch): squared CC7 volume law, and the
// authored pitch chain applied -- see the DirectPatch/Dynamic3D paths. The
// original left static sources doppler-free, so no doppler term here.
// AL_MAX_DISTANCE dropped with the AL_NONE distance model; the authored
// curve is applied in CalculateSourceVolumeScale.
//
//Static models have their position freely available as relative positions and stand still
//
// FIDELITY (docs/SOUND.md F9): statics took brightness alone in the
// original -- no distance term on this path.
//
float static_gainhf = 1.0f;
if (EFX_Available() && UseSourceBrightnessScale())
{
PatchResource *filter_patch =
Cast_Object(PatchResource*, GetAudioResource());
Check(filter_patch);
Scalar midi_cutoff =
CalculateSourceBrightnessScale() *
(Scalar)filter_patch->GetMaxMIDIFilterCutoff();
static_gainhf = EFX_CutoffScaleToGainHF(
(float)(midi_cutoff / (Scalar)MIDI_MAX_CONTROL_VALUE)
);
}
const float static_note_pitch = RPNotePitchFactor((int)GetCurrentNoteValue());
for (int i=0; i < channelSet.count; i++)
{
alSourcef(channelSet.sources[i], AL_GAIN, volume_scale);
alSourcef(channelSet.sources[i], AL_GAIN,
volume_scale * volume_scale * patch_resource->GetZoneBassGain(i));
alSourcef(channelSet.sources[i], AL_PITCH, (float)relativePitch * static_note_pitch);
alSource3f(channelSet.sources[i],AL_POSITION,relative_position.x,relative_position.y,relative_position.z);
alSourcef(channelSet.sources[i],AL_MAX_DISTANCE,audio_location->getMaxDistance(audio_head));
if (EFX_Available())
{
EFX_SetSourceLowpassGainHF(channelSet.sources[i], static_gainhf);
}
}
//
+7
View File
@@ -541,6 +541,13 @@ public:
virtual Logical IsAudioSourceClipped(AudioHead *audio_head);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Mix levels
//
public:
AudioControlValue
CalculateSourceVolumeScale();
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// SetPosition
//
+34 -1
View File
@@ -128,8 +128,25 @@ void
// #endif
SAMPLEINFO info;
//
// Ask this patch for no more zones than it has.
//
// sourceSet.count 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 immediately before this
// runs (see Static3DPatchSource::StartImplementation), and a
// further-away patch can have fewer zones than the one the source was
// sized for - so the count outruns this patch's zone list, and the
// zones past the end come back as "no such zone".
//
int zone_count = PRESET_getNumSamples(bankID,patchID);
if (zone_count > sourceSet.count)
{
zone_count = sourceSet.count;
}
//Attach buffers
for (int i=0; i < sourceSet.count; i++)
for (int i=0; i < zone_count; i++)
{
info = PRESET_getSampleInfo(bankID,patchID,i);
if (info.bufferIndex >= 0)
@@ -310,3 +327,19 @@ MIDINRPNValue
Check(patch_level_of_detail);
return patch_level_of_detail->GetMaxMIDIFilterCutoff();
}
//
//#############################################################################
//#############################################################################
//
float
PatchResource::GetZoneBassGain(int zone_index)
{
Check(this);
PatchLevelOfDetail *patch_level_of_detail =
Cast_Object(PatchLevelOfDetail*, GetAudioLevelOfDetail());
Check(patch_level_of_detail);
return patch_level_of_detail->GetZoneBassGain(zone_index);
}
+20
View File
@@ -37,6 +37,12 @@ struct PRESETINFO
extern PRESETINFO allPresets[2][100];
//
// Defined in L4AUDRES.cpp; declared here rather than including that header so
// the level-of-detail and resource headers stay independent of each other.
//
float RPBufferBassGain(int buffer_index);
bool PRESET_isImplemented(int bank, int preset);
int PRESET_getNumSamples(int bank, int preset);
SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd);
@@ -69,6 +75,14 @@ public:
GetVoiceCount()
{return PRESET_getNumSamples(bankID,patchID);}
//
// Gain this zone takes from the Home/End bass trim, 1.0 when untouched.
//
float
GetZoneBassGain(int zone_index)
{return RPBufferBassGain(
PRESET_getSampleInfo(bankID,patchID,zone_index).bufferIndex);}
//
//-----------------------------------------------------------------------
// BuildFromPage
@@ -163,6 +177,12 @@ public:
void
SetupPatch(SourceSet sourceSet);
//
// Gain this zone takes from the Home/End bass trim, 1.0 when untouched.
//
float
GetZoneBassGain(int zone_index);
MIDINRPNValue
GetMaxMIDIFilterCutoff();
};
+155
View File
@@ -18,6 +18,130 @@
ALuint *g_buffers;
int g_numBuffers;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Bass trim ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// RP412AUDIOBASS, 0.0..1.0, default 1.0 (the mix exactly as authored), stepped
// live by the Home/End keys.
//
// The arcade pod ran the game at unity and did its volume and tone shaping in
// hardware -- an external amplifier and a 3-way crossover. A desktop player has
// neither, so the low band needs a control in software. This is the crossover's
// low trim; the master volume (L4AUDRND.cpp) is the amplifier's.
//
// It cannot be an EFX filter: the OpenAL this game ships implements only
// AL_FILTER_LOWPASS, so there is no low shelf or bandpass to lean on, and the
// one direct filter a source gets is already carrying the authored brightness
// model. So the trim is a GAIN, applied per zone in the mix.
//
// That works because of HOW the low end is built. RP's soundbanks carry their
// weight in discrete deep layer zones whose per-zone tuning bakes out to a very
// low playback rate -- 13 zones sit below 8 kHz, between 3.4 and 5.2 octaves
// below their recorded pitch, against 81% of the set at 22 kHz and up. A zone's
// baked rate is therefore a reliable proxy for which band it occupies, so
// attenuating the low-rate zones is a genuine low-band trim rather than a blunt
// overall cut.
//
// Ramp: untouched at or above 22050 Hz, full trim at or below 5512 Hz, log
// interpolated between, so nothing steps abruptly at a threshold. Each buffer's
// DEPTH is fixed at load; the trim itself is read at mix time, which is what
// lets the keys move it while sounds are playing.
//
static const ALsizei kBassTrimFullRate = 5512; // at/below: full trim
static const ALsizei kBassTrimNoneRate = 22050; // at/above: untouched
static const char kBassTrimFile[] = "bass.cfg";
static const float kBassTrimStep = 0.05f;
static float *g_bufferBassDepth = NULL; // one per loaded buffer
static float g_bassTrim = 1.0f;
//
// How much of the trim a buffer at this rate takes: 0 = untouched, 1 = fully.
//
static float
RPBassDepthForRate(ALsizei rate)
{
if (rate >= kBassTrimNoneRate) return 0.0f;
if (rate <= kBassTrimFullRate) return 1.0f;
const float span = (float)log((double)kBassTrimNoneRate / (double)kBassTrimFullRate);
return (float)log((double)kBassTrimNoneRate / (double)rate) / span;
}
void
RPBassTrimInitialize()
{
g_bassTrim = 1.0f;
if (const char *setting = getenv("RP412AUDIOBASS"))
{
float value = (float)atof(setting);
if (value >= 0.0f && value <= 1.0f)
{
g_bassTrim = value;
}
}
//
// Whatever the player last set with the keys wins, exactly as the master
// volume behaves -- environ.ini only decides where an untouched machine
// starts out.
//
if (FILE *cfg = fopen(kBassTrimFile, "rt"))
{
float value = -1.0f;
if (fscanf(cfg, "%f", &value) == 1 && value >= 0.0f && value <= 1.0f)
{
g_bassTrim = value;
}
fclose(cfg);
}
Tell("Audio bass trim " << (int)(g_bassTrim * 100.0f + 0.5f) << "%\n");
}
void
RPBassTrimStep(int direction)
{
g_bassTrim += (direction > 0) ? kBassTrimStep : -kBassTrimStep;
if (g_bassTrim < 0.0f) g_bassTrim = 0.0f;
if (g_bassTrim > 1.0f) g_bassTrim = 1.0f;
g_bassTrim = (float)((int)(g_bassTrim / kBassTrimStep + 0.5f)) * kBassTrimStep;
if (FILE *cfg = fopen(kBassTrimFile, "wt"))
{
fprintf(cfg, "%.2f\n", g_bassTrim);
fclose(cfg);
}
Tell("Audio bass trim " << (int)(g_bassTrim * 100.0f + 0.5f) << "%\n");
}
float
RPBassTrim()
{
return g_bassTrim;
}
//
// The gain a zone takes at the current trim. 1.0 whenever the player has not
// touched it, so the default costs one multiply by one.
//
float
RPBufferBassGain(int buffer_index)
{
if (g_bassTrim >= 0.999f || g_bufferBassDepth == NULL
|| buffer_index < 0 || buffer_index >= g_numBuffers)
{
return 1.0f;
}
return 1.0f - (1.0f - g_bassTrim) * g_bufferBassDepth[buffer_index];
}
//#############################################################################
//####################### AudioObjectStream #############################
//#############################################################################
@@ -566,6 +690,18 @@ void
g_buffers = NULL;
g_numBuffers = 0;
}
else
{
//
// Parallel to g_buffers: how much of the bass trim each zone takes.
//
RPBassTrimInitialize();
g_bufferBassDepth = new float[g_numBuffers];
for (int b = 0; b < g_numBuffers; b++)
{
g_bufferBassDepth[b] = 0.0f;
}
}
}
int bufferInd = 0;
@@ -647,6 +783,15 @@ void
sf_read_raw(file,data,size);
sf_close(file);
//
// Record which band this zone sits in, for the Home/End bass
// trim. Fixed per buffer; the trim itself is read at mix time.
//
if (g_bufferBassDepth != NULL)
{
g_bufferBassDepth[bufferInd] = RPBassDepthForRate(alSampleRate);
}
//Feed the buffer
alBufferData(g_buffers[bufferInd],format,data,size,alSampleRate);
PRESET_setBufferIndex(i,j,k,bufferInd);
@@ -726,6 +871,16 @@ void
ALuint AL_getBuffer(int index)
{
//
// 0 is AL_NONE - "no buffer" - which alSourcei accepts and which detaches
// the source rather than crashing. An index that is out of range means a
// zone that does not exist, and the only thing an unchecked lookup here
// can do about it is read whatever lies past the array.
//
if (g_buffers == NULL || index < 0 || index >= g_numBuffers)
{
return 0;
}
return g_buffers[index];
}
+10
View File
@@ -9,6 +9,16 @@ ALuint AL_getBuffer(int index);
extern ALuint *g_buffers;
extern int g_numBuffers;
//
// RP412AUDIOBASS low-band trim, stepped live by the Home/End keys. Applied as
// a per-zone gain in the mix; see the comment block in L4AUDRES.cpp for why it
// lives here and not in EFX.
//
void RPBassTrimInitialize();
void RPBassTrimStep(int direction);
float RPBassTrim();
float RPBufferBassGain(int buffer_index);
//class AudioHardware;
+311 -28
View File
@@ -2,9 +2,20 @@
#pragma hdrstop
#include "l4audrnd.h"
#include "l4audefx.h"
#include "..\munga\notation.h"
#include "openal/alc.h"
#include <stdio.h>
//
// Master volume limits, shared by the startup load and the PgUp/PgDn step.
// The file sits beside the exe with the other runtime state.
//
static const char kAudioVolumeFile[] = "volume.cfg";
static const float kAudioVolumeStep = 0.05f;
static const float kAudioVolumeMax = 2.0f;
//
//#############################################################################
// L4AudioRenderer
@@ -379,6 +390,66 @@ void
{
ALCcontext *context = alcCreateContext(device,NULL);
alcMakeContextCurrent(context);
//
// FIDELITY (docs/SOUND.md F9/F11): bring up the EFX bridge that carries
// the authored brightness/distance lowpass and the wet-exterior reverb
// send. Needs the context current, and the reverb gain has already been
// read from AUDIO.INI into the head above. Inert without ALC_EXT_EFX.
//
EFX_Initialize(audio_head->GetGlobalReverbScale());
//
// Master volume. There was no listener gain at all before -- the mix
// always ran at unity -- so restoring the authored dynamics gave players
// no way to pull the whole thing down. This lives in environ.ini rather
// than AUDIO.INI deliberately: AUDIO.INI is byte-identical to the file
// that shipped in 1995 and is worth keeping that way.
//
// Default is 1.0, i.e. exactly the previous behaviour -- the knob only
// does something when someone asks for it.
//
{
float master_volume = 1.0f;
if (const char *setting = getenv("RP412AUDIOVOLUME"))
{
float value = (float)atof(setting);
if (value >= 0.0f && value <= kAudioVolumeMax)
{
master_volume = value;
}
}
//
// Whatever the player last set with the volume keys wins over the
// environ.ini figure: the keys are the amplifier knob, and a knob
// stays where it was left. environ.ini sets where it starts on a
// machine that has never been touched.
//
if (FILE *cfg = fopen(kAudioVolumeFile, "rt"))
{
float value = -1.0f;
if (fscanf(cfg, "%f", &value) == 1
&& value >= 0.0f && value <= kAudioVolumeMax)
{
master_volume = value;
}
fclose(cfg);
}
gRPMasterVolume = master_volume;
alListenerf(AL_GAIN, master_volume);
Tell("Audio master volume " << (int)(master_volume * 100.0f + 0.5f) << "%\n");
}
//
// The bass trim is not set here: it is a per-zone gain owned by the
// resource manager (L4AUDRES.cpp), which needs the buffers to exist
// first. PreloadResources initialises it below.
//
}
//
@@ -1257,6 +1328,190 @@ Logical
return resources_available;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Master volume ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The pod ran at unity and left volume to an external amplifier, so the game
// never had a level control. Standing in for that amplifier means the player
// needs to reach it while playing, not only through environ.ini -- hence the
// PgUp/PgDn binding in L4Application::KeyCommandMessageHandler.
//
// Page keys specifically: they produce no typed character, so they cannot
// collide with any of the engine's character-keyed commands the way '+'/'-'
// would, they are bound to nothing in any RP layout, and they exist on
// tenkeyless keyboards.
//
float gRPMasterVolume = 1.0f;
void
RPAudioMasterVolumeStep(int direction)
{
gRPMasterVolume += (direction > 0) ? kAudioVolumeStep : -kAudioVolumeStep;
if (gRPMasterVolume < 0.0f) gRPMasterVolume = 0.0f;
if (gRPMasterVolume > kAudioVolumeMax) gRPMasterVolume = kAudioVolumeMax;
//
// Snap to the step grid so repeated presses cannot drift on float error and
// land somewhere that never reads back as a round number.
//
gRPMasterVolume =
(float)((int)(gRPMasterVolume / kAudioVolumeStep + 0.5f)) * kAudioVolumeStep;
alListenerf(AL_GAIN, gRPMasterVolume);
//
// Persist immediately. A pod operator setting the level expects it to still
// be there after the cabinet is power-cycled, and there is no settings UI to
// hang it off.
//
if (FILE *cfg = fopen(kAudioVolumeFile, "wt"))
{
fprintf(cfg, "%.2f\n", gRPMasterVolume);
fclose(cfg);
}
Tell("Audio master volume " << (int)(gRPMasterVolume * 100.0f + 0.5f) << "%\n");
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ OpenAL source pool ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Sources are expensive to create and destroy and are a HARD per-context
// resource (this driver grants 256 mono). Generating one per sound event and
// deleting it on release burns through that ceiling during busy play even
// though steady-state demand is modest, which shows up as sounds silently
// failing to start. Generate once, recycle forever.
//
// The cap sits below the driver grant with a reserve, so growth stops on our
// terms rather than on an alGenSources failure. Growth also stops by itself if
// a driver offers fewer sources than the cap -- a failed generate simply ends
// growth and the pool recycles what it already has.
//
static const int kAudioPoolMax = 512; // free-list array size
static const int kAudioPoolCap = 240; // grow no further than this
static ALuint gAudioPoolFree[kAudioPoolMax];
static int gAudioPoolFreeCount = 0; // entries parked in gAudioPoolFree
static int gAudioPoolTotal = 0; // sources ever generated (<= cap)
static long gAudioPoolReuses = 0; // diagnostics
int RPAudioPoolSize() { return gAudioPoolTotal; }
int RPAudioPoolFree() { return gAudioPoolFreeCount; }
long RPAudioPoolReuses() { return gAudioPoolReuses; }
//
// Reset a source to a neutral state so nothing carries across owners.
//
static void
RPAudioScrubSource(ALuint src)
{
ALint state = AL_STOPPED;
alGetSourcei(src, AL_SOURCE_STATE, &state);
if (state == AL_PLAYING || state == AL_PAUSED)
{
alSourceStop(src);
}
alSourcei(src, AL_BUFFER, 0); // detach (nothing is queued here)
alSourcei(src, AL_LOOPING, AL_FALSE); // or the next owner inherits a loop
alSourcef(src, AL_GAIN, 1.0f);
alSourcef(src, AL_PITCH, 1.0f);
alSourcei(src, AL_SOURCE_RELATIVE, AL_FALSE);
alSource3f(src, AL_POSITION, 0.0f, 0.0f, 0.0f);
alSource3f(src, AL_VELOCITY, 0.0f, 0.0f, 0.0f);
//
// Drop the EFX state too. Without this a recycled name can carry a 3D
// source's reverb send into a dry cockpit sound, or a distant source's
// lowpass into a close one.
//
EFX_ClearSourceEffects(src);
alGetError(); // swallow any property complaint
}
//
// Hand out a source: recycle first, generate only while under the cap.
// False means genuinely out, and the caller retries after the steal loop runs.
//
Logical
RPAudioPoolAcquire(ALuint *out)
{
Check_Pointer(out);
while (gAudioPoolFreeCount > 0)
{
ALuint src = gAudioPoolFree[--gAudioPoolFreeCount];
if (alIsSource(src)) // a context reset invalidates names
{
++gAudioPoolReuses;
*out = src;
return True;
}
--gAudioPoolTotal; // stale name: forget it
}
if (gAudioPoolTotal >= kAudioPoolCap)
{
return False;
}
ALuint src = 0;
alGetError();
alGenSources(1, &src);
if (alGetError() != AL_NO_ERROR || !alIsSource(src))
{
return False; // driver said no before our cap
}
++gAudioPoolTotal;
#if DEBUG_LEVEL>0
{
//
// One line per high-water band, so a log shows how close real play gets
// to the ceiling without spamming.
//
static int s_notified = 0;
if (gAudioPoolTotal >= s_notified + 25)
{
s_notified = gAudioPoolTotal;
Tell("Audio source pool high-water: " << gAudioPoolTotal
<< " of " << kAudioPoolCap << "\n");
}
}
#endif
*out = src;
return True;
}
//
// Take a source back. Scrubbed and parked, never deleted.
//
void
RPAudioPoolRelease(ALuint src)
{
if (!alIsSource(src))
{
return;
}
RPAudioScrubSource(src);
if (gAudioPoolFreeCount < kAudioPoolMax)
{
gAudioPoolFree[gAudioPoolFreeCount++] = src;
return;
}
alDeleteSources(1, &src); // unreachable: cap < array size
--gAudioPoolTotal;
}
//
//#############################################################################
// RequestAudioChannels
@@ -1271,30 +1526,54 @@ Logical
Check(this);
Check(source_request);
//Do we have enough?
//
// SOURCE POOLING (docs/SOUND.md). This used to alGenSources per sound
// event, with ReleaseSourceSet alDeleteSources'ing on release -- so play
// activity CHURNED through OpenAL's per-context source limit (the driver
// grants 256 mono here). Recovering the soundbanks took the voice count
// per sound from about 1.1 zones to about 2.6, roughly doubling that churn.
//
// The BT tree measured this exact problem: raising the budget was NOT the
// fix, recycling was, and it was a net CPU win besides. Sources are now
// generated once and handed back to a free list, so steady-state play costs
// no allocation at all.
//
int requested = source_request->count;
bool failed = true;
alGetError();
if (requested > (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0])))
{
requested = (int)(sizeof(source_request->sources) / sizeof(source_request->sources[0]));
source_request->count = requested;
}
for (int i = 0; i < requested; i++)
{
if (!alIsSource(source_request->sources[i]))
if (source_request->sources[i] != 0 && alIsSource(source_request->sources[i]))
{
alGenSources(1, source_request->sources + i);
continue; // slot already holds a live source
}
}
ALenum error = alGetError();
if (error == AL_NO_ERROR)
{
failed = false;
}
if (failed)
{
return False;
ALuint src = 0;
if (!RPAudioPoolAcquire(&src))
{
//
// Out of sources. Hand back everything acquired on THIS attempt so a
// failed request cannot strand voices -- the renderer's steal loop
// will free some and retry.
//
for (int j = 0; j < i; j++)
{
if (source_request->sources[j] != 0)
{
RPAudioPoolRelease(source_request->sources[j]);
source_request->sources[j] = 0;
}
}
return False;
}
source_request->sources[i] = src;
}
return True;
@@ -1375,23 +1654,27 @@ Logical
void L4AudioRenderer::ReleaseSourceSet(SourceSet &sourceSet)
{
//
// SOURCE POOLING (docs/SOUND.md): park each source on the free list rather
// than destroying it. RPAudioPoolRelease stops it, detaches its buffer and
// scrubs the state -- including the EFX filter and reverb send -- so the
// next owner starts clean.
//
// The bulk alDeleteSources(count, sources) this replaces was also a leak
// waiting to happen: per the AL spec it is ATOMIC, so ONE invalid name in
// the array (an empty slot of a partial set, or the old -1 sentinel on a
// double release) meant NOTHING was deleted and the whole set leaked.
// Slots are parked at 0, which is never a valid AL name -- unlike -1, which
// alIsSource would be asked about as 0xFFFFFFFF.
//
for (int i = 0; i < sourceSet.count; i++)
{
ALenum state;
alGetSourcei(sourceSet.sources[i], AL_SOURCE_STATE, &state);
if (state == AL_PLAYING)
if (sourceSet.sources[i] != 0)
{
alSourceStop(sourceSet.sources[i]);
RPAudioPoolRelease(sourceSet.sources[i]);
sourceSet.sources[i] = 0;
}
}
alDeleteSources(sourceSet.count, sourceSet.sources);
for (int i = 0; i < sourceSet.count; i++)
{
sourceSet.sources[i] = -1;
}
}
//~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer profile bits ~~~~~~~~~~~~~~~~~~~~~~~~~
+8
View File
@@ -6,6 +6,14 @@
#include "l4audres.h"
#include "openal/al.h"
//
// Master volume, standing in for the amplifier the cabinets had. Stepped by
// PgUp/PgDn (L4APP.cpp) and persisted to volume.cfg; see L4AUDRND.cpp.
//
extern float gRPMasterVolume;
void RPAudioMasterVolumeStep(int direction);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ L4AudioRenderer ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+74
View File
@@ -6,6 +6,8 @@
#include "l4ctrl.h"
#include "l4keybd.h"
#include "l4app.h"
#include "l4audrnd.h" // RPAudioMasterVolumeStep, for the PgUp/PgDn keys
#include "l4audres.h" // RPBassTrimStep, for the Home/End keys
#include "l4dinput.h"
#include "..\munga\appmgr.h"
#include "dxutils.h"
@@ -1513,6 +1515,78 @@ void
// Update the PC keyboard mapping group
//-------------------------------------------------------------------------
//
//
//-------------------------------------------------------------------------
// Master volume, PgUp louder / PgDn quieter.
//
// The cabinets ran the game at unity and left level to an external
// amplifier and crossover; without that hardware the player has to be able
// to reach the volume while playing.
//
// POLLED, not taken off the key message below, and that is deliberate. The
// pump below only ever consumes WM_KEYUP / WM_SYSKEYUP / WM_CHAR from the
// front of the queue, and the front-end runs message loops of its own, so
// key messages are raced for and routinely lost -- measured here at roughly
// two of every six presses arriving. That is survivable for a one-shot like
// the abort chord; it is not survivable for a control you tap repeatedly to
// find a level. Reading the key state directly costs nothing and cannot be
// dropped.
//
// Page keys because they produce no typed character, so they cannot collide
// with the character-keyed commands the pump feeds, nothing else in RP binds
// them, and they exist on tenkeyless keyboards.
//
// The foreground check keeps an alt-tabbed game from eating the volume keys
// of whatever the player switched to.
//-------------------------------------------------------------------------
//
// Home/End do the same for the bass trim - the crossover's low band to
// PgUp/PgDn's amplifier.
//
{
static int volume_up_held = 0;
static int volume_down_held = 0;
static int bass_up_held = 0;
static int bass_down_held = 0;
int focused = 0;
if (HWND foreground = GetForegroundWindow())
{
DWORD foreground_process = 0;
GetWindowThreadProcessId(foreground, &foreground_process);
focused = (foreground_process == GetCurrentProcessId());
}
const int up = focused && (GetAsyncKeyState(VK_PRIOR) & 0x8000) != 0;
const int down = focused && (GetAsyncKeyState(VK_NEXT) & 0x8000) != 0;
const int bass_up = focused && (GetAsyncKeyState(VK_HOME) & 0x8000) != 0;
const int bass_down = focused && (GetAsyncKeyState(VK_END) & 0x8000) != 0;
if (up && !volume_up_held)
{
RPAudioMasterVolumeStep(+1);
}
if (down && !volume_down_held)
{
RPAudioMasterVolumeStep(-1);
}
if (bass_up && !bass_up_held)
{
RPBassTrimStep(+1);
}
if (bass_down && !bass_down_held)
{
RPBassTrimStep(-1);
}
volume_up_held = up;
volume_down_held = down;
bass_up_held = bass_up;
bass_down_held = bass_down;
}
if (flags.keyboardExists)
{
//RB 1/20/07
+1392
View File
File diff suppressed because it is too large Load Diff
+85
View File
@@ -0,0 +1,85 @@
//===========================================================================//
// File: l4joy.h //
// Project: MUNGA Brick: generic joystick reader //
// Contents: DirectInput 8 sticks, HOTAS throttles and pedals //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
//########################################################################
//
// L4JOY - the generic-joystick reader.
//
// PadRIO reads XInput, which covers Xbox-class pads and nothing else.
// This layer adds every OTHER game device Windows knows - flight sticks,
// HOTAS throttles, twist grips, rudder pedals, wheels - through
// DirectInput 8, the standard generic-HID game API. It exposes up to
// joyMaxDevices attached devices as normalized state blocks; the PadRIO
// poll maps them onto the pod's control channels through the joydev /
// joyaxis / joybutton / joyhat rows of bindings.txt (L4PADBINDINGS.h),
// the same binding machinery the pad and keyboard already use.
//
// XInput-class devices are EXCLUDED here, or they would double-feed
// through both APIs and every input would count twice. A DirectInput
// device whose VID/PID also appears in a RawInput device path containing
// the "IG_" marker is an XInput device - the documented detection that
// does not drag in WMI.
//
// This is distinct from the legacy L4DINPUT.cpp DIJoystick, the 1995-era
// single-device `Joystick` engine interface reachable only through the
// old L4CONTROLS=DIJOYSTICK profile. That path is untouched.
//
// RP412JOYCONFIG=1 runs the interactive setup wizard at boot: it asks
// the player to move each control, works out which device and axis moved
// and which way, and writes the joystick section of bindings.txt.
// RP412JOYLOG=1 logs device attach/detach.
//
// Ported from BT411, whose glass cockpit needed the same thing.
//
//########################################################################
enum
{
joyMaxDevices = 4,
joyAxisCount = 8, // X Y Z RX RY RZ SL0 SL1 (DIJOYSTATE2 order)
joyButtonCount = 32, // buttons exposed to bindings (DI carries 128)
joyHatCount = 4
};
struct RPJoyDeviceState
{
int attached;
float axis[joyAxisCount]; // normalized -1..1, raw: deadzones
// are the binding layer's business
unsigned buttons; // bit n = button n held
int hat[joyHatCount]; // POV in centidegrees, -1 = centered
char name[64]; // product name ("T.16000M", ...)
};
//
// Lifecycle. Init is lazy-safe (Poll calls it) and returns the attached
// non-XInput device count. Re-enumeration for hot-plug happens inside
// Poll on a ~3 s cadence whenever nothing is attached.
//
int RPJoyInit(void);
void RPJoyShutdown(void);
void RPJoyPoll(void);
int RPJoyDeviceCount(void);
const RPJoyDeviceState *RPJoyDevice(int index); // NULL out of range/detached
//
// Case-insensitive product-name substring match to a device index, -1 for
// no match. This is what a named joydev slot resolves through.
//
int RPJoyFindDevice(const char *name_substring);
//
// The RP412JOYCONFIG capture wizard (console UI; called from RPL4.CPP
// before the front end). Returns 0 if it wrote a config, non-zero on
// abort or no device.
//
int RPJoyConfigWizard(void);
+33
View File
@@ -142,15 +142,48 @@ namespace
//---------------------------------------------------------------
void Worker()
{
bool ownsApartment = false;
try
{
init_apartment();
ownsApartment = true;
}
catch (...)
{
// apartment already set on this thread; carry on
}
//
// C++/WinRT caches an activation factory the first time a type
// is used, and that cache is PROCESS-wide - it outlives this
// thread. The apartment does not: COM tears it down when the
// worker exits and unloads the Lights server with it, because
// by then nothing holds a reference.
//
// So the cached factory is left pointing into an address range
// that no longer has a module in it, and the NEXT race's worker
// calls straight through it - the crash was a call through the
// stale vtable, on the second race, every time. A machine with
// no Dynamic Lighting keyboard is not spared: asking for the
// device selector is enough to populate the cache.
//
// Clear it before the apartment goes, and on every way out of
// here rather than only the tidy one - the watcher setup below
// returns early when Dynamic Lighting is unavailable.
//
struct WinRTExit
{
bool owns;
~WinRTExit()
{
clear_factory_cache();
if (owns)
{
uninit_apartment();
}
}
} winrtExit{ ownsApartment };
std::mutex claimedLock;
std::vector<ClaimedArray> claimed;
bool anySeen = false;
+137 -3
View File
@@ -24,6 +24,7 @@
#include "..\munga\appmsg.h"
#include "..\munga\mission.h"
#include "..\munga\notation.h"
#include "..\munga\spooler.h"
//#include <netnub.hpp>
@@ -213,7 +214,15 @@ L4NetworkManager::L4NetworkManager():
}
networkEggNotationFile = new NotationFile(egg_name);
Register_Object(networkEggNotationFile);
networkEggNotationFile->WriteFile("last.egg");
//
// NOT last.egg. That name now belongs to the egg saved beside
// last.spl for playback, and this is something else entirely - a
// debug dump of whatever egg was just loaded. The two collided, and
// playback picking this one up as if it were a recording's companion
// read a host table of the wrong length out of the spool header,
// walked off the end of it, and corrupted the heap.
//
networkEggNotationFile->WriteFile("last-loaded.egg");
currentNetworkState = NormalState;
ReceiveEggFileMessage egg_message(-1, 10, "local egg", 10);
@@ -378,7 +387,7 @@ void
networkEggNotationFile = new NotationFile(egg_path);
Register_Object(networkEggNotationFile);
networkEggNotationFile->WriteFile("last.egg");
networkEggNotationFile->WriteFile("last-loaded.egg");
// In network mode (the owner pod of a multiplayer race) the state
// gate must open or CheckBuffers keeps dropping mesh packets - a
@@ -447,6 +456,14 @@ void
Check(application->GetHostManager());
application->GetHostManager()->AdoptLocalHost(my_l4host);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: stand-alone local host '"
<< (const char *) mission_host_data->GetAddressString()
<< "' hostType=" << (int) mission_host_data->GetHostType()
<< " - posting LoadMission\n" << std::flush;
}
//
//--------------------------------------------------------------------
// Now, since this host creator is for stand-alone mode, send the load
@@ -559,6 +576,85 @@ void
nextOpenHostID++;
}
//
// A recording starts with a header, and this is where it can be written.
//
// A spool opens with the application ID, the resource major version,
// and one (remote, hostID) pair per host named in the egg - playback
// reads them straight back in L4PlaybackNetworkManager::StartConnecting
// and refuses the file without them. The first recordings this build
// made were packets only, so playback met a zero where the application
// ID should be and said "Not a spool file for this application".
//
// It goes here rather than in the recorder because every part of it is
// network-layer knowledge, and here is the moment all of it first
// exists: the hosts have just been created and the mission is in hand.
//
if (Application::IsRecording())
{
SpoolRecorder *recorder = SpoolRecorder_Get();
if (!recorder->IsArmed())
{
recorder->Arm();
}
if (recorder->IsArmed() && !recorder->HeaderWritten())
{
SpoolFile *header_spool = recorder->GetSpool();
ResourceFile *res_file = application->GetResourceFile();
Check(res_file);
*(ApplicationID*)header_spool->GetPointer() =
application->GetApplicationID();
header_spool->AdvancePointer(sizeof(ApplicationID));
int major_version = res_file->versionArray[1];
*(int*)header_spool->GetPointer() = major_version;
header_spool->AdvancePointer(sizeof(major_version));
HostManager *header_host_mgr = application->GetHostManager();
Check(header_host_mgr);
Mission::HostIterator header_iterator(mission);
MissionHostData *header_host_data;
int header_host_count = 0;
while ((header_host_data = header_iterator.ReadAndNext()) != NULL)
{
CString header_name(header_host_data->GetAddressString());
SOCKADDR_IN header_address;
NetTransport_Get()->Resolve((LPSTR)header_name, &header_address);
if (header_address.sin_port == 0)
{
header_address.sin_port = htons(localGamePort);
}
Host *header_host = header_host_mgr->FindHost(header_address);
//
// Same guard as the arcade spooler needs: a host can be in
// the egg without being connected, and the pair still has to
// be written or every entry after it shifts.
//
*(Logical*)header_spool->GetPointer() = (header_host == NULL)
? True
: (header_host != header_host_mgr->GetLocalHost());
header_spool->AdvancePointer(sizeof(Logical));
*(HostID*)header_spool->GetPointer() = (header_host == NULL)
? (HostID) 0
: header_host->GetHostID();
header_spool->AdvancePointer(sizeof(HostID));
++header_host_count;
}
recorder->MarkHeaderWritten();
DEBUG_STREAM << "Record: spool header written, " << header_host_count
<< " host(s) from the egg\n" << std::flush;
}
}
//
// All the hosts are created, the connects/listens done.
//
@@ -811,7 +907,7 @@ void
#if defined(LAB_ONLY)
DEBUG_STREAM << "Created egg\n";
#endif
networkEggNotationFile->WriteFile("last.egg");
networkEggNotationFile->WriteFile("last-loaded.egg");
currentNetworkState = NormalState;
//
@@ -1102,6 +1198,34 @@ void L4NetworkManager::Send(
our_host_manager = application->GetHostManager();
Check(our_host_manager);
base_host = our_host_manager->GetRemoteHost(host_ID);
//
// A host that is not in the table at all. Two playtest machines died
// here on the same instruction - the podium was up, a peer had left,
// and a drop zone assignment dispatched to a host GetRemoteHost had
// no row for, so GetConnectStatus read offset 0x24 off a NULL. The
// table's rows never leave it (disconnect only marks a host offline),
// so an absent ID was never adopted: host -1 is the known case, the
// ownerless map entities every log shows as 'Entity -1:106'. Whatever
// the ID's provenance, a message to a host that does not exist gets
// the same answer as one to a host that is offline - dropped - and
// the log names the ID so the next occurrence explains itself.
//
if (base_host == NULL)
{
static int said = 0;
if (said < 8)
{
++said;
DEBUG_STREAM << "Send: no host " << host_ID
<< " in the table (client " << (int) client
<< ", message " << message->messageID
<< ") - dropped, not crashed\n" << std::flush;
}
CLEAR_SEND_PACKET();
return;
}
l4host = Cast_Object(L4Host*, base_host);
if(l4host->GetConnectStatus() != L4Host::OnLineConnectionStatus)
{
@@ -1233,6 +1357,16 @@ Logical L4NetworkManager::SendMessageToNetnub(
Check(host_manager);
receiving_host = Cast_Object(L4Host*, host_manager->GetRemoteHost(host_ID));
//
// Same guard as Send, one layer down: an ID with no row in the table
// answers True - message consumed, nothing to retry - exactly like a
// host that is offline.
//
if (receiving_host == NULL)
{
return True;
}
if (receiving_host->GetConnectStatus() != L4Host::OnLineConnectionStatus)
{
return True;
+180 -2
View File
@@ -2,8 +2,138 @@
#pragma hdrstop
#include "l4nettransport.h"
#include "..\munga\appmgr.h"
#include <Ws2tcpip.h>
//########################################################################
// Waiting for a peer without hanging the window
//########################################################################
namespace
{
Logical gInWait = False; // re-entrancy guard
Logical gWaitCancelled = False;
char gOriginalTitle[256] = "";
Logical gTitleSaved = False;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// How long Connect keeps redialling.
//
// It was two minutes, which was right for what it was written for: an
// arcade console redialling a pod that is still booting and WILL answer,
// on a LAN with nothing else to go wrong. Over Steam the same two minutes
// is a host staring at a dead window, because a peer that has not answered
// in twenty seconds is not coming - it never launched, or it is behind
// something the relay cannot cross.
//
int
NetTransport_ConnectWaitSeconds()
{
static int cached = -1;
if (cached < 0)
{
const char *setting = getenv("RP412CONNECTWAIT");
cached = (setting != NULL) ? atoi(setting) : 20;
if (cached < 2) { cached = 2; }
if (cached > 300) { cached = 300; }
}
return cached;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
NetTransport_SetWaitProgress(const char *text)
{
if (ghWnd == 0)
{
return;
}
if (!gTitleSaved)
{
GetWindowTextA(ghWnd, gOriginalTitle, sizeof(gOriginalTitle) - 1);
gTitleSaved = True;
}
//
// The title bar because it is the one surface guaranteed to exist
// here: this runs before the engine block, so there is no renderer to
// draw a progress screen with yet.
//
SetWindowTextA(ghWnd, (text != NULL) ? text : gOriginalTitle);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
NetWaitResult
NetTransport_PumpAndSleep(int milliseconds)
{
//
// Dispatching a message can run application code, and that code can
// reach a connect of its own. Nested pumping would then deliver the
// same messages twice and let an inner wait consume the escape meant
// for the outer one, so an inner call just sleeps.
//
if (gInWait)
{
Sleep(milliseconds);
return NetWaitContinue;
}
gInWait = True;
NetWaitResult result = NetWaitContinue;
MSG message;
while (PeekMessage(&message, NULL, 0, 0, PM_REMOVE))
{
if (message.message == WM_QUIT)
{
//
// Put it back: the message loop that owns the shutdown has
// to see this, not us.
//
PostQuitMessage((int) message.wParam);
result = NetWaitQuit;
break;
}
if (message.message == WM_KEYDOWN && message.wParam == VK_ESCAPE)
{
gWaitCancelled = True;
}
TranslateMessage(&message);
DispatchMessage(&message);
}
if (result == NetWaitContinue && gWaitCancelled)
{
result = NetWaitCancelled;
}
if (result == NetWaitContinue)
{
Sleep(milliseconds);
}
gInWait = False;
return result;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// Cleared when a connect sequence starts, so an escape pressed during one
// race does not cancel the next one.
//
void
NetTransport_ClearWaitCancel()
{
gWaitCancelled = False;
}
//########################################################################
// WinsockNetTransport - the TCP wire the arcade always used, moved
// verbatim out of L4NET.CPP behind the NetTransport seam. Behavior
@@ -117,7 +247,28 @@ namespace
// the peer may not be listening yet. A refused TCP socket
// is dead - every attempt needs a fresh one.
//
DWORD deadline = GetTickCount() + 120 * 1000;
//
// NOTE the connect() below is still BLOCKING - the socket only
// goes nonblocking once it has succeeded - so an unreachable
// host (filtered rather than refused) sits in the OS SYN retry
// for around twenty seconds with nothing we can do about it.
// Pumping between redials fixes the refused case, which is the
// common one; the unreachable case needs the socket made
// nonblocking before connect() and a select() on our own
// timeout. Not done here because this is the LAN/direct path -
// a Steam host goes through SteamNetTransport::Connect.
//
DWORD deadline =
GetTickCount() + (DWORD) NetTransport_ConnectWaitSeconds() * 1000;
char progress[256];
sprintf(
progress,
"Red Planet - connecting to %s ...",
inet_ntoa(remote->sin_addr)
);
NetTransport_SetWaitProgress(progress);
for (;;)
{
SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
@@ -164,6 +315,7 @@ namespace
closesocket(sock);
return InvalidConnection;
}
NetTransport_SetWaitProgress(NULL);
return (Connection) sock;
}
@@ -174,9 +326,35 @@ namespace
{
DEBUG_STREAM << "ERROR: connect() failed with "
<< wsa_error << "!\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
Sleep(250); // peer not up yet - redial
//
// Peer not up yet - redial, answering the window meanwhile.
//
DWORD left = (DWORD)((LONG)(deadline - GetTickCount()) / 1000);
sprintf(
progress,
"Red Planet - connecting to %s ... %us left, ESC to cancel",
inet_ntoa(remote->sin_addr),
(unsigned) left
);
NetTransport_SetWaitProgress(progress);
for (int slept = 0; slept < 250; slept += 50)
{
NetWaitResult wait = NetTransport_PumpAndSleep(50);
if (wait != NetWaitContinue)
{
DEBUG_STREAM << "Connect "
<< ((wait == NetWaitCancelled) ? "cancelled" : "abandoned, quitting")
<< " by the user\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
}
}
}
+41
View File
@@ -124,3 +124,44 @@ NetTransport *
NetTransport_Get();
void
NetTransport_Set(NetTransport *transport);
//########################################################################
// Waiting for a peer without hanging the window.
//
// Connect retries until the peer answers, because the egg-ACK ordering
// means it may still be booting - that part is deliberate. What was not
// deliberate is that the wait slept without pumping messages, so Windows
// saw a process that had stopped answering and painted the whole thing
// "Not responding" for up to two minutes. It happens before the engine
// block, so there is no render loop keeping the window alive either.
//
// Sleep through this instead. It pumps, so the window keeps drawing and
// can be moved; it watches for a cancel; and it is re-entrancy guarded,
// because dispatching a message can run application code that reaches a
// connect of its own.
//########################################################################
enum NetWaitResult
{
NetWaitContinue, // carry on waiting
NetWaitCancelled, // the user pressed escape
NetWaitQuit // WM_QUIT arrived; the caller must unwind
};
NetWaitResult
NetTransport_PumpAndSleep(int milliseconds);
// How long Connect keeps redialling, in seconds. RP412CONNECTWAIT
// overrides; see the definition for why the default came down from 120.
int
NetTransport_ConnectWaitSeconds();
// Progress, shown in the window title while a connect is outstanding.
// Pass NULL to put the original title back.
void
NetTransport_SetWaitProgress(const char *text);
// Forget a previous escape, so one race's cancel cannot cancel the next.
// Call before starting a connect sequence.
void
NetTransport_ClearWaitCancel();
+242 -30
View File
@@ -2,6 +2,7 @@
#pragma hdrstop
#include "l4padbindings.h"
#include "l4joy.h" // joyButtonCount / joyHatCount, the parse limits
#include <XInput.h>
#include <stdio.h>
@@ -79,6 +80,20 @@ namespace
{ "Throttle", BindAxisThrottle },
{ "LeftPedal", BindAxisLeftPedal }, { "RightPedal", BindAxisRightPedal },
{ "JoystickY", BindAxisJoystickY }, { "JoystickX", BindAxisJoystickX },
{ "Pedals", BindAxisPedals },
};
// DirectInput's axis order, which is what the joy* rows name
const NameValue kJoyAxisNames[] =
{
{ "X", BindJoyAxisX }, { "Y", BindJoyAxisY }, { "Z", BindJoyAxisZ },
{ "RX", BindJoyAxisRX }, { "RY", BindJoyAxisRY }, { "RZ", BindJoyAxisRZ },
{ "SL0", BindJoyAxisSL0 }, { "SL1", BindJoyAxisSL1 },
};
const NameValue kJoyHatNames[] =
{
{ "up", 0 }, { "right", 1 }, { "down", 2 }, { "left", 3 },
};
Logical NameEquals(const char *a, const char *b)
@@ -185,10 +200,91 @@ namespace
}
//---------------------------------------------------------------
// One line of the profile grammar
// Shared tail of the two axis-source rows: [invert] [deadzone <d>]
// [rate <n>], in any order. 'lever' rides along for the rows that
// can take it - a NULL lever means the word is not legal here, and
// a padaxis row is exactly that: the pad's own triggers already
// read 0..1, so there is no half-travel to rescue.
//---------------------------------------------------------------
Logical ParseLine(char *tokens[], int token_count, PadBindingProfile *profile)
Logical ParseAxisOptions(
char *tokens[], int token_count, int first,
Logical *invert, Scalar *deadzone, Scalar *rate,
Logical *lever = NULL)
{
for (int i = first; i < token_count; ++i)
{
if (NameEquals(tokens[i], "invert"))
{
*invert = True;
}
else if (NameEquals(tokens[i], "lever") && lever != NULL)
{
*lever = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], deadzone))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], rate))
{
return False;
}
}
else
{
return False;
}
}
return True;
}
//---------------------------------------------------------------
// One line of the profile grammar. joy_slot carries the joydev
// state forward from line to line - joy rows attach to the slot
// most recently declared.
//---------------------------------------------------------------
Logical ParseLine(
char *tokens[], int token_count, PadBindingProfile *profile,
int *joy_slot)
{
//
// joydev is the one row that can be two tokens long ("joydev 1"),
// so it is answered before the four-token floor below.
//
if (NameEquals(tokens[0], "joydev") && token_count >= 2)
{
int slot = -1;
if (sscanf(tokens[1], "%d", &slot) != 1 ||
slot < 0 || slot >= BindJoyDeviceSlots)
{
return False;
}
*joy_slot = slot;
//
// The rest of the line is a product-name substring, rejoined
// with single spaces ("Saitek Pro Flight" is four tokens).
//
profile->joyDeviceMatch[slot][0] = '\0';
for (int i = 2; i < token_count; ++i)
{
if (i > 2)
{
strncat(profile->joyDeviceMatch[slot], " ",
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
strncat(profile->joyDeviceMatch[slot], tokens[i],
sizeof(profile->joyDeviceMatch[slot]) -
strlen(profile->joyDeviceMatch[slot]) - 1);
}
return True;
}
if (token_count < 4)
{
return False;
@@ -267,35 +363,96 @@ namespace
{
return False;
}
PadPadAxisBinding *binding = &profile->padAxes[profile->padAxisCount++];
memset(binding, 0, sizeof(*binding));
binding->source = source;
binding->axis = axis;
for (int i = 4; i < token_count; ++i)
//
// Built aside and only then committed: a row whose options
// go bad half way through is a REJECTED row, and taking the
// slot first would leave the good half of it bound anyway,
// under a log line that says it was skipped.
//
PadPadAxisBinding candidate;
memset(&candidate, 0, sizeof(candidate));
candidate.source = source;
candidate.axis = axis;
if (!ParseAxisOptions(tokens, token_count, 4,
&candidate.invert, &candidate.deadzone, &candidate.rate))
{
if (NameEquals(tokens[i], "invert"))
{
binding->invert = True;
}
else if (NameEquals(tokens[i], "deadzone") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->deadzone))
{
return False;
}
}
else if (NameEquals(tokens[i], "rate") && i + 1 < token_count)
{
if (!ParseNumber(tokens[++i], &binding->rate))
{
return False;
}
}
else
{
return False;
}
return False;
}
profile->padAxes[profile->padAxisCount++] = candidate;
return True;
}
//---------------------------------------------------------------
// Generic joystick rows, all attaching to the current joydev slot
//---------------------------------------------------------------
if (NameEquals(tokens[0], "joyaxis") && NameEquals(tokens[2], "axis"))
{
int source = LookupTable(kJoyAxisNames,
sizeof(kJoyAxisNames) / sizeof(kJoyAxisNames[0]), tokens[1]);
int axis = LookupTable(kRioAxisNames,
sizeof(kRioAxisNames) / sizeof(kRioAxisNames[0]), tokens[3]);
if (source < 0 || axis < 0 ||
profile->joyAxisCount >= PadBindingProfile::maxJoyAxes)
{
return False;
}
PadJoyAxisBinding candidate;
memset(&candidate, 0, sizeof(candidate));
candidate.device = *joy_slot;
candidate.source = source;
candidate.axis = axis;
if (!ParseAxisOptions(tokens, token_count, 4,
&candidate.invert, &candidate.deadzone, &candidate.rate,
&candidate.lever))
{
return False;
}
profile->joyAxes[profile->joyAxisCount++] = candidate;
return True;
}
if (NameEquals(tokens[0], "joybutton") && NameEquals(tokens[2], "button"))
{
int button = -1;
int address;
if (sscanf(tokens[1], "%d", &button) != 1 ||
button < 0 || button >= joyButtonCount ||
!ParseAddress(tokens[3], &address) ||
profile->joyButtonCount >= PadBindingProfile::maxJoyButtons)
{
return False;
}
Logical toggle = (token_count > 4 && NameEquals(tokens[4], "toggle"));
PadJoyButtonBinding *binding =
&profile->joyButtons[profile->joyButtonCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->button = button;
binding->address = address;
binding->toggle = toggle;
return True;
}
if (NameEquals(tokens[0], "joyhat") && token_count >= 5 &&
NameEquals(tokens[3], "button"))
{
int hat = -1;
int direction = LookupTable(kJoyHatNames,
sizeof(kJoyHatNames) / sizeof(kJoyHatNames[0]), tokens[2]);
int address;
if (sscanf(tokens[1], "%d", &hat) != 1 ||
hat < 0 || hat >= joyHatCount || direction < 0 ||
!ParseAddress(tokens[4], &address) ||
profile->joyHatCount >= PadBindingProfile::maxJoyHats)
{
return False;
}
PadJoyHatBinding *binding = &profile->joyHats[profile->joyHatCount++];
memset(binding, 0, sizeof(*binding));
binding->device = *joy_slot;
binding->hat = hat;
binding->direction = direction;
binding->address = address;
return True;
}
@@ -320,10 +477,20 @@ namespace
"# key <name> axis <axis> rate <n-per-second>\n"
"# pad <button> button <addr> [toggle]\n"
"# padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n-per-second>]\n"
"# joydev <slot> [product-name substring]\n"
"# joyaxis <src> axis <axis> [invert] [lever] [deadzone <d>] [rate <n>]\n"
"# joybutton <n> button <addr> [toggle]\n"
"# joyhat <n> <up|down|left|right> button <addr>\n"
"#\n"
"# <addr> RIO input address: lamp buttons 0x00-0x47, internal keypad\n"
"# 0x50-0x5F, external keypad 0x60-0x6F (hex or decimal).\n"
"# <axis> Throttle | LeftPedal | RightPedal | JoystickY | JoystickX\n"
"# | Pedals - a signed axis that works the pedal PAIR, positive\n"
"# for the right pedal and negative for the left, so one rudder\n"
"# bar or twist grip drives both. Two-pedal hardware - racing\n"
"# pedals, rudder pedals with an axis per foot - skips the\n"
"# composite and binds LeftPedal and RightPedal directly; the\n"
"# game mixes the pair into yaw the way the pod always did.\n"
"# <name> Keys name: A-Z, D0-D9 (digit row), F1-F12, NumPad0-NumPad9,\n"
"# Up, Down, Left, Right, Space, Enter, PageUp, PageDown,\n"
"# OemMinus, Oemplus, Oemcomma, OemPeriod, ...\n"
@@ -336,6 +503,44 @@ namespace
"# back on release; 'rate' walks the axis by <n> per second and the\n"
"# position sticks (the throttle). Every lamp button is also clickable\n"
"# on the on-screen cockpit, so unbound addresses are never stranded.\n"
"#\n"
"# ---- Flight sticks, HOTAS throttles and rudder pedals --------------\n"
"#\n"
"# EASIEST: run joyconfig.bat once. It asks you to move each control,\n"
"# works out which device and axis you moved and which way round it\n"
"# reads, and writes the joy* rows below a marker line at the end of\n"
"# this file. Everything you have written yourself is kept. Xbox-class\n"
"# pads need none of this - they are the pad* rows above.\n"
"#\n"
"# By hand: joydev picks the device for the rows that follow it - a\n"
"# name substring binds that product, a bare slot number binds the Nth\n"
"# stick Windows lists. <src> for joyaxis is the DirectInput axis name,\n"
"# X Y Z RX RY RZ SL0 SL1: a twist grip is usually RZ and a HOTAS\n"
"# throttle usually Z or SL0. A joyaxis on Throttle with no 'rate' is\n"
"# treated as a real lever and OWNS the channel - its full travel is\n"
"# the full throttle range, rather than nudging the position the way a\n"
"# spring-centred pad stick has to.\n"
"#\n"
"# 'lever' marks a source that rests at one END of its travel instead of\n"
"# in the middle - a floor pedal, a slider. Windows reports it as a full\n"
"# -1..1 axis all the same, so without the word half the travel sits\n"
"# below zero and the first half of the press does nothing; with it the\n"
"# travel maps onto 0..1 and the deadzone measures from the released end.\n"
"#\n"
"# joydev 0 T.16000M\n"
"# joyaxis X axis JoystickX invert deadzone 0.08\n"
"# joyaxis Y axis JoystickY invert deadzone 0.08\n"
"# joyaxis RZ axis Pedals deadzone 0.08\n"
"# joyaxis SL0 axis Throttle deadzone 0\n"
"# joybutton 0 button 0x40\n"
"# joyhat 0 up button 0x42\n"
"#\n"
"# ...and the same stick with racing pedals on a second device, one\n"
"# axis per foot instead of the composite:\n"
"#\n"
"# joydev 1 Pedals\n"
"# joyaxis Y axis LeftPedal lever deadzone 0.05\n"
"# joyaxis RZ axis RightPedal lever deadzone 0.05\n"
"\n"
"# ---- Flight: number pad + modifiers -------------------------------\n"
"# The whole letter board stays free for the MFD banks; flight lives\n"
@@ -499,6 +704,7 @@ void
char line[256];
int line_number = 0;
int error_count = 0;
int joy_slot = 0; // joy rows before any joydev belong to slot 0
const char *cursor = source;
while (*cursor != '\0')
{
@@ -523,7 +729,7 @@ void
{
continue;
}
if (!ParseLine(tokens, token_count, profile))
if (!ParseLine(tokens, token_count, profile, &joy_slot))
{
++error_count;
DEBUG_STREAM << "PadBindings: " << kBindingsFileName << " line "
@@ -542,4 +748,10 @@ void
<< profile->padAxisCount << " pad axes"
<< (error_count ? " (with rejected lines)" : "")
<< "\n" << std::flush;
if (profile->joyAxisCount || profile->joyButtonCount || profile->joyHatCount)
{
DEBUG_STREAM << "PadBindings: joystick - " << profile->joyAxisCount
<< " axes, " << profile->joyButtonCount << " buttons, "
<< profile->joyHatCount << " hat directions\n" << std::flush;
}
}
+90 -1
View File
@@ -18,12 +18,32 @@
// key <name> axis <axis> deflect <n> | rate <n>
// pad <button> button <addr> [toggle]
// padaxis <src> axis <axis> [invert] [deadzone <d>] [rate <n>]
// joydev <slot> [product-name substring...]
// joyaxis <src> axis <axis> [invert] [lever] [deadzone <d>] [rate <n>]
// joybutton <n> button <addr> [toggle]
// joyhat <n> <up|down|left|right> button <addr>
//
// <addr> is a RIO input address: lamp buttons 0x00-0x47, internal
// keypad 0x50-0x5F, external keypad 0x60-0x6F. Loaded from
// bindings.txt beside the exe; written there (self-documenting, with
// the full default profile) on first run. Bad lines are logged and
// skipped, good lines always win.
//
// The joy* rows drive generic DirectInput devices - flight sticks,
// HOTAS throttles, twist grips, rudder pedals (L4JOY.h). They attach to
// the most recent joydev slot, or slot 0 if no joydev came first. A slot
// naming a product substring binds THAT device; a bare slot binds the
// Nth attached non-XInput device. Source names follow the DirectInput
// layout - X Y Z RX RY RZ SL0 SL1 - where a twist grip is usually RZ
// and a HOTAS throttle usually Z or SL0. RP412JOYCONFIG=1 writes these
// rows for you by asking the player to move each control.
//
// 'lever' says the source is a one-way control that rests at one end of
// its travel rather than in the middle - a floor pedal, a throttle
// slider. DirectInput reports it as a full -1..1 axis all the same, so
// without the keyword half the travel sits below zero and the first
// half of the press does nothing. It maps that travel onto 0..1, and
// the deadzone then measures from the released end instead of centre.
//########################################################################
enum PadBindRioAxis
@@ -33,6 +53,14 @@ enum PadBindRioAxis
BindAxisRightPedal,
BindAxisJoystickY,
BindAxisJoystickX,
//
// A signed composite, not a channel the pod has: positive presses
// the right pedal, negative the left. One physical control - a
// rudder bar, a twist grip, a stick axis - works the pedal pair the
// way a foot never could, one or the other and never both. It
// decomposes into the real pair when the poll applies it.
//
BindAxisPedals,
BindAxisCount
};
@@ -89,6 +117,55 @@ struct PadPadAxisBinding
Scalar rate; // 0 = direct position, >0 = speed integrate
};
//
// Generic joystick (DirectInput - L4JOY.h). device is a joydev SLOT,
// resolved to a live device at poll time by the slot's name substring or
// by its ordinal, so unplugging and replugging does not rewrite the file.
//
enum { BindJoyDeviceSlots = 4 };
enum PadBindJoyAxis
{
BindJoyAxisX = 0,
BindJoyAxisY,
BindJoyAxisZ,
BindJoyAxisRX,
BindJoyAxisRY,
BindJoyAxisRZ,
BindJoyAxisSL0,
BindJoyAxisSL1,
BindJoyAxisCount
};
struct PadJoyAxisBinding
{
int device; // joydev slot
int source; // PadBindJoyAxis
int axis; // PadBindRioAxis
Logical invert;
Logical lever; // rests at one end: -1..1 travel means 0..1
Scalar deadzone; // normalized 0..1
Scalar rate; // 0 = direct position, >0 = speed integrate
};
struct PadJoyButtonBinding
{
int device; // joydev slot
int button; // 0-31
int address;
Logical toggle;
Logical wasDown;
Logical latched;
};
struct PadJoyHatBinding
{
int device; // joydev slot
int hat; // 0-3
int direction; // 0 up, 1 right, 2 down, 3 left
int address;
};
struct PadBindingProfile
{
enum
@@ -96,7 +173,10 @@ struct PadBindingProfile
maxKeyButtons = 128,
maxKeyAxes = 32,
maxPadButtons = 32,
maxPadAxes = 16
maxPadAxes = 16,
maxJoyAxes = 24,
maxJoyButtons = 48,
maxJoyHats = 16
};
PadKeyButtonBinding keyButtons[maxKeyButtons];
@@ -107,6 +187,15 @@ struct PadBindingProfile
int padButtonCount;
PadPadAxisBinding padAxes[maxPadAxes];
int padAxisCount;
PadJoyAxisBinding joyAxes[maxJoyAxes];
int joyAxisCount;
PadJoyButtonBinding joyButtons[maxJoyButtons];
int joyButtonCount;
PadJoyHatBinding joyHats[maxJoyHats];
int joyHatCount;
// "" = take the slot's ordinal attached device
char joyDeviceMatch[BindJoyDeviceSlots][64];
};
// Load bindings.txt from the working directory into the profile,
+312 -5
View File
@@ -3,6 +3,7 @@
#include "l4padrio.h"
#include "l4keylight.h"
#include "l4joy.h"
#include <XInput.h>
#pragma comment(lib, "xinput9_1_0.lib")
@@ -41,6 +42,112 @@ namespace
return (GetAsyncKeyState(virtual_key) & 0x8000) != 0;
}
//
// RP412INPUTFOCUS - do the controls answer only while the game is the
// window in front? On unless the file says 0.
//
// The pod was the only thing running on its cabinet, so the virtual
// RIO reads the key state directly rather than waiting on the message
// pump. That is the right call for latency and it is why the pedals
// feel like pedals - but a direct read is a read of the WHOLE
// keyboard, whatever has focus, so a player alt-tabbed into a text
// editor was flying the pod with every note they typed.
//
Logical InputNeedsFocus()
{
static int setting = -1;
if (setting < 0)
{
const char *value = getenv("RP412INPUTFOCUS");
setting = (value != NULL && *value == '0') ? 0 : 1;
}
return setting ? True : False;
}
//
// Whether the foreground window is one of OURS - the process, not one
// particular handle. The cockpit is a shell full of child panes, the
// exploded view is six windows of its own and the plasma glass
// another, so any of them being in front is the game being in front.
// Matching a single HWND would drop the controls the moment somebody
// clicked an MFD.
//
Logical ProcessHasFocus()
{
HWND foreground = GetForegroundWindow();
if (foreground == NULL)
{
return False;
}
DWORD foreground_process = 0;
GetWindowThreadProcessId(foreground, &foreground_process);
return (foreground_process == GetCurrentProcessId()) ? True : False;
}
//
// A generic stick axis is already normalized -1..1, so the deadzone
// is a plain cut about centre with the remainder rescaled - press
// just past the edge and you get just past zero, not a step.
//
Scalar JoyAxisValue(Scalar raw, Scalar deadzone)
{
if (deadzone <= 0.0f)
{
return raw;
}
if (raw > -deadzone && raw < deadzone)
{
return (Scalar) 0;
}
Scalar value = (raw > 0.0f)
? (raw - deadzone) / (1.0f - deadzone)
: (raw + deadzone) / (1.0f - deadzone);
if (value > 1.0f) value = 1.0f;
if (value < -1.0f) value = -1.0f;
return value;
}
//
// A one-way control - a floor pedal, a slider - rests at one END of
// its travel, not in the middle, and DirectInput still reports it as
// a full -1..1 axis. Fold that travel onto 0..1 so the pedal starts
// answering as soon as it moves instead of at half depression, and
// measure the deadzone from the released end, where the slack in a
// tired return spring actually lives.
//
Scalar JoyLeverValue(Scalar raw, Scalar deadzone)
{
Scalar value = (raw + 1.0f) * 0.5f;
if (value <= deadzone)
{
return (Scalar) 0;
}
if (value > 1.0f) value = 1.0f;
return value;
}
//
// A POV hat reports centidegrees clockwise from up, or -1 centered.
// The 45-degree window each way is what makes the diagonals press
// both of their neighbours, which is how a four-way hat is read.
//
Logical JoyHatHeld(int centidegrees, int direction)
{
if (centidegrees < 0)
{
return False;
}
int degrees = (centidegrees / 100) % 360;
switch (direction)
{
case 0: return (degrees >= 315 || degrees <= 45) ? True : False;
case 1: return (degrees >= 45 && degrees <= 135) ? True : False;
case 2: return (degrees >= 135 && degrees <= 225) ? True : False;
case 3: return (degrees >= 225 && degrees <= 315) ? True : False;
}
return False;
}
void KeyLightLog(const char *line)
{
DEBUG_STREAM << line << "\n" << std::flush;
@@ -166,6 +273,34 @@ PadRIO::PadRIO()
activeInstance = this;
DEBUG_STREAM << "PadRIO: virtual RIO active (XInput pad + keyboard)\n" << std::flush;
DEBUG_STREAM << "PadRIO: controls "
<< (InputNeedsFocus()
? "answer only while the game window has focus"
: "answer whether the game has focus or not (RP412INPUTFOCUS=0)")
<< "\n" << std::flush;
//
// Only open DirectInput when the profile actually asks for it. A
// player on keyboard and pad should not pay for an enumeration of
// every HID on the machine, and joyconfig.bat is what writes the
// rows that turn this on.
//
if (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0)
{
int found = RPJoyInit();
DEBUG_STREAM << "PadRIO: joystick bindings present, " << found
<< " generic device(s) attached\n" << std::flush;
for (int d = 0; d < found; ++d)
{
const RPJoyDeviceState *state = RPJoyDevice(d);
if (state != NULL)
{
DEBUG_STREAM << "PadRIO: [" << d << "] " << state->name
<< "\n" << std::flush;
}
}
}
}
PadRIO::~PadRIO()
@@ -244,6 +379,11 @@ void
profile.padButtons[i].latched = False;
profile.padButtons[i].wasDown = False;
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
profile.joyButtons[i].latched = False;
profile.joyButtons[i].wasDown = False;
}
}
void
@@ -297,6 +437,25 @@ void
}
lastPollTick = now;
//---------------------------------------------------------------
// Is the game the window in front? Every source below is gated on
// this - keyboard, pad and stick alike.
//
// Gated rather than skipped, and that is the whole trick: each
// source reads as RELEASED instead of the poll returning early, so
// the diffs further down turn whatever was held at the moment you
// switched away into proper release events. Bail out instead and a
// key held on alt-tab stays down until you come back, which is the
// stuck throttle this is meant to prevent rather than cause.
//
// The throttle accumulator is the deliberate exception. It is the
// pod's one sticky axis and it integrates what the controls ask
// for, so controls asking for nothing simply stop moving it - you
// come back to the speed you left, not to a dead stop.
//---------------------------------------------------------------
Logical input_live =
(!InputNeedsFocus() || ProcessHasFocus()) ? True : False;
//---------------------------------------------------------------
// Find / keep the XInput pad. Probing empty slots is slow, so an
// absent pad is only re-probed every 3 seconds.
@@ -305,7 +464,7 @@ void
memset(&pad, 0, sizeof(pad));
Logical pad_live = False;
if (padIndex >= 0)
if (input_live && padIndex >= 0)
{
pad_live = (XInputGetState((DWORD) padIndex, &pad) == ERROR_SUCCESS);
if (!pad_live)
@@ -314,7 +473,7 @@ void
padIndex = -1;
}
}
if (padIndex < 0 && (now - lastPadCheckTick) >= 3000)
if (input_live && padIndex < 0 && (now - lastPadCheckTick) >= 3000)
{
lastPadCheckTick = now;
for (DWORD i = 0; i < 4; ++i)
@@ -349,7 +508,7 @@ void
for (int i = 0; i < profile.keyButtonCount; ++i)
{
PadKeyButtonBinding *binding = &profile.keyButtons[i];
Logical down = KeyDown(binding->virtualKey);
Logical down = input_live && KeyDown(binding->virtualKey);
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
@@ -391,6 +550,88 @@ void
}
}
}
//---------------------------------------------------------------
// Generic joysticks. The slots are resolved every poll rather than
// cached, so a stick unplugged mid-race simply stops answering and
// one plugged back in picks up where it left off.
//---------------------------------------------------------------
int joyDevice[BindJoyDeviceSlots];
Logical joyLive = False;
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = -1;
}
//
// Unfocused this whole block is skipped, which leaves every joyDevice
// slot at -1 - so the button, hat and axis loops below find no device
// and read released and centred on their own. The stick is opened
// DISCL_BACKGROUND (it has to be, or it stops answering the moment a
// pane takes focus), so not polling it is what makes it go quiet.
//
if (input_live && (profile.joyAxisCount > 0 || profile.joyButtonCount > 0 ||
profile.joyHatCount > 0))
{
RPJoyPoll();
for (int slot = 0; slot < BindJoyDeviceSlots; ++slot)
{
joyDevice[slot] = (profile.joyDeviceMatch[slot][0] != '\0')
? RPJoyFindDevice(profile.joyDeviceMatch[slot])
: ((RPJoyDevice(slot) != NULL) ? slot : -1);
if (joyDevice[slot] >= 0)
{
joyLive = True;
}
}
}
for (int i = 0; i < profile.joyButtonCount; ++i)
{
PadJoyButtonBinding *binding = &profile.joyButtons[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
Logical down = (state != NULL) &&
(state->buttons & (1u << binding->button)) != 0;
if (binding->toggle && down && !binding->wasDown)
{
binding->latched = !binding->latched;
}
binding->wasDown = down;
if (binding->toggle ? binding->latched : down)
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < profile.joyHatCount; ++i)
{
const PadJoyHatBinding *binding = &profile.joyHats[i];
const RPJoyDeviceState *state =
(binding->device >= 0 && binding->device < BindJoyDeviceSlots)
? RPJoyDevice(joyDevice[binding->device]) : NULL;
if (state != NULL &&
JoyHatHeld(state->hat[binding->hat], binding->direction))
{
if (binding->address < buttonUnits)
{
desired[binding->address] = 1;
}
else if (binding->address >= 0x50 &&
binding->address < 0x50 + keypadUnits)
{
keypadDesired[binding->address - 0x50] = 1;
}
}
}
for (int i = 0; i < buttonUnits; ++i)
{
if (screenButton[i])
@@ -446,7 +687,7 @@ void
for (int i = 0; i < profile.keyAxisCount; ++i)
{
const PadKeyAxisBinding *binding = &profile.keyAxes[i];
if (KeyDown(binding->virtualKey))
if (input_live && KeyDown(binding->virtualKey))
{
if (binding->mode == BindKeyRate)
{
@@ -502,6 +743,70 @@ void
}
}
//---------------------------------------------------------------
// Joystick axes. A physical throttle lever is the one source that
// does not add into the pile: it has an absolute position, so its
// full travel IS the channel and it takes ownership rather than
// nudging an accumulator that a spring-centred pad stick has to.
//---------------------------------------------------------------
Logical throttleLever = False;
Scalar throttleLeverValue = (Scalar) 0;
if (joyLive)
{
for (int i = 0; i < profile.joyAxisCount; ++i)
{
const PadJoyAxisBinding *binding = &profile.joyAxes[i];
if (binding->device < 0 || binding->device >= BindJoyDeviceSlots)
{
continue;
}
const RPJoyDeviceState *state = RPJoyDevice(joyDevice[binding->device]);
if (state == NULL)
{
continue;
}
Scalar raw = (Scalar) state->axis[binding->source];
if (binding->invert)
{
raw = -raw;
}
if (binding->axis == BindAxisThrottle && binding->rate == 0.0f)
{
// -1..1 of lever travel onto the 0..1 the pod runs on
throttleLeverValue = (raw + 1.0f) * 0.5f;
throttleLever = True;
continue;
}
Scalar value = binding->lever
? JoyLeverValue(raw, binding->deadzone)
: JoyAxisValue(raw, binding->deadzone);
if (binding->rate > 0.0f)
{
rate[binding->axis] += value * binding->rate;
}
else
{
deflect[binding->axis] += value;
}
}
}
//
// The composite pedal axis becomes the pair the pod actually has.
// One signed source presses one pedal or the other, never both,
// which is what a rudder bar or a twist grip does.
//
Scalar pedals = deflect[BindAxisPedals];
if (pedals > 0.0f)
{
deflect[BindAxisRightPedal] += pedals;
}
else if (pedals < 0.0f)
{
deflect[BindAxisLeftPedal] += -pedals;
}
throttleAccum = Clamp01(throttleAccum + rate[BindAxisThrottle] * delta_t);
Scalar x = deflect[BindAxisJoystickX];
@@ -511,7 +816,9 @@ void
if (y > 1.0f) y = 1.0f;
if (y < -1.0f) y = -1.0f;
Throttle = Clamp01(throttleAccum + deflect[BindAxisThrottle]);
Throttle = throttleLever
? Clamp01(throttleLeverValue)
: Clamp01(throttleAccum + deflect[BindAxisThrottle]);
LeftPedal = Clamp01(deflect[BindAxisLeftPedal]);
RightPedal = Clamp01(deflect[BindAxisRightPedal]);
// The profile encodes the pod's stick sign convention; L4PADFLIP
+39 -2
View File
@@ -249,14 +249,51 @@ void ParticleEmitter::Execute()
}
}
//
// Drop everything bound to the device we were last given.
//
// Null-safe, and it clears what it drops. Neither was true before: this
// runs on the device-lost path ahead of a Reset, where a texture that
// never loaded (a missing VIDEO\particles.png is enough) left one of
// these NULL and took the Reset down with it, and a released pointer
// left in place is a dangling one the moment anything looks again.
//
void ParticleEngine::Destroy()
{
mVertBuffer->Release();
mParticleTexture->Release();
if (mVertBuffer != NULL)
{
mVertBuffer->Release();
mVertBuffer = NULL;
}
if (mParticleTexture != NULL)
{
mParticleTexture->Release();
mParticleTexture = NULL;
}
//
// The paint paths test this before touching anything, so clearing it
// makes the gap between a Destroy and the next Initialize safe.
//
mDevice = NULL;
}
void ParticleEngine::Initialize(LPDIRECT3DDEVICE9 device)
{
//
// Whatever is still held belongs to the PREVIOUS device, and holding
// it kept that device alive. A fresh renderer is built per mission,
// so a new device used to arrive here while the old one's vertex
// buffer (D3DPOOL_DEFAULT) and texture still referenced it -
// ~DPLRenderer's release never reached zero and the whole device
// survived the race that made it, back buffer and depth buffer and
// all. That is one leaked render target per race.
//
// 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.
//
Destroy();
mDevice = device;
memset(mInstalledEffects, 0, sizeof(mInstalledEffects));
+251 -2
View File
@@ -124,10 +124,38 @@ void
ResolveAddress(host_name, &net_address);
Host *host = host_mgr->FindHost(net_address);
//
// FindHost answers NULL for a host named in the egg that is not
// actually connected, and this went straight on to call
// host->GetHostID(). In an arcade every station in the egg is on
// the wire, so the case could not arise; anywhere else it is the
// ordinary state of affairs, and it crashed the spooling
// application before the mission could even start.
//
// The table below is read back one pair per egg host, in egg
// order, so a missing host cannot simply be skipped - that would
// shift every entry after it. Write the pair, say what happened,
// and carry on.
//
if (host == NULL)
{
DEBUG_STREAM << "Spool: host '" << host_name
<< "' is in the egg but not connected - recording it as"
<< " remote with no ID. Packets from it will not map back.\n"
<< std::flush;
*(Logical*)spool->GetPointer() = True;
spool->AdvancePointer(sizeof(Logical));
*(HostID*)spool->GetPointer() = (HostID) 0;
spool->AdvancePointer(sizeof(HostID));
continue;
}
*(Logical*)spool->GetPointer() = (host != host_mgr->GetLocalHost());
spool->AdvancePointer(sizeof(Logical));
*(HostID*)spool->GetPointer() = host->GetHostID();
spool->AdvancePointer(sizeof(HostID));
}
@@ -167,11 +195,143 @@ void
//###################### L4PlaybackNetworkManager #########################
//#############################################################################
//
// Whether the spool's header has been taken off the front of the stream, so
// SpoolerTask::Execute knows when the cursor is standing on a packet rather
// than on the header. Cleared when a playback manager is built, since the
// single-binary loop can play a second spool in the same process.
//
namespace
{
Logical gSpoolHeaderConsumed = False;
}
Logical
SpoolHeaderConsumed()
{
return gSpoolHeaderConsumed;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
L4PlaybackNetworkManager::L4PlaybackNetworkManager():
NetworkManager(L4PlaybackNetworkManager::DefaultData)
{
gSpoolHeaderConsumed = False;
//
// Give this application its mission.
//
// Nothing else will. A spool is a record of what MOVED, not of the
// world it moved through - no track, no models, no drop zones - and
// playback has neither a wire to be sent an egg over nor a console to
// send one. L4NetworkManager does exactly this for single user mode,
// but that is the pod's network manager; this one descends from
// NetworkManager and inherited none of it, so a playback build came up
// with a cockpit, no world behind it, and nothing in the log to say
// why. A black screen is what that looks like.
//
const char *egg_name =
((L4Application *) application)->GetEggNotationFileName();
//
// Fall back to the egg saved with the recording.
//
// Every spool is written with its egg beside it under the same stem,
// and last.egg alongside last.spl, precisely so that a recording is one
// self-contained thing. Having to name the egg by hand invites naming
// the WRONG one - frontend.egg is rewritten by the next race set up on
// the machine, and a spool played against a different track would load
// happily and show nonsense.
//
static char found_egg[MAX_PATH];
if (egg_name == NULL || strlen(egg_name) == 0)
{
CString spool_name = ((L4Application *) application)->GetSpoolFileName();
const char *spool_text =
(!spool_name) ? "last.spl" : (const char *) spool_name;
strncpy(found_egg, spool_text, sizeof(found_egg) - 1);
found_egg[sizeof(found_egg) - 1] = '\0';
size_t length = strlen(found_egg);
if (length > 4)
{
strcpy(found_egg + length - 4, ".egg");
FILE *probe = fopen(found_egg, "r");
if (probe != NULL)
{
fclose(probe);
egg_name = found_egg;
DEBUG_STREAM << "Playback: using the egg saved with the"
<< " recording, '" << found_egg << "'\n" << std::flush;
}
}
}
if (egg_name == NULL || strlen(egg_name) == 0)
{
DEBUG_STREAM << "Playback: no egg. A spool records the race but not the"
<< " track it was run on, and no egg was found beside the spool -"
<< " name one with -egg.\n" << std::flush;
return;
}
DEBUG_STREAM << "Playback: loading world from egg '" << egg_name
<< "'\n" << std::flush;
networkEggNotationFile = new NotationFile(egg_name);
Register_Object(networkEggNotationFile);
//
// Decide now whether this is a camera station, not later.
//
// StartConnecting already works this out from the local host's type and
// sets it - but StartConnecting runs after the mission is created, and
// the cockpit is built before that. The log says so plainly: the egg is
// read here, SVGA16 fits the cockpit eleven lines later, and the Live
// Cam line arrives five lines after THAT. So a camera's recording came
// back with a pod's cockpit over it - five instrument panes and the map
// in the middle - because the answer arrived after the panes had been
// made.
//
// The egg has it: the first entry in [pilots] is the station that
// owned the race, and its own section carries the hostType. Reading it
// here is early enough, and it is the same fact StartConnecting will
// confirm from the host table afterwards.
//
const char *owner_address;
if (networkEggNotationFile->GetEntry("pilots", "pilot", &owner_address))
{
int owner_host_type = 0;
if (networkEggNotationFile->GetEntry(
owner_address, "hostType", &owner_host_type))
{
Logical owner_is_camera =
(owner_host_type == (int) CameraShipHostType);
Application::SetCameraStation(owner_is_camera);
DEBUG_STREAM << "Playback: the owning station '" << owner_address
<< "' recorded as hostType " << owner_host_type
<< (owner_is_camera
? " - a Live Cam, so no instrument panes and the map"
" landscape in the corner"
: " - a pod, so the full cockpit")
<< "\n" << std::flush;
}
}
//
// The handler is NetworkManager's, and it ends in CreateMission, which
// is what calls StartConnecting below to read the host table back out
// of the spool.
//
ReceiveEggFileMessage egg_message(-1, 10, "local egg", 10);
application->Post(DefaultEventPriority, this, &egg_message);
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -243,9 +403,72 @@ void
else
{
host_mgr->AdoptLocalHost(my_host);
//
// Lay the cockpit out as whatever this station WAS.
//
// Application::SetCameraStation is normally the front end's
// answer to a question asked on the setup screen, and playback
// never sees the setup screen - so a recording made from a Live
// Cam replayed with five instrument panes hung over the view
// and the map back in the middle, which is a pod's cockpit, not
// a camera's. The egg knows: it is the same egg the race ran
// on, and it says what this host was.
//
Application::SetCameraStation(
mission_host_data->GetHostType() == CameraShipHostType
);
if (mission_host_data->GetHostType() == CameraShipHostType)
{
DEBUG_STREAM << "Playback: this station recorded as a Live Cam"
<< " - no instrument panes, map landscape\n" << std::flush;
}
}
}
//
// Did the header end where the packets begin?
//
// The table above holds one pair per host named in the EGG, and the
// reader trusts the egg to have as many hosts as the spool was written
// with. Hand it a different egg and the count differs, the read pointer
// stops short of - or past - the first packet, and every packet after
// that is parsed from the middle of something else. The first symptom is
// a nonsense message length, and the second is a corrupted heap: exit
// 0xC0000374, no message, nothing in the log.
//
// A packet here should start with a plausible length. If it does not,
// the egg does not belong to this spool, and saying so is worth more
// than whatever the heap does next.
//
{
NetworkPacket *first = (NetworkPacket*) spool->GetPointer();
int first_length = (int) first->messageData.messageLength;
if (first_length < (int) sizeof(Receiver::Message)
|| first_length > 65536
|| first_length > (int) spool->GetBytesRemaining())
{
DEBUG_STREAM << "\n\nError - this egg does not belong to this spool."
<< " The host table ran to the wrong length and the first packet"
<< " reads as " << first_length << " bytes, which cannot be"
<< " right. Play it back with the .egg that was saved beside"
<< " it.\n" << std::flush;
PostQuitMessage(AbortExitCodeID);
return;
}
}
//
// The cursor is now standing on the first packet, so the spooler task
// may start reading. Set only after the sanity check above, so a spool
// whose header did not add up is never played at all.
//
gSpoolHeaderConsumed = True;
DEBUG_STREAM << "Playback: header consumed, "
<< (int) spool->GetBytesRemaining() << " bytes of packets to play\n"
<< std::flush;
//
// Now, just send the load message
//
@@ -663,6 +886,32 @@ void
return;
}
//
// Nothing may be read until the header has been taken off the front.
//
// A spool opens with the application ID, the resource version and one
// (remote, hostID) pair per host named in the egg, and the length of
// that depends on the egg - so it can only be read once the mission
// exists, which is what L4PlaybackNetworkManager::StartConnecting does.
// Until then the cursor is sitting on the header, and this function
// happily parsed it as packet one: the application ID read as a client
// ID, and ours is 0, which is NetworkManagerClientID exactly. The pair
// of host IDs after it read as a message length of 1 and a message ID
// of 3 - and message 3 on the network manager is ReceiveEggFile, which
// builds a Mission. From a packet. That was the heap corruption.
//
// The WaitingForEgg case below is for an arcade spool that CARRIES its
// egg as network manager packets, which cannot work with this format
// anyway: reading those packets means passing the header first, and
// passing the header means already knowing the egg. A recording made
// by this build keeps its egg beside it instead, which is why there is
// no such circle to break.
//
if (!SpoolHeaderConsumed())
{
return;
}
//
//-----------------------------------------------------------------------
// We have a spool file, so interpret it based upon the application state
+8
View File
@@ -54,6 +54,14 @@ public:
//################### L4PlaybackNetworkManager #########################
//##########################################################################
// True once a spool's header has been read off the front of the stream, so
// the cursor is standing on a packet. SpoolerTask must not read before
// this: the header's length depends on the egg's host count, so it can only
// be taken off once the mission exists, and a task that reads early parses
// the header as a packet. See SpoolerTask::Execute.
Logical
SpoolHeaderConsumed();
class L4PlaybackNetworkManager:
public NetworkManager
{
+143 -8
View File
@@ -3,6 +3,41 @@
#include "l4steamtransport.h"
//########################################################################
// Is steam_api.dll actually here?
//
// Defined outside the RP412_STEAM guard on purpose: callers ask without
// caring how the build was configured, and a build without the SDK
// truthfully has no client library. See the header for why every Steam
// path has to come through here first.
//
// The answer is cached because it is asked on menu paint, and because a
// DLL that appeared halfway through a session is not a case worth
// supporting - the delay-load helper would have bound the first miss
// anyway.
//########################################################################
Logical
SteamNetTransport_ClientLibraryPresent()
{
#ifdef RP412_STEAM
static int present = -1;
if (present < 0)
{
present = (LoadLibraryA("steam_api.dll") != NULL) ? 1 : 0;
if (!present)
{
DEBUG_STREAM << "Steam: steam_api.dll not found beside the exe - "
<< "Steam features off, staying on TCP\n" << std::flush;
}
}
return present ? True : False;
#else
return False;
#endif
}
#ifdef RP412_STEAM
#include "l4nettransport.h"
@@ -350,18 +385,59 @@ namespace
target.Clear();
target.SetIPv4(remote_fake_ip, fake_port);
// mirror the TCP retry-while-refused loop, bounded: the
// egg-ACK ordering means the peer may not be listening yet
DWORD deadline = GetTickCount() + 120 * 1000;
//
// Mirror the TCP retry-while-refused loop, bounded: the
// egg-ACK ordering means the peer may not be listening yet.
//
// Every wait below goes through NetTransport_PumpAndSleep, so
// the window keeps painting and answering instead of being
// declared "Not responding" for the whole attempt. There is no
// render loop yet at this point - this runs before the engine
// block - so nothing else is keeping it alive.
//
//
// The deadline is PER ATTEMPT, and attempts are capped, rather
// than one budget across all attempts.
//
// The 2026-08-11 playtest showed why, on every machine that met
// the one badly-NATed peer: the first attempt goes for a direct
// path, burns around ten seconds, and dies with 'timed out
// attempting to connect' (5003) or 'negotiate rendezvous'
// (5008); the SECOND attempt comes up through Valve's relay and
// succeeds - whenever it is given time. Under one shared budget
// the relay attempt inherited whatever the direct attempt left,
// and the logs show it being cut off mid-connect ('attempt 2
// ended in state 1' - still connecting) at the 20s mark. Races
// then could not assemble, because the mesh needs every pair.
//
// Three attempts at RP412CONNECTWAIT each: the observed failure
// connects on attempt 2 at about half a minute, the truly
// unreachable peer costs about a minute instead of twenty
// seconds, and ESC still works throughout.
//
DWORD wait_seconds = (DWORD) NetTransport_ConnectWaitSeconds();
DWORD started = GetTickCount();
int attempt = 0;
const int kMaxAttempts = 3;
char progress[256];
sprintf(
progress,
"Red Planet - connecting to %s ...",
inet_ntoa(remote->sin_addr)
);
NetTransport_SetWaitProgress(progress);
for (;;)
{
++attempt;
DWORD deadline = GetTickCount() + wait_seconds * 1000;
HSteamNetConnection handle =
SteamNetworkingSockets()->ConnectByIPAddress(target, 0, NULL);
if (handle == k_HSteamNetConnection_Invalid)
{
DEBUG_STREAM << "SteamNetTransport: ConnectByIPAddress refused the call\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
AddConnection(handle, remote->sin_addr.S_un.S_addr, remote->sin_port);
@@ -390,7 +466,35 @@ namespace
{
break;
}
Sleep(25);
//
// Count down out loud. Two minutes of a silent frozen
// window gave a host nothing to act on - not which peer
// was missing, not how long was left, not a way out.
//
DWORD left = (DWORD)((LONG)(deadline - GetTickCount()) / 1000);
sprintf(
progress,
"Red Planet - connecting to %s (try %d of %d) ... %us left, ESC to cancel",
inet_ntoa(remote->sin_addr),
attempt, kMaxAttempts,
(unsigned) left
);
NetTransport_SetWaitProgress(progress);
NetWaitResult wait = NetTransport_PumpAndSleep(25);
if (wait != NetWaitContinue)
{
DEBUG_STREAM << "SteamNetTransport: connect "
<< ((wait == NetWaitCancelled) ? "cancelled" : "abandoned, quitting")
<< " after " << ((GetTickCount() - started) / 1000)
<< "s\n" << std::flush;
SteamNetworkingSockets()->CloseConnection(handle, 0, "cancelled", false);
RemoveConnection(handle);
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
}
if (state == k_ESteamNetworkingConnectionState_Connected)
{
@@ -401,20 +505,41 @@ namespace
}
DEBUG_STREAM << "SteamNetTransport: connect succeeded (attempt "
<< attempt << ")\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return (Connection) handle;
}
// attempt failed - drop it and retry until the deadline
// attempt failed - drop it and retry with a fresh window
DEBUG_STREAM << "SteamNetTransport: attempt " << attempt
<< " ended in state " << (int) state << "\n" << std::flush;
SteamNetworkingSockets()->CloseConnection(handle, 0, "retry", false);
RemoveConnection(handle);
if ((LONG)(GetTickCount() - deadline) >= 0)
if (attempt >= kMaxAttempts)
{
DEBUG_STREAM << "SteamNetTransport: connect timed out\n" << std::flush;
DEBUG_STREAM << "SteamNetTransport: gave up after "
<< attempt << " attempts of " << wait_seconds
<< "s each (RP412CONNECTWAIT), "
<< ((GetTickCount() - started) / 1000)
<< "s in all\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
Sleep(1000);
//
// A second between redials, still answering the window.
//
for (int slept = 0; slept < 1000; slept += 50)
{
NetWaitResult wait = NetTransport_PumpAndSleep(50);
if (wait != NetWaitContinue)
{
DEBUG_STREAM << "SteamNetTransport: connect "
<< ((wait == NetWaitCancelled) ? "cancelled" : "abandoned, quitting")
<< " between attempts\n" << std::flush;
NetTransport_SetWaitProgress(NULL);
return InvalidConnection;
}
}
}
}
@@ -686,6 +811,16 @@ Logical
return True;
}
//
// Before ANY Steam symbol: steam_api.dll is delay-loaded, so calling
// into it when it is missing raises the helper's fatal exception
// rather than failing. This is the gate that makes the DLL optional.
//
if (!SteamNetTransport_ClientLibraryPresent())
{
return False;
}
if (!SteamAPI_Init())
{
DEBUG_STREAM << "SteamNetTransport: SteamAPI_Init failed "
+21 -1
View File
@@ -14,7 +14,8 @@
//########################################################################
// SteamNetTransport - the retail wire (l4steamtransport.cpp). Built
// only under RP412_STEAM (Steamworks SDK vendored at
// extern\steamworks_sdk_164; steam_api.dll ships beside the exe).
// extern\steamworks_sdk_164; steam_api.dll ships beside the exe, but is
// delay-loaded and optional - see the note further down).
//
// Method mapping onto ISteamNetworkingSockets:
//
@@ -55,6 +56,25 @@
// egg is distributed.
//########################################################################
//########################################################################
// steam_api.dll is DELAY-LOADED (see the DelayLoadDLLs setting in
// RP_L4.vcxproj), so the game runs on a machine that has never seen
// Steam - a plain import of a missing DLL kills the process at load time
// with 0xC0000135, before a window or a single log line.
//
// The catch is that delay loading only MOVES the failure: the first call
// to a delay-loaded function whose DLL cannot be found raises a fatal
// exception instead. So every path that would reach a Steam symbol has
// to ask this first. It is declared outside the RP412_STEAM guard, and
// answers False in a build without the SDK, so callers need no #ifdef.
//
// Cheap and idempotent: one LoadLibrary on first call, cached after.
// Deliberately the same plain-name load the delay-load helper itself
// does, so a yes here means the helper will succeed too.
//########################################################################
Logical
SteamNetTransport_ClientLibraryPresent();
#ifdef RP412_STEAM
// Bring Steam up and make this the process transport. False (with the
+42 -1
View File
@@ -14,6 +14,7 @@
SystemClock SystemClock::timer;
long SystemClock::ticksPerSecond;
__int64 SystemClock::perfCounterFreq;
__int64 SystemClock::perfCounterOrigin;
//RB 1/20/07
//volatile long fast_time = 0L;
@@ -53,11 +54,42 @@ void Timer_Handler()
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
//
// Milliseconds since this process started.
//
// It used to be milliseconds since the machine BOOTED, which is what
// QueryPerformanceCounter counts from, and that had two consequences.
//
// The quiet one: Scalar is a 32-bit float, so an absolute time held in one
// loses resolution as the number grows. Consecutive representable values
// are 3.9ms apart after nine hours of uptime, 15.6ms after a day and a
// half, and 31.25ms after three days - by which point the clock can no
// longer resolve a single 20ms physics step. Anything computed by
// subtracting two absolute times IN FLOAT inherits that, and the joystick
// poll interval in L4CTRL is exactly such a subtraction: its 50ms test
// quietly becomes 62.5ms after three days of uptime and 125ms after
// twelve, so a player's controls grow less responsive the longer the
// machine has been switched on. The smoke emitter in L4VIDRND has the same
// defect, where adding a small interval to a large timestamp can round to
// no change at all.
//
// The loud one: this returns a long, and milliseconds since boot overflows
// one after 24.8 days.
//
// Counting from launch fixes both at the source and leaves every Time
// arithmetic path untouched - those subtract ticks as integers and were
// always exact. Peer machines already disagreed about this origin, having
// booted at different moments, so the network is no worse off; reconciling
// that is what RP412NETCLOCK does.
//
long SystemClock::GetRTC()
{
LARGE_INTEGER count;
QueryPerformanceCounter(&count);
return (long)((count.QuadPart * (__int64)1000) / SystemClock::perfCounterFreq);
return (long)(
((count.QuadPart - SystemClock::perfCounterOrigin) * (__int64)1000)
/ SystemClock::perfCounterFreq
);
}
double SystemClock::GetHiRes()
@@ -97,6 +129,15 @@ SystemClock::SystemClock()
//SystemClock::ticksPerSecond = freq.QuadPart;
SystemClock::perfCounterFreq = freq.QuadPart;
SystemClock::ticksPerSecond = 1000L;
//
// Time zero. Set here rather than on the first GetRTC call so that the
// origin is fixed before anything can read the clock, and so no two
// threads can race to establish it.
//
LARGE_INTEGER origin;
QueryPerformanceCounter(&origin);
SystemClock::perfCounterOrigin = origin.QuadPart;
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+332 -49
View File
@@ -192,7 +192,7 @@ void SVGA16::BuildWindows(unsigned int width, unsigned int height, bool windowed
mPresentParams[j].hDeviceWindow = gaugeWindows[j];
mPresentParams[j].Flags = 0;
mPresentParams[j].FullScreen_RefreshRateInHz = (windowed)?D3DPRESENT_RATE_DEFAULT:60;
mPresentParams[j].PresentationInterval = D3DPRESENT_RATE_DEFAULT;
mPresentParams[j].PresentationInterval = RPPresentationInterval();
mPresentParams[j].BackBufferFormat = D3DFMT_R5G6B5;
//pp.EnableAutoDepthStencil = TRUE;
//pp.AutoDepthStencilFormat = D3DFMT_D24X8;
@@ -3828,6 +3828,13 @@ static LRESULT CALLBACK
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
static WNDPROC gCockpitBaseProc = NULL;
//
// Which window we subclassed, so the destructor can put its own proc
// back. The shell is the GAME window: it outlives the cockpit and
// carries the console screen from one race to the next.
//
static HWND gCockpitShellWindow = NULL;
static LRESULT CALLBACK
CockpitShellProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
@@ -3974,9 +3981,25 @@ static RadarPlacement
{ "RIGHTCENTRE", RadarMidRight }
};
cached = RadarBottomCenter;
//
// The pod had it dead centre under the viewscreen, so that is the
// default for a pod. A Live Cam is a shot, not a cockpit: dead
// centre is the worst place to put a panel on a picture, so a
// camera station defaults to the bottom-left corner instead.
// L4RADARPOS still overrides either way.
//
// A pod and a Live Cam want this display in different places, and
// the same host does both from one menu - so they get a knob each
// and neither has to be edited when the role changes. L4RADARPOS
// places the pod's radar, L4MAPPOS the camera's map. Defaults
// differ too: dead centre under the viewscreen is where the
// cabinet had it, and the worst place to put a panel on a shot.
const Logical camera_station = Application::IsCameraStation();
const char *variable = camera_station ? "L4MAPPOS" : "L4RADARPOS";
const char *text = getenv("L4RADARPOS");
cached = camera_station ? RadarBottomLeft : RadarBottomCenter;
const char *text = getenv(variable);
if (text != NULL)
{
for (int i = 0; i < (int) (sizeof(names) / sizeof(names[0])); ++i)
@@ -3997,8 +4020,9 @@ static RadarPlacement
"left side, centred",
"right side, centred"
};
DEBUG_STREAM << "SVGA16: radar on the " << described[cached]
<< "\n" << std::flush;
DEBUG_STREAM << "SVGA16: " << (camera_station ? "map" : "radar")
<< " on the " << described[cached]
<< " (" << variable << ")\n" << std::flush;
}
return (RadarPlacement) cached;
}
@@ -4036,22 +4060,44 @@ void
DisplayScalePercent("L4MFDSCALE_LL", group);
percent[SplitMFDLowerRight] =
DisplayScalePercent("L4MFDSCALE_LR", group);
percent[SplitMap] = DisplayScalePercent("L4RADARSCALE", 100);
//
// Sized by its own knob for the same reason it is placed by one: a
// camera's map and a pod's radar are the same pane doing different
// jobs, and the host switches between them from the menu without
// touching this file.
//
percent[SplitMap] = Application::IsCameraStation()
? DisplayScalePercent("L4MAPSCALE", 100)
: DisplayScalePercent("L4RADARSCALE", 100);
DEBUG_STREAM << "SVGA16: secondary displays at UL "
<< percent[SplitMFDUpperLeft] << "% UC "
<< percent[SplitMFDUpperCenter] << "% UR "
<< percent[SplitMFDUpperRight] << "% LL "
<< percent[SplitMFDLowerLeft] << "% LR "
<< percent[SplitMFDLowerRight] << "% radar "
<< percent[SplitMFDLowerRight] << "% "
<< (Application::IsCameraStation() ? "map " : "radar ")
<< percent[SplitMap] << "%\n" << std::flush;
}
//
// A Live Cam's map lies down: the pod's is 324x432 because the glass
// was mounted portrait in the cabinet, and a camera station has no
// cabinet, so the same pixels are shown the way they are drawn.
//
const Logical camera_station = Application::IsCameraStation();
GlassSize glass[SplitViewCount];
for (int view = 0; view < SplitViewCount; ++view)
{
int base_w = (view == SplitMap) ? cockpitRadarBaseW : cockpitMfdBaseW;
int base_h = (view == SplitMap) ? cockpitRadarBaseH : cockpitMfdBaseH;
if (view == SplitMap && camera_station)
{
int swap = base_w;
base_w = base_h;
base_h = swap;
}
glass[view].w = (base_w * percent[view]) / 100;
glass[view].h = (base_h * percent[view]) / 100;
}
@@ -4358,6 +4404,15 @@ SVGA16::SVGA16(
// Split-view mode: decide before BuildWindows so the packed gauge
// windows can stay hidden.
//------------------------------------------------------------------
//
// Before anything else: the constructor calls Update() below, and
// both of these are read in there. mDisplayToUpdate was only being
// set at the END of the constructor, so that first pass indexed the
// display arrays with whatever was on the stack.
//
mDisplayToUpdate = 0;
mDisplaysCopiedThisPass = 0;
splitViews = False;
cockpitViewscreen = NULL;
Logical explodedViews = False;
@@ -4640,31 +4695,53 @@ SVGA16::SVGA16(
GlassSize glass[SplitViewCount];
CockpitGlassSizes(scale_num, glass);
splitView[SplitMFDUpperLeft] = new MFDSplitView(
"MFD upper left", init_width, init_height,
glass[SplitMFDUpperLeft].w, glass[SplitMFDUpperLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x2F, 0, cockpit);
splitView[SplitMFDUpperCenter] = new MFDSplitView(
"MFD upper center", init_width, init_height,
glass[SplitMFDUpperCenter].w, glass[SplitMFDUpperCenter].h, 0, 0,
MFDSplitView::MFDStrips, 0x27, 0, cockpit);
splitView[SplitMFDUpperRight] = new MFDSplitView(
"MFD upper right", init_width, init_height,
glass[SplitMFDUpperRight].w, glass[SplitMFDUpperRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x37, 0, cockpit);
splitView[SplitMFDLowerLeft] = new MFDSplitView(
"MFD lower left", init_width, init_height,
glass[SplitMFDLowerLeft].w, glass[SplitMFDLowerLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x0F, 0, cockpit);
splitView[SplitMFDLowerRight] = new MFDSplitView(
"MFD lower right", init_width, init_height,
glass[SplitMFDLowerRight].w, glass[SplitMFDLowerRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x07, 0, cockpit);
// map is portrait: source rotated 90 degrees clockwise
splitView[SplitMap] = new MFDSplitView(
"Map", init_height, init_width,
glass[SplitMap].w, glass[SplitMap].h, 0, 0,
MFDSplitView::SideColumns, 0x10, 0x18, cockpit);
//---------------------------------------------------------------
// A Live Cam gets the map and nothing else. The five instrument
// MFDs belong to a pod: with no pod behind them their panes carry
// no image at all and composite as black rectangles over the
// viewscreen, which is what the arcade's camera cabinet avoided by
// running with -lc. LayoutCockpit and FillSplitMFD both already
// skip a NULL pane, so leaving them unbuilt is all it takes.
//---------------------------------------------------------------
if (!Application::IsCameraStation())
{
splitView[SplitMFDUpperLeft] = new MFDSplitView(
"MFD upper left", init_width, init_height,
glass[SplitMFDUpperLeft].w, glass[SplitMFDUpperLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x2F, 0, cockpit);
splitView[SplitMFDUpperCenter] = new MFDSplitView(
"MFD upper center", init_width, init_height,
glass[SplitMFDUpperCenter].w, glass[SplitMFDUpperCenter].h, 0, 0,
MFDSplitView::MFDStrips, 0x27, 0, cockpit);
splitView[SplitMFDUpperRight] = new MFDSplitView(
"MFD upper right", init_width, init_height,
glass[SplitMFDUpperRight].w, glass[SplitMFDUpperRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x37, 0, cockpit);
splitView[SplitMFDLowerLeft] = new MFDSplitView(
"MFD lower left", init_width, init_height,
glass[SplitMFDLowerLeft].w, glass[SplitMFDLowerLeft].h, 0, 0,
MFDSplitView::MFDStrips, 0x0F, 0, cockpit);
splitView[SplitMFDLowerRight] = new MFDSplitView(
"MFD lower right", init_width, init_height,
glass[SplitMFDLowerRight].w, glass[SplitMFDLowerRight].h, 0, 0,
MFDSplitView::MFDStrips, 0x07, 0, cockpit);
}
//
// The map's source canvas is landscape and the pod rotates it 90
// degrees clockwise to match its portrait mounting, which is why
// the pane is built with the source dimensions swapped. A camera
// shows it as drawn, so it is not swapped and not rotated (see the
// copy in ExecuteBackground).
//
splitView[SplitMap] = Application::IsCameraStation()
? new MFDSplitView(
"Map", init_width, init_height,
glass[SplitMap].w, glass[SplitMap].h, 0, 0,
MFDSplitView::SideColumns, 0x10, 0x18, cockpit)
: new MFDSplitView(
"Map", init_height, init_width,
glass[SplitMap].w, glass[SplitMap].h, 0, 0,
MFDSplitView::SideColumns, 0x10, 0x18, cockpit);
//---------------------------------------------------------------
// Everything above built the panes at a starting size; the
@@ -4679,9 +4756,25 @@ SVGA16::SVGA16(
GetClientRect(cockpit, &inner);
LayoutCockpit(inner.right, inner.bottom);
// catch maximise / restore / drag-resize and re-fit
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
//
// Catch maximise / restore / drag-resize and re-fit - ONCE.
//
// The destructor puts the original proc back, so ordinarily
// this window is unsubclassed by the time a second race
// builds a new cockpit. The guard is for the case where it
// was not: subclassing an already-subclassed window makes
// SetWindowLongPtr hand back CockpitShellProc itself as the
// "original", and the proc below then chains to itself on
// every single message until the stack runs out. That is a
// stack overflow a few frames into the second race, with no
// hint of a cause in the log.
//
if (gCockpitShellWindow != cockpit)
{
gCockpitBaseProc = (WNDPROC) SetWindowLongPtrA(
cockpit, GWLP_WNDPROC, (LONG_PTR) CockpitShellProc);
gCockpitShellWindow = cockpit;
}
//
// Sticky placement for the shell. Position AND size: nothing
@@ -4917,6 +5010,30 @@ SVGA16::~SVGA16()
cockpitViewscreen = NULL;
}
//
// Give the game window its own proc back, and stop answering for a
// cockpit that is about to stop existing.
//
// The window survives us - it is the one that shows the console
// screen between races - so both of these outlived their subject.
// The subclass was the worse of the two: the next race re-subclassed
// the same window and CockpitShellProc ended up chained to itself.
// activeCockpit was the quieter one, left pointing at this object
// after it was freed, ready for the next WM_SIZE to lay out a
// cockpit that had already gone.
//
if (gCockpitShellWindow != NULL)
{
SetWindowLongPtrA(
gCockpitShellWindow, GWLP_WNDPROC, (LONG_PTR) gCockpitBaseProc);
gCockpitShellWindow = NULL;
gCockpitBaseProc = NULL;
}
if (activeCockpit == this)
{
activeCockpit = NULL;
}
Check_Fpu();
}
@@ -4997,12 +5114,16 @@ Logical SVGA16::Update(Logical forceAll)
GaugeRenderer *renderer = application->GetGaugeRenderer();
if (!valid || renderer == NULL)
{
mDisplaysCopiedThisPass = 0;
CLEAR_SCREEN_COPY();
return False; // Do no more!
}
if (++mDisplayToUpdate >= NUMGAUGEWINDOWS)
// one display per call; the rotation steps at the end of the function
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0;
}
//Top MFD's
L4GraphicsPort *UL = static_cast<L4GraphicsPort*>(renderer->GetGraphicsPort("auxUL2"));
@@ -5045,7 +5166,29 @@ Logical SVGA16::Update(Logical forceAll)
lrMask |= (lrMask << 16);
} else
{
//No MFDs to draw, break out early
//No MFDs to draw, break out early. The sweep counter resets
//too: leaving it part-used would keep the renderer in its
//copy phase, and it never draws another gauge while there.
//
// The ROTATION has to step on the way out as well, and did
// not. It lives at the END of this function, so returning
// from here parked mDisplayToUpdate on the display we cannot
// service - and a station with no MFD ports can never
// service it, so the counter stopped dead the first time it
// landed there. Display 0's copy, which is the map, then
// never ran again: a Live Cam, whose cameraInit page
// configures the secondary port and nothing else, kept
// whatever the one early pass had put on the pane. A blank
// map, because nothing had registered with the renderer that
// early, beside a score frozen at its opening value - while
// the canvas underneath went on being drawn perfectly, which
// is what made this so hard to see.
mDisplayToUpdate++;
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0;
}
mDisplaysCopiedThisPass = 0;
return False;
}
} else
@@ -5057,7 +5200,14 @@ Logical SVGA16::Update(Logical forceAll)
secPalette = &((SVGA16 *) secPort->graphicsDisplay)->palette[secPort->paletteID];
} else
{
//No secondary, skip
//No secondary, skip - and end the sweep, as above, stepping
//past the display we cannot service for the same reason.
mDisplayToUpdate++;
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0;
}
mDisplaysCopiedThisPass = 0;
return False;
}
}
@@ -5072,23 +5222,83 @@ Logical SVGA16::Update(Logical forceAll)
{
if (splitView[SplitMap] != NULL)
{
// Map is portrait-mounted: rotate 90 degrees clockwise.
// dest(x,y) = source(row = srcH-1-x, col = y)
Word *source_base = pixelBuffer.Data.MapPointer;
unsigned long *dest = splitView[SplitMap]->Pixels();
int src_w = pixelBuffer.Data.Size.x;
int src_h = pixelBuffer.Data.Size.y;
for (int dy = 0; dy < src_w; ++dy)
//
// RP412CAMLOG. A camera's map showed content at mission
// start and then went black, which is three different
// faults wearing one symptom: the copy stopping, the
// SOURCE canvas going blank, or the pane not repainting.
// Counting non-zero source pixels as we pass tells them
// apart - a live canvas with a black pane is the third,
// a blank canvas is the second, and no line at all is the
// first. Every five seconds; the count is a sample of one
// row in sixteen, which is plenty to tell blank from not.
//
if (RPCameraLog())
{
for (int dx = 0; dx < src_h; ++dx)
static Scalar next_copy_say = 0.0f;
if ((Scalar) Now() >= next_copy_say)
{
Word pixel = source_base[(src_h - 1 - dx) * src_w + dy];
PaletteTriplet *entry =
&secPalette->paletteData.Color[pixel & secMask];
*dest++ = ((unsigned long) entry->Red << 16) |
((unsigned long) entry->Green << 8) |
((unsigned long) entry->Blue);
next_copy_say = ((Scalar) Now()) + 5.0f;
long lit = 0;
for (int sy = 0; sy < src_h; sy += 16)
{
Word *row = source_base + sy * src_w;
for (int sx = 0; sx < src_w; ++sx)
{
if (row[sx] != 0)
{
++lit;
}
}
}
DEBUG_STREAM << "CamLog: map copy running, source "
<< src_w << "x" << src_h << ", " << lit
<< " lit pixels sampled, mask 0x" << std::hex
<< secMask << std::dec << "\n" << std::flush;
}
}
if (Application::IsCameraStation())
{
//
// Live Cam: straight through, row by row. The pod's
// rotation below is about how the glass was bolted
// into the cabinet, and there is no cabinet here.
//
for (int dy = 0; dy < src_h; ++dy)
{
for (int dx = 0; dx < src_w; ++dx)
{
Word pixel = source_base[dy * src_w + dx];
PaletteTriplet *entry =
&secPalette->paletteData.Color[pixel & secMask];
*dest++ = ((unsigned long) entry->Red << 16) |
((unsigned long) entry->Green << 8) |
((unsigned long) entry->Blue);
}
}
}
else
{
// Map is portrait-mounted: rotate 90 degrees clockwise.
// dest(x,y) = source(row = srcH-1-x, col = y)
for (int dy = 0; dy < src_w; ++dy)
{
for (int dx = 0; dx < src_h; ++dx)
{
Word pixel = source_base[(src_h - 1 - dx) * src_w + dy];
PaletteTriplet *entry =
&secPalette->paletteData.Color[pixel & secMask];
*dest++ = ((unsigned long) entry->Red << 16) |
((unsigned long) entry->Green << 8) |
((unsigned long) entry->Blue);
}
}
}
splitView[SplitMap]->Repaint();
@@ -5227,7 +5437,80 @@ Logical SVGA16::Update(Logical forceAll)
// if (end.ticks - start.ticks > 100)
// end = start;
return False; // True == 'more to do'
//
//------------------------------------------------------------------
// Step the rotation, and say "more to do" until every display has
// had its turn.
//
// This used to return False unconditionally, which told the gauge
// renderer its copy phase was over after a SINGLE display. A full
// gauge sweep - one gauge per background pass, so as many passes as
// there are active gauges - therefore refreshed one display, and the
// map, one of three, came round only every third sweep.
//
// That is invisible with frame time to spare, because the background
// loop keeps running until the frame budget is used up and gets
// through several sweeps. On a big map the 3D foreground eats the
// whole budget, the loop drops to the one pass per frame it is
// guaranteed, and the map goes seconds between refreshes - which is
// what the field reports describe, on exactly those maps. 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, which is the tell that led here.
//------------------------------------------------------------------
//
mDisplayToUpdate++;
if (mDisplayToUpdate >= NUMGAUGEWINDOWS)
{
mDisplayToUpdate = 0;
}
if (++mDisplaysCopiedThisPass < NUMGAUGEWINDOWS)
{
return True; // call again - there are displays waiting
}
mDisplaysCopiedThisPass = 0;
//
// RP412GAUGEDIAG=1 reports how often the displays are actually being
// refreshed. Pixel-watching from outside cannot tell a display that
// is not refreshing from one whose picture simply is not changing,
// and that ambiguity is exactly what makes "my map froze" hard to
// pin down. This counts the real thing.
//
{
static int diagnostics = -1;
if (diagnostics < 0)
{
const char *setting = getenv("RP412GAUGEDIAG");
diagnostics = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diagnostics)
{
static unsigned long window_start = 0;
static int sweeps = 0;
unsigned long now = GetTickCount();
++sweeps;
if (window_start == 0)
{
window_start = now;
}
else if (now - window_start >= 2000)
{
// tenths, by hand: whole sweeps per second rounds the
// interesting cases - a starved pipeline managing two
// thirds of a sweep a second reads as a flat "0/s".
int tenths = sweeps * 10000 / (int)(now - window_start);
DEBUG_STREAM << "GaugeDiag: " << sweeps << " display sweep(s) in "
<< (now - window_start) << " ms ("
<< (tenths / 10) << '.' << (tenths % 10) << "/s, "
<< NUMGAUGEWINDOWS << " displays each)\n" << std::flush;
window_start = now;
sweeps = 0;
}
}
}
return False; // the sweep is complete
}
+11
View File
@@ -293,6 +293,17 @@ private:
int mDisplayToUpdate;
//------------------------------------------------------------------
// How many displays this copy pass has refreshed.
//
// The gauge renderer's copy phase ends the moment Update() reports it
// has finished, and Update() reported that after ONE display - so a
// whole gauge sweep refreshed a single display, and the map, one of
// three, came round only every third sweep. Counting them out means
// one sweep refreshes all of them.
//------------------------------------------------------------------
int mDisplaysCopiedThisPass;
//------------------------------------------------------------------
// Split-view mode (L4MFDSPLIT=1): the five channel-packed MFDs and
// the rotated map render as their own desktop windows; the packed
+346 -35
View File
@@ -17,6 +17,10 @@
#include "..\munga\nttmgr.h"
#include "..\munga\app.h"
#include "l4particles.h"
#include "l4padrio.h" // PadRIO::IsActive, for the per-frame lamp sweep
#include "..\munga\gaugrend.h"
#include "..\munga\lamp.h"
#include "..\munga\mode.h"
#include "DXUtils.h"
using namespace std;
@@ -27,10 +31,71 @@ using namespace std;
LPDIRECT3D9 gD3D = NULL;
//
//#############################################################################
// RPPresentationInterval
//#############################################################################
//
// Every device in this game is created vsync-locked, and on a machine with
// a spare 23 cores that is the most expensive line in the build.
//
// The game is one thread: simulation, 3D, gauge drawing and the display
// copies all take turns on it. The frame loop runs the foreground, then
// spends whatever is LEFT of the frame on the background gauge work. A
// Present that blocks until the panel's next retrace spends that remainder
// doing nothing at all - and the gauge loop, guaranteed only a single step
// per frame, gets exactly that single step. A pass over the gauge list
// needs about twenty, so the cockpit falls to two passes a second and every
// slow-tier instrument sits seconds behind.
//
// That is why lowering TARGETFPS "fixed" the instruments: it did not make
// anything faster, it just made the frame long enough that there was time
// left over after the wait.
//
// So this is a knob. 0 = IMMEDIATE, Present returns and the leftover frame
// time goes to the gauges where it belongs. Tearing is the cost, and on a
// pod cockpit whose instruments are the point, it is a cheap one.
//
DWORD
RPPresentationInterval()
{
static DWORD
interval = 0xFFFFFFFF;
if (interval == 0xFFFFFFFF)
{
const char
*setting = getenv("RP412VSYNC");
interval = (setting != NULL && atoi(setting) == 0)
? D3DPRESENT_INTERVAL_IMMEDIATE
: D3DPRESENT_INTERVAL_DEFAULT;
DEBUG_STREAM << "Video: presentation interval "
<< ((interval == D3DPRESENT_INTERVAL_IMMEDIATE)
? "IMMEDIATE (RP412VSYNC=0)" : "vsync")
<< "\n" << std::flush;
}
return interval;
}
// Single-window cockpit: viewscreen child window the scene presents into
// (NULL = present to the device window as always).
HWND gMainPresentWindow = NULL;
//
// Alt+W wireframe (RP412DEVKEYS). File scope rather than a DPLRenderer
// member because a fresh renderer is built per mission - a member would
// drop the toggle every time the race restarted, which is exactly when
// you are looking at geometry.
//
// DPLToggleWireframe only flips this; the fill mode is applied once per
// frame in ExecuteImplementation. Setting it there rather than in the key
// handler means it re-asserts itself after a device Reset, which reverts
// the fill mode to D3DFILL_SOLID underneath us.
//
static Logical gWireframe = 0;
//STUBBED: DPL RB 1/14/07
// when this is resolved it can be removed
#include "..\DPLSTUB.h"
@@ -1686,16 +1751,33 @@ DPLRenderer::DPLRenderer(
mPresentParams.hDeviceWindow = hWnd;
mPresentParams.Flags = 0;
mPresentParams.FullScreen_RefreshRateInHz = (fullscreen)?60:D3DPRESENT_RATE_DEFAULT;
mPresentParams.PresentationInterval = D3DPRESENT_RATE_DEFAULT;
mPresentParams.PresentationInterval = RPPresentationInterval();
mPresentParams.BackBufferFormat = D3DFMT_X8R8G8B8;
mPresentParams.EnableAutoDepthStencil = TRUE;
mPresentParams.AutoDepthStencilFormat = D3DFMT_D24X8;
mPresentParams.Windowed = !fullscreen;
if (fullscreen)
{
mPresentParams.BackBufferWidth = screenWidth;
mPresentParams.BackBufferHeight = screenHeight;
}
//
// The render size is asked for, not suggested - windowed as well as
// full-screen. Left at zero, D3D sizes the back buffer to the device
// window's client area AT THIS MOMENT, and everything downstream is
// built from the size we asked for instead: the projection matrix
// takes its aspect from it, and the reticle is centred on it. A
// window that is not exactly that size therefore renders at the
// wrong shape and gets rescaled on the way to the viewscreen pane.
//
// It only showed up on a SECOND race. A fresh renderer is built per
// mission while the window carries its cockpit placement across, so
// the first race creates its device against a still-bordered window
// - near enough the asked-for size to pass - and the next one
// against the borderless full-monitor client, which on a 3440x1440
// panel meant a 1.778 image drawn across a 2.389 target.
//
// -fit picks a render size to land on the viewscreen 1:1, so honour
// it: the back buffer is that size, and Present scales it to the
// pane in one uniform step.
//
mPresentParams.BackBufferWidth = screenWidth;
mPresentParams.BackBufferHeight = screenHeight;
HRESULT hr;
@@ -1725,6 +1807,15 @@ DPLRenderer::DPLRenderer(
//}
//DEBUG_STREAM<<"**************************"<<std::endl<<"**************************"<<std::endl<<std::flush;
//
// NULL before anything can leave this constructor early. It was not
// in the initialiser list, so until CreateDevice wrote it the member
// held whatever was on the stack - and the bail-out below it, plus
// the one that has always been here, both run the destructor and its
// SAFE_RELEASE(mDevice) over exactly that.
//
mDevice = NULL;
if (mPrimaryIndex == NULL)
{
DEBUG_STREAM<<"Unable to locate a suitable primary device index."<<std::endl<<std::flush;
@@ -1732,16 +1823,76 @@ DPLRenderer::DPLRenderer(
return;
}
V(gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice));
//
// RP412VERTEXPROC=hw asks the GPU to transform vertices instead of
// this thread.
//
// The device has always been created SOFTWARE_VERTEXPROCESSING - every
// vertex on the track transformed and lit on the CPU, on the one core
// this game uses for everything. That was not a choice when the engine
// was written; there was no hardware to hand it to. There is now, and
// the foreground is spending 17 ms of an 18 ms frame while the gauge
// loop starves on the 1 ms left over.
//
// On by default, and sw is the way back. Fixed-function T&L is not
// bit-identical between the old software path and a driver, so the
// escape hatch stays - but the picture was checked against both and
// the difference is not the one worth defending. A cockpit whose
// instruments update twice a second is.
//
DWORD vertex_processing = D3DCREATE_SOFTWARE_VERTEXPROCESSING;
{
const char *setting = getenv("RP412VERTEXPROC");
if (setting == NULL || (*setting != 's' && *setting != 'S'))
{
D3DCAPS9 caps;
if (SUCCEEDED(gD3D->GetDeviceCaps(*mPrimaryIndex, D3DDEVTYPE_HAL, &caps))
&& (caps.DevCaps & D3DDEVCAPS_HWTRANSFORMANDLIGHT) != 0)
{
vertex_processing = D3DCREATE_HARDWARE_VERTEXPROCESSING;
}
else
{
DEBUG_STREAM << "Video: adapter has no hardware T&L - "
<< "staying on software vertex processing\n" << std::flush;
}
}
DEBUG_STREAM << "Video: vertex processing "
<< ((vertex_processing == D3DCREATE_HARDWARE_VERTEXPROCESSING)
? "HARDWARE" : "software (RP412VERTEXPROC=sw)")
<< "\n" << std::flush;
}
V(gD3D->CreateDevice(*mPrimaryIndex, D3DDEVTYPE_HAL, hWnd, vertex_processing, &mPresentParams, &mDevice));
if (FAILED(hr))
{
DEBUG_STREAM<<"Couldn't create HARDWARE_VERTEXPROCESSING device."<<std::endl<<std::flush;
DEBUG_STREAM<<"Couldn't create the requested device - falling back to software vertex processing."<<std::endl<<std::flush;
V(gD3D->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hWnd, D3DCREATE_SOFTWARE_VERTEXPROCESSING, &mPresentParams, &mDevice));
if (FAILED(hr))
{
PostQuitMessage(1);
}
}
//
// PostQuitMessage is a message, not a return. The fallback used to
// post one and then carry straight on into the Clear below, which
// dereferenced a device that was never created - so a machine that
// could not give us the mode we asked for died on an access
// violation instead of saying so.
//
// What was asked for goes in the line, because that is the question
// this failure raises: the back buffer is the requested size now,
// windowed as well as full-screen, so a request the adapter will not
// meet is the thing to look at first.
//
if (FAILED(hr) || mDevice == NULL)
{
DEBUG_STREAM << "DPLRenderer: no D3D device for a "
<< mPresentParams.BackBufferWidth << "x"
<< mPresentParams.BackBufferHeight
<< (mPresentParams.Windowed ? " windowed" : " full-screen")
<< " back buffer (hr=0x" << std::hex << hr << std::dec
<< ") - giving up\n" << std::flush;
PostQuitMessage(1);
return;
}
mDevice->Clear(0, NULL, D3DCLEAR_TARGET, 0xFF000000, 0.0f, 0);
@@ -3555,6 +3706,16 @@ DPLRenderer::~DPLRenderer()
// the next race of the single-binary loop - drop them with the device
d3d_OBJECT::FlushTextureCache();
//
// The particle engine is one of those caches and was missed. Its
// vertex buffer is D3DPOOL_DEFAULT and its texture belongs to this
// device, so while they were held the release below never reached
// zero: every race left a whole live device behind it, and the next
// race's Initialize was the only thing that ever let one go. Drop
// them here and the device dies with the mission that made it.
//
ParticleEngine::Destroy();
SAFE_RELEASE(mDevice);
SAFE_RELEASE(gD3D);
//STUBBED: DPL RB 1/14/07
@@ -5983,11 +6144,84 @@ void
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Execute Method, performs the rendering of one frame
//
//
//===========================================================================
// RPSweepCockpitLamps
//
// Push the cockpit lamp STATE once per frame, instead of once per gauge
// cycle.
//
// The on-screen vRIO buttons light themselves from PadRIO::GetLampState,
// and they redraw with their MFD strip. What FILLS that store is
// LampManager::Update -> AssertNewLampValue -> SetLamp, and that rides
// the gauge renderer's FOREGROUND turn - which comes round only once per
// full gauge cycle. On a busy map the cycle takes the best part of a
// second, so the lit buttons froze and any flash stalled while the 3D
// view, a separate per-frame render, stayed perfectly smooth. BT411 saw
// the same thing on its glass surround and fixed it the same way.
//
// It is cheap: a sweep over the lamps, no raster, and AssertNewLampValue
// already drops anything that has not changed - so this pushes no extra
// traffic, it only stops changes arriving late.
//
// Only when a PadRIO is active, i.e. cockpit-less play. With real serial
// hardware selected the pod keeps its authentic bandwidth-paced cadence,
// untouched. RP412LAMPSWEEP=0 restores the once-per-cycle behaviour.
//===========================================================================
//
static void
RPSweepCockpitLamps()
{
static int
enabled = -1;
if (enabled < 0)
{
const char
*setting = getenv("RP412LAMPSWEEP");
enabled = (setting != NULL && setting[0] == '0') ? 0 : 1;
}
if (!enabled || !PadRIO::IsActive() || application == NULL)
{
return;
}
//
// Only while a mission is actually running. This is called from the
// top of the frame, ahead of the state switch below, so it would
// otherwise fire while the mission is still being built and the
// gauges do not exist yet.
//
if (application->GetApplicationState() != Application::RunningMission)
{
return;
}
GaugeRenderer
*renderer = application->GetGaugeRenderer();
ModeManager
*modes = application->GetModeManager();
if (renderer != NULL && modes != NULL)
{
LampManager
*lamps = renderer->GetLampManager();
if (lamps != NULL)
{
lamps->Update(modes->GetModeMask());
}
}
}
void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::InterestingEntityIterator* all_iterator)
{
Component *component;
HRESULT hr;
RPSweepCockpitLamps(); // keep the lit buttons tracking the sim (see above)
// timing variables
__int64 ticks = HiResNowTicks();
#ifdef LOGFRAMERATE
@@ -6177,10 +6411,31 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
DWORD currentFog;
mDevice->GetRenderState(D3DRS_FOGCOLOR, &currentFog);
hr = mDevice->Clear(0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER, currentFog, 1.0f, 0);
//
// The frame is normally cleared to the fog colour so the horizon has
// nothing to hide. Under Alt+W it clears to BLACK instead, and the sky
// pass is skipped entirely (below) - so what backs the wireframe is
// black rather than a lit dome, which is the whole point of asking for
// wireframe. Nothing on the solid path changes.
//
hr = mDevice->Clear(
0, NULL, D3DCLEAR_TARGET | D3DCLEAR_ZBUFFER,
gWireframe ? D3DCOLOR_XRGB(0, 0, 0) : currentFog,
1.0f, 0
);
hr = mDevice->BeginScene();
//
// Alt+W wireframe. Set every frame from the flag rather than once at
// toggle time - see gWireframe. It is turned back off for the 2D pass
// below; everything between here and there draws as edges.
//
mDevice->SetRenderState(
D3DRS_FILLMODE,
gWireframe ? D3DFILL_WIREFRAME : D3DFILL_SOLID
);
mDevice->SetFVF(L4VERTEX_FVF);
D3DXMATRIX viewTransform;
@@ -6257,19 +6512,29 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
mDevice->SetTransform(D3DTS_PROJECTION, &mProjectionMatrix);
if (!l4_application->IsDead())
//
// The sky is not drawn at all under Alt+W - the black clear above
// stands in for it. Drawing it solid lights the top of the screen and
// drowns the edges you turned wireframe on to look at; drawing it in
// wireframe is worse, filling the same area with the dome's own
// tessellation. Skipping it costs nothing: the dome only ever covers
// pixels the clear already owns.
//
if (!gWireframe)
{
std::list<d3d_OBJECT*>::const_iterator iter;
for (iter = this->mConsolidatedStaticObjects.begin(); iter != this->mConsolidatedStaticObjects.end(); ++iter)
if (!l4_application->IsDead())
{
(*iter)->Draw(PASS_SKY, &viewTransform, mTargetRenderTime);
}
}
std::list<d3d_OBJECT*>::const_iterator iter;
for (d3d_OBJECT *obj = mRenderLists[PASS_SKY]; obj != NULL; obj = obj->GetNext(PASS_SKY))
obj->Draw(PASS_SKY, &viewTransform, mTargetRenderTime);
for (iter = this->mConsolidatedStaticObjects.begin(); iter != this->mConsolidatedStaticObjects.end(); ++iter)
{
(*iter)->Draw(PASS_SKY, &viewTransform, mTargetRenderTime);
}
}
for (d3d_OBJECT *obj = mRenderLists[PASS_SKY]; obj != NULL; obj = obj->GetNext(PASS_SKY))
obj->Draw(PASS_SKY, &viewTransform, mTargetRenderTime);
}
//Reactivate fog
mDevice->SetRenderState(D3DRS_FOGSTART, *((DWORD*)(&currentFogNear)));
@@ -6314,6 +6579,12 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
// Wrap it up by doing the 2D pass
//
mDevice->SetFVF(L4VERTEX_2D_FVF);
//
// Always solid from here on: the gunsight and the cam-ship HUD are
// textured quads on this device, and in wireframe they come out as bare
// diagonals.
//
mDevice->SetRenderState(D3DRS_FILLMODE, D3DFILL_SOLID);
mDevice->SetRenderState(D3DRS_ZWRITEENABLE, true);
mDevice->SetTextureStageState(0, D3DTSS_COLOROP, D3DTOP_MODULATE);
mDevice->SetTextureStageState(0, D3DTSS_COLORARG1, D3DTA_DIFFUSE);
@@ -6339,6 +6610,48 @@ void DPLRenderer::ExecuteImplementation(RendererComplexity, RendererOrigin::Inte
hr = mDevice->Present(NULL, NULL, gMainPresentWindow, NULL);
//
// RP412GAUGEDIAG=1: frames per second, on the same 2-second window the
// display-sweep line uses so the two read side by side.
//
// Without this the gauge rate has to be argued about rather than
// measured. A sweep rate far below the frame rate means the gauges are
// STARVED - the 3D is fine and the background loop is not getting
// through its cycle. A sweep rate that tracks the frame rate means
// there is nothing wrong with the gauges at all and the frame itself
// is the problem. Those two want opposite fixes, and the sweep line
// alone cannot tell them apart.
//
{
static int diagnostics = -1;
if (diagnostics < 0)
{
const char *setting = getenv("RP412GAUGEDIAG");
diagnostics = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diagnostics)
{
static unsigned long window_start = 0;
static int frames = 0;
unsigned long now = GetTickCount();
++frames;
if (window_start == 0)
{
window_start = now;
}
else if (now - window_start >= 2000)
{
int tenths = frames * 10000 / (int)(now - window_start);
DEBUG_STREAM << "FrameDiag: " << frames << " frame(s) in "
<< (now - window_start) << " ms ("
<< (tenths / 10) << '.' << (tenths % 10) << "/s)\n"
<< std::flush;
window_start = now;
frames = 0;
}
}
}
// hand the whole target back
if (mPresentationAspect > 0.0f)
{
@@ -6555,19 +6868,17 @@ void
void
DPLRenderer::DPLToggleWireframe()
{
//STUBBBED: DPL RB 1/14/07
//static Logical wireframe_on = 0;
//
// The DPL original set a renderer property here
// (dpl_render_prop_wireframe) and was stubbed out with the rest of the
// DPL calls in the 2007 D3D port. D3D9 has no equivalent global: the
// fill mode is device state, so all this does is record the intent and
// let the frame apply it. See gWireframe at the top of this file.
//
gWireframe = !gWireframe;
//if ((wireframe_on ^= 1) != 0)
//{
// DEBUG_STREAM << "wireframe ON" << std::endl << std::flush;
// dpl_SetRenderProperty(dpl_render_prop_wireframe, dpl_render_value_on, NULL );
//}
//else
//{
// DEBUG_STREAM << "wireframe OFF" << std::endl << std::flush;
// dpl_SetRenderProperty(dpl_render_prop_wireframe, dpl_render_value_off, NULL );
//}
DEBUG_STREAM << "wireframe " << (gWireframe ? "ON" : "OFF")
<< std::endl << std::flush;
}
//
//#############################################################################
+8
View File
@@ -643,3 +643,11 @@ public:
};
extern LPDIRECT3D9 gD3D;
//
// The presentation interval every device is created with. RP412VSYNC=0
// makes it IMMEDIATE, so Present returns instead of waiting for the
// panel's retrace - see the definition in L4VIDEO.cpp for why that
// matters far more here than tearing does.
//
DWORD RPPresentationInterval();
+558 -54
View File
@@ -11,6 +11,16 @@
#include "l4app.h"
#include "..\RP\VTV.h"
//
// Where the eye was this frame, and a counter of frames the eye has been
// built for. Written by DPLEyeRenderable::Execute, read by
// RootRenderable::Execute, which draws afterwards in the same frame - the
// counter is what proves that rather than assuming it.
//
static D3DXVECTOR3 gEyeWorld(0.0f, 0.0f, 0.0f);
static unsigned gEyeFrame = 0;
static unsigned gLastSampledFrame = 0;
// RB 1/14/07
//#include <dpl\dpl.h>
//#include <dpl\dpl_2d.h>
@@ -1082,8 +1092,310 @@ void
// DPL_FLUSH_DCS ( myDCS );
// }
myRenderer->GetMatrixStack()->Push();
//
// The transform to DRAW with, blended across the fixed simulation
// step this frame falls inside - see Entity::GetRenderToWorld. This
// is the whole of render interpolation on the vehicle side: the
// renderable already ran once per frame and simply re-read a value
// that only changed at the physics rate.
//
LinearMatrix renderToWorld;
myEntity->GetRenderToWorld(&renderToWorld);
Matrix4x4 tempMatrix;
tempMatrix = myEntity->localToWorld;
tempMatrix = renderToWorld;
//
// RP412CAMLOG: what the tick actually looks like on screen.
//
// The complaint is a RHYTHMIC tick as a pod moves past the camera,
// and rhythm is the clue: a fixed period points at a cadence in our
// own code rather than at the network, which has no period. So
// measure the angle the pod subtends at the eye, frame by frame,
// and report the INTERVAL BETWEEN the lurches rather than merely
// counting them. That interval names the culprit - about 0.02s is
// the physics step, 0.03s the update rate, 0.4s the twenty-step
// quaternion renormalisation in Mover::BeginStep.
//
// Angle rather than distance because a pod crossing the view moves
// far across the screen while barely changing range, which is
// exactly the geometry the tick was reported in.
//
// Both samples are taken in one frame - the eye's, marked by
// gEyeFrame - because a pod and a camera read from different frames
// alias against each other no matter what the game is doing.
//
if (RPCameraLog() && gEyeFrame != gLastSampledFrame)
{
EntityID traced = MoverTracedEntity();
if (traced != EntityID::Null && traced == myEntity->GetEntityID())
{
//
// Scoped out here, not inside the range test below, because
// the foreign-eye check has to read them before that test
// runs.
//
static Vector3D last_dir;
static Logical have_last = False;
static Scalar next_say = 0.0f;
static Scalar mean_angle = 0.0f;
static Scalar last_event = 0.0f;
static Scalar interval_sum = 0.0f;
static Scalar interval_min = 1000.0f;
static Scalar interval_max = 0.0f;
static int interval_count = 0;
static int frames = 0;
static int lurches = 0;
static int stalls = 0;
static Scalar last_angle = 0.0f;
static Logical have_angle = False;
static Scalar fraction_sum = 0.0f;
static int pinned_high = 0;
static int pinned_low = 0;
static Point3D last_pod(0.0f, 0.0f, 0.0f);
static Point3D last_eye(0.0f, 0.0f, 0.0f);
static Logical have_pod = False;
static Scalar last_pod_step = 0.0f;
static Scalar last_eye_step = 0.0f;
static Scalar eye_travel = 0.0f;
static int pod_stalls = 0;
static int eye_stalls = 0;
static int foreign_eye = 0;
gLastSampledFrame = gEyeFrame;
Vector3D to_pod;
to_pod.x = renderToWorld(3,0) - gEyeWorld.x;
to_pod.y = renderToWorld(3,1) - gEyeWorld.y;
to_pod.z = renderToWorld(3,2) - gEyeWorld.z;
Scalar range = to_pod.Length();
//
// Reject a sample taken through a DIFFERENT eye.
//
// A camera station draws the map on the gauge wheel as well
// as the world, and that render runs the eye renderable too,
// so gEyeFrame ticks for it and this trace was comparing the
// map's viewpoint against the main one and calling the
// difference a stall. The tell was the period: 0.34s with
// its minimum and maximum identical to six figures, which is
// twenty frames exactly. Nothing in the network or the
// simulation keeps time that well - only a render schedule
// does. The stall and lurch counts converging to the same
// number said the same thing, since a stray viewpoint
// produces one short step going out and one long one coming
// back.
//
// A real camera at racing speed moves under a metre between
// frames, so twenty is far outside anything legitimate while
// still tolerating a genuine cut between trackside cameras.
//
Scalar eye_jump = 0.0f;
if (have_pod)
{
Scalar dx = gEyeWorld.x - last_eye.x;
Scalar dy = gEyeWorld.y - last_eye.y;
Scalar dz = gEyeWorld.z - last_eye.z;
eye_jump = (Scalar) sqrt((double)(dx*dx + dy*dy + dz*dz));
}
if (eye_jump > 20.0f)
{
++foreign_eye;
have_last = False; // do not bridge across it
have_angle = False;
have_pod = False;
}
else if (range > 0.1f)
{
Vector3D dir;
dir.Divide(to_pod, range);
++frames;
//
// Split the pod's motion from the camera's.
//
// The angle above is measured BETWEEN the two, so a
// hitch in the pan and a hitch in the pod are the same
// reading - and the symptom is specifically a pod moving
// past, which is when the pan rate peaks. Measuring each
// on its own says which one to go and fix.
//
// Same ratio test as the angle, and for the same reason:
// consecutive frames of smooth motion are near equal
// whatever the speed.
//
Point3D pod_now;
pod_now.x = renderToWorld(3,0);
pod_now.y = renderToWorld(3,1);
pod_now.z = renderToWorld(3,2);
if (have_pod)
{
Vector3D pod_moved;
pod_moved.Subtract(pod_now, last_pod);
Vector3D eye_moved;
eye_moved.x = gEyeWorld.x - last_eye.x;
eye_moved.y = gEyeWorld.y - last_eye.y;
eye_moved.z = gEyeWorld.z - last_eye.z;
Scalar pod_step = pod_moved.Length();
Scalar eye_step = eye_moved.Length();
if (last_pod_step > 0.0001f && pod_step < last_pod_step * 0.4f)
{
++pod_stalls;
}
if (last_eye_step > 0.0001f && eye_step < last_eye_step * 0.4f)
{
++eye_stalls;
}
last_pod_step = pod_step;
last_eye_step = eye_step;
eye_travel += eye_step;
}
last_pod = pod_now;
last_eye.x = gEyeWorld.x;
last_eye.y = gEyeWorld.y;
last_eye.z = gEyeWorld.z;
have_pod = True;
//
// How far through the physics step this frame is being
// drawn. This is the number the interpolation actually
// uses, so it is worth reading directly rather than
// inferring from the picture. It should sweep smoothly
// from 0 to 1 and wrap. Pinned at 1 means the
// simulation is behind and interpolation has degenerated
// to drawing the latest step over and over - which IS
// stepping, at the physics rate, however smooth the
// packets were.
//
Scalar fraction = myEntity->renderStepFraction;
fraction_sum += fraction;
if (fraction > 0.99f) { ++pinned_high; }
if (fraction < 0.01f) { ++pinned_low; }
if (have_last)
{
Scalar dot =
dir.x*last_dir.x + dir.y*last_dir.y + dir.z*last_dir.z;
if (dot > 1.0f) { dot = 1.0f; }
if (dot < -1.0f) { dot = -1.0f; }
Scalar angle = (Scalar) acos((double) dot);
//
// Compare each frame against the frame BEFORE it,
// not against a running mean. The angular step
// varies six hundredfold between a pod at 119m and
// the same pod at 5m, so a long mean cannot keep up
// during a pass and reports its own lag as a lurch -
// which is precisely what the previous version of
// this trace did, in the one window that mattered.
//
// Consecutive frames of a smooth pass are nearly
// equal however fast the sweep, so their ratio is
// immune to range. A tick is one frame that barely
// moves followed by one that catches up, so the
// stall is the event worth timing.
//
if (have_angle && last_angle > 0.00002f)
{
Scalar ratio = angle / last_angle;
if (ratio > 2.5f)
{
++lurches;
}
else if (ratio < 0.4f)
{
++stalls;
Scalar now = (Scalar) Now();
if (last_event > 0.0f)
{
Scalar gap = now - last_event;
interval_sum += gap;
++interval_count;
if (gap < interval_min) { interval_min = gap; }
if (gap > interval_max) { interval_max = gap; }
}
last_event = now;
}
}
last_angle = angle;
have_angle = True;
mean_angle = (mean_angle > 0.0f)
? (mean_angle * 0.95f + angle * 0.05f)
: angle;
}
last_dir = dir;
have_last = True;
if ((Scalar) Now() >= next_say)
{
if (next_say > 0.0f)
{
DEBUG_STREAM << "CamLog: on-screen motion - " << frames
<< " frames, " << stalls << " stall(s), "
<< lurches << " lurch(es), period "
<< ((interval_count > 0)
? (interval_sum / interval_count)
: 0.0f)
<< "s (" << interval_min << ".." << interval_max
<< "), mean step " << (mean_angle * 1000.0f)
<< " mrad, range " << range << "m\n" << std::flush;
DEBUG_STREAM << "CamLog: render fraction - mean "
<< ((frames > 0) ? (fraction_sum / frames) : 0.0f)
<< ", " << pinned_high << " pinned at 1, "
<< pinned_low << " at 0, of " << frames
<< " frames ("
<< ((pinned_high * 4 > frames)
? "simulation is behind, interpolation degenerate"
: "sweeping normally")
<< ")\n" << std::flush;
DEBUG_STREAM << "CamLog: motion split - pod "
<< pod_stalls << " stall(s), eye "
<< eye_stalls << " stall(s), " << foreign_eye
<< " foreign-eye sample(s) rejected, eye travelled "
<< eye_travel << "m, verdict "
<< ((pod_stalls > eye_stalls * 2)
? "the pod"
: ((eye_stalls > pod_stalls * 2)
? "the camera"
: "both, or neither"))
<< "\n" << std::flush;
}
next_say = ((Scalar) Now()) + 5.0f;
frames = 0;
lurches = 0;
stalls = 0;
interval_sum = 0.0f;
interval_min = 1000.0f;
interval_max = 0.0f;
interval_count = 0;
fraction_sum = 0.0f;
pinned_high = 0;
pinned_low = 0;
pod_stalls = 0;
eye_stalls = 0;
eye_travel = 0.0f;
foreign_eye = 0;
}
}
}
}
myRenderer->GetMatrixStack()->MultMatrix(&tempMatrix.ToD3DMatrix());
//myLocalToWorld = *myRenderer->GetMatrixStack()->GetTop();
@@ -2364,7 +2676,76 @@ ReticleRenderable::ReticleRenderable(
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
device->CreateVertexBuffer(sizeof(L4VERTEX_2D) * 8, D3DUSAGE_WRITEONLY, L4VERTEX_2D_FVF, D3DPOOL_MANAGED, &mVB, NULL);
device->CreateVertexBuffer(sizeof(L4VERTEX_2D) * crosshairVertexCount, D3DUSAGE_WRITEONLY, L4VERTEX_2D_FVF, D3DPOOL_MANAGED, &mVB, NULL);
//
// Nothing is written here. The crosshair is measured against the
// render target it will actually be drawn into, and that is not
// known to be the renderer's requested size - see RebuildCrosshair.
//
mBuiltWidth = 0.0f;
mBuiltHeight = 0.0f;
mBuiltOriginX = -1.0f;
mBuiltOriginY = -1.0f;
RebuildCrosshair();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Centre the crosshair on the target it is about to be drawn into.
//
// It used to be baked once, in the constructor, from the renderer's
// GetWidth()/GetHeight() - the size the renderer ASKED for. A windowed
// device does not necessarily get it: BackBufferWidth/Height are only
// filled in for fullscreen (L4VIDEO.cpp), so windowed the back buffer is
// whatever the device window's client area happened to be when the
// device was created. A fresh renderer is built per mission while the
// window carries its restored cockpit placement across, so the two agree
// on the first race and can disagree on the next one - which put the
// crosshair off-centre by half the difference, and only ever on a second
// game.
//
// Measuring the viewport at draw time settles it for every case at once:
// the windowed mismatch, a device reset, and the podium's pillarbox crop.
//
void ReticleRenderable::RebuildCrosshair()
{
Check(this);
if (mVB == NULL)
{
return;
}
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
if (device == NULL)
{
return;
}
D3DVIEWPORT9 viewport;
if (FAILED(device->GetViewport(&viewport)) ||
viewport.Width == 0 || viewport.Height == 0)
{
return;
}
float width = (float) viewport.Width;
float height = (float) viewport.Height;
//
// Origin included: a viewport that moves without resizing still
// carries the crosshair with it.
//
float origin_x = (float) viewport.X;
float origin_y = (float) viewport.Y;
if (width == mBuiltWidth && height == mBuiltHeight &&
origin_x == mBuiltOriginX && origin_y == mBuiltOriginY)
{
return;
}
mBuiltWidth = width;
mBuiltHeight = height;
mBuiltOriginX = origin_x;
mBuiltOriginY = origin_y;
L4VERTEX_2D *verts;
mVB->Lock(0, 0, (void**)&verts, 0);
@@ -2372,62 +2753,125 @@ ReticleRenderable::ReticleRenderable(
DWORD color = D3DCOLOR_XRGB(0, 128, 0);
float segmentLen = 5.0f / 192.0f;
float spread = 5.0f / 256.0f;
float width = myRenderer->GetWidth();
float height = myRenderer->GetHeight();
float centerX = width / 2.0f;
float centerY = height / 2.0f;
//
// Pre-transformed vertices are absolute screen pixels - the viewport
// clips them but does not shift them - so its origin is carried here.
//
float centerX = (float) viewport.X + width / 2.0f;
float centerY = (float) viewport.Y + height / 2.0f;
// top segment
verts[0].x = centerX;
verts[0].y = centerY - (spread + segmentLen) * height;
verts[0].z = 0.0f;
verts[0].rhw = 1.0f;
verts[0].color = color;
//
// Land on the pixel CENTRE, not the corner, so the quads below span
// whole pixels: an arm one pixel wide about a centre of x.5 runs from
// x.0 to x+1.0 and covers exactly one column.
//
centerX = (float)(int) centerX + 0.5f;
centerY = (float)(int) centerY + 0.5f;
verts[1].x = centerX;
verts[1].y = centerY - spread * height;
verts[1].z = 0.0f;
verts[1].rhw = 1.0f;
verts[1].color = color;
//
// Worth a line in the log: it names the target, the renderer's
// requested size and where the crosshair actually landed, so a
// report of it being off says which of the three moved.
//
DEBUG_STREAM << "Reticle: centred at " << centerX << "," << centerY
<< " on the " << viewport.Width << "x" << viewport.Height
<< " viewport at " << viewport.X << "," << viewport.Y
<< " (renderer asked for " << myRenderer->GetWidth() << "x"
<< myRenderer->GetHeight() << ")";
if (viewport.Width != myRenderer->GetWidth() ||
viewport.Height != myRenderer->GetHeight())
{
//
// Not the crosshair's problem alone: the projection matrix is
// built from the requested size too, so a target this does not
// match is being rendered at the wrong aspect and rescaled on
// the way to the pane.
//
DEBUG_STREAM << " - TARGET DISAGREES, aspect "
<< ((float) viewport.Width / (float) viewport.Height)
<< " drawn as "
<< ((float) myRenderer->GetWidth() / (float) myRenderer->GetHeight());
}
DEBUG_STREAM << "\n" << std::flush;
// right segment
verts[2].x = centerX + (spread + segmentLen) * height;
verts[2].y = centerY;
verts[2].z = 0.0f;
verts[2].rhw = 1.0f;
verts[2].color = color;
//
// Each arm is a quad one pixel thick rather than a line. D3D9 line
// rasterisation follows the diamond-exit rule and is free to differ
// between drivers on a segment that runs along a pixel boundary,
// which is how a crosshair loses one pair of arms and keeps the
// other. Triangles have a fill rule that does not vary, so an arm
// spanning whole pixels lands the same way everywhere - and on a
// full-screen device, where both viewport dimensions are usually
// even and BOTH pairs sit on boundaries, that is the difference
// between a crosshair and nothing at all.
//
float inner = spread * height;
float outer = (spread + segmentLen) * height;
verts[3].x = centerX + spread * height;
verts[3].y = centerY;
verts[3].z = 0.0f;
verts[3].rhw = 1.0f;
verts[3].color = color;
//
// Thickness follows the target the way the arms' length does, rather
// than being one pixel whatever the resolution.
//
// One pixel is a width the PRESENTATION can lose. The scene goes to
// the viewscreen pane through a stretch, and when the back buffer is
// wider than the pane that stretch samples straight past a feature a
// single pixel across. A second race rendering 3440 wide into the
// 2553-wide pane is 0.74 across and 1.00 down, which took the
// vertical arms and left the horizontal ones standing - the crosshair
// was being drawn correctly and thrown away on the way to the glass.
//
// Scaling it also keeps the pod's proportions: one pixel at the 480
// lines this was drawn for is three at 1440, not a hairline.
//
float thickness = (float)(int)(height / 480.0f);
if (thickness < 1.0f)
{
thickness = 1.0f;
}
float half = thickness * 0.5f;
// bottom segment
verts[4].x = centerX;
verts[4].y = centerY + (spread + segmentLen) * height;
verts[4].z = 0.0f;
verts[4].rhw = 1.0f;
verts[4].color = color;
struct Arm
{
float x0, y0, x1, y1; // opposite corners, in pixels
};
const Arm arms[4] =
{
// top
{ centerX - half, centerY - outer, centerX + half, centerY - inner },
// right
{ centerX + inner, centerY - half, centerX + outer, centerY + half },
// bottom
{ centerX - half, centerY + inner, centerX + half, centerY + outer },
// left
{ centerX - outer, centerY - half, centerX - inner, centerY + half }
};
verts[5].x = centerX;
verts[5].y = centerY + spread * height;
verts[5].z = 0.0f;
verts[5].rhw = 1.0f;
verts[5].color = color;
int v = 0;
for (int a = 0; a < 4; ++a)
{
const Arm &arm = arms[a];
//
// Two triangles, corners in the order top-left, top-right,
// bottom-left / top-right, bottom-right, bottom-left. Culling is
// turned off for the draw, so the winding does not have to agree
// with the renderer's global cull mode.
//
const float quad_x[6] =
{ arm.x0, arm.x1, arm.x0, arm.x1, arm.x1, arm.x0 };
const float quad_y[6] =
{ arm.y0, arm.y0, arm.y1, arm.y0, arm.y1, arm.y1 };
// left segment
verts[6].x = centerX - (spread + segmentLen) * height;
verts[6].y = centerY;
verts[6].z = 0.0f;
verts[6].rhw = 1.0f;
verts[6].color = color;
verts[7].x = centerX - spread * height;
verts[7].y = centerY;
verts[7].z = 0.0f;
verts[7].rhw = 1.0f;
verts[7].color = color;
for (int c = 0; c < 6; ++c)
{
verts[v].x = quad_x[c];
verts[v].y = quad_y[c];
verts[v].z = 0.0f;
verts[v].rhw = 1.0f;
verts[v].color = color;
++v;
}
}
Verify(v == crosshairVertexCount);
mVB->Unlock();
}
@@ -2497,9 +2941,26 @@ void ReticleRenderable::Render(int pass, const D3DXMATRIX *viewTransform)
{
LPDIRECT3DDEVICE9 device = myRenderer->GetDevice();
//
// The target is whatever is bound right now, so ask it now. A
// compare against the size already built means this costs one
// GetViewport a frame and rewrites nothing until it moves.
//
RebuildCrosshair();
//
// The arms are quads, and which way round they wind is not worth
// making the renderer's global cull mode responsible for.
//
DWORD cull_mode;
device->GetRenderState(D3DRS_CULLMODE, &cull_mode);
device->SetRenderState(D3DRS_CULLMODE, D3DCULL_NONE);
device->SetTexture(0, NULL);
device->SetStreamSource(0, mVB, 0, sizeof(L4VERTEX_2D));
device->DrawPrimitive(D3DPT_LINELIST, 0, 4);
device->DrawPrimitive(D3DPT_TRIANGLELIST, 0, 8);
device->SetRenderState(D3DRS_CULLMODE, cull_mode);
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -2656,6 +3117,7 @@ CameraShipHUDRenderable::CameraShipHUDRenderable(Entity *entity, ExecutionType e
//
playerRank = NULL;
oldPlayerRank = NULL;
rankCount = 0;
nameDCS = NULL;
rankDCS = NULL;
nameInstance = NULL;
@@ -2699,6 +3161,18 @@ CameraShipHUDRenderable::CameraShipHUDRenderable(Entity *entity, ExecutionType e
Verify(camera_player_count <= MAX_PLAYER_NAMES);
playerRank = new (int (*[camera_player_count]));
Register_Pointer(playerRank);
//
// Cleared, because the fill below is by bitmap index and leaves
// gaps: with a Live Cam host the racing players start at index 2
// and nothing ever claims slot 0. new[] does not zero, so those
// gaps were uninitialised heap read as int* - the access
// violation in Execute.
//
rankCount = camera_player_count;
for (int slot = 0; slot < rankCount; ++slot)
{
playerRank[slot] = NULL;
}
Player *active_player;
if(player_group)
{
@@ -2946,8 +3420,14 @@ void CameraShipHUDRenderable::Execute()
// whenever they change!
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
for(int ii=0; ii<playerCount; ++ii)
// Over the whole array, not playerCount, and skipping the slots no
// bitmap index claimed - see rankCount in the header.
for(int ii=0; ii<rankCount; ++ii)
{
if (playerRank[ii] == NULL)
{
continue;
}
if (oldPlayerRank[ii] != (*playerRank[ii]))
{
oldPlayerRank[ii] = *playerRank[ii];
@@ -5483,7 +5963,17 @@ void
// time based upon the setting of the eyepoint rotation
//----------------------------------------------------------------------
//
if((myEyepointRotation && *myEyepointRotation != oldEyepointRotation) || oldLocalToWorld != myEntity->localToWorld || mForceUpdate)
//
// The last term is render interpolation. This gate rebuilt the view
// only when the entity's localToWorld CHANGED, which happens at the
// physics rate - so with the world now drawn on a blended transform the
// eye would have gone on stepping, and the judder would simply have
// moved from the scenery to the camera. While a blend is in progress
// the view has to be rebuilt every frame; renderStepFraction is zero
// whenever interpolation is off or idle, so the old behaviour is
// untouched. See Entity::GetRenderToWorld.
//
if((myEyepointRotation && *myEyepointRotation != oldEyepointRotation) || oldLocalToWorld != myEntity->localToWorld || mForceUpdate || myEntity->renderStepTaken)
{
mForceUpdate = false;
oldLocalToWorld = myEntity->localToWorld;
@@ -5507,6 +5997,20 @@ void
mat = mat4;
D3DXVECTOR3 pos(mat(3,0), mat(3, 1), mat(3,2));
//
// Where the eye ended up this frame, for the on-screen motion
// trace in RootRenderable::Execute. The eye is built once per
// frame and before the world is drawn, so it also serves as the
// frame marker that keeps the two samples in the same frame -
// which is the whole point, since sampling a pod and a camera
// from different frames is what made three earlier attempts at
// this question meaningless.
//
gEyeWorld.x = mat(3,0);
gEyeWorld.y = mat(3,1);
gEyeWorld.z = mat(3,2);
gEyeFrame++;
mat(3,0) = 0;
mat(3,1) = 0;
mat(3,2) = 0;
+26
View File
@@ -783,6 +783,19 @@ class ReticleRenderable :
void Render(int pass, const D3DXMATRIX *viewTransform);
protected:
//
// The crosshair is written as pre-transformed vertices - screen
// PIXELS - so it is only centred for the target it was measured
// against. Re-measure before drawing and rebuild when that
// target changes, rather than baking it once at construction.
//
void RebuildCrosshair();
// four arms, two triangles each
enum { crosshairVertexCount = 24 };
// viewport the vertex buffer currently describes
float mBuiltWidth, mBuiltHeight, mBuiltOriginX, mBuiltOriginY;
// Last known position of the reticle
Vector2DOf<float> myOldReticlePosition;
@@ -833,6 +846,19 @@ class CameraShipHUDRenderable :
int
playerCount;
//
// How long playerRank/oldPlayerRank actually are. NOT playerCount:
// the arrays are keyed by playerBitmapIndex, which a CameraShip
// player takes a slot in too, so they are sized for the racing
// players plus the camera players and are SPARSE - a slot whose
// bitmap index nobody claimed stays NULL. Walking them to
// playerCount instead read past the last claimed slot, which is
// how a Live Cam host (bitmap index 1, and not in "Players") took
// the whole game down.
//
int
rankCount;
int
oldFollowedPlayerIndex,
*followedPlayerIndex;
+5
View File
@@ -151,6 +151,7 @@
<ClCompile Include="..\MUNGA\INTEREST.cpp" />
<ClCompile Include="..\MUNGA\INTORGN.cpp" />
<ClCompile Include="..\MUNGA\ITERATOR.cpp" />
<ClCompile Include="..\MUNGA\INPUTSCRIPT.cpp" />
<ClCompile Include="..\MUNGA\JMOVER.cpp" />
<ClCompile Include="..\MUNGA\JOINT.cpp" />
<ClCompile Include="..\MUNGA\LAMP.cpp" />
@@ -227,6 +228,7 @@
<ClCompile Include="..\MUNGA\WRHOUS.cpp" />
<ClCompile Include=".\DXUtils.cpp" />
<ClCompile Include=".\L4APP.cpp" />
<ClCompile Include=".\L4AUDEFX.cpp" />
<ClCompile Include=".\L4AUDHDW.cpp" />
<ClCompile Include=".\L4AUDIO.cpp" />
<ClCompile Include=".\L4AUDLVL.cpp" />
@@ -263,6 +265,7 @@
<ConformanceMode>true</ConformanceMode>
</ClCompile>
<ClCompile Include=".\L4PADBINDINGS.cpp" />
<ClCompile Include=".\L4JOY.cpp" />
<ClCompile Include=".\L4PADRIO.cpp" />
<ClCompile Include=".\L4PCSPAK.cpp" />
<ClCompile Include=".\L4PLASMA.cpp" />
@@ -431,6 +434,7 @@
<ClInclude Include="..\MUNGA\WRHOUS.h" />
<ClInclude Include=".\DXUtils.h" />
<ClInclude Include=".\L4APP.H" />
<ClInclude Include=".\L4AUDEFX.h" />
<ClInclude Include=".\L4AUDHDW.h" />
<ClInclude Include=".\L4AUDIO.h" />
<ClInclude Include=".\L4AUDLVL.h" />
@@ -454,6 +458,7 @@
<ClInclude Include=".\L4NETTRANSPORT.h" />
<ClInclude Include=".\L4KEYLIGHT.h" />
<ClInclude Include=".\L4PADBINDINGS.h" />
<ClInclude Include=".\L4JOY.h" />
<ClInclude Include=".\L4STEAMTRANSPORT.h" />
<ClInclude Include=".\L4PARTICLES.h" />
<ClInclude Include=".\L4MFDVIEW.h" />
+6
View File
@@ -486,6 +486,9 @@
<ClCompile Include=".\L4AUDHDW.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
<ClCompile Include=".\L4AUDEFX.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
<ClCompile Include=".\L4AUDIO.cpp">
<Filter>Source Files\MUNGA_L4</Filter>
</ClCompile>
@@ -1058,6 +1061,9 @@
<ClInclude Include=".\L4APP.H">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
<ClInclude Include=".\L4AUDEFX.h">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
<ClInclude Include=".\L4AUDHDW.h">
<Filter>Header Files\MUNGA_L4</Filter>
</ClInclude>
+7
View File
@@ -32,6 +32,13 @@ SAMPLEINFO PRESET_getSampleInfo(int bank, int preset, int sampleInd)
default.file = "";
default.implemented = false;
default.loop = SampleLoop::LoopAtWill;
//
// -1 = no buffer. Every caller tests bufferIndex >= 0 before using it as
// an index, and this one field was being left as whatever was on the
// stack - so "this zone does not exist" read as a real buffer whenever
// the garbage happened to be positive, and indexed g_buffers with it.
//
default.bufferIndex = -1;
if (sampleInd < 0 || sampleInd >= allPresets[bank-1][preset].sampleNum)
{
+19 -3
View File
@@ -75,6 +75,18 @@ Winners Circle camera is framed off the award stand itself rather than
off whoever is standing on it, so the shot is the same one for every
player at every head count.
[v4.12.7](https://gitea.mysticmachines.com/VWE/RP412/releases/tag/v4.12.7)
is about sticks. Anything that is not an Xbox-class pad — a flight stick,
a HOTAS throttle, a twist grip, rudder pedals, a wheel — comes in through
DirectInput rather than XInput, and the game could not see any of it.
Now it can, and `joyconfig.bat` sets it up: the wizard asks you to move
each control in turn, works out which device and axis answered and which
way round it reads, and writes the joystick rows of `bindings.txt`,
leaving anything you have edited yourself alone. A twist grip or rudder
bar drives both pedals through one signed `Pedals` axis, and a real
throttle lever owns its channel outright rather than nudging a position
the way a spring-centred stick has to. Ported from the sibling BT411.
## Playing
Grab the release zip (or run `pack-dist.ps1` on a build). Single player:
@@ -84,9 +96,13 @@ team/position columns and its own track list). Steam multiplayer: see
[docs/STEAM-3-MACHINE-TEST.md](docs/STEAM-3-MACHINE-TEST.md) (until RP412
has its own AppID it runs under Spacewar, 480).
The two config files beside the exe are self-documenting: **environ.ini**
(every engine option, commented) and **bindings.txt** (every key, pad
button, and axis; written with the full default layout on first run).
The config files beside the exe are self-documenting and none of them
ship: the game writes each one the first time it needs it and then leaves
it alone, so a new build dropped over an existing folder keeps every
setting. **environ.ini** is every engine option, commented; **bindings.txt**
every key, pad button and axis; **pilot.cfg** your callsign and loadout;
**mfd_layout.cfg** where you dragged the windows. Delete any of them to
start that part over with the current defaults.
Default controls: numpad flies (8/2/4/6 stick, 7/9 pedals, 0 trigger),
Shift/Ctrl throttle, Alt reverse, arrows look, Space fires, letter rows
are the MFD button banks as printed on the panel. **Alt+Q** aborts a
+16
View File
@@ -147,8 +147,17 @@ void
{
Check(this);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: CreateRPCameraShip - making the camera ship\n"
<< std::flush;
}
CameraDirector::CreateCameraShip(time_slice);
SetPerformance(&RPCameraDirector::BeARPDirector);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: camera ship up, now directing\n" << std::flush;
}
}
//#############################################################################
@@ -172,6 +181,13 @@ RPCameraDirector::RPCameraDirector(
{
SetPerformance(&RPCameraDirector::CreateRPCameraShip);
}
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: RPCameraDirector built, football="
<< (int) (martianFootball ? 1 : 0)
<< " replicant=" << (int) (GetInstance() == ReplicantInstance ? 1 : 0)
<< "\n" << std::flush;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+158 -2
View File
@@ -19,7 +19,40 @@
// fade; the rest is the podium. Kept under the +30s LightsOut post so that
// never fires while the stand is up.
//
const Scalar winnersCircleHoldTime = 11.0f;
// RP412PODIUMHOLD tunes it, and RP412PODIUM=0 declines it entirely: that
// option promises "straight to the results", and it used to get the full
// eleven seconds against a black screen anyway, because this timer never
// asked whether there was a podium to hold the mission open FOR. Zero
// here means "do not override the base fade" - the stock 3 seconds.
//
static Scalar
WinnersCircleHoldTime()
{
static Scalar
hold = (Scalar) -1;
if (hold < (Scalar) 0)
{
const char *podium = getenv("RP412PODIUM");
if (podium != NULL && atoi(podium) == 0)
{
hold = (Scalar) 0;
}
else
{
const char *setting = getenv("RP412PODIUMHOLD");
hold = (setting != NULL) ? (Scalar) atof(setting) : (Scalar) 11;
//
// Under a second cuts into the race's own fade-out, and a
// minute is a stuck-looking screen; both read as bugs, not
// choices.
//
if (hold < (Scalar) 1) hold = (Scalar) 1;
if (hold > (Scalar) 60) hold = (Scalar) 60;
}
}
return hold;
}
//#############################################################################
//######################## RPPlayer__StatusMessage ######################
@@ -214,7 +247,19 @@ void
//
if (application->GetApplicationState() == Application::EndingMission)
{
fadeTimeRemaining = winnersCircleHoldTime;
Scalar hold = WinnersCircleHoldTime();
if (hold > (Scalar) 0)
{
fadeTimeRemaining = hold;
DEBUG_STREAM << "WinnersCircle: holding the mission open "
<< hold << "s for the stand\n" << std::flush;
}
else
{
DEBUG_STREAM << "WinnersCircle: podium off - the race fade "
<< "stands (" << fadeTimeRemaining << "s) and the results "
<< "come straight up\n" << std::flush;
}
}
Check_Fpu();
}
@@ -382,6 +427,96 @@ void RPPlayer::ResetAfterDeath(DropZone::ReplyMessage *message)
ForceUpdate();
SetSimulationState(DropZoneAcquiredState);
dropZoneLocation = message->dropZoneLocation;
//
//------------------------------------------------------------------
// Fixed-step: the RECOVERY goes on the vehicle's own step grid.
//
// The event queue runs on wall clock, so a Reset fired from it
// lands between different sim steps on every run - the crash was
// measured bit-identical between runs and the first divergence was
// the step after the pod stood back up. The vehicle applies the
// teleport itself at the first step one SIM second after now; the
// message still makes its round trip below, but only for the
// player-state bookkeeping - the handler leaves the physics to the
// schedule it can see is pending.
//------------------------------------------------------------------
//
if (Simulation::FixedStep() > (Scalar) 0 &&
playerVehicle != NULL && playerVehicle->GetClassID() == VTVClassID)
{
VTV *vtv = (VTV *) playerVehicle;
//
// Anchored to the DEATH and quantized to a half-second grid.
//
// This handler runs when the drop-zone reply finally comes off
// the event queue, and the whole death-to-here chain is wall
// clock - the fry retries repost at Now()+2.0, and the measured
// arrival is about five sim-seconds after death, give or take a
// few STEPS of queue jitter. An anchor of death+1.0 is long past
// by then, so "fire at the next step" inherited the jitter
// whole.
//
// The death stamp is the last step-exact event in the chain, so:
// take the measured gap, add the second the old code waited, and
// round UP to the next half-second AFTER THE DEATH. The jitter
// is hundredths; the nearest grid boundary is tenths away; every
// run lands in the same cell and fires on the same step. The
// felt delay is the same six-ish seconds it has always been.
//
Time due;
Time death_mark;
if (vtv->ConsumeDeathClock(&death_mark))
{
Scalar gap = vtv->GetLastPerformance() - death_mark;
const Scalar quantum = (Scalar) 0.5;
int cells = (int)((gap + (Scalar) 1.0) / quantum) + 1;
due = death_mark;
due += quantum * (Scalar) cells;
}
else
{
due = vtv->GetLastPerformance();
due += 1.0f;
}
vtv->ScheduleRespawn(
message->dropZoneLocation, due,
(goalEntity != NULL)
? goalEntity->localOrigin.linearPosition
: Point3D(0.0f, 0.0f, 0.0f),
(goalEntity != NULL) ? True : False);
respawnScheduled = True;
//
// Under the trace, say what was scheduled in run-comparable
// terms: the pad (identity by position), and how far ahead of
// the vehicle's clock the due time sits. Two runs that disagree
// here diverge before the physics gets a vote.
//
{
static int diag = -1;
if (diag < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diag)
{
char buffer[160];
sprintf(buffer,
"PhysTrace: respawn #%d scheduled, pad %.2f,%.2f "
"due in %.4f sim-s\n",
deathCount,
(double) message->dropZoneLocation.linearPosition.x,
(double) message->dropZoneLocation.linearPosition.z,
(double)(Scalar)(due - vtv->GetLastPerformance()));
DEBUG_STREAM << buffer << std::flush;
}
}
}
Time when = Now();
when += 1.0f;
application->Post(HighEventPriority, this, message, when);
@@ -422,6 +557,7 @@ void
}
AlwaysExecute();
deathCount = 0;
respawnScheduled = False;
}
//
@@ -467,6 +603,26 @@ void
{
VTV *vtv = (VTV*)playerVehicle;
Check(vtv);
//
// A SCHEDULED respawn means the vehicle already holds - or has
// already applied - its teleport and goal-flip, on its own step
// grid. Resetting it AGAIN here, at whatever wall instant this
// message came off the queue, would re-teleport it mid-step and
// put the nondeterminism straight back.
//
// The flag, not "is fixed stepping on": this same leg also runs
// for the FIRST spawn of the mission, where the Reset below is
// what wakes a Mover out of its initial stasis. Gating on the
// mode alone skipped that wake-up and parked the pod, frozen at
// exactly its spawn point, for an entire race.
//
if (respawnScheduled)
{
respawnScheduled = False;
Check_Fpu();
return;
}
vtv->Reset(message->dropZoneLocation, VTV::RegularReset);
}
+12
View File
@@ -291,6 +291,18 @@ public:
Entity
*goalEntity;
//
// True between ResetAfterDeath handing the recovery to the vehicle's
// step grid (fixed-step only) and the bookkeeping message coming back
// round. The message handler must NOT Reset the vehicle again in that
// window - but it MUST still Reset on the first spawn of the mission,
// which is what wakes a Mover out of its initial stasis. Gating on
// "is fixed stepping on" instead of on this flag skipped that wake-up
// and froze the pod on its pad for the whole race.
//
Logical
respawnScheduled;
private:
static const IndexEntry AttributePointers[];
+6
View File
@@ -5,6 +5,7 @@
#include "rpmssn.h"
#include "rpplayer.h"
#include "rpdirect.h"
#include "..\munga\app.h" // RPCameraLog
#include "crusher.h"
#include "runner.h"
#include "blocker.h"
@@ -170,6 +171,11 @@ Player*
else
{
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: registry making an RPCameraDirector (game model '"
<< mission->GetGameModel() << "')\n" << std::flush;
}
RPCameraDirector::MakeMessage create_director(
RPCameraDirector::MakeMessageID,
sizeof(RPCameraDirector::MakeMessage),
+146 -3
View File
@@ -803,9 +803,17 @@ void
Check(*damageZones);
(*damageZones)->TakeDamage(collision_damage);
localVelocity.angularMotion.x += 8.0f * Random - 4.0f;
localVelocity.angularMotion.y += 8.0f * Random - 4.0f;
localVelocity.angularMotion.z += 8.0f * Random - 4.0f;
//
// The tumble draws from the vehicle's OWN stream, not the
// global Random - the global one is shared with the frame
// loop's consumers (particles, mostly), so its position here
// depended on how many frames had rendered. This kick goes
// straight into physics state; it was the last wall-clocked
// input left in the whole death cycle.
//
localVelocity.angularMotion.x += 8.0f * TumbleRandom() - 4.0f;
localVelocity.angularMotion.y += 8.0f * TumbleRandom() - 4.0f;
localVelocity.angularMotion.z += 8.0f * TumbleRandom() - 4.0f;
}
}
worldLinearAcceleration = zippy_accel;
@@ -1273,6 +1281,18 @@ VTV::VTV(
boosterSmokeDensity = 0.0f;
doorHitNormal = Vector3D::Identity;
lastDoorHit = Time::Null;
respawnPending = False;
respawnHaveGoal = False;
deathClockValid = False;
//
// Creation order is deterministic, so each vehicle's tumble stream
// is too - see TumbleRandom in the header.
//
{
static unsigned long tumble_births = 0;
tumbleSeed = 0x52503431UL + 7919UL * ++tumble_births;
}
heightAboveTerrain = 0.0f;
forwardVelocity = 0.0f;
hornBlast = -1;
@@ -1682,6 +1702,119 @@ void
subsystem->DeathReset(reset_command);
}
}
//
// The controls mapper too. It is subsystem ZERO, below
// BasicSubsystemCount, so the loop above has never reached it - which
// was fine while it had nothing to reset. It does now: the respawn
// throttle latch, which holds the pod at zero throttle until the
// player's control has come back to zero. See
// VTVControlsMapper::DeathReset for which resets latch and why.
//
{
Subsystem *mapper = GetSubsystem(ControlsMapperSubsystem);
if (mapper)
{
Check(mapper);
mapper->DeathReset(reset_command);
}
}
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
VTV::ScheduleRespawn(
const Origin &new_origin,
const Time &due,
const Point3D &face_toward,
Logical have_goal
)
{
Check(this);
Check(&new_origin);
respawnOrigin = new_origin;
respawnDue = due;
respawnGoal = face_toward;
respawnHaveGoal = have_goal;
respawnPending = True;
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
// The pending respawn lands here, at the top of the first STEP whose clock
// has reached its due time - the same step count after death on every run
// and every frame rate. The old path applied the Reset from the event
// queue, which runs on wall clock: the crash was deterministic and the
// recovery was not, measured as two identical runs diverging on the first
// step after the pod stood back up.
//
// The turn-to-face-the-scorezone flip is the same arithmetic
// RPPlayer::PointVTVTowardGoal does, done here because it reads the
// POST-reset heading - it belongs to the same step as the teleport.
//
void
VTV::BeginStep()
{
Check(this);
if (respawnPending && !(GetLastPerformance() < respawnDue))
{
respawnPending = False;
{
static int diag = -1;
if (diag < 0)
{
const char *setting = getenv("RP412PHYSTRACE");
diag = (setting != NULL && atoi(setting) != 0) ? 1 : 0;
}
if (diag)
{
char buffer[120];
sprintf(buffer,
"PhysTrace: respawn fired, %.4f sim-s late, pad %.2f,%.2f\n",
(double)(Scalar)(GetLastPerformance() - respawnDue),
(double) respawnOrigin.linearPosition.x,
(double) respawnOrigin.linearPosition.z);
DEBUG_STREAM << buffer << std::flush;
}
}
Reset(respawnOrigin, RegularReset);
if (respawnHaveGoal)
{
Vector3D to_goal;
to_goal.Subtract(respawnGoal, localOrigin.linearPosition);
UnitVector current_heading;
localToWorld.GetFromAxis(Z_Axis, &current_heading);
Scalar length_to_goal = to_goal.LengthSquared();
if (length_to_goal > SMALL)
{
Scalar dot_prod =
(to_goal * current_heading) / Sqrt(length_to_goal);
if (dot_prod >= 0.0f)
{
Quaternion turn_around;
Quaternion y_roll(0.0f, 1.0f, 0.0f, 0.0);
turn_around.Multiply(
localOrigin.angularPosition, y_roll);
localOrigin.angularPosition = turn_around;
localToWorld = localOrigin;
}
}
ForceUpdate();
}
}
Mover::BeginStep();
Check_Fpu();
}
@@ -3100,6 +3233,16 @@ void
if (damageLevel >= 1.0f)
{
vtv->SetSimulationState(VTV::BurningState);
//
// Stamp the death on the vehicle's own step clock, here at the
// one site that declares it dead. The respawn schedule anchors
// to this instant - the last step-exact event in the death
// chain - so the recovery lands the same number of steps after
// the crash on every run. Marked once; repeated damage while
// already burning does not move it.
//
vtv->MarkDeathClock();
}
//
+84
View File
@@ -504,6 +504,58 @@ public:
void
Reset(const Origin &new_origin, int reset_command);
//
// A respawn that lands on this vehicle's own step grid. Under fixed
// stepping the player's death recovery cannot ride the event queue -
// the queue runs on wall clock, and a Reset that fires at a wall
// instant lands between different sim steps on every run. Scheduled
// here instead, BeginStep applies it at the first step whose clock
// reaches 'due': same step count after death, every run, every
// frame rate. The goal point rides along because the turn-to-face-
// the-scorezone flip depends on the POST-reset heading, so it has
// to happen in the same step as the teleport.
//
void
ScheduleRespawn(
const Origin &new_origin,
const Time &due,
const Point3D &face_toward,
Logical have_goal
);
void
BeginStep();
//
// The instant this vehicle died, on its own step clock - stamped at
// the single site that sets BurningState, which runs inside the step
// machinery and is therefore already deterministic. The respawn
// schedule anchors HERE rather than at the moment the drop-zone
// reply happens to come off the event queue: the death is the last
// step-exact event in the chain, so "one second after death" is the
// same step count on every run. Marked once per death; consuming it
// re-arms it for the next one.
//
void
MarkDeathClock()
{
if (!deathClockValid)
{
deathClock = GetLastPerformance();
deathClockValid = True;
}
}
Logical
ConsumeDeathClock(Time *when_out)
{
if (!deathClockValid)
{
return False;
}
*when_out = deathClock;
deathClockValid = False;
return True;
}
void
DeathShutdown(int shutdown_command);
@@ -514,6 +566,38 @@ public:
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Navigation support
protected:
// the pending step-grid respawn - see ScheduleRespawn
Origin
respawnOrigin;
Time
respawnDue,
deathClock;
Point3D
respawnGoal;
Logical
respawnPending,
respawnHaveGoal,
deathClockValid;
//
// The out-of-world tumble's own random stream. The global Random is
// shared with frame-cadence consumers - particles above all - so its
// position when a burning pod draws from it depends on how many
// frames have rendered, which is wall clock, which makes the tumble
// differ between identical runs. A per-vehicle generator seeded by
// creation order keeps the tumble looking random while drawing the
// same kicks at the same steps every run.
//
unsigned long
tumbleSeed;
Scalar
TumbleRandom()
{
tumbleSeed = tumbleSeed * 1103515245UL + 12345UL;
return (Scalar)((tumbleSeed >> 16) & 0x7FFF) / (Scalar) 32767;
}
Scalar
targetRangeExponent,
currentRangeExponent;
+97
View File
@@ -5,6 +5,7 @@
#include "vtvpwr.h"
#include "..\munga\icom.h"
#include "..\munga\app.h"
#include "..\munga\inputscript.h"
#include "rpplayer.h"
#include "vtv.h"
@@ -400,6 +401,28 @@ VTVControlsMapper::AttributeIndexSet& VTVControlsMapper::GetAttributeIndex()
// Model Support
//
//
// How close to zero the throttle control must come to release the latch,
// as a fraction of full travel. Wide enough for a resting pad trigger, a
// wound-down keyboard axis, or a real lever's potentiometer sitting a few
// counts off its stop - the pod bay hardware is where this will matter -
// and narrow enough that it cannot be satisfied by easing off.
//
static const Scalar kThrottleLatchRelease = 0.05f;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
VTVControlsMapper::DeathReset(int reset_command)
{
Check(this);
if (reset_command == VTV::RegularReset)
{
throttleLatched = True;
}
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
@@ -414,6 +437,36 @@ void
VTVPower *power_system =
Cast_Object(VTVPower*, vtv->GetSubsystem(VTV::PowerSubsystem));
//
//----------------------------------------------------------------
// RP412INPUTSCRIPT: scripted driving, on this subsystem's own step
// clock.
//
// This is the one place every mapper - RIO, Thrustmaster, pad -
// funnels through, and it runs per SIMULATION STEP, so a scripted
// value lands on the same step of every run whatever the frame
// rate. Overriding at the RIO or the controls manager would key
// the timeline to the frame loop, which is wall clock, which is
// the thing the whole harness exists to keep out of the physics.
//
// Only the player's own vehicle: replicants get their state from
// the network, and the mapper does not run for them anyway.
//----------------------------------------------------------------
//
if (RPInputScript_Active())
{
float script_throttle, script_x, script_y, script_pedals;
if (RPInputScript_Sample(GetLastPerformance(),
&script_throttle, &script_x, &script_y, &script_pedals))
{
throttlePosition = script_throttle;
stickPosition.x = script_x;
stickPosition.y = script_y;
pedalsPosition = script_pedals;
}
}
//
//----------------------------------------------
// Make sure the control inputs are within range
@@ -424,6 +477,43 @@ void
Verify(stickPosition.y >= -1.0f && stickPosition.y <= 1.0f);
Verify(pedalsPosition >= -1.0f && pedalsPosition <= 1.0f);
//
//----------------------------------------------------------------
// The respawn throttle latch.
//
// A pod that dies at full throttle reappears under a hand still
// holding full throttle, and used to launch on it. From a respawn
// until the control has been seen back at (near) zero, the mapper
// computes as though the throttle were zero.
//
// The zeroing is for this pass only - the true lever position is
// put back at the bottom of the function - so the attribute the
// cockpit gauge and the watchers read stays the player's actual
// hand, which is the thing they need to see to bring it down. It
// also means the release test reads the real control each step
// rather than last step's overwrite: RIO analog events arrive on
// CHANGE, so an overwritten attribute would otherwise sit at zero,
// release the latch on its own, and hand back a live throttle the
// moment the hand moved.
//
// Placed after the input script so a scripted run latches the same
// way a hand-driven one does, per step, deterministically.
//----------------------------------------------------------------
//
Scalar lever = throttlePosition;
if (throttleLatched)
{
if (lever <= kThrottleLatchRelease)
{
throttleLatched = False;
}
else
{
throttlePosition = 0.0f;
}
}
//
//----------------------------------------------------------------
// Figure out the proper control model to use based upon the speed
@@ -802,6 +892,12 @@ void
intercom->SetPTTStatus((Logical) pttStatus > 0);
}
//
// Put the real lever position back - see the latch above. Identity
// whenever the latch is idle.
//
throttlePosition = lever;
Check_Fpu();
}
@@ -918,6 +1014,7 @@ VTVControlsMapper::VTVControlsMapper(
stickPosition.y = 0.0f;
throttlePosition = 0.0f;
pedalsPosition = 0.0f;
throttleLatched = False; // the first-spawn Reset sets it
reverseThrust = 0;
liftCut = 0;
+26
View File
@@ -182,6 +182,16 @@ protected:
ControlsButton
previousPTTStatus; // used to detect change in PTT status
//
// True from a respawn until the throttle control has been seen at
// (near) zero. While set, InterpretControls behaves as though the
// throttle were zero, whatever the hand is doing; the attribute
// itself keeps the real lever value so the cockpit gauge shows the
// player what they must bring down. See DeathReset.
//
Logical
throttleLatched;
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Model Support
//
@@ -206,6 +216,22 @@ public:
void
SetConfigurationState(Logical enter_config);
//
// Latch the throttle at zero until the player's control comes back to
// zero. Called by VTV::Reset on a regular reset - a death respawn, and
// the first spawn - because the hand that was holding full throttle
// when the pod died is still holding it when the pod reappears, and
// the pod used to launch on it. The arcade never had the problem the
// same way: its throttle was a physical lever the pilot had to move
// regardless.
//
// Football and mission review resets do not latch: one repositions
// every pod mid-game where the friction is unwanted, the other has no
// controls at all.
//
void
DeathReset(int reset_command);
virtual void
CreateTemporaryEventMappings(
Receiver *receiver,
+249 -25
View File
@@ -22,12 +22,15 @@
#include "rpl4pb.h"
#include "rpl4fe.h"
#include "rpl4environ.h"
#include "rpl4console.h"
#include "rpl4lobby.h"
#include "..\munga_l4\l4steamtransport.h"
#include "..\munga_l4\l4splr.h"
#include "..\munga_l4\l4mfdview.h" // RPWindowLayout_*
#include "..\munga_l4\l4joy.h" // RPJoyConfigWizard
#include "rpl4ver.h"
#include "rpl4build.h" // generated: RP412_VERSION / RP412_VERSION_LONG
#include "..\munga\resver.h"
#include "..\munga\resource.h"
// added for game status drawing support
@@ -39,6 +42,7 @@
#include <strsafe.h>
#include <direct.h>
#include <shellapi.h>
#include <time.h> // the test-build expiry check
#define SPOOL_SIZE 0x600000
@@ -148,6 +152,15 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
SetUnhandledExceptionFilter(RPL4CrashDumpFilter);
//
// Which build this is, before anything else can fail. The patch number
// is this repository's commit count and the hash beside it names the
// commit, so a log from a test machine says exactly where it came from.
// A trailing '+' means the tree had uncommitted changes when it was
// built. See stamp-version.ps1.
//
DEBUG_STREAM << "Red Planet " << RP412_VERSION_LONG << std::endl << std::flush;
// load up our environment variables
//controls
if(getenv("L4CONTROLS") == NULL)
@@ -162,34 +175,138 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
putenv("TARGETFPS=60");
if(getenv("MAXPARTICLES") == NULL)
putenv("MAXPARTICLES=8192");
FILE *file;
char line[1024];
if (fopen_s(&file, "environ.ini", "r") == 0)
//
// environ.ini: written on first run and read here. The exe owns the
// template rather than the packaging script laying one down on every
// unzip, so a tester can drop a new build over an old folder and keep
// their settings. See rpl4environ.h.
//
RPL4Environ_Load();
//
//-------------------------------------------------------------------------
// Test builds have a shelf life.
//
// A tester still racing a fortnight-old binary reports things that were
// fixed a week ago, and the afternoon spent chasing them is gone. So the
// build says plainly that it is out of date and stops, rather than
// running on and being quietly wrong about what it is.
//
// This is a nudge, not a lock: the date comes from the machine's own
// clock, so anyone determined can wind it back, and RP412NOEXPIRY=1 is
// there for us when an old build has to be run on purpose. It is
// deliberately not listed in environ.ini - a bypass every tester can see
// is a bypass every tester will use, and then the build never goes stale
// for the one person it was meant to stop.
//
// $expireDays in stamp-version.ps1 is what sets this, and 0 turns it off
// for a real release.
//-------------------------------------------------------------------------
//
#if RP412_EXPIRES
{
while (!feof(file))
const char *no_expiry = getenv("RP412NOEXPIRY");
Logical overridden = (no_expiry != NULL && atoi(no_expiry) != 0);
__time64_t raw_now = _time64(NULL);
struct tm today;
if (!overridden && _localtime64_s(&today, &raw_now) == 0)
{
if (fgets(line, sizeof(line), file))
int now_stamp =
(today.tm_year + 1900) * 10000 + (today.tm_mon + 1) * 100 + today.tm_mday;
int expiry_stamp =
RP412_EXPIRY_YEAR * 10000 + RP412_EXPIRY_MONTH * 100 + RP412_EXPIRY_DAY;
if (now_stamp > expiry_stamp)
{
for (int i = strlen(line); i >= 0; i--)
if (line[i] == '\n' || line[i] == '\r')
line[i] = 0;
// the file is self-documenting: skip comments, blanks,
// and anything that is not KEY=VALUE
char *setting = line;
while (*setting == ' ' || *setting == '\t')
++setting;
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
continue;
putenv(setting);
DEBUG_STREAM << "Build expired on " << RP412_EXPIRY_TEXT
<< " - refusing to run\n" << std::flush;
char notice[512];
sprintf(notice,
"This Red Planet test build has expired.\n\n"
" Build %s\n"
" Expired %s\n\n"
"Test builds are good for a fortnight so that nobody spends an "
"afternoon chasing something that was fixed a week ago.\n\n"
"Ask for the current one.",
RP412_VERSION_LONG, RP412_EXPIRY_TEXT);
MessageBoxA(NULL, notice, "Red Planet - test build expired",
MB_OK | MB_ICONWARNING | MB_SETFOREGROUND);
return 1;
}
//
// The last few days get a line in the log, so somebody reading a
// report can see the build was nearly out rather than wondering.
//
struct tm expiry_day;
memset(&expiry_day, 0, sizeof(expiry_day));
expiry_day.tm_year = RP412_EXPIRY_YEAR - 1900;
expiry_day.tm_mon = RP412_EXPIRY_MONTH - 1;
expiry_day.tm_mday = RP412_EXPIRY_DAY;
//
// The END of the expiry day, because that is the rule the check
// above enforces - the build is good for all of that date and
// refuses the morning after. Anchoring at midday instead would
// report one day fewer than the build actually has left.
//
expiry_day.tm_hour = 23;
expiry_day.tm_min = 59;
expiry_day.tm_sec = 59;
expiry_day.tm_isdst = -1;
__time64_t expiry_time = _mktime64(&expiry_day);
if (expiry_time != (__time64_t) -1)
{
int days_left = (int)((expiry_time - raw_now) / (24 * 60 * 60));
if (days_left <= 3)
{
DEBUG_STREAM << "Build expires " << RP412_EXPIRY_TEXT << " ("
<< days_left << " day(s) left)\n" << std::flush;
}
}
}
fclose(file);
else if (overridden)
{
DEBUG_STREAM << "Build expiry (" << RP412_EXPIRY_TEXT
<< ") waived by RP412NOEXPIRY\n" << std::flush;
}
}
#endif
DEBUG_STREAM << "Red Planet 4.12.6" << std::endl << std::flush;
DEBUG_STREAM << "L4CONTROLS=" << getenv("L4CONTROLS") << std::endl << std::flush;
//
// The frame target and whether drawing interpolates, stated outright.
// Both were previously invisible: TARGETFPS appeared nowhere in the log,
// so a run could not be checked afterwards against what it was actually
// asked for, and interpolation only announced itself when switched OFF.
// Between them that cost a wasted test run and an ambiguous one.
//
// The mismatch note matters because a frame target that is not the
// physics rate used to mean visibly stepped motion - it no longer does
// while interpolation is on, which is exactly why the log should say
// which of the two states it is in.
//
{
const char *interp = getenv("RP412INTERP");
Logical blending = !(interp != NULL && atoi(interp) == 0);
const char *physics = getenv("RP412PHYSICSHZ");
int fps = atoi(getenv("TARGETFPS"));
int hz = (physics != NULL) ? atoi(physics) : 0;
DEBUG_STREAM << "Video: frame target " << fps << " fps, drawing "
<< (blending ? "interpolated across the physics step" : "on exact physics steps")
<< std::endl << std::flush;
if (!blending && hz > 0 && fps != hz)
{
DEBUG_STREAM << "Video: " << fps << " fps against a " << hz
<< " Hz physics step with interpolation off - motion will step"
<< std::endl << std::flush;
}
}
#ifdef RP412_STEAM
//
// RP412STEAM=1 (environ.ini or a Steam launch) swaps the wire to
@@ -256,12 +373,41 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
}
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;
//
// The style the window is BORN with, rather than one worked out and
// then thrown away - the old code computed a style and handed
// CreateWindowEx a literal WS_OVERLAPPEDWINDOW regardless.
//
// Windowed keeps the full overlapped set on purpose: the sizing
// border and the maximise box are how the player arranges the
// cockpit, and mfd_layout.cfg remembers where they left it. The
// borderless modes are born borderless instead of being restyled a
// moment later, so there is no framed window on screen first.
//
DWORD wsStyle = WS_OVERLAPPEDWINDOW;
if (L4Application::GetFullscreen() || L4Application::GetFitDisplay())
{
wsStyle = WS_POPUP;
}
//
// -res is a RENDER size, so it is the client area that has to be it.
// Passed straight to CreateWindowEx it sets the OUTER rectangle and
// the chrome comes out of the middle - which is how -res 640 480
// came to present into a 624x441 client.
//
RECT wanted;
wanted.left = 0;
wanted.top = 0;
wanted.right = (LONG) L4Application::GetScreenWidth();
wanted.bottom = (LONG) L4Application::GetScreenHeight();
AdjustWindowRect(&wanted, wsStyle, FALSE);
hWnd = CreateWindowEx(0, L"MainWndClass", L"RPL4", wsStyle, 0, 0,
wanted.right - wanted.left, wanted.bottom - wanted.top,
(HWND)NULL, (HMENU)NULL, hInstance, (LPVOID)NULL);
if (!hWnd)
return FALSE;
ShowWindow(hWnd, nShowCmd);
@@ -278,6 +424,45 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
RPWindowLayout_Register(hWnd, "RPL4", True);
RPWindowLayout_Load();
}
else if (L4Application::GetFitDisplay())
{
//
// -fit has no saved placement to restore, but it does have a
// shape to take, and it has to take it NOW. SVGA16 applies the
// same borderless full-monitor rect when it assembles the
// cockpit, which is after the first mission has already built
// its D3D device against the window as it stands - so the first
// race of a session used to render to a bordered client and
// every race after it to the borderless monitor. Same lobby,
// same settings, different target and different frame cost.
//
// Settle the window here and every mission of the session,
// first included, is set up against the identical client area.
//
L4Application::FitWindowToMonitor(hWnd);
}
//
//-------------------------------------------------------------------------
// 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
@@ -305,6 +490,31 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
int last_launch_mode = FELaunchSingle;
//
//-------------------------------------------------------------------------
// A hand-fed egg run stays unmarshaled - no console, so the race never
// ends - UNLESS it is given a length. RP412MISSIONSECONDS supplies one,
// and with it '-egg' can play a whole race through to the buzzer, the
// podium and the results.
//
// That is the difference between a shortcut that can only be watched and
// one that can be TESTED: everything after the chequered flag - the fade,
// the winners' circle, the teardown - was unreachable from the command
// line, so it could only ever be exercised by hand through the menu.
//-------------------------------------------------------------------------
//
if (!front_end_mode && L4Application::GetEggNotationFileName())
{
const char *egg_seconds = getenv("RP412MISSIONSECONDS");
if (egg_seconds != NULL && atoi(egg_seconds) > 0)
{
DEBUG_STREAM << "LocalConsole: marshalling the hand-fed egg run ("
<< atoi(egg_seconds) << "s, RP412MISSIONSECONDS)\n" << std::flush;
L4Application::SetNetworkCommonFlatAddress(0);
RPL4LocalConsole_Install(atoi(egg_seconds));
}
}
for (;;)
{
if (front_end_mode)
@@ -428,12 +638,26 @@ int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine
if (!spoolFileName)
spoolFileName = "last.spl";
//
// Say which of the two this is. The choice was made in silence,
// and the two look identical from outside for the first few
// seconds - one plays a race back, the other sits there - so a
// spool that failed to load was indistinguishable from a spool
// that loaded and had not started yet.
//
SpoolFile *spool = spool_mgr->GetStoredSpoolFile(spoolFileName);
if (spool)
{
DEBUG_STREAM << "Playback: " << spoolFileName
<< " loaded, " << (int) spool->GetBytesRemaining()
<< " bytes to play\n" << std::flush;
new_app = new RPL4PlaybackApplication(hInstance, hWnd, &resources, spool);
} else
{
DEBUG_STREAM << "Playback: could not load '" << spoolFileName
<< "' - starting idle with nothing to show. It must exist, be"
<< " no larger than " << (int) SPOOL_SIZE
<< " bytes, and have been written by this build.\n" << std::flush;
new_app = new RPL4IdleApplication(hInstance, hWnd, &resources);
}
+34
View File
@@ -11,6 +11,7 @@
#include "..\munga_l4\l4ctrl.h"
#include "..\munga_l4\l4mppr.h"
#include "..\munga\appmgr.h"
#include "..\munga\spooler.h"
#include "rpl4mode.h"
#include "..\rp\vtv.h"
#include "rpl4mppr.h"
@@ -244,6 +245,14 @@ void
// you would be the one empty spot. Turn it inside out before the shot.
//---------------------------------------------------------------------
//
//
// Timed, because the black screen between the race fading out and the
// stand fading in is several seconds long and the fade only accounts
// for 0.7 of them. Nothing else runs while this does - the whole game
// is one thread - so whatever these two cost IS that gap.
//
Time podiumOutsideStart = Now();
dpl_renderer->ShowViewpointFromOutside();
//
@@ -253,8 +262,21 @@ void
// whatever order the players were created.
//---------------------------------------------------------------------
//
Time podiumNamesStart = Now();
dpl_renderer->SortAndReloadNameBitmaps();
{
Time podiumNamesEnd = Now();
char timing[160];
sprintf(timing,
"WinnersCircle: exterior %.0f ms, name plates %.0f ms\n",
(double)((Scalar)(podiumNamesStart - podiumOutsideStart) * 1000.0f),
(double)((Scalar)(podiumNamesEnd - podiumNamesStart) * 1000.0f));
DEBUG_STREAM << timing << std::flush;
}
//
//---------------------------------------------------------------------
// Widen to 45 degrees and pull back in front of the stand so the whole
@@ -446,6 +468,18 @@ void
Post(LowEventPriority, this, &podium_message, event_time);
DEBUG_STREAM << "WinnersCircle: race over, fading out\n" << std::flush;
//
// Write the recording here rather than at teardown. This is the
// first of the two StopMissions - the buzzer, not the fade timer -
// so it is the end of the RACE, and everything worth keeping has
// arrived. Waiting for teardown would risk the process going away
// first and taking the spool with it.
//
if (Application::IsRecording())
{
SpoolRecorder_Get()->Save();
}
}
L4Application::StopMissionMessageHandler(message);
+518 -11
View File
@@ -6,6 +6,7 @@
#include "..\munga\appmgr.h"
#include "..\munga\appmsg.h"
#include "..\munga\console.h"
#include "..\munga\spooler.h"
#include "..\rp\rpcnsl.h"
#include "..\munga_l4\l4app.h"
#include "..\munga_l4\l4net.h"
@@ -80,6 +81,9 @@ namespace
NetTransport::Connection
connection;
int state; // last reported application state (-1 unknown)
int lastScore; // last telemetry score heard mid-race
int lastScoreHostID; // the host ID the pod itself reported
Logical haveScore; // any telemetry score at all yet
Logical eggAcknowledged;
DWORD lastQueryTick;
DWORD eggSentTick; // 0 = never sent
@@ -164,6 +168,46 @@ namespace
pod->connection, packet, (int) sizeof(NetworkPacketHeader) + size);
}
//---------------------------------------------------------------
// The same packet, delivered to the station on this machine.
//
// A real pod bay console sits on its own machine and every station
// hears it over the wire. Here it is colocated, and the network stack
// is quite right not to push bytes through a socket to reach a client
// in the same process - but the console had gone further than that and
// called application->Dispatch, stepping past the client's receive
// entry altogether. Anything watching packets therefore never saw the
// console speak: the first Live Cam recording held all 14,342 of the
// racer's packets and not one LoadMission, RunMission or StopMission,
// because those were the messages that came from inside the house.
//
// So build the packet SendWire would have built and hand it to the
// client's own front door. No socket, no wire, no copy of the protocol
// - just the delivery arriving where a delivery arrives.
//---------------------------------------------------------------
void DeliverLocal(int client_ID, const void *message, int size)
{
char packet[sizeof(NetworkPacketHeader) + 1400];
if (size > (int) sizeof(packet) - (int) sizeof(NetworkPacketHeader))
{
return;
}
memset(packet, 0, sizeof(NetworkPacketHeader));
NetworkPacketHeader *header = (NetworkPacketHeader *) packet;
header->clientID = (NetworkClient::ClientID) client_ID;
header->gameID = 0;
header->fromHost = 1; // the console's reserved host ID
memcpy(packet + sizeof(NetworkPacketHeader), message, size);
NetworkPacket *received = (NetworkPacket *) packet;
Check(application);
application->ReceiveNetworkPacket(received, &received->messageData);
}
void SendEggTo(RemotePod *pod)
{
int chunk_count = (gEggWireSize + 999) / 1000;
@@ -259,7 +303,27 @@ namespace
(int) message->GetFinalScore());
pod->scored = True;
}
// VTV telemetry (IDs 2-6) skips through for now
//
// Running telemetry. The pods have been sending it all
// along (it fed the arcade console's status board); this
// console skipped it - until a player Alt+Q'ed mid-race
// and vanished from the score sheet, because a pod that
// leaves never sends its EndMission score. Keep the last
// score heard, and the host ID the pod itself reported
// with it, so the buzzer can stand it in for a pod that
// is not there to answer. Leaving early keeps the points
// earned; it just stops earning more.
//
else if ((int) base->messageID ==
ConsolePlayerVTVScoreUpdateMessageID)
{
ConsolePlayerVTVScoreUpdateMessage *message =
(ConsolePlayerVTVScoreUpdateMessage *) base;
pod->lastScore = (int) message->GetPlayerScore();
pod->lastScoreHostID = (int) message->GetPlayerHostID();
pod->haveScore = True;
}
// the rest of the telemetry (IDs 2-4, 6) skips through
}
else if ((int) header->clientID == (int) NetworkClient::NetworkManagerClientID)
{
@@ -348,10 +412,311 @@ namespace
DEBUG_STREAM << "LocalConsole: stopping local pod\n" << std::flush;
InterlockedExchange(&gMissionRunning, 0);
Application::StopMissionMessage message(0);
application->Dispatch(&message);
DeliverLocal(
NetworkClient::ApplicationClientID,
&message,
(int) message.messageLength
);
gPhase = PhaseStopped;
}
//---------------------------------------------------------------
// The countdown the engine shows, taken from the clock that will
// actually end the race (gMissionClockHook - see APPMGR.h).
//
// Called on the game thread, reading two volatile LONGs the console
// thread writes with InterlockedExchange. Aligned 32-bit reads, and
// a torn value could only mistime the cockpit clock by one tick of
// a countdown nobody reads to the millisecond - not worth a lock on
// the frame path.
//---------------------------------------------------------------
Logical MissionClock(Scalar *seconds_remaining)
{
//
// Only answer for the race this console is actually marshalling.
// Nothing ever uninstalls the hook, so a player who hosts a race
// and then joins somebody else's lobby still has it wired up -
// and in that race the console is a bystander whose gLengthMs and
// gRunStartTick belong to the previous mission entirely.
//
if (gWatchedApp == NULL || gWatchedApp != application)
{
return False;
}
if (!gMissionRunning)
{
return False; // not started, or already stopped
}
LONG length_ms = gLengthMs;
if (length_ms <= 0)
{
return False; // endless: nothing to count down
}
// DWORD subtraction, so a GetTickCount wrap costs nothing
LONG elapsed_ms = (LONG)(GetTickCount() - (DWORD) gRunStartTick);
LONG left_ms = length_ms - elapsed_ms;
if (left_ms < 0)
{
//
// The console polls at 250 ms, so the clock reaches zero
// slightly before the stop is dispatched. Hold at zero
// rather than showing negative time in the cockpit.
//
left_ms = 0;
}
*seconds_remaining = (Scalar) left_ms / 1000.0f;
return True;
}
//---------------------------------------------------------------
// The commit board: the pod bay ritual, on screen.
//
// In the pod bay the mission is COMMITTED first - every pod preps
// up to, but not past, the launch - and then the operator presses
// the launch button. The prep window is not dead time: the MFDs
// and map buttons are alive before the mission starts, so pilots
// use it to set maps and presets, and the wait is part of the
// game's social fabric.
//
// This is that ritual for a lobby race. The host's menu button
// says COMMIT; committing marshals everyone exactly as before, but
// where the console used to fire RunMission the instant all pods
// staged, it now arms a LAUNCH button on a small status board and
// waits for the operator. The board lists every pod and what it is
// doing - connecting, loading, READY - so the host can see who the
// room is waiting on, and it gets out of the way the moment the
// mission drops.
//
// Game thread throughout: created, painted, clicked and destroyed
// inside ConsoleTick, so the game's own message pump (already
// alive - that is why the MFDs work) delivers its input, and no
// state crosses a thread. WS_EX_NOACTIVATE keeps the game window
// focused: PadRIO controls answer only while it is, and a launch
// click must not cost the host their controls.
//---------------------------------------------------------------
HWND gStatusWindow = NULL;
Logical gLaunchArmed = False; // everyone staged: button lit
Logical gLaunchRequested = False; // the operator pressed it
RECT gLaunchRect; // client coords, hit-tested
int gShownStates[maxRemotePods + 2];
const COLORREF kBoardGreen = RGB(64, 255, 64);
const COLORREF kBoardGreenDim = RGB(24, 140, 24);
const char *StateWord(int state)
{
switch (state)
{
case -1: return "connecting";
case Application::WaitingForEgg: return "waiting for egg";
case Application::CreatingMission:
case Application::LoadingMission: return "loading";
case Application::WaitingForLaunch: return "READY";
case Application::LaunchingMission:
case Application::RunningMission: return "running";
default: return "starting";
}
}
LRESULT CALLBACK StatusBoardWndProc(
HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_MOUSEACTIVATE:
// take the click, leave the focus with the game
return MA_NOACTIVATE;
case WM_ERASEBKGND:
return 1;
case WM_LBUTTONDOWN:
{
int x = (int)(short) LOWORD(lParam);
int y = (int)(short) HIWORD(lParam);
if (gLaunchArmed &&
x >= gLaunchRect.left && x < gLaunchRect.right &&
y >= gLaunchRect.top && y < gLaunchRect.bottom)
{
gLaunchRequested = True;
}
}
return 0;
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hwnd, &ps);
RECT client;
GetClientRect(hwnd, &client);
HDC mem = CreateCompatibleDC(hdc);
HBITMAP surface = CreateCompatibleBitmap(
hdc, client.right, client.bottom);
HGDIOBJ old_surface = SelectObject(mem, surface);
FillRect(mem, &client,
(HBRUSH) GetStockObject(BLACK_BRUSH));
HBRUSH frame = CreateSolidBrush(kBoardGreenDim);
FrameRect(mem, &client, frame);
DeleteObject(frame);
HFONT font = CreateFontA(-14, 0, 0, 0, FW_NORMAL,
FALSE, FALSE, FALSE, ANSI_CHARSET, OUT_DEFAULT_PRECIS,
CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY,
DEFAULT_PITCH | FF_MODERN, "Consolas");
HGDIOBJ old_font = SelectObject(mem, font);
SetBkMode(mem, TRANSPARENT);
int row_h = 20;
RECT line;
line.left = 10;
line.right = client.right - 10;
line.top = 8;
line.bottom = line.top + row_h;
SetTextColor(mem, kBoardGreen);
DrawTextA(mem, "MISSION COMMITTED", -1, &line,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
line.top += row_h + 4;
line.bottom += row_h + 4;
//
// One row per pod: the host first, then the remotes in
// [pilots] order. READY rows bright, the rest dim, so
// who the room is waiting on reads at a glance.
//
// Every state on this board comes from gShownStates -
// the values the TICK stored when it decided whether to
// arm - and nothing is re-read at paint time. The first
// version re-read application->GetApplicationState()
// here, and the host's own row said 'connecting' under
// a lit LAUNCH button: two reads of one fact from two
// places, disagreeing - the exact mistake behind five
// broken instruments in one night of this project. One
// frame of reference: the tick decides, the paint
// repeats what it decided.
//
char text[96];
sprintf(text, "%-14s %s",
(gPilotNameCount > 0) ? gPilotNames[0] : "HOST",
StateWord(gShownStates[0]));
SetTextColor(mem,
(gShownStates[0] == Application::WaitingForLaunch)
? kBoardGreen : kBoardGreenDim);
DrawTextA(mem, text, -1, &line,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
for (int i = 0; i < gRemotePodCount; ++i)
{
line.top += row_h;
line.bottom += row_h;
const char *name = (i + 1 < gPilotNameCount)
? gPilotNames[i + 1] : gRemotePods[i].address;
sprintf(text, "%-14s %s", name,
StateWord(gShownStates[i + 1]));
SetTextColor(mem,
(gShownStates[i + 1] == Application::WaitingForLaunch)
? kBoardGreen : kBoardGreenDim);
DrawTextA(mem, text, -1, &line,
DT_LEFT | DT_VCENTER | DT_SINGLELINE);
}
//
// The operator's button. Lit only when every system is
// ready - the same condition that used to fire the run
// automatically.
//
gLaunchRect.left = 10;
gLaunchRect.right = client.right - 10;
gLaunchRect.bottom = client.bottom - 8;
gLaunchRect.top = gLaunchRect.bottom - 26;
HBRUSH button = CreateSolidBrush(
gLaunchArmed ? kBoardGreen : kBoardGreenDim);
FrameRect(mem, &gLaunchRect, button);
DeleteObject(button);
SetTextColor(mem,
gLaunchArmed ? kBoardGreen : kBoardGreenDim);
DrawTextA(mem,
gLaunchArmed ? "L A U N C H" : "waiting for pods...",
-1, &gLaunchRect,
DT_CENTER | DT_VCENTER | DT_SINGLELINE);
BitBlt(hdc, 0, 0, client.right, client.bottom,
mem, 0, 0, SRCCOPY);
SelectObject(mem, old_font);
DeleteObject(font);
SelectObject(mem, old_surface);
DeleteObject(surface);
DeleteDC(mem);
EndPaint(hwnd, &ps);
}
return 0;
}
return DefWindowProcA(hwnd, message, wParam, lParam);
}
void CreateStatusBoard()
{
static Logical class_registered = False;
if (!class_registered)
{
class_registered = True;
WNDCLASSA window_class;
memset(&window_class, 0, sizeof(window_class));
window_class.lpfnWndProc = StatusBoardWndProc;
window_class.hInstance = GetModuleHandleA(NULL);
window_class.hCursor = LoadCursor(NULL, IDC_ARROW);
window_class.hbrBackground = (HBRUSH) GetStockObject(BLACK_BRUSH);
window_class.lpszClassName = "RPCommitBoard";
RegisterClassA(&window_class);
}
int height = 8 + 24 + (gRemotePodCount + 1) * 20 + 12 + 26 + 8;
int width = 280;
//
// Top-right of the game window, inset a little - over the 3D
// view, clear of the instrument panes along the edges.
//
RECT game;
GetWindowRect(ghWnd, &game);
gStatusWindow = CreateWindowExA(
WS_EX_TOPMOST | WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
"RPCommitBoard", "", WS_POPUP,
game.right - width - 48, game.top + 64,
width, height,
ghWnd, NULL, GetModuleHandleA(NULL), NULL);
if (gStatusWindow != NULL)
{
ShowWindow(gStatusWindow, SW_SHOWNOACTIVATE);
}
for (int i = 0; i < maxRemotePods + 2; ++i)
{
gShownStates[i] = -999;
}
}
void DestroyStatusBoard()
{
if (gStatusWindow != NULL)
{
DestroyWindow(gStatusWindow);
gStatusWindow = NULL;
}
gLaunchArmed = False;
gLaunchRequested = False;
}
//---------------------------------------------------------------
// The game-thread tick: state reporting + engine-safe execution
//---------------------------------------------------------------
@@ -371,6 +736,32 @@ namespace
switch (gPhase)
{
case PhaseWaiting:
//
// A mission can die before it ever runs - Alt+Q during the
// commit hold, or while loading. The console only learned a
// mission was over from PhaseRunning, so an abort during prep
// left it in PhaseWaiting forever, MissionCompleted() answered
// False, and WinMain's single-binary loop fell out to the
// DESKTOP instead of returning to the setup screen. The commit
// hold makes the prep window somewhere players actually stand,
// so the hole finally had traffic. A prep death counts as a
// completed mission now: back to the menu, lobby intact.
//
if (state == Application::EndingMission ||
state == Application::StoppingMission ||
state == Application::AbortingMission)
{
DEBUG_STREAM << "LocalConsole: mission ended during prep - "
<< "back to the menu\n" << std::flush;
InterlockedExchange(&gMissionRunning, 0);
gPhase = PhaseStopped;
DestroyStatusBoard();
if (gNetworkRace)
{
DisconnectRemotes();
}
break;
}
if (gNetworkRace)
{
MarshalRemotes();
@@ -392,28 +783,104 @@ namespace
}
//
// Everyone staged: launch the race everywhere
// The commit board, from the moment there is anything to
// show. It repaints only when a state actually changes -
// this ticks every frame.
//
if (gRemotePodCount > 0 && !gRunSent)
{
if (gStatusWindow == NULL)
{
CreateStatusBoard();
}
if (gStatusWindow != NULL)
{
Logical changed = (gShownStates[0] != state);
gShownStates[0] = state;
for (int i = 0; i < gRemotePodCount; ++i)
{
if (gShownStates[i + 1] != gRemotePods[i].state)
{
gShownStates[i + 1] = gRemotePods[i].state;
changed = True;
}
}
if (changed)
{
InvalidateRect(gStatusWindow, NULL, FALSE);
}
}
}
//
// Everyone staged: ARM. The console used to fire the run
// itself right here; the run belongs to the operator now,
// pod bay fashion, and the prep hold is the point - the
// MFDs and map buttons are alive, so pilots set maps and
// presets before the drop.
//
if (!gRunSent &&
state == Application::WaitingForLaunch &&
AllRemotesInState(Application::WaitingForLaunch))
{
DEBUG_STREAM << "LocalConsole: all pods staged - RUN\n" << std::flush;
for (int i = 0; i < gRemotePodCount; ++i)
if (!gLaunchArmed)
{
Application::RunMissionMessage run;
SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID,
&run, (int) run.messageLength);
gLaunchArmed = True;
DEBUG_STREAM << "LocalConsole: all pods staged - "
<< "committed, LAUNCH is the operator's\n"
<< std::flush;
if (gStatusWindow != NULL)
{
InvalidateRect(gStatusWindow, NULL, FALSE);
}
}
//
// A solo commit (no remote pods) launches itself: with
// nobody to wait for there is no board, and the old
// instant start is what a lone pilot expects.
//
if (gLaunchRequested || gRemotePodCount == 0)
{
DEBUG_STREAM << "LocalConsole: RUN\n" << std::flush;
for (int i = 0; i < gRemotePodCount; ++i)
{
Application::RunMissionMessage run;
SendWire(&gRemotePods[i], NetworkClient::ApplicationClientID,
&run, (int) run.messageLength);
}
Application::RunMissionMessage local_run;
DeliverLocal(
NetworkClient::ApplicationClientID,
&local_run,
(int) local_run.messageLength
);
gRunSent = True;
// out of the way: the host has a race to fly
DestroyStatusBoard();
}
}
else if (gLaunchArmed && !gRunSent)
{
//
// Somebody fell back out of readiness (a reload, a
// drop). Disarm rather than launching a room that is
// no longer whole.
//
gLaunchArmed = False;
if (gStatusWindow != NULL)
{
InvalidateRect(gStatusWindow, NULL, FALSE);
}
Application::RunMissionMessage local_run;
application->Dispatch(&local_run);
gRunSent = True;
}
}
if (state == Application::RunningMission)
{
gPhase = PhaseRunning;
DestroyStatusBoard(); // however the run began
gWatchedApp = application;
gResultCount = 0;
InterlockedExchange(&gRunStartTick, (LONG) GetTickCount());
@@ -439,6 +906,7 @@ namespace
// mission ended some other way (pilot exit etc.)
InterlockedExchange(&gMissionRunning, 0);
gPhase = PhaseStopped;
DestroyStatusBoard();
DisconnectRemotes();
}
else if (gStopRequested)
@@ -469,6 +937,27 @@ namespace
else if (AllRemotesScored() ||
(LONG)(GetTickCount() - gRemoteStopTick) >= 5000)
{
//
// Stand in for anyone who is not here to answer. A pod
// that left mid-race - Alt+Q, a crash, a dropped link -
// never sends EndMission, and used to vanish from the
// score sheet as though it had not raced at all. Its
// last telemetry score is the score it left with.
//
for (int i = 0; i < gRemotePodCount; ++i)
{
if (!gRemotePods[i].scored && gRemotePods[i].haveScore)
{
DEBUG_STREAM << "LocalConsole: "
<< gRemotePods[i].address
<< " never reported - keeping its last"
<< " telemetry score\n" << std::flush;
CollectFinalScore(
gRemotePods[i].lastScoreHostID,
gRemotePods[i].lastScore);
gRemotePods[i].scored = True;
}
}
DispatchLocalStop();
DisconnectRemotes();
}
@@ -518,10 +1007,15 @@ namespace
gRunSent = False;
gRemoteStopsSent = False;
gLocalEggFed = False;
DestroyStatusBoard(); // no board survives into a new race
// game-thread execution point
gPerFrameHook = &ConsoleTick;
// the cockpit clock now counts down the same clock that will stop
// the race, rather than the engine's own reckoning of it
gMissionClockHook = &MissionClock;
// results intake from the RP layer
gConsoleScoreSink = &CollectFinalScore;
@@ -568,6 +1062,13 @@ Logical
strncpy(gEggPath, egg_path, sizeof(gEggPath) - 1);
gEggPath[sizeof(gEggPath) - 1] = '\0';
//
// The recording needs this too. A spool says what moved; the egg says
// what it moved through, and the console is where the egg's name is
// actually known.
//
SpoolRecorder_Get()->SetEggPath(gEggPath);
//
// The wire image of the egg: file newlines become NULs, exactly
// what the arcade console sent (RPMission.ToEggFileMessages)
@@ -634,6 +1135,12 @@ Logical
// booting; runs before the engine block so nothing is waiting.
//
NetTransport_Get()->Startup();
//
// An escape pressed during the last race must not cancel this one.
//
NetTransport_ClearWaitCancel();
const char *cursor = remote_pod_list;
while (*cursor != '\0' && gRemotePodCount < maxRemotePods)
{
+835
View File
@@ -0,0 +1,835 @@
#include "rpl4.h"
#pragma hdrstop
#include "rpl4environ.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
//########################################################################
// environ.ini - see rpl4environ.h for why the exe owns this rather than
// the packaging script.
//########################################################################
namespace
{
const char kEnvironFileName[] = "environ.ini";
//-------------------------------------------------------------------
// The shipped configuration, verbatim. Lifted out of pack-dist.ps1
// so there is one source of truth and the exe alone can produce a
// working install.
//-------------------------------------------------------------------
const char kEnvironTemplate[] =
"# ============================================================================\n"
"# environ.ini - Red Planet 4.12 configuration\n"
"# ============================================================================\n"
"# One KEY=VALUE per line, read at game start. Lines starting with # or ;\n"
"# are comments; anything without an = is ignored. Delete a line (or\n"
"# comment it out) to fall back to the built-in default.\n"
"#\n"
"# Input bindings live in bindings.txt beside the exe (written with the\n"
"# full documented layout on first run; delete it to restore defaults).\n"
"#\n"
"# Your callsign and loadout are remembered in pilot.cfg beside the exe.\n"
"# Set them on the setup screen once and they come back every session,\n"
"# however you left - launching, joining a lobby, or quitting. Delete\n"
"# that file to start over.\n"
"\n"
"# ---- Core (the shipped configuration) --------------------------------------\n"
"\n"
"# Control stack: tokens separated by ; or , processed left to right.\n"
"# PAD the virtual RIO (XInput controller + keyboard,\n"
"# rebindable via bindings.txt)\n"
"# RIO real serial cockpit hardware on COM1\n"
"# RIO:COMn same, on another port (RIO:COM3, ...)\n"
"# KEYBOARD the engine keyboard handler\n"
"# MOUSE, JOYSTICK, FLIGHTSTICKPRO, THRUSTMASTER, DIJOYSTICK\n"
"# legacy pointer/joystick drivers (untested here)\n"
"# Unset falls back to KEYBOARD alone.\n"
"L4CONTROLS=PAD;KEYBOARD\n"
"\n"
"# Read the keyboard, pad and stick only while the game is the window in\n"
"# front. The pod was the only thing running on its cabinet, so the\n"
"# virtual RIO reads the key state directly rather than waiting on the\n"
"# message pump - which means it reads it whatever is in front, and\n"
"# switching to another window to type flies the pod around while you\n"
"# type in it.\n"
"# 1 controls go neutral when you switch away (default)\n"
"# 0 read them regardless, as earlier builds did\n"
"# Any window of the game counts as the game, so clicking an MFD pane or\n"
"# the plasma glass does not drop your controls. Real RIO cockpit\n"
"# hardware is unaffected either way - this is the keyboard, pad and\n"
"# joystick path only.\n"
"RP412INPUTFOCUS=1\n"
"\n"
"# Renderer bring-up argument. Only its presence is checked (the DPL\n"
"# resolution parsing it once fed is gone) and the game refuses to start\n"
"# without it - any non-empty value works. Leave as shipped.\n"
"DPLARG=1\n"
"\n"
"# DPL (renderer/scene) configuration file, searched beside the exe.\n"
"# Any notation file name; RPDPL.INI is the one that ships.\n"
"L4DPLCFG=RPDPL.INI\n"
"\n"
"# Gauge (MFD/instrument) canvas. Must name a page of GAUGE\\L4GAUGE.INI:\n"
"# 640x480x8 | 640x480x16 | 800x600x16\n"
"# Unset disables the gauge renderer (and with it all MFDs).\n"
"L4GAUGE=640x480x16\n"
"\n"
"# Plasma display.\n"
"# SCREEN render the pod's plasma glass in-window (currently\n"
"# parked off-layout)\n"
"# COM1, COM2... drive real plasma glass on that serial port\n"
"# (9600 baud, N81)\n"
"# Unset = no plasma display.\n"
"L4PLASMA=SCREEN\n"
"\n"
"# 0 = classic separate gauge windows; 1 = the single-window glass\n"
"# cockpit (all seven displays composed on a locked 1920x1080 canvas\n"
"# around the viewscreen); 2 = exploded diagnostic view (each display\n"
"# in its own native-resolution desktop window - MFDs 640x480, map\n"
"# 480x640 - decoded exactly as the pod's VDB split them, no downscale).\n"
"L4MFDSPLIT=1\n"
"\n"
"# The game window - and in the exploded view (L4MFDSPLIT=2) each display\n"
"# window - is placed fresh every launch, so moving one somewhere useful\n"
"# never survived the menu-race-menu loop. This remembers where you put\n"
"# them, in mfd_layout.cfg beside this file:\n"
"# off / 0 / unset computed placement only, no file (default)\n"
"# load put the windows back where they were saved\n"
"# save the same, and re-save on every finished drag\n"
"# The game window gets its size back too, so you can size the cockpit to\n"
"# suit your monitor once and keep it. The display windows get position\n"
"# only: their size follows their content and their button banks, so an\n"
"# old one is never restored over them. Arrange everything once with\n"
"# save, then leave it on load.\n"
"#\n"
"# The plasma display window takes part too, under \"Plasma Display\".\n"
"#\n"
"# Each line in mfd_layout.cfg reads <title>=<x>,<y>,<w>,<h>, and you can\n"
"# append ,noframe to take that window's title bar and border off - a\n"
"# cockpit that fills the monitor edge to edge without -fit taking the\n"
"# whole screen. Put the window where you want it first: a bare window\n"
"# has nothing to drag by. Delete the flag to get the frame back.\n"
"#RP412MFDLAYOUT=off\n"
"\n"
"# Size of the six secondary displays in the glass cockpit, as a\n"
"# percentage of their pod size. The pod bolted them down at one size;\n"
"# on a big panel there is room to trade viewscreen for instrument, so\n"
"# turn these up if you want to actually read the other displays while\n"
"# you fly. 100 = as the pod had them. Range 25-200 (out-of-range and\n"
"# unreadable values fall back to the group setting, then to 100).\n"
"#\n"
"# The scaling is applied in canvas units, before the cockpit is fitted\n"
"# to your window, so a given number looks the same on every monitor.\n"
"# The layout stays legal whatever you ask for - the panes are clamped\n"
"# against their actual neighbours, shrinking uniformly so a display\n"
"# never comes out stretched. They do overlap the viewscreen, exactly\n"
"# as the pod's bezels did, but never each other.\n"
"#\n"
"# L4MFDSCALE sets all five green MFDs at once.\n"
"L4MFDSCALE=100\n"
"\n"
"# ...and any single display can override it. Uncomment one to size it\n"
"# on its own - useful if you only care about, say, the damage readout.\n"
"# UL upper left UC upper center UR upper right\n"
"# LL lower left LR lower right\n"
"#L4MFDSCALE_UL=100\n"
"#L4MFDSCALE_UC=100\n"
"#L4MFDSCALE_UR=100\n"
"#L4MFDSCALE_LL=100\n"
"#L4MFDSCALE_LR=100\n"
"\n"
"# The portrait radar/map, sized on its own (it already sits at 1.35x\n"
"# the MFDs by default). It shares the canvas with whichever MFD is\n"
"# above it, so at extreme settings one of the two gives way.\n"
"L4RADARSCALE=100\n"
"\n"
"# Where the radar sits:\n"
"# CENTER bottom centre, under the viewscreen, as the pod had it\n"
"# (default; BOTTOM and CENTRE mean the same)\n"
"# LEFT bottom left corner (or BOTTOMLEFT)\n"
"# RIGHT bottom right corner (or BOTTOMRIGHT)\n"
"# MIDLEFT left edge, halfway up (or LEFTCENTER / LEFTCENTRE)\n"
"# MIDRIGHT right edge, halfway up (or RIGHTCENTER / RIGHTCENTRE)\n"
"# Anywhere but CENTER stops it blocking the middle of the road, which\n"
"# is worth having on a wide screen.\n"
"#\n"
"# In a bottom corner it is one of three panes along the bottom, and the\n"
"# lower MFD whose corner it takes slides inboard beside it. Halfway up\n"
"# a side it leaves the bottom row entirely and sits between that side's\n"
"# two MFDs - roomy on a tall radar, but if the MFDs on that side are\n"
"# also scaled up, the radar is the one that gives way (it has to clear\n"
"# both of them, and it grows from the middle in both directions).\n"
"L4RADARPOS=CENTER\n"
"\n"
"# The same pane as a LIVE CAM, which is a different job: no pod, so no\n"
"# instrument MFDs beside it, the map lies down landscape instead of the\n"
"# pod's portrait mounting, and dead centre is the worst place to put a\n"
"# panel on a shot. It therefore has its own position and size, taking\n"
"# the same values as L4RADARPOS / L4RADARSCALE above and defaulting to\n"
"# the bottom-left corner.\n"
"#\n"
"# Two settings rather than one on purpose: the host picks Racer or Live\n"
"# Cam from the setup screen, so switching role must not mean editing\n"
"# this file. Each role remembers its own.\n"
"L4MAPPOS=LEFT\n"
"L4MAPSCALE=100\n"
"\n"
"# The Winners Circle: at the end of a race the finishers are stood on\n"
"# the award platform in finishing order, with each pilot's callsign on\n"
"# the plate beside their spot, and held there for a few seconds before\n"
"# the results screen. 1 = show it, 0 = straight to the results.\n"
"RP412PODIUM=1\n"
"\n"
"# The shot is framed for you, but these move the camera if you want it\n"
"# somewhere else. Distances are in game units, measured from the middle\n"
"# of the group of finishers.\n"
"# STANDOFF how far out in front of the stand the camera sits\n"
"# HEIGHT how far above the group\n"
"# AIM height of the point it looks at, relative to the group -\n"
"# negative tilts down, positive tilts up\n"
"# ASPECT the stand was composed for a 4:3 pod monitor, so the shot\n"
"# is cropped to that shape with black either side. 0 runs it\n"
"# full width instead.\n"
"# FADEIN seconds to come up out of the black after the race fades\n"
"# CAM 0 watches from your own cockpit rather than off the stand\n"
"#RP412PODIUMSTANDOFF=36\n"
"#RP412PODIUMHEIGHT=12\n"
"#RP412PODIUMAIM=2\n"
"#RP412PODIUMASPECT=1.333\n"
"#RP412PODIUMFADEIN=0.45\n"
"#RP412PODIUMCAM=1\n"
"\n"
"# How long the podium holds before the results screen, in seconds\n"
"# (1-60). The stand is worth a look but eleven seconds of one parked\n"
"# pod is a long look in single player. With RP412PODIUM=0 there is no\n"
"# hold at all - straight to the results, as that option promises.\n"
"#RP412PODIUMHOLD=11\n"
"\n"
"# Override the game length the menu picked, in seconds. The shortest the\n"
"# menu offers is 3:00, which is a long wait when what you are testing is\n"
"# what happens at the buzzer. Unset = use the menu's choice.\n"
"#RP412MISSIONSECONDS=20\n"
"\n"
"# Simulation/render frame rate, integer frames/second. The desktop\n"
"# default is 60; the arcade pods shipped at 25.\n"
"TARGETFPS=60\n"
"\n"
"# The physics step, in steps per second. 50 is the default: the\n"
"# simulation advances in fixed 20 ms steps whatever the display does,\n"
"# so the SAME race plays out on every machine - measured bit-identical\n"
"# at 30, 60 and 144 fps, through a scripted lap with a crash, a burn\n"
"# and two respawns. A pod at 30 fps and a pod at 144 are finally in\n"
"# the same gravity.\n"
"#\n"
"# The alternatives, all exact on the engine's millisecond clock:\n"
"# 25 the arcade pods' rate - the step the original handling was\n"
"# tuned against, coarsest contact response\n"
"# 100 the smoothest contact and terrain response\n"
"# 0 the original frame-coupled physics, where the frame rate is\n"
"# part of the simulation - kept for comparison\n"
"# (Rates that do not divide 1000 evenly - 60, say - quietly run at the\n"
"# nearest millisecond step instead; the log says so if you try one.)\n"
"#\n"
"# What to feel for between rates: hover bounce, wall hits, how the pod\n"
"# takes the crest of a hill. Report the rate with the verdict.\n"
"RP412PHYSICSHZ=50\n"
"\n"
"# 0 = draw the simulation's exact stepped positions, as builds before\n"
"# this one did. On by default, and it is what lets the frame rate be\n"
"# anything you like.\n"
"#\n"
"# With a fixed physics step the drawn position only changes at the step\n"
"# rate, so any other frame rate holds each position for a whole number\n"
"# of frames and the motion visibly steps - worse the FASTER the machine,\n"
"# since 240 fps holds each one for nearly five frames. Drawing therefore\n"
"# blends across the step it is inside. The simulation is not touched: the\n"
"# same script drives the same race bit for bit either way, which is what\n"
"# RP412PHYSTRACE below is for.\n"
"#\n"
"# The cost is that the picture trails the simulation by up to one step\n"
"# (20 ms at 50 Hz). Turn it off to see the stepping it removes, or if you\n"
"# would rather have those 20 ms than smooth motion.\n"
"#RP412INTERP=0\n"
"\n"
"# Set 0 to go back to the original guess at when the next update for\n"
"# another player's pod will arrive. On by default.\n"
"#\n"
"# Between updates, another player's pod is moved by dead reckoning: it\n"
"# advances toward where it is projected to be by a fraction of the gap\n"
"# each step, and that fraction is decided by when the next update is\n"
"# expected. The original code expected the next gap to match the one\n"
"# before it, which held on a LAN where the gaps were all alike. Over the\n"
"# internet one late packet doubles the expected gap, the fraction\n"
"# collapses, the pod barely moves for a step and then catches up - a\n"
"# tick roughly once a second on a live connection.\n"
"#\n"
"# The default instead takes the middle value of the last eight gaps, so\n"
"# one straggler is ignored while a real change in the rate is still\n"
"# followed. This changes how other players' pods MOVE, not merely how\n"
"# they are drawn, so it affects collisions with them too - keep it the\n"
"# same on every machine in a race.\n"
"#RP412NETPREDICT=0\n"
"\n"
"# How long one background pass may spend drawing cockpit gauges, in\n"
"# milliseconds. The gauges and the MFD/map displays are redrawn in the\n"
"# time left over after the 3D view; on a big, busy map there is none\n"
"# left, and at the original one-gauge-per-pass the map and the countdown\n"
"# clock could sit frozen for seconds at a time - until something (a\n"
"# death, say) lightened the 3D view enough for the backlog to drain.\n"
"# Working to a slice ties the refresh rate to elapsed time instead. Set\n"
"# 0 for the old behaviour; raise it to favour the displays over frame\n"
"# rate.\n"
"RP412GAUGESLICE=2\n"
"\n"
"# How many times the map redraws per turn of the gauge rate wheel, 1 to\n"
"# 16. The renderer gives each gauge one step of a sixteen-step wheel and\n"
"# a gauge redraws only on its own step, so a map left on one step waits a\n"
"# whole turn. 16 = redraw on every step (default); 1 = whatever the gauge\n"
"# data asks for, which is how it behaved before this existed. Each step\n"
"# costs one map redraw against a pass that runs ninety gauges.\n"
"RP412MAPRATE=16\n"
"\n"
"# 1 = log how many times a second every cockpit display is actually\n"
"# refreshed, to rpl4.log. Watching the screen cannot tell a display that\n"
"# has stopped refreshing from one whose picture simply is not changing.\n"
"#RP412GAUGEDIAG=1\n"
"\n"
"# 0 = light the on-screen cockpit buttons on the same slow cadence the\n"
"# arcade pod's serial hardware used. The lamp state is filled once per\n"
"# gauge cycle, so under the load described above the lit buttons froze\n"
"# and flashing ones stalled while the 3D view stayed perfectly smooth.\n"
"# On by default: the buttons are refreshed every frame instead. Ignored\n"
"# when real RIO hardware is selected - the pod keeps its own cadence.\n"
"#RP412LAMPSWEEP=0\n"
"\n"
"# 1 = Steam networking (lobbies, FakeIP mesh). Needs steam_api.dll, the\n"
"# Steam client running, and steam_appid.txt beside the exe; missing any\n"
"# of them logs the reason and falls back to plain TCP - nothing here can\n"
"# stop the game starting, and steam_api.dll being absent altogether is\n"
"# fine. 0 = TCP only.\n"
"RP412STEAM=1\n"
"\n"
"# Line up each remote player's clock with ours, so their vehicle is\n"
"# extrapolated from when its update was SENT rather than when it\n"
"# arrived. Without it every remote pod sits one network latency behind\n"
"# where it should be - invisible on the 1ms arcade LAN the engine was\n"
"# written for, a constant 50-150ms of lag over the internet. 0 restores\n"
"# the old arrival-time behaviour if you want to compare.\n"
"#RP412NETCLOCK=0\n"
"\n"
"# How long each connection attempt to another machine may take, in\n"
"# seconds. 2 to 300, default 20, and up to three attempts are made.\n"
"#\n"
"# Per attempt matters. Steam tries a direct path first, and behind some\n"
"# routers that burns ten seconds and fails; the second attempt comes up\n"
"# through Valve's relay and succeeds - if it is given time. The first\n"
"# playtest with six players showed a shared budget cutting that second\n"
"# attempt off mid-connect, and races could not assemble because the\n"
"# mesh needs every machine to reach every other. Three attempts of\n"
"# twenty seconds each connects the awkward router in about half a\n"
"# minute, and gives up on a truly unreachable one inside a minute.\n"
"#\n"
"# The window stays alive throughout, the title bar names the peer and\n"
"# counts down each attempt, and ESC gives up immediately.\n"
"RP412CONNECTWAIT=20\n"
"\n"
"# How much memory to set aside for a recording, in megabytes. 1 to 512,\n"
"# default 100.\n"
"#\n"
"# RECORDING on the setup screen keeps a spool of the race in SPOOLS\\,\n"
"# named for the time it finished, and copies it to last.spl. It records\n"
"# what THIS machine received, so a Live Cam host - which watches rather\n"
"# than races, and therefore hears every pod over the wire - keeps the\n"
"# most complete account of a race there is.\n"
"#\n"
"# A full grid sends on the order of 17KB a second, so a hundred\n"
"# megabytes is around an hour and a half. If a race outlasts the buffer\n"
"# the recording simply stops and the race carries on - it is never worth\n"
"# interrupting a race to protect a recording of it - and the log says so.\n"
"RP412RECORDSIZE=100\n"
"\n"
"# ---- Test harness -----------------------------------------------------------\n"
"\n"
"# The knobs that make a run repeatable and measurable. All are off\n"
"# unless set and cost nothing when off; none belongs in a real race.\n"
"# They exist so a claim about the game can be tested instead of argued.\n"
"\n"
"# Dump the gauge profile to rpl4.log every N seconds: every cockpit\n"
"# display with its rate mask and tier, how often it ran and what it\n"
"# cost. This is the engine's own ProfileReport, which was only ever\n"
"# reachable from the arcade RIO mapper's F11 before.\n"
"#RP412GAUGEPROFILE=8\n"
"\n"
"# 1 = log renderable construction and what each frame is made of, so a\n"
"# model that never got built can be told from one that is simply out\n"
"# of shot.\n"
"#RP412RENDERDIAG=1\n"
"\n"
"# 1 = trace the player pod's position to rpl4.log on the SIMULATION's\n"
"# own clock, stopping the pod dead at the green light so every run\n"
"# starts from rest. Two runs of the same race then compare sample for\n"
"# sample - this is the instrument that proved RP412PHYSICSHZ plays the\n"
"# same race at every frame rate, bit for bit.\n"
"#RP412PHYSTRACE=1\n"
"\n"
"# Try this drop zone first at spawn instead of a random pick. The pick\n"
"# is seeded by RANDOM=, but a seed only repeats a run if the same\n"
"# NUMBER of draws comes before the pick, and that count rides on load\n"
"# timing - so pin the pad too, or two 'identical' runs start over\n"
"# different ground. Falls back to the random walk if the zone is\n"
"# taken, so it cannot wedge.\n"
"#RP412SPAWNZONE=3\n"
"\n"
"# Drive the pod from a timeline file instead of the controls - the\n"
"# same lap, exactly, every run. One row per change, held until the\n"
"# next row: time-in-seconds throttle stickX stickY pedals, values\n"
"# 0..1 for throttle and -1..1 elsewhere, # for comments. Times are\n"
"# SIMULATION seconds from the green light, so with RP412PHYSICSHZ set\n"
"# the same script is the same race at any frame rate - this is how\n"
"# driving, not just settling, gets verified bit-identical.\n"
"#RP412INPUTSCRIPT=testlap.txt\n"
"\n"
"# 1 = log the XInput-class controllers the generic-joystick scan skips\n"
"# (attached DirectInput devices are always logged). For debugging a pad\n"
"# that answers twice or a stick that does not answer at all.\n"
"#RP412JOYLOG=1\n"
"\n"
"# ---- Optional ---------------------------------------------------------------\n"
"\n"
"# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"
"# lamp buttons glow with the panel, flash modes and all.\n"
"# Unset or nonzero = on (the default); 0 = off.\n"
"#RP412KEYLIGHT=0\n"
"\n"
"# The cabinets ran the game at unity and did all their volume and tone\n"
"# shaping outside it, in an amplifier and a 3-way crossover. You almost\n"
"# certainly have neither, so these two stand in for them. Both default\n"
"# to leaving the mix exactly as the pod played it.\n"
"\n"
"# Master volume, 0.0 to 2.0, the amplifier's knob. 1.0 is unity. The\n"
"# sound effects now carry the pitch, layering and dynamics the original\n"
"# AWE32 soundbanks ask for, which is a good deal livelier than earlier\n"
"# 4.12 builds - lower this if the whole thing sits too hot.\n"
"#\n"
"# PageUp and PageDown change it while you play, in steps of 0.05, and\n"
"# whatever you leave it on is written to volume.cfg beside the exe and\n"
"# used from then on - so this line only decides where a machine that has\n"
"# never been touched starts out. Delete volume.cfg to come back here.\n"
"#RP412AUDIOVOLUME=0.8\n"
"\n"
"# Bass trim, 0.0 to 1.0, the crossover's low band. 1.0 is the low end\n"
"# exactly as authored. The soundbanks put real weight under collisions,\n"
"# engines and explosions - deep layers earlier builds played at the\n"
"# wrong rate, so they barely sounded at all. Lower this to pull that\n"
"# back; it eases in below 22kHz of playback rate and reaches full cut\n"
"# on the deepest layers, leaving the mid and top alone.\n"
"#\n"
"# Home and End change it while you play, in steps of 0.05, and what you\n"
"# leave it on is written to bass.cfg beside the exe and used from then\n"
"# on - so this line only decides where an untouched machine starts.\n"
"# Delete bass.cfg to come back here.\n"
"#RP412AUDIOBASS=0.7\n"
"\n"
"# Invert the stick on top of whatever bindings.txt produces:\n"
"# X = invert X only, Y = invert Y only, XY = both (case-insensitive).\n"
"#L4PADFLIP=XY\n"
"\n"
"# Who transforms the vertices: hw hands it to the GPU, sw does it on the\n"
"# CPU as this engine always has. There was no hardware to hand it to when\n"
"# it was written; there is now, and it is not close - on a busy track the\n"
"# 3D foreground drops from about 17ms a frame to under half a\n"
"# millisecond, and all of that time goes back to the cockpit displays,\n"
"# which is what makes the map, the clock and the gauges live rather than\n"
"# updating every few seconds.\n"
"# Falls back to sw by itself if the adapter has no hardware T&L. sw is\n"
"# the way back if a driver's fixed-function lighting or fog looks wrong -\n"
"# the two are not bit-identical.\n"
"RP412VERTEXPROC=hw\n"
"\n"
"# 0 = present without waiting for the panel's retrace. Costs tearing,\n"
"# buys latency. Measured to make very little difference to the frame\n"
"# budget here - the frame is full of work, not waiting - so this is a\n"
"# preference rather than a fix.\n"
"#RP412VSYNC=0\n"
"\n"
"# Anti-aliasing sample count, passed straight to Direct3D 9:\n"
"# 0 = off, else 2..16 as the GPU supports (1 selects the driver's\n"
"# \"nonmaskable\" mode; unsupported counts fail device creation).\n"
"#MULTISAMPLE=0\n"
"\n"
"# Particle budget, integer. Default 8192.\n"
"#MAXPARTICLES=8192\n"
"\n"
"# On-screen plasma glass (L4PLASMA=SCREEN only). SCALE = integer pixel\n"
"# size 1..16, default 4 (out-of-range values are ignored). POS = window\n"
"# top-left as X,Y screen coordinates; unset = auto, parked below the\n"
"# main window.\n"
"#L4PLASMASCALE=4\n"
"#L4PLASMAPOS=0,0\n"
"\n"
"# Fixed random seed (repeatable runs): any unsigned integer.\n"
"# Unset seeds from the clock.\n"
"#RANDOM=12345\n"
"\n"
"# ---- LAN play without Steam -------------------------------------------------\n"
"# Host a race over plain TCP: list the member pods' console channels\n"
"# (members run: rpl4opt.exe -windowed -res 1920 1080 -net 1501).\n"
"# RP412HOSTPODS comma-separated IP[:port] list, one entry per member\n"
"# pod; port defaults to 1501 per entry\n"
"# RP412HOSTPORT this machine's console port, integer > 0\n"
"# (default 1501)\n"
"# RP412HOSTADDR this machine's LAN IP as members can reach it\n"
"# (default 127.0.0.1)\n"
"#RP412HOSTPODS=192.168.1.20:1501,192.168.1.21:1501\n"
"#RP412HOSTPORT=1501\n"
"#RP412HOSTADDR=192.168.1.10\n"
"\n"
"# ---- Developer / testing ----------------------------------------------------\n"
"\n"
"# Nonzero arms the debug keys. Two of them do something in this build:\n"
"# Alt+W wireframe. The sky is not drawn and the view clears to\n"
"# black, so the edges stand on their own; the gunsight and\n"
"# cam-ship HUD stay solid.\n"
"# Alt+E write the event queue to the log.\n"
"# The others are stubs the 2007 DPL->Direct3D port left behind and do\n"
"# nothing at all. They are named here so a dead key is not mistaken for\n"
"# a broken one: Alt+V predator vision, Alt+F frame dump, Alt+/ perf\n"
"# stats and Alt+K free memory are silent; Alt+R dither pattern and\n"
"# Alt+P eyepoint position at least say so in the log.\n"
"#\n"
"# All of them arrive through the engine keyboard handler, so L4CONTROLS\n"
"# has to include KEYBOARD - the shipped stack does. That handler reads\n"
"# one key per frame off the front of the message queue and so drops\n"
"# presses; if a key seems dead, press it again before believing it.\n"
"# 0 or unset = off. (Alt+Q, the mission abort, is always live.)\n"
"#RP412DEVKEYS=1\n"
"\n"
"# Nonzero traces the Live Cam bring-up: the egg's host type and game\n"
"# model, the registry choosing a camera director, the director making\n"
"# its camera ship, and the launch handshake. A camera station is picked\n"
"# purely by egg data (hostType=1 and vehicle=camera on that host's\n"
"# entry), so without this there is nothing in the log to say how far it\n"
"# got. Prints once a second while a station sits unlaunched.\n"
"#RP412CAMLOG=1\n"
"\n"
"# Console race-length override, integer seconds (short test races).\n"
"# Values <= 0 are ignored.\n"
"#L4CONSOLELEN=30\n"
"\n"
"# Nonzero = Steam transport loopback self-test at boot (logs PASS/FAIL).\n"
"#RP412STEAMSELFTEST=1\n"
"\n"
"# ---- Arcade heritage (multi-monitor pods; not used on the desktop) ----------\n"
"# PRIMGAUGE / SECGAUGE / MFDGAUGE / MFDGAUGE2 pin a display to a monitor\n"
"# by adapter index (0, 1, 2...). SPANDISABLE: 0 = let the MFDs span one\n"
"# wide surface, nonzero = separate windows (setting MFDGAUGE2 alone also\n"
"# forces spanning off). L4EYES = \"x y z xrot yrot zrot [type]\" floats\n"
"# for a detached camera; a type starting with r offsets it relative to\n"
"# the pod. L4INTERCOM enables the crew intercom - only its presence\n"
"# matters (traditionally COM2). NOMODES skips the mode/lamp programming;\n"
"# presence alone triggers it, even NOMODES=0. LOGSIZE > 0 sizes the\n"
"# trace log in dev builds compiled with tracing.\n"
"#PRIMGAUGE=1\n"
"#SECGAUGE=2\n"
"#MFDGAUGE=3\n"
"#MFDGAUGE2=4\n"
"#SPANDISABLE=1\n"
"#L4EYES=1\n"
"#L4INTERCOM=COM2\n"
"#NOMODES=1\n"
"#LOGSIZE=1000000\n"
;
//-------------------------------------------------------------------
// Does the player's file mention this key at all - set, or commented
// out, or with whitespace in front of it?
//
// Deliberately generous: a key that is mentioned in ANY form is left
// alone. The alternative failure is worse than a missed notice, since
// environ.ini is applied line by line and a second copy of a key
// further down the file would silently override the player's own.
//-------------------------------------------------------------------
Logical FileMentionsKey(const char *text, const char *key, int key_length)
{
const char *cursor = text;
while ((cursor = strstr(cursor, key)) != NULL)
{
//
// Must be a whole key: preceded by start-of-line, whitespace
// or a comment mark, and followed by '='.
//
const char *after = cursor + key_length;
Logical starts_token =
(cursor == text) ||
(cursor[-1] == '\n') || (cursor[-1] == '\r') ||
(cursor[-1] == ' ') || (cursor[-1] == '\t') ||
(cursor[-1] == '#') || (cursor[-1] == ';');
if (starts_token)
{
const char *scan = after;
while (*scan == ' ' || *scan == '\t')
{
++scan;
}
if (*scan == '=')
{
return True;
}
}
cursor = after;
}
return False;
}
//-------------------------------------------------------------------
// Name every template key the player's file has never heard of. Not
// a fix - their file stays theirs - but it puts the reason for a
// missing feature in the log we already ask testers for.
//-------------------------------------------------------------------
void ReportUnmentionedKeys(const char *file_text)
{
char missing[1024]; // what gets printed
char seen[1024]; // the same keys as "KEY=", so the mention
// test above can dedupe against them
missing[0] = '\0';
seen[0] = '\0';
int count = 0; // how many are missing
int listed = 0; // how many fitted in the line
const char *cursor = kEnvironTemplate;
while (*cursor != '\0')
{
const char *line = cursor;
const char *end = strchr(line, '\n');
int length = (end != NULL) ? (int)(end - line) : (int) strlen(line);
cursor = (end != NULL) ? (end + 1) : (line + length);
//
// A template key line is "KEY=..." or "#KEY=..." - the
// commented ones are options that ship switched off, and a
// player who has never seen them wants to know they exist.
//
const char *scan = line;
int remaining = length;
if (remaining > 0 && *scan == '#')
{
++scan;
--remaining;
}
if (remaining <= 0 || !(isalpha((unsigned char) *scan) || *scan == '_'))
{
continue;
}
int key_length = 0;
while (key_length < remaining &&
(isalnum((unsigned char) scan[key_length]) || scan[key_length] == '_'))
{
++key_length;
}
if (key_length >= remaining || scan[key_length] != '=' || key_length > 60)
{
continue;
}
char key[64];
memcpy(key, scan, key_length);
key[key_length] = '\0';
if (FileMentionsKey(file_text, key, key_length))
{
continue;
}
//
// Templates list some keys twice (documented once, shown
// again in an example); do not name one twice.
//
if (FileMentionsKey(seen, key, key_length))
{
continue;
}
++count;
if (strlen(seen) + key_length + 3 < sizeof(seen))
{
strcat(seen, key);
strcat(seen, "=\n");
}
if (strlen(missing) + key_length + 3 < sizeof(missing))
{
if (missing[0] != '\0')
{
strcat(missing, ", ");
}
strcat(missing, key);
++listed;
}
}
if (count > 0)
{
//
// Say when the list is short of the count rather than letting
// a full buffer quietly shorten the answer.
//
DEBUG_STREAM << "Environ: " << kEnvironFileName << " does not mention "
<< count << " option(s) this build knows: " << missing;
if (listed < count)
{
DEBUG_STREAM << ", and " << (count - listed) << " more";
}
DEBUG_STREAM << "\nEnviron: they are at their built-in defaults - delete "
<< kEnvironFileName << " to get the documented file back\n"
<< std::flush;
}
}
}
void
RPL4Environ_Load()
{
//
// First run: lay down the documented default. From here on the file
// belongs to whoever is sitting at this machine.
//
FILE *file = fopen(kEnvironFileName, "rb");
if (file == NULL)
{
FILE *out = fopen(kEnvironFileName, "wb");
if (out != NULL)
{
fwrite(kEnvironTemplate, 1, strlen(kEnvironTemplate), out);
fclose(out);
DEBUG_STREAM << "Environ: wrote default " << kEnvironFileName
<< "\n" << std::flush;
}
else
{
DEBUG_STREAM << "Environ: could not write " << kEnvironFileName
<< " - running on built-in defaults\n" << std::flush;
}
file = fopen(kEnvironFileName, "rb");
}
if (file == NULL)
{
return;
}
fseek(file, 0, SEEK_END);
long size = ftell(file);
fseek(file, 0, SEEK_SET);
if (size <= 0)
{
fclose(file);
return;
}
char *text = new char[size + 1];
size_t read = fread(text, 1, size, file);
text[read] = '\0';
fclose(file);
//
// One KEY=VALUE per line. Comments, blanks and anything without an
// '=' are skipped; everything else goes into the environment, which
// is why a line here beats a variable set in the shell.
//
int applied = 0;
char line[1024];
//
// Keys already applied, so a second copy of one can be reported. The
// file is applied line by line, so a later line silently beats an
// earlier one - which is a genuinely expensive way to lose an evening:
// a TARGETFPS added at the top of this file was overridden by the one
// the template ships further down, and the test it was written for
// looked like it had failed rather than never having run.
//
// Named rather than counted: knowing WHICH key and which lines is the
// whole value.
//
char seen_keys[4096];
int seen_length = 0;
seen_keys[0] = '\0';
int line_number = 0;
const char *cursor = text;
while (*cursor != '\0')
{
++line_number;
int length = 0;
while (cursor[length] != '\0' && cursor[length] != '\n' &&
length < (int) sizeof(line) - 1)
{
line[length] = cursor[length];
++length;
}
line[length] = '\0';
cursor += length;
while (*cursor == '\n' || *cursor == '\r')
{
++cursor;
}
for (int i = length - 1; i >= 0; --i)
{
if (line[i] == '\r' || line[i] == '\n')
{
line[i] = '\0';
}
}
char *setting = line;
while (*setting == ' ' || *setting == '\t')
{
++setting;
}
if (*setting == '\0' || *setting == '#' || *setting == ';' ||
strchr(setting, '=') == NULL)
{
continue;
}
//
// Warn on a repeat before applying it, naming the key and both
// lines. The later value is the one that survives, which is worth
// stating outright rather than leaving to be deduced.
//
{
char key[128];
int key_length = 0;
while (setting[key_length] != '\0' && setting[key_length] != '=' &&
key_length < (int) sizeof(key) - 1)
{
key[key_length] = setting[key_length];
++key_length;
}
key[key_length] = '\0';
// entries are stored as "KEY\tLINE\n"
char needle[132];
sprintf(needle, "\n%s\t", key);
const char *found = (seen_length > 0) ? strstr(seen_keys, needle) : NULL;
if (found != NULL)
{
DEBUG_STREAM << "Environ: " << key << " is set twice - line "
<< atoi(found + strlen(needle)) << " and line " << line_number
<< "; the LATER one wins\n" << std::flush;
}
else if (seen_length + key_length + 16 < (int) sizeof(seen_keys))
{
seen_length += sprintf(seen_keys + seen_length, "\n%s\t%d",
key, line_number);
}
}
putenv(setting);
++applied;
}
DEBUG_STREAM << "Environ: " << applied << " setting(s) from "
<< kEnvironFileName << "\n" << std::flush;
ReportUnmentionedKeys(text);
delete[] text;
}
+44
View File
@@ -0,0 +1,44 @@
//===========================================================================//
// File: rpl4environ.h //
// Project: MUNGA Brick: Red Planet LBE Application //
// Contents: environ.ini - written on first run, then the player's //
//---------------------------------------------------------------------------//
// Copyright (C) 1994-1995, Virtual World Entertainment, Inc. //
// PROPRIETARY AND CONFIDENTIAL //
//===========================================================================//
#pragma once
#include "..\munga\style.h"
//########################################################################
//
// environ.ini is the game's configuration: one KEY=VALUE per line, read
// once at startup and pushed into the environment, so every option the
// engine reads through getenv can be set from a file a player can open.
//
// The exe owns the template and writes it when the file is absent, the
// same way bindings.txt works, rather than the packaging script laying
// one down on every unzip. That is what lets a tester drop a new build
// over an old folder and keep their settings: the file is theirs from
// the moment it exists, and nothing overwrites it.
//
// It cannot simply be optional. Without it L4GAUGE is unset, which
// disables the gauge renderer and takes every MFD with it, and
// L4MFDSPLIT is unset, which is the packed-window arcade layout rather
// than the glass cockpit. The shipped values are the desktop game; the
// built-in getenv fallbacks are the 1995 pod.
//
// The cost of a file that is never overwritten is that a tester carrying
// one across many builds stops being offered new options. Options added
// later default to "behave as before", so nothing breaks - but it does
// go unnoticed, so the load names any template key the player's file
// does not mention. That line in rpl4.log is what turns "the podium does
// not work" into "your environ.ini predates RP412PODIUM".
//
//########################################################################
// Write environ.ini if it is not there, then read it into the
// environment. Call once, before anything reads a setting.
void
RPL4Environ_Load();
+1380 -143
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -34,6 +34,15 @@ int
const char *
RPL4FrontEnd_LastPilotNames();
//
// The callsign the player typed on the setup screen (persisted in
// pilot.cfg). The lobby publishes this as the member name: what somebody
// writes in the box is what the room and the race should call them.
// Empty, or still the untouched default, when they have never set one.
//
const char *
RPL4FrontEnd_Callsign();
// How the last Run() ended: a plain race, hosting a network race (the
// console marshals the other pods), or entering one as a member pod
// (the lobby owner's console marshals us; no local egg, no console).
@@ -110,6 +119,16 @@ void
Logical
RPL4FrontEnd_IsFootballSelected();
//
// True when this player's setup menu has YOUR ROLE on Live Cam. Only the
// HOST's pick is acted on - the host is the one that writes the egg - so
// the lobby publishes this and then shows it against the owner's row
// only. A member who picked it still races, which is what their row goes
// on saying.
//
Logical
RPL4FrontEnd_IsCameraRole();
// Hosted-race pilots fed by the Steam lobby: overrides the
// RP412HOSTPODS parsing with real personas and loadouts. owner_address
// is this pod's mesh IP (the FakeIP); count 0 clears the override.
@@ -123,6 +142,11 @@ struct FEHostedPilot
// football: the member's own picks, empty = assign one for them
char team[32]; // team key ("Red/Pink", ...)
char position[16]; // "runner" / "crusher" / "blocker"
// the member picked Live Cam: hostType=1, vehicle=camera in the egg.
// Somebody still has to race - the egg builder strips every cam pick
// if honouring them would leave nothing to point a camera at.
Logical camera;
};
void
RPL4FrontEnd_SetHostedPilots(
+333 -45
View File
@@ -1,4 +1,5 @@
#include "rpl4.h"
#pragma hdrstop
#define PRELOAD_ART
@@ -1556,6 +1557,21 @@ void
DrawNames(worldToView);
localView.DetachRecorder();
//
// RP412CAMLOG: the sweep reached the drawing phase at all. If the
// counts above are non-zero and this never appears, the gauge
// slice is not getting this display as far as phase 3.
//
if (RPCameraLog())
{
static int drew = 0;
if (drew < 3)
{
++drew;
DEBUG_STREAM << "CamLog: nav drew (phase 3 reached)\n" << std::flush;
}
}
// deliberately falls through into default case
default:
@@ -1630,6 +1646,26 @@ void
Test_Tell("x= " << xMin << "..." << xMax << "\n");
Test_Tell("y= " << yMin << "..." << yMax << "\n");
Test_Tell("z= " << zMin << "..." << zMax << "\n");
//
// RP412CAMLOG, on a clock rather than every sweep - this runs at the
// gauge rate and would drown the log. Says whether the nav display has
// a sane centre and scale, and repeats so the centre can be seen
// tracking (or not tracking) as the camera moves.
//
if (RPCameraLog())
{
static Scalar next_bounds_say = 0.0f;
if ((Scalar) Now() >= next_bounds_say)
{
next_bounds_say = ((Scalar) Now()) + 5.0f;
DEBUG_STREAM << "CamLog: nav scale " << currentScale
<< "m across, " << pixelsPerMeter << " px/m, centre "
<< viewing_position.x << "," << viewing_position.z
<< " bounds x " << xMin << ".." << xMax
<< " z " << zMin << ".." << zMax << "\n" << std::flush;
}
}
Check_Fpu();
}
@@ -1660,6 +1696,33 @@ void
xMin, yMin, zMin,
xMax, yMax, zMax
);
//
// RP412CAMLOG. The counts are the whole question: furniture with no
// content means either nothing fell inside the bounds above, or the
// drawing steps are not being reached. Reported for the first few
// sweeps only - the lists are rebuilt at the gauge rate.
//
//
// Sampled on a clock, NOT for the first N sweeps. The first sweeps run
// while the mission is still coming up and nothing has registered
// with the renderer yet, so they report zero on a station whose map
// then works perfectly - which is exactly the false reading this trace
// gave the first time it was written.
//
if (RPCameraLog())
{
static Scalar next_nav_say = 0.0f;
if ((Scalar) Now() >= next_nav_say)
{
next_nav_say = ((Scalar) Now()) + 5.0f;
ChainIteratorOf<Entity*> statics(staticEntityList.GetInstanceList());
ChainIteratorOf<Entity*> movers(movingEntityList.GetInstanceList());
DEBUG_STREAM << "CamLog: nav in bounds - " << statics.GetSize()
<< " static, " << movers.GetSize() << " moving\n" << std::flush;
}
}
Check_Fpu();
}
@@ -1951,7 +2014,75 @@ GPS::GPS(
//-----------------------------------------------------------
background = new Video8BitBuffered(width, height);
Register_Object(background);
//
//-----------------------------------------------------------------
// RP412MAPRATE - how many of the sixteen rate steps the map redraws
// on.
//
// The gauge renderer walks a 16-bit rate wheel: one bit per full
// pass over every active gauge, shifted right each pass and reset at
// the bottom (GAUGREND.cpp). A gauge redraws only on the step its
// configured rate names, so a map on one bit redraws once per
// SIXTEEN passes - and its update period is sixteen passes however
// cheap the redraw is.
//
// That was fine when a pass was quick. Racing, the background loop
// only gets what is left of the frame after the 3D, passes fall to
// about five a second, and sixteen of them is over three seconds
// between map updates - measured, on a 60 fps display with the 3D
// perfectly smooth. Nothing is slow here; the map is just waiting
// its turn on a wheel built for a machine that came round faster.
//
// Drawing on more of the steps costs one gauge's redraw per step,
// against a pass that runs ninety of them. It does not make the
// wheel turn faster - it stops the map needing a whole turn.
//-----------------------------------------------------------------
//
{
static int updates = -1;
if (updates < 0)
{
const char *setting = getenv("RP412MAPRATE");
updates = (setting != NULL) ? atoi(setting) : 16;
// 1..16, and only the powers of two divide the wheel evenly
if (updates > 16) updates = 16;
if (updates < 1) updates = 1;
}
if (updates > 1)
{
GaugeRate mask = 0;
for (int step = 0; step < 16; step += (16 / updates))
{
mask |= (GaugeRate)(0x8000 >> step);
}
//
// QUALIFIED, both of them. This constructor's first
// parameter is also called 'rate', so a bare assignment
// here writes the PARAMETER and leaves the member holding
// whatever the gauge data asked for - which is what it did,
// silently, and cost a long hunt for a writer that did not
// exist. oldRate is not shadowed, which is why it took the
// value and the pair disagreed.
//
// Gauge::Disable(False) restores rate from oldRate when the
// mode system enables a gauge, so both have to carry it or
// the first activation puts the old rate back.
//
Gauge::rate = mask;
Gauge::oldRate = mask;
}
}
needsStaticUpdate = True;
//
// Nothing drawn yet, so there is no picture to add a placement to and
// no scale it would be added at. The first pass builds both.
//
backgroundBuilt = False;
builtMinX = builtMinY = builtMinZ = (Scalar) 0;
builtMaxX = builtMaxY = builtMaxZ = (Scalar) 0;
Check_Fpu();
}
@@ -1989,6 +2120,95 @@ void
void
GPS::NotifyOfNewInterestingEntity(Entity *entity)
{
Check(this);
Check(entity);
if (!entity->IsDerivedFrom(*Terrain::GetClassDerivations()))
{
Check_Fpu();
return;
}
//
// A rebuild is already owed, or there is no picture to add to yet.
//
if (needsStaticUpdate || !backgroundBuilt)
{
needsStaticUpdate = True;
Check_Fpu();
return;
}
//
// Terrain arrives as you DRIVE - the interest system hands each piece
// over as it comes into range, not all of it at load - and this used
// to order a rebuild of the entire map for every one of them. A
// rebuild redraws every placement on the track, so the cost of one
// arriving piece was the whole track, and the map went seconds
// between updates on the tracks that draw the most: Tour De Mars at
// 351 placements and Ares' Armpits at 320, against 80-140 for a
// typical one. It is also not interruptible - the gauge loop checks
// its time slice BETWEEN gauges - so a rebuild stalled every other
// display with it.
//
// But the bounds are what set the scale, and the scale is what the
// whole cached picture was drawn at. An arrival that leaves the
// bounds alone leaves every placement already on the map exactly
// where it belongs, and the only thing missing from the picture is
// the new one. Draw that, and nothing else.
//
// Only a piece that moves an EDGE of the track changes the scale, and
// then the picture really is wrong everywhere and has to be redrawn.
// That is rare, and it gets rarer as the track fills in.
//
// RPL4GaugeRenderer::NotifyOfNewInterestingEntity adds the entity to
// staticEntities before chaining here, so these bounds already
// account for the arrival being judged.
//
Scalar
minX, minY, minZ,
maxX, maxY, maxZ;
Check(renderer);
renderer->GetStaticBounds(
&minX, &minY, &minZ,
&maxX, &maxY, &maxZ
);
if (minX != builtMinX || minY != builtMinY || minZ != builtMinZ ||
maxX != builtMaxX || maxY != builtMaxY || maxZ != builtMaxZ)
{
needsStaticUpdate = True;
}
else if (DrawStaticEntity(entity))
{
//
// Only when something was actually drawn. Terrain without a
// GaugeImage never reaches the map - on these tracks that is
// most of it, 256 of Tour De Mars' 607 - and blitting the
// background again for one of those is pure cost.
//
needsScreenUpdate = True;
}
Check_Fpu();
}
//
//#############################################################################
// NotifyOfBecomingUninterestingEntity
//#############################################################################
//
// The map showed only currently-interesting terrain before any of this,
// and it still does. A placement cannot be un-drawn from a composited
// picture, so a departure is the one case left that costs a full rebuild.
//
// Nothing used to listen for this at all: departures were swept up by the
// rebuild that the very next ARRIVAL ordered, which is no longer ordered.
// Without this the map would keep showing terrain that had gone until
// something moved the bounds.
//
void
GPS::NotifyOfBecomingUninterestingEntity(Entity *entity)
{
Check(this);
Check(entity);
@@ -2104,61 +2324,34 @@ void
ChainIteratorOf<Entity*>
i(staticEntityList.GetInstanceList());
Check(renderer);
L4Warehouse
*warehouse = (L4Warehouse *) renderer->warehousePointer;
Check(warehouse);
Entity
*entity;
L4GaugeImage
*gauge_image;
AffineMatrix
worldToView,
localToView;
Vector3D
scaling_vector;
//------------------------------------
// Scale the display
//------------------------------------
Verify(!Small_Enough(pixelsPerMeter));
scaling_vector.x = pixelsPerMeter;
scaling_vector.y = pixelsPerMeter;
scaling_vector.z = pixelsPerMeter;
worldToView.BuildIdentity();
worldToView *= centeringOffset; // translation
worldToView *= scaling_vector;
int
drawn = 0;
while ((entity=i.ReadAndNext()) != NULL)
{
Check(entity);
//-------------------------------------
// Draw image
//-------------------------------------
gauge_image = warehouse->
gaugeImageBin.GetIfAlreadyExists(entity->GetResourceID());
if (gauge_image != NULL)
{
Check(gauge_image);
localToView.Multiply(entity->localToWorld, worldToView);
gauge_image->Draw(
LODIndex, // value set by creator
metersPerPixel,
&backgroundView,
0, // default color
(AffineMatrix &) localToView
);
warehouse->gaugeImageBin.Release(entity->GetResourceID());
}
DrawStaticEntity(entity);
}
//
// The picture now matches these bounds, and they are what the scale
// everything on it was drawn at came from. Remember them: an arrival
// that leaves them alone can be added to this picture rather than
// replacing it.
//
builtMinX = minX;
builtMinY = minY;
builtMinZ = minZ;
builtMaxX = maxX;
builtMaxY = maxY;
builtMaxZ = maxZ;
backgroundBuilt = True;
//-----------------------------------------------------------
// Redraw new background, restart moving entity display
//-----------------------------------------------------------
@@ -2166,6 +2359,71 @@ void
Check_Fpu();
}
//
//#############################################################################
// DrawStaticEntity
//#############################################################################
//
// One placement onto the cached background, at the scale that background
// was built at. Shared by the full rebuild above and by the single-arrival
// path in NotifyOfNewInterestingEntity, so the two cannot drift into
// drawing the same track two different ways.
//
Logical
GPS::DrawStaticEntity(Entity *entity)
{
Check(this);
Check(entity);
Check(background);
Check(renderer);
L4Warehouse
*warehouse = (L4Warehouse *) renderer->warehousePointer;
Check(warehouse);
L4GaugeImage
*gauge_image =
warehouse->gaugeImageBin.GetIfAlreadyExists(entity->GetResourceID());
if (gauge_image == NULL)
{
return False; // nothing of it appears on the map
}
Check(gauge_image);
L4BytePort
backgroundPort(background, "background", 0);
GraphicsView
backgroundView(&backgroundPort);
backgroundView.SetOrigin(width>>1, height>>1);
Vector3D
scaling_vector;
scaling_vector.x = pixelsPerMeter;
scaling_vector.y = pixelsPerMeter;
scaling_vector.z = pixelsPerMeter;
AffineMatrix
worldToView,
localToView;
worldToView.BuildIdentity();
worldToView *= centeringOffset; // translation
worldToView *= scaling_vector;
localToView.Multiply(entity->localToWorld, worldToView);
gauge_image->Draw(
LODIndex, // value set by creator
metersPerPixel,
&backgroundView,
0, // default color
(AffineMatrix &) localToView
);
warehouse->gaugeImageBin.Release(entity->GetResourceID());
return True;
}
void
GPS::Execute()
@@ -2655,6 +2913,36 @@ void
intercomEnabled = intercom->GetChannel() != Icom::undefinedChannel;
}
//
// RP412CAMLOG. Says what the ranking widget can see: how many
// players are in the group and what rank and score each carries.
// A score that never moves is either not replicated to a camera
// host or simply has not changed - this tells the two apart. Every
// few seconds, not every sweep.
//
if (RPCameraLog())
{
static Scalar next_say = 0.0f;
if ((Scalar) Now() >= next_say)
{
next_say = ((Scalar) Now()) + 5.0f;
ChainIteratorOf<Node*> say(all_players->groupMembers);
Player *seen;
int shown = 0;
DEBUG_STREAM << "CamLog: ranking sees " << say.GetSize()
<< " player(s):";
while ((seen = (Player*) say.ReadAndNext()) != NULL && shown < 8)
{
++shown;
DEBUG_STREAM << " [bmp " << seen->playerBitmapIndex
<< " rank " << seen->playerRanking
<< " score " << (int) seen->currentScore << "]";
}
DEBUG_STREAM << " ourRank=" << currentRanking << "\n" << std::flush;
}
}
//-------------------------------------------
// Clear the current array
//-------------------------------------------
+27 -1
View File
@@ -330,12 +330,27 @@ public:
BecameActive(); // virtual function in 'GaugeBase'
void
NotifyOfNewInterestingEntity(Entity *entity);
//
// Terrain that leaves interest range is dropped from the map, and
// there is no way to un-draw one placement from a composited picture
// - so this is the one case that still costs a full rebuild.
//
void
NotifyOfBecomingUninterestingEntity(Entity *entity);
void
Execute();
protected:
void
UpdateStaticEntities();
//
// Draw one placement into the cached background, at the scale that
// background was built at. False if the entity has no GaugeImage and
// so never appears on the map at all - which on the big tracks is
// most of the terrain in them.
//
Logical
DrawStaticEntity(Entity *entity);
enum
{
@@ -344,7 +359,18 @@ protected:
Logical
needsStaticUpdate,
needsScreenUpdate;
needsScreenUpdate,
// is there a picture worth adding a single placement to?
backgroundBuilt;
//
// The static bounds the background was last built for. They decide
// the scale, so terrain arriving inside them can be drawn onto the
// picture instead of forcing a new one - see
// GPS::NotifyOfNewInterestingEntity.
//
Scalar
builtMinX, builtMinY, builtMinZ,
builtMaxX, builtMaxY, builtMaxZ;
Scalar
LODIndex,
metersPerPixel,
+660 -94
View File
@@ -13,7 +13,17 @@
Logical RPL4Lobby_Available() { return False; }
Logical RPL4Lobby_Configured() { return False; }
Logical RPL4Lobby_InRoom() { return False; }
int RPL4Lobby_Host(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_HostOpen() { return HostOpenFailed; }
int RPL4Lobby_JoinOpen(HWND) { return JoinOpenNothingFound; }
int RPL4Lobby_MemberPoll() { return MemberPollClosed; }
void RPL4Lobby_GetSetup(char *a, char *b, char *c, char *d, char *e)
{ a[0] = b[0] = c[0] = d[0] = e[0] = '\0'; }
Logical RPL4Lobby_IsOwner() { return False; }
void RPL4Lobby_Pump() { }
void RPL4Lobby_PublishSetup() { }
int RPL4Lobby_RosterLines(char [][48], int) { return 0; }
Logical RPL4Lobby_HostLaunch() { return False; }
void RPL4Lobby_Leave() { }
int RPL4Lobby_Join(HINSTANCE, HWND) { return LobbyRoomLeft; }
int RPL4Lobby_Room(HINSTANCE, HWND) { return LobbyRoomLeft; }
void RPL4Lobby_PushRaceResults() { }
@@ -23,6 +33,7 @@ void RPL4Lobby_PullRaceResults() { }
#include "rpl4fe.h"
#include "rpl4console.h"
#include "rpl4build.h" // generated: RP412_VERSION, for the build guard
#include "..\munga_l4\l4steamtransport.h"
#pragma pack(push, 8)
@@ -51,6 +62,40 @@ namespace
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
//-------------------------------------------------------------------
// Simulation protocol revision. Bump this whenever a change makes
// two builds simulate the same mission differently - it is not the
// wire format alone. Map entity ownership is dealt by advancing a
// shared cursor once per map entity, so anything that changes which
// entities are dealt at all silently desynchronizes who owns what.
//
// 2 - doorframes became local Hermit clockwork and are no longer
// dealt, which shifts every subsequent map entity's owner
// 1 - the 3-machine verified Steam build
//-------------------------------------------------------------------
const char kNetRevision[] = "2";
const char kNetRevKey[] = "nr";
// this member picked Live Cam rather than a grid slot
const char kCamKey[] = "cam";
//
// The exact build, so a room cannot mix them.
//
// kNetRevision above is hand-maintained and only bumped when someone
// decides a change alters the simulation - which means two DIFFERENT
// builds normally carry the same revision and will happily race each
// other. That is fine when the difference really is cosmetic and
// disastrous when the judgement was wrong, and it is not a judgement
// anyone should have to make correctly every time. The patch number is
// the repository's commit count, so this compares the actual binary.
//
// Both guards stay: the revision still refuses a mix that is known to
// simulate differently even between builds that agree here, which
// matters for anyone hand-editing a version.
//
const char kBuildKey[] = "bld";
// the owner's mission setup, shown to everyone in the room
const char kMapKey[] = "mp";
const char kTimeKey[] = "td";
@@ -148,8 +193,27 @@ namespace
sprintf(value, "%d", SteamNetTransport_GetFakeGamePort());
SteamMatchmaking()->SetLobbyMemberData(gLobby, "gp", value);
//
// The name in the room is the CALLSIGN from the setup screen, not
// the Steam persona. Publishing the persona meant the box a player
// types their name into had no effect on anything they could see -
// and because it never came from a file, wiping the install
// directory did not shake it loose either.
//
// The persona is kept as the fallback for somebody who has never
// set a callsign: better to appear as yourself than as "Pilot".
//
char name[32];
SanitizeName(SteamFriends()->GetPersonaName(), name, sizeof(name));
const char *callsign = RPL4FrontEnd_Callsign();
if (callsign != NULL && callsign[0] != '\0' &&
strcmp(callsign, "Pilot") != 0)
{
SanitizeName(callsign, name, sizeof(name));
}
else
{
SanitizeName(SteamFriends()->GetPersonaName(), name, sizeof(name));
}
SteamMatchmaking()->SetLobbyMemberData(gLobby, "nm", name);
char vehicle[24], color[16], badge[24];
@@ -158,12 +222,27 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "cl", color);
SteamMatchmaking()->SetLobbyMemberData(gLobby, "bd", badge);
//
// Live Cam. The loadout above still goes out unchanged - the pick
// is a role, not a vehicle, and it is only acted on for the host,
// so a member's own row must go on showing what it will really
// fly. The room screen reads this against the owner's row.
//
SteamMatchmaking()->SetLobbyMemberData(gLobby, kCamKey,
RPL4FrontEnd_IsCameraRole() ? "1" : "0");
// football: this member's own team and position pick
SteamMatchmaking()->SetLobbyMemberData(gLobby, "tm",
RPL4FrontEnd_TeamKey(RPL4FrontEnd_GetTeamIndex()));
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
// what this build simulates like, so a mismatched room cannot launch
SteamMatchmaking()->SetLobbyMemberData(gLobby, kNetRevKey, kNetRevision);
// and which build it actually IS - see kBuildKey
SteamMatchmaking()->SetLobbyMemberData(gLobby, kBuildKey, RP412_VERSION);
//---------------------------------------------------------------
// Only the owner's menu decides the mission, so the owner also
// publishes what it picked: the scenario (members need it to know
@@ -172,6 +251,10 @@ namespace
//---------------------------------------------------------------
if (IsOwner())
{
// members check these before they act on the owner's go, and
// a joiner checks the build before it even sits down
SteamMatchmaking()->SetLobbyData(gLobby, kNetRevKey, kNetRevision);
SteamMatchmaking()->SetLobbyData(gLobby, kBuildKey, RP412_VERSION);
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
@@ -226,6 +309,9 @@ namespace
char badge[24];
char team[32]; // football pick
char position[16];
char netRev[8]; // simulation protocol revision
char build[24]; // the exact build, see kBuildKey
Logical camera; // picked Live Cam (acted on for the host)
Logical published;
};
@@ -266,6 +352,15 @@ namespace
strncpy(member->position,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
sizeof(member->position) - 1);
strncpy(member->netRev,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
sizeof(member->netRev) - 1);
strncpy(member->build,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kBuildKey),
sizeof(member->build) - 1);
member->camera = (atoi(
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kCamKey)) != 0)
? True : False;
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
@@ -313,6 +408,7 @@ namespace
strncpy(pilot->badge, members[i].badge, sizeof(pilot->badge) - 1);
strncpy(pilot->team, members[i].team, sizeof(pilot->team) - 1);
strncpy(pilot->position, members[i].position, sizeof(pilot->position) - 1);
pilot->camera = members[i].camera;
if (pods[0] != '\0')
{
@@ -333,6 +429,190 @@ namespace
putenv(port_env);
}
//---------------------------------------------------------------
// Leaving and launching, callable from either screen.
//
// These began as blocks inside the room's message loop, which meant
// only the room could do either - and the host no longer lives in
// the room: hosting is claimed from the setup menu, where the track
// is still changeable and the roster shows under GAME LENGTH. The
// room keeps calling them; the menu calls them too.
//---------------------------------------------------------------
void LeaveLobbyNow()
{
if (!gInLobby)
{
return;
}
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
// plain menu launches must not inherit lobby hosting
RPL4FrontEnd_SetHostedPilots(NULL, NULL, 0);
static char clear_env[] = "RP412HOSTPODS=";
putenv(clear_env);
}
//
// The owner's go: verify the room, publish the roster, prime the
// hosted-race path. Answers False when somebody has not published or
// is the wrong build - the caller says so however it says things.
//
Logical OwnerLaunchNow()
{
MemberInfo members[kMaxLobbyMembers];
int member_count = CollectMembers(members);
Logical all_published = True;
Logical all_same_build = True;
for (int i = 0; i < member_count; ++i)
{
if (!members[i].published)
{
all_published = False;
}
if (strcmp(members[i].netRev, kNetRevision) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << members[i].name
<< " simulates like rev '" << members[i].netRev
<< "', we are rev '" << kNetRevision << "'\n" << std::flush;
}
//
// The exact build too. An empty string is an older build that
// predates the key and cannot be trusted to match either.
//
if (strcmp(members[i].build, RP412_VERSION) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << members[i].name
<< " is build '"
<< (members[i].build[0] ? members[i].build : "(older)")
<< "', we are '" << RP412_VERSION << "'\n" << std::flush;
}
}
if (!all_published || !all_same_build || member_count < 1)
{
DEBUG_STREAM << "Lobby: not everyone is ready yet\n" << std::flush;
return False;
}
++gLastGoNonce;
char go[800];
sprintf(go, "%d:", gLastGoNonce);
for (int i = 0; i < member_count; ++i)
{
char entry[96];
sprintf(entry, "%s|%d|%d|%I64u;", members[i].ip,
members[i].consolePort, members[i].gamePort,
members[i].id.ConvertToUint64());
if (strlen(go) + strlen(entry) < sizeof(go))
{
strcat(go, entry);
}
}
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go);
RegisterRoster(members, member_count);
PrimeHostedRace(members, member_count);
return True;
}
//
// The member's side of the same coin: has anything happened that this
// station must act on? Answers in the header's RPL4LobbyMemberPoll
// values - nothing / launch (peers already registered) / closed (we
// have already left).
//
int MemberPollNow()
{
if (!gInLobby)
{
return MemberPollClosed;
}
//
// The host leaving hands Steam's lobby ownership to somebody
// else - which would silently turn a joiner's page into a host's.
// For now there is one host and it is whoever opened the lobby:
// if ownership lands on us, the host is gone, so fold the room.
//
if (IsOwner())
{
DEBUG_STREAM << "Lobby: the host left - closing\n" << std::flush;
LeaveLobbyNow();
return MemberPollClosed;
}
//
// A room whose owner simulates differently than we do would
// desynchronize silently rather than fail, so sit the race out
// instead of flying into it.
//
const char *owner_rev = LobbyText(kNetRevKey);
if (owner_rev[0] != '\0' &&
strcmp(owner_rev, kNetRevision) != 0)
{
DEBUG_STREAM << "Lobby: owner simulates like rev '"
<< owner_rev << "', we are rev '" << kNetRevision
<< "' - not launching\n" << std::flush;
LeaveLobbyNow();
return MemberPollClosed;
}
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go != NULL && go[0] != '\0')
{
int nonce = atoi(go);
if (nonce > gLastGoNonce)
{
gLastGoNonce = nonce;
// register every rostered peer with the transport
SteamNetTransport_SetEnginePorts(1501);
const char *cursor = strchr(go, ':');
cursor = (cursor != NULL) ? cursor + 1 : go;
while (*cursor != '\0')
{
char ip[32];
int console_port = 0, game_port = 0;
unsigned __int64 steam_id = 0;
int n = 0;
while (cursor[n] != '\0' && cursor[n] != '|' &&
n < (int) sizeof(ip) - 1)
{
ip[n] = cursor[n];
++n;
}
ip[n] = '\0';
cursor += n;
if (*cursor == '|')
{
console_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|') ++cursor;
}
if (*cursor == '|')
{
game_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
}
if (*cursor == '|')
{
steam_id = _strtoui64(++cursor, NULL, 10);
}
while (*cursor != '\0' && *cursor != ';') ++cursor;
if (*cursor == ';') ++cursor;
if (ip[0] != '\0' && console_port > 0 && game_port > 0)
{
SteamNetTransport_RegisterPeer(
ip, console_port, game_port, steam_id);
}
}
return MemberPollLaunch;
}
}
return MemberPollNothing;
}
//---------------------------------------------------------------
// The room screen (front-end style: green on black)
//---------------------------------------------------------------
@@ -543,7 +823,29 @@ namespace
// travel on the wire; the catalogs turn them back into
// names, so nobody reads "bttlbrg".
//-----------------------------------------------------------
if (FootballLobby())
//
// A build the room cannot race with is worth saying before
// anything about loadouts. A joiner is now turned away at the
// door, so this should only ever show for a member who was
// already seated when the guard arrived - but the launch check
// still refuses on it, and a host is owed the reason.
//
if (member->published && member->build[0] != '\0' &&
strcmp(member->build, RP412_VERSION) != 0)
{
sprintf(text, "BUILD %s", member->build);
DrawTextA(mem, text, -1, &row,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
//
// A Live Cam brings no vehicle, so the row says the role where
// the loadout would go - ANY row now, since the egg honours a
// member's pick the same as the host's. Football first, though:
// there are no cameras in football (the egg builder strips the
// pick), so a football room shows the team sheet and saying
// LIVE CAM there would be the room lying about the grid.
//
else if (FootballLobby())
{
if (member->team[0] != '\0')
{
@@ -554,6 +856,11 @@ namespace
DrawTextA(mem, text, -1, &row, DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
}
else if (member->camera)
{
DrawTextA(mem, "LIVE CAM", -1, &row,
DT_RIGHT | DT_VCENTER | DT_SINGLELINE);
}
else if (member->vehicle[0] != '\0')
{
sprintf(text, "%s - %s",
@@ -779,12 +1086,7 @@ namespace
if (room.leaveClicked)
{
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
// plain menu launches must not inherit lobby hosting
RPL4FrontEnd_SetHostedPilots(NULL, NULL, 0);
static char clear_env[] = "RP412HOSTPODS=";
putenv(clear_env);
LeaveLobbyNow();
outcome = LobbyRoomLeft;
break;
}
@@ -795,38 +1097,11 @@ namespace
if (room.launchClicked)
{
room.launchClicked = False;
room.memberCount = CollectMembers(room.members);
Logical all_published = True;
for (int i = 0; i < room.memberCount; ++i)
if (OwnerLaunchNow())
{
if (!room.members[i].published)
{
all_published = False;
}
}
if (all_published && room.memberCount >= 1)
{
++gLastGoNonce;
char go[800];
sprintf(go, "%d:", gLastGoNonce);
for (int i = 0; i < room.memberCount; ++i)
{
char entry[96];
sprintf(entry, "%s|%d|%d|%I64u;", room.members[i].ip,
room.members[i].consolePort, room.members[i].gamePort,
room.members[i].id.ConvertToUint64());
if (strlen(go) + strlen(entry) < sizeof(go))
{
strcat(go, entry);
}
}
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, go);
RegisterRoster(room.members, room.memberCount);
PrimeHostedRace(room.members, room.memberCount);
outcome = LobbyLaunchHost;
break;
}
DEBUG_STREAM << "Lobby: not everyone is ready yet\n" << std::flush;
}
//
@@ -834,57 +1109,17 @@ namespace
//
if (!IsOwner())
{
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go != NULL && go[0] != '\0')
{
int nonce = atoi(go);
if (nonce > gLastGoNonce)
{
gLastGoNonce = nonce;
// register every rostered peer with the transport
SteamNetTransport_SetEnginePorts(1501);
const char *cursor = strchr(go, ':');
cursor = (cursor != NULL) ? cursor + 1 : go;
while (*cursor != '\0')
{
char ip[32];
int console_port = 0, game_port = 0;
unsigned __int64 steam_id = 0;
int n = 0;
while (cursor[n] != '\0' && cursor[n] != '|' &&
n < (int) sizeof(ip) - 1)
{
ip[n] = cursor[n];
++n;
}
ip[n] = '\0';
cursor += n;
if (*cursor == '|')
{
console_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|') ++cursor;
}
if (*cursor == '|')
{
game_port = atoi(++cursor);
while (*cursor != '\0' && *cursor != '|' && *cursor != ';') ++cursor;
}
if (*cursor == '|')
{
steam_id = _strtoui64(++cursor, NULL, 10);
}
while (*cursor != '\0' && *cursor != ';') ++cursor;
if (*cursor == ';') ++cursor;
int poll = MemberPollNow();
if (ip[0] != '\0' && console_port > 0 && game_port > 0)
{
SteamNetTransport_RegisterPeer(
ip, console_port, game_port, steam_id);
}
}
outcome = LobbyLaunchMember;
break;
}
if (poll == MemberPollLaunch)
{
outcome = LobbyLaunchMember;
break;
}
if (poll == MemberPollClosed)
{
outcome = LobbyRoomLeft;
break;
}
}
@@ -938,9 +1173,67 @@ Logical
return gInLobby;
}
int
RPL4Lobby_Host(HINSTANCE instance, HWND main_window)
//
// Host and Join are the two places that reach Steam without the
// transport having got there first, so they carry their own guard.
// steam_api.dll is delay-loaded and calling into it when it is absent
// raises the helper's fatal exception - the menu already greys these on
// Available(), but a dead DLL must not depend on the UI for safety.
//
namespace
{
//
// The one worldwide search, shared by joining and by the one-host
// rule: is there an open RP412 lobby right now?
//
Logical FindOpenLobby(CSteamID *found)
{
gCallDone = False;
SteamMatchmaking()->AddRequestLobbyListStringFilter(
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
// the default lobby search is distance-filtered (roughly same
// region) - RP412 races are worldwide
SteamMatchmaking()->AddRequestLobbyListDistanceFilter(
k_ELobbyDistanceFilterWorldwide);
SteamAPICall_t call = SteamMatchmaking()->RequestLobbyList();
gCalls.listResult.Set(call, &gCalls, &LobbyCalls::OnList);
if (!WaitForCall(15000))
{
return False;
}
*found = gCallLobby;
return True;
}
}
int
RPL4Lobby_HostOpen()
{
if (!SteamNetTransport_ClientLibraryPresent() || gInLobby)
{
return gInLobby ? HostOpenCreated : HostOpenFailed;
}
//
// One host at a time, while the whole flow is young: if an RP412
// lobby is already open anywhere, this button does not open a second
// one - the caller says join it instead. Check-then-create is not
// airtight against two people clicking in the same breath, but for a
// playtest group coordinating over voice it is the rule they asked
// for.
//
{
CSteamID existing;
if (FindOpenLobby(&existing))
{
DEBUG_STREAM << "Lobby: " << existing.ConvertToUint64()
<< " is already open - one host at a time, join it instead\n"
<< std::flush;
return HostOpenLobbyExists;
}
}
gCallDone = False;
SteamAPICall_t call =
SteamMatchmaking()->CreateLobby(k_ELobbyTypePublic, kMaxLobbyMembers);
@@ -948,20 +1241,240 @@ int
if (!WaitForCall(15000))
{
DEBUG_STREAM << "Lobby: CreateLobby failed\n" << std::flush;
return LobbyRoomLeft;
return HostOpenFailed;
}
gLobby = gCallLobby;
gInLobby = True;
gLastGoNonce = 0;
SteamMatchmaking()->SetLobbyData(gLobby, kLobbyTagKey, "1");
SteamMatchmaking()->SetLobbyData(gLobby, kGoKey, "");
DEBUG_STREAM << "Lobby: hosting " << gLobby.ConvertToUint64() << "\n" << std::flush;
return RunRoom(instance, main_window);
//
// The owner's row and the mission setup, published immediately: a
// joiner can arrive seconds from now, and what they see first is
// whatever is on the books.
//
PublishMemberData();
DEBUG_STREAM << "Lobby: hosting " << gLobby.ConvertToUint64()
<< " from the setup menu\n" << std::flush;
return HostOpenCreated;
}
//
// Joining, the same shape as hosting: sit down in the lobby and return -
// the setup menu stays up, the mission column greys out and mirrors the
// host's picks, and the go arrives through RPL4Lobby_MemberPoll.
//
int
RPL4Lobby_JoinOpen(HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent() || gInLobby)
{
return gInLobby ? JoinOpenJoined : JoinOpenNothingFound;
}
CSteamID target;
if (!FindOpenLobby(&target))
{
DEBUG_STREAM << "Lobby: no open race lobby found\n" << std::flush;
return JoinOpenNothingFound;
}
gCallDone = False;
SteamAPICall_t call = SteamMatchmaking()->JoinLobby(target);
gCalls.enterResult.Set(call, &gCalls, &LobbyCalls::OnEnter);
if (!WaitForCall(15000))
{
DEBUG_STREAM << "Lobby: join failed\n" << std::flush;
return JoinOpenNothingFound;
}
gLobby = gCallLobby;
gInLobby = True;
//
// Refuse a room running a different build, and refuse it HERE rather
// than at launch - the same door guard the room flow has always had,
// word for word. See the room version for why it is loud.
//
{
const char *host_build = SteamMatchmaking()->GetLobbyData(gLobby, kBuildKey);
if (host_build == NULL || strcmp(host_build, RP412_VERSION) != 0)
{
DEBUG_STREAM << "Lobby: room is build '"
<< ((host_build != NULL && host_build[0]) ? host_build : "(older)")
<< "', we are '" << RP412_VERSION
<< "' - leaving, everyone must run the same build\n" << std::flush;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
char said[256];
sprintf(said,
"That race is running Red Planet %s.\n"
"You are running %s.\n\n"
"Everyone in a race has to be on the same build - the\n"
"simulation has to agree exactly. Swap to a matching\n"
"build and join again.",
(host_build != NULL && host_build[0]) ? host_build : "an older build",
RP412_VERSION);
MessageBoxA(main_window, said, "Different build",
MB_OK | MB_ICONINFORMATION);
return JoinOpenWrongBuild;
}
}
// answer only launches newer than anything already in the lobby
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
gLastGoNonce = (go != NULL) ? atoi(go) : 0;
// our row, visible to the host's roster immediately
PublishMemberData();
DEBUG_STREAM << "Lobby: joined " << gLobby.ConvertToUint64()
<< " from the setup menu\n" << std::flush;
return JoinOpenJoined;
}
int
RPL4Lobby_MemberPoll()
{
if (!gInLobby)
{
return MemberPollClosed;
}
return MemberPollNow();
}
//
// The host's published mission setup, as display names, for the greyed
// mission column to mirror. Empty strings before the first publish lands.
//
void
RPL4Lobby_GetSetup(
char *scenario, char *map, char *time_of_day,
char *weather, char *length)
{
strncpy(scenario, LobbyText(kScenarioKey), 47); scenario[47] = '\0';
strncpy(map, LobbyText(kMapKey), 47); map[47] = '\0';
strncpy(time_of_day, LobbyText(kTimeKey), 47); time_of_day[47] = '\0';
strncpy(weather, LobbyText(kWeatherKey), 47); weather[47] = '\0';
strncpy(length, LobbyText(kLengthKey), 47); length[47] = '\0';
}
Logical
RPL4Lobby_IsOwner()
{
return gInLobby && IsOwner();
}
void
RPL4Lobby_Pump()
{
if (SteamNetTransport_ClientLibraryPresent())
{
SteamAPI_RunCallbacks();
}
}
void
RPL4Lobby_PublishSetup()
{
if (gInLobby)
{
PublishMemberData();
}
}
//
// The roster, as display lines for the setup menu: callsign plus what it
// is bringing, a cam tag for a member who picked Live Cam, and a build
// warning where launch would refuse - so the host can see WHY the room is
// not ready, standing in the same screen the launch button is on.
//
int
RPL4Lobby_RosterLines(char lines[][48], int max_lines)
{
if (!gInLobby || max_lines <= 0)
{
return 0;
}
MemberInfo members[kMaxLobbyMembers];
int count = CollectMembers(members);
int written = 0;
Logical football = FootballLobby();
for (int i = 0; i < count && written < max_lines; ++i)
{
const char *name = members[i].name[0] ? members[i].name : "(joining...)";
const char *note = "";
if (strcmp(members[i].build, RP412_VERSION) != 0)
{
note = " WRONG BUILD";
}
else if (!members[i].published)
{
note = " ...";
}
//
// The whole loadout, not just the ride: colour and badge for a
// race, team and position for football - what the host is about
// to commit everyone AS, visible before the commit. A camera
// brings none of it; the pick replaces the lot.
//
if (members[i].camera && !football)
{
_snprintf(lines[written], 47, "%-12s LIVE CAM%s", name, note);
}
else if (football)
{
_snprintf(lines[written], 47, "%-12s %s %s %s%s",
name, members[i].vehicle,
members[i].team[0] ? members[i].team : "(no team)",
members[i].position[0] ? members[i].position : "",
note);
}
else
{
_snprintf(lines[written], 47, "%-12s %s %s %s%s",
name, members[i].vehicle, members[i].color,
(members[i].badge[0] &&
_stricmp(members[i].badge, "None") != 0)
? members[i].badge : "",
note);
}
lines[written][47] = '\0';
++written;
}
return written;
}
Logical
RPL4Lobby_HostLaunch()
{
if (!gInLobby || !IsOwner())
{
return False;
}
return OwnerLaunchNow();
}
void
RPL4Lobby_Leave()
{
LeaveLobbyNow();
}
int
RPL4Lobby_Join(HINSTANCE instance, HWND main_window)
{
if (!SteamNetTransport_ClientLibraryPresent())
{
return LobbyRoomLeft;
}
gCallDone = False;
SteamMatchmaking()->AddRequestLobbyListStringFilter(
kLobbyTagKey, "1", k_ELobbyComparisonEqual);
@@ -988,6 +1501,50 @@ int
}
gLobby = gCallLobby;
gInLobby = True;
//
// Refuse a room running a different build, and refuse it HERE rather
// than at launch. The owner's launch check would catch it, but only
// after everyone has picked a loadout and pressed go, and all it can do
// then is silently decline to start - which reads as the host's button
// being broken. Far better to say so on the way in.
//
// The owner publishes its build as lobby data, so this costs one read.
// An empty string means a host older than the key, which is equally a
// mismatch: it cannot be trusted to be this build.
//
{
const char *host_build = SteamMatchmaking()->GetLobbyData(gLobby, kBuildKey);
if (host_build == NULL || strcmp(host_build, RP412_VERSION) != 0)
{
DEBUG_STREAM << "Lobby: room is build '"
<< ((host_build != NULL && host_build[0]) ? host_build : "(older)")
<< "', we are '" << RP412_VERSION
<< "' - leaving, everyone must run the same build\n" << std::flush;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
//
// Say so on screen, not just in the log. A player who is bounced
// out of a room with no explanation will try again, and again,
// and then report that joining is broken - which is precisely
// the afternoon this guard exists to prevent.
//
char said[256];
sprintf(said,
"That race is running Red Planet %s.\n"
"You are running %s.\n\n"
"Everyone in a race has to be on the same build - the\n"
"simulation has to agree exactly. Swap to a matching\n"
"build and join again.",
(host_build != NULL && host_build[0]) ? host_build : "an older build",
RP412_VERSION);
MessageBoxA(main_window, said, "Different build",
MB_OK | MB_ICONINFORMATION);
return LobbyRoomLeft;
}
}
// answer only launches newer than anything already in the lobby
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
gLastGoNonce = (go != NULL) ? atoi(go) : 0;
@@ -1047,10 +1604,19 @@ void
//
// The owner publishes right after its race teardown - ours may
// finish first, so give the sheet a moment to arrive
// finish first, so give the sheet a moment to arrive.
//
// Unless WE aborted. An Alt+Q leaves a race that is still running
// everywhere else, and its sheet will not exist for minutes - the
// eight second wait below was eight seconds of frozen window for a
// player who had just asked to leave. Exit_Code carries the abort
// (the race loop resets it after the menu); take one free look and
// otherwise skip - the sheet's nonce keeps it eligible to show
// after the race actually ends.
//
const char *sheet = NULL;
DWORD deadline = GetTickCount() + 8 * 1000;
DWORD deadline = (Exit_Code != 0)
? GetTickCount() : GetTickCount() + 8 * 1000;
for (;;)
{
SteamAPI_RunCallbacks();
+80 -2
View File
@@ -46,9 +46,87 @@ Logical
Logical
RPL4Lobby_InRoom();
// Create a lobby / find-and-join one, then run the room screen.
//------------------------------------------------------------------------
// Hosting, from the setup menu.
//
// The host never leaves the configuration page: HostOpen claims the lobby
// and returns immediately, the menu keeps showing (and changing) the
// mission, joiners appear under GAME LENGTH via RosterLines, and LAUNCH
// GAME goes through HostLaunch. The room screen is for members only.
//------------------------------------------------------------------------
enum RPL4LobbyHostOpen
{
HostOpenFailed = 0, // Steam said no (or is not there)
HostOpenCreated, // the lobby is ours (also: already was)
HostOpenLobbyExists // one host at a time - join the open one
};
// Create the lobby and publish our row + the mission setup. Does not
// open any window. Refuses when an RP412 lobby is already open anywhere:
// one host at a time while the flow is young.
int
RPL4Lobby_Host(HINSTANCE instance, HWND main_window);
RPL4Lobby_HostOpen();
enum RPL4LobbyJoinOpen
{
JoinOpenNothingFound = 0, // no open lobby, or Steam said no
JoinOpenJoined, // seated; our row is published
JoinOpenWrongBuild // bounced at the door (dialog shown)
};
// Find the open lobby and sit down in it. Does not open any window: the
// setup menu stays up, mission column greyed and mirroring the host.
int
RPL4Lobby_JoinOpen(HWND main_window);
enum RPL4LobbyMemberPoll
{
MemberPollNothing = 0, // keep waiting
MemberPollLaunch, // the go arrived; peers registered - launch
MemberPollClosed // the lobby is over (host left / mismatch)
};
// A seated member's heartbeat question: anything to act on?
int
RPL4Lobby_MemberPoll();
// The host's published mission setup as display names (each buffer at
// least 48 bytes). Empty strings before the first publish arrives.
void
RPL4Lobby_GetSetup(
char *scenario, char *map, char *time_of_day,
char *weather, char *length);
// True when we are in a lobby we own.
Logical
RPL4Lobby_IsOwner();
// Run the Steam callbacks once - the menu's timer calls this so member
// joins/leaves and their data show up while no room screen is pumping.
void
RPL4Lobby_Pump();
// Republish our member row and (as owner) the mission setup. Call after
// the host changes anything on the menu, so members' rooms track it.
void
RPL4Lobby_PublishSetup();
// The roster as display lines ("callsign vehicle [cam|WRONG BUILD]").
// Returns the number written, 0 when not in a lobby.
int
RPL4Lobby_RosterLines(char lines[][48], int max_lines);
// The owner's go: verify everyone, publish the roster, prime the hosted
// race. False (and a log line saying who) when the room is not ready.
Logical
RPL4Lobby_HostLaunch();
// Leave the lobby and clear the hosted-race environment.
void
RPL4Lobby_Leave();
// Find-and-join a lobby, then run the room screen (members).
int
RPL4Lobby_Join(HINSTANCE instance, HWND main_window);
+118 -56
View File
@@ -444,9 +444,18 @@ void
//----------------------------------------
// Notify of mode change
//----------------------------------------
//
// Unqualified, so the platform's override is the one that runs. The
// RIO carries the four mode lamps on the Upper Right MFD and lights
// them from here (VTVRIOMapper::NotifyOfControlModeChange); naming
// the class suppressed the virtual call and landed on the base's
// no-op instead, so the lamps never followed the mode the pilot had
// just selected. Its neighbour has always gone out this way - see
// VTVControlsMapper::SetConfigurationState.
//
if (previous_mode != controlMode)
{
L4VTVControlsMapper::NotifyOfControlModeChange(controlMode);
NotifyOfControlModeChange(controlMode);
}
Check_Fpu();
}
@@ -725,6 +734,13 @@ void
mode_manager->AddModeMask(previousPresetModeMask);
}
//-----------------------------------
// Move the lamps with the mappings.
// Doing it here rather than in the
// switch handler keeps the keyboard
// presets (1-6) in step as well.
//-----------------------------------
NotifyOfPresetChange(previousPresetNumber, preset_number);
//-----------------------------------
// Save the new preset number
//-----------------------------------
previousPresetNumber = preset_number;
@@ -733,6 +749,19 @@ void
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
L4VTVControlsMapper::NotifyOfPresetChange(
int /*old_preset*/,
int /*new_preset*/
)
{
Check(this);
// The base mapper has no preset lamps to move.
Check_Fpu();
}
//#############################################################################
//########################### ThrustmasterMapper ##############################
//#############################################################################
@@ -854,18 +883,18 @@ void
//-------------------------------------------------------
// Set driving modes
//-------------------------------------------------------
case 'b':
case 'B': SetControlsMode(BasicMode); break;
case 's':
case 'S': SetControlsMode(StandardMode); break;
case 'v':
case 'V': SetControlsMode(VeteranMode); break;
case 'm':
case 'M': SetControlsMode(MasterMode); break;
//
// B / S / V / M used to drop straight into Basic, Standard,
// Veteran and Master here. The driving mode is a panel
// decision - the four buttons on the Upper Right MFD, with the
// lamps that say which one you are in - and a bare letter key
// changing it behind the player's back is not that. Worse in
// 4.12 than it ever was in the pod: the whole letter board is
// the MFD banks now, so those four letters are buttons in their
// own right and would have fired twice.
//
// Nothing replaces them. Press the mode you want.
//
//-------------------------------------------------------
// Configuration stuff
//-------------------------------------------------------
@@ -1400,45 +1429,13 @@ void
if (message->dataContents > 0)
{
//-----------------------------------
// Choose a new preset
// Choose a new preset. PresetEnable
// ignores a repeat of the lit switch
// and moves the lamps itself.
//-----------------------------------
int
current_preset_number = (message->dataContents - 1)
- LBE4ControlsManager::ButtonSecondary7;
if (previousPresetNumber != current_preset_number)
{
//-----------------------------------
// Set the old preset lamp to 'dim'
//-----------------------------------
if (previousPresetNumber >= 0)
{
Verify(previousPresetNumber < presetCount);
if (modeLamp[previousPresetNumber] != NULL)
{
Check(modeLamp[previousPresetNumber]);
modeLamp[previousPresetNumber]->SetState(L4Lamp::LampStateDim);
}
}
//-----------------------------------
// Set the new preset lamp to 'on'
//-----------------------------------
if (current_preset_number >= 0)
{
Verify(current_preset_number < presetCount);
if (modeLamp[current_preset_number] != NULL)
{
Check(modeLamp[current_preset_number]);
modeLamp[current_preset_number]->SetState(L4Lamp::LampStateOn);
}
}
//-----------------------------------
// Change presets
//-----------------------------------
PresetEnable(current_preset_number);
}
PresetEnable(
(message->dataContents - 1) - LBE4ControlsManager::ButtonSecondary7
);
}
Check_Fpu();
}
@@ -1626,6 +1623,18 @@ void
modeLamp[lamp_number]->SetState(L4Lamp::LampStateOn);
}
}
//----------------------------------
// Remember what is lit
//----------------------------------
//
// previousControlMode is the lamp the NEXT change dims, and nothing
// used to write it after construction set it to -1. Every mode
// therefore lit its own lamp against a dim that matched nothing, and
// the panel accumulated lamps instead of following the selection.
//
previousControlMode = controlMode;
//-----------------------------------
// Invoke ancestral method
//-----------------------------------
@@ -1655,6 +1664,44 @@ void
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// The six amber switches down the map's right flank. Called by PresetEnable,
// so the lamps follow the mappings no matter what asked for the change.
//
void
VTVRIOMapper::NotifyOfPresetChange(int old_preset, int new_preset)
{
Check(this);
//----------------------------------
// Set the old preset lamp to 'dim'
//----------------------------------
if (old_preset >= 0)
{
Verify(old_preset < presetCount);
if (presetLamp[old_preset] != NULL)
{
Check(presetLamp[old_preset]);
presetLamp[old_preset]->SetState(L4Lamp::LampStateDim);
}
}
//----------------------------------
// Set the new preset lamp to 'on'
//----------------------------------
if (new_preset >= 0)
{
Verify(new_preset < presetCount);
if (presetLamp[new_preset] != NULL)
{
Check(presetLamp[new_preset]);
presetLamp[new_preset]->SetState(L4Lamp::LampStateOn);
}
}
Check_Fpu();
}
//#############################################################################
// Construction and Destruction Support
//
@@ -1680,6 +1727,20 @@ VTVRIOMapper::VTVRIOMapper(
leftPedal = 0.0f;
rightPedal = 0.0f;
//------------------------------------------------
// There are no lamps until the mapping blocks
// below make them - and under NOMODES they never
// do, so the notify methods must see NULLs.
//------------------------------------------------
{
int
i;
for(i=0; i<configLampCount; ++i) configLamp[i] = NULL;
for(i=0; i<modeLampCount; ++i) modeLamp[i] = NULL;
for(i=0; i<presetCount; ++i) presetLamp[i] = NULL;
}
Check(application);
LBE4ControlsManager
*controls = Cast_Object(
@@ -1915,13 +1976,14 @@ VTVRIOMapper::VTVRIOMapper(
this
);
// These lamps are explicitly controlled by SelectPresetMessageHandler
modeLamp[i] = CreateControlledLamp(button_number[i]);
// These lamps are explicitly controlled by NotifyOfPresetChange.
// They are six, and they are NOT the four mode lamps above.
presetLamp[i] = CreateControlledLamp(button_number[i]);
if (modeLamp[i] != NULL)
if (presetLamp[i] != NULL)
{
Check(modeLamp[i]);
modeLamp[i]->SetState(
Check(presetLamp[i]);
presetLamp[i]->SetState(
(i==0)? L4Lamp::LampStateOn : L4Lamp::LampStateDim
);
}
+9
View File
@@ -104,6 +104,12 @@ ModeMask
//
void
PresetEnable(int preset_number);
// Announced by PresetEnable for EVERY preset change, whichever way it was
// triggered - map-flank switch or keyboard. Platforms carrying preset
// lamps move them here; the base mapper has none.
virtual void
NotifyOfPresetChange(int old_preset, int new_preset);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Protected data
//
@@ -250,6 +256,9 @@ public:
void
NotifyOfConfigurationModeChange(Logical new_state);
void
NotifyOfPresetChange(int old_preset, int new_preset);
void
SetPerformance(Performance performance)
{
+6
View File
@@ -74,6 +74,12 @@ void
gameModel = strdup(player_model);
Register_Pointer(gameModel);
if (RPCameraLog())
{
DEBUG_STREAM << "CamLog: local player node '" << (const char *) player_node
<< "' gameModel '" << gameModel << "'\n" << std::flush;
}
dropZoneName = NULL;
const char* drop_zone = NULL;
notation_file->GetEntry(player_node, "dropzone", &drop_zone);
+62 -2
View File
@@ -357,8 +357,20 @@ Logical
//
if (GetApplicationState() == RunningMission)
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
// same rule as Application::ExecuteForeground - the console's
// countdown when there is one, our own reckoning otherwise. There
// is no console in mission review, so this takes the fallback.
Scalar console_remaining;
if (gMissionClockHook != NULL &&
(*gMissionClockHook)(&console_remaining))
{
secondsRemainingInGame = console_remaining;
}
else
{
secondsRemainingInGame =
currentMission->GetGameLength() - (Now() - gameStarted);
}
}
CLEAR_FOREGROUND_PROCESSING();
@@ -407,6 +419,54 @@ void
NetworkManager *net_mgr = GetNetworkManager();
Check(net_mgr);
NetworkClient *client = net_mgr->GetNetworkClientPointer(packet->clientID);
//
// Say what is being routed where, for the first few.
//
// Playback dies inside Mission::Mission, reached through the egg file
// handler, while dispatching spooled packets - so a packet is arriving
// at a client that is not the one it was recorded for. Client IDs and
// message IDs are both small integers counted from the same base
// (NetworkClient::NextMessageID is 3, so the interest manager's
// NewDynamicEntity and the network manager's ReceiveEggFile are BOTH
// message 3), which makes a mis-routed packet look perfectly valid to
// whoever receives it. Naming the pair and the client it resolved to
// ends the guessing.
//
// A NULL client is the other candidate: Check() is a no-op in a
// release build, so a missing interest manager would be a call through
// nothing rather than a complaint.
//
//
// Behind RP412CAMLOG now that playback works. It earned its place -
// one line of it identified the header being parsed as packet one - but
// a station in a pod bay should not be writing a log line per packet
// for the first two dozen packets of every replay.
//
if (RPCameraLog())
{
static int said = 0;
if (said < 24)
{
++said;
DEBUG_STREAM << "Playback: packet client=" << (int) packet->clientID
<< " message=" << (int) packet->messageData.messageID
<< " length=" << (int) packet->messageData.messageLength
<< " -> client " << (void *) client
<< (client == NULL ? " (NULL!)" : "")
<< (client == (NetworkClient *) net_mgr ? " = the NETWORK MANAGER" : "")
<< (client == (NetworkClient *) this ? " = the application" : "")
<< (client == (NetworkClient *) GetInterestManager()
? " = the interest manager" : "")
<< "\n" << std::flush;
}
}
if (client == NULL)
{
return;
}
Check(client);
client->ReceiveNetworkPacket(packet, &packet->messageData);
Check_Fpu();
+21 -1
View File
@@ -66,10 +66,28 @@
<AdditionalOptions>/FORCE:MULTIPLE %(AdditionalOptions)</AdditionalOptions>
<AdditionalLibraryDirectories>..\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
<!-- legacy_stdio_definitions satisfies the June-2010 dxerr.lib on modern MSVC. -->
<AdditionalDependencies>ws2_32.lib;dinput8.lib;dxguid.lib;OpenAL32.lib;libsndfile-1.lib;d3d9.lib;legacy_stdio_definitions.lib;steam_api.lib;%(AdditionalDependencies)</AdditionalDependencies>
<AdditionalDependencies>ws2_32.lib;dinput8.lib;dxguid.lib;OpenAL32.lib;libsndfile-1.lib;d3d9.lib;legacy_stdio_definitions.lib;steam_api.lib;delayimp.lib;%(AdditionalDependencies)</AdditionalDependencies>
<!-- steam_api.dll is DELAY-LOADED so the game runs without it: a
statically imported DLL that is merely absent kills the process
at load time, before any window or log line (0xC0000135). Now
nothing touches it until Steam is actually asked for, and
SteamNetTransport_ClientLibraryPresent() gates the call sites so
a missing DLL falls back to TCP instead of raising the
delay-load helper's fatal exception. delayimp.lib supplies that
helper. -->
<DelayLoadDLLs>steam_api.dll;%(DelayLoadDLLs)</DelayLoadDLLs>
<RandomizedBaseAddress>false</RandomizedBaseAddress>
<GenerateDebugInformation>true</GenerateDebugInformation>
</Link>
<!-- rpl4build.h is generated, not committed: the patch number is the
repository's commit count, so a hardcoded one would be stale the
moment it was committed. The script rewrites the header only when
the stamp actually changes, so this does not drag RPL4.CPP through
a recompile on every build. -->
<PreBuildEvent>
<Command>powershell -NoProfile -ExecutionPolicy Bypass -File "$(ProjectDir)..\stamp-version.ps1"</Command>
<Message>Stamping the build version from git</Message>
</PreBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
@@ -109,6 +127,7 @@
<ClCompile Include=".\RPL4APP.cpp" />
<ClCompile Include=".\RPL4CONSOLE.cpp" />
<ClCompile Include=".\RPL4FE.cpp" />
<ClCompile Include=".\RPL4ENVIRON.cpp" />
<ClCompile Include=".\RPL4LOBBY.cpp" />
<ClCompile Include=".\RPL4ARND.cpp" />
<ClCompile Include=".\RPL4GAUG.cpp" />
@@ -147,6 +166,7 @@
<ClInclude Include=".\RPL4APP.h" />
<ClInclude Include=".\RPL4CONSOLE.h" />
<ClInclude Include=".\RPL4FE.h" />
<ClInclude Include=".\rpl4environ.h" />
<ClInclude Include=".\RPL4LOBBY.h" />
<ClInclude Include=".\RPL4ARND.h" />
<ClInclude Include=".\RPL4GAUG.h" />
+351 -384
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More