Commit Graph
217 Commits
Author SHA1 Message Date
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