b3df9f08045ca454ebe106d68e4092c244d76384
246
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
b3df9f0804 |
Twenty launches say the fried-pod fix holds
Verification run for
|
||
|
|
5c597edf68 |
The loop tests the build you just made
Each pod sandbox keeps its own copy of rpl4opt.exe, planted by setup.ps1. Run a batch without refreshing it and you are testing whatever was built the last time setup ran - so a bug you fixed an hour ago reproduces perfectly, on the binary that still has it, and reads as the fix having failed. That is not hypothetical. The playerVehicle fix went in at 11:20; a 20-launch batch started at 11:23 trapped at 00486ebe, the same address as before, because all four pods were still running the 09:33 exe. Ten minutes of looking at a crash that had already been fixed. So the loop now copies Release\rpl4opt.exe into every pod before it starts, and logs the hash and build time it planted. A batch whose first line does not name the build you expect is a batch you can throw away without reading the rest. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f86b604015 |
A fried pod stops being anybody's vehicle
Caught by the podium rig on the ninth launch, under full page heap, after
eight clean ones - the first free named by the debugger rather than inferred,
which is what that rig exists for.
Player::playerVehicle is a bare Entity* that nothing ever cleared. When a
vehicle is destroyed it goes on death row, and FryDeathRowTask deletes one
entity per frame from it. The Player that was driving it keeps pointing at
the freed block. A replicant Player runs GoToVehicle EVERY frame - that is
its whole performance, reading playerVehicle->localOrigin to follow its pod
around the map - so from the moment the vehicle was fried, every frame read
freed memory.
It normally gets away with it, because the block is still mapped and still
holds plausible-looking garbage. That is its own bug hiding inside this one:
a remote pod quietly following coordinates that are no longer anything.
Under page heap the block is decommitted instead of merely stale, so it
faults at once:
ExceptionAddress: rpl4opt!Quaternion::operator=
[inlined in Player::GoToVehicle+0xe]
reading 31e08aea - a freed allocation whose free stack is
operator delete <- VTV::`scalar deleting destructor'
<- FryDeathRowTask::Execute
while `this` was a live, busy RPPlayer built from a network packet
(RPPlayer::Make <- Registry::MakeEntityMessageHandler <-
InterestManager::NewDynamicEntityHandler) - a REMOTE player, exactly the one
whose performance is GoToVehicle.
So the vehicle stops being anybody's vehicle as it is condemned. Done there
rather than in ~Entity on purpose: at condemn time the entity is still whole
and every group is still standing, and it happens exactly once - a destructor
doing this would also run during Shutdown, when the Players group may already
be gone.
NOT yet verified as fixed: the fault appeared once in nine launches, so
showing it gone needs a run of twenty-odd under page heap, which needs the
elevated gflags step. The trap log and the tally are kept in
playtestlogs\podium-trap-2026-08-14 (gitignored), and the 752 MB dump is at
%TEMP%\rp412-podium-repro\podB\.
Whether this is ALSO the original podium teardown crash is untested. It fits
- a VTV freed while others still hold it would be deleted a second time at
Shutdown, giving ~JointedMover a segment table full of reused memory, which
is the vtable-dword-holding-a-small-float signature those dumps had - but
that is a hypothesis, and the trapped stack is not that stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
b5b2efab28 |
The podium rig can run all night and count honestly
Repeated launches, escalation step 2. One mission per process is all the game gives you - the teardown IS the exit - so more teardowns means running the whole rig again, and loop.ps1 does that unattended. The point of a loop is samples: one clean run of an intermittent fault says almost nothing, twenty says something. The point of THIS loop is that a launch which did not happen must never be counted as one of them. Three ways it earns that, each of which it got wrong first and now gets right: Wait-Quiet, not a sleep. Pods hold their console and game ports and the next field cannot bind them; a three second gap had launch 2 come up on ports launch 1 still owned, sit through the whole race timeout with nothing placed, and report clean. It now waits for the processes to be gone AND the ports to leave TIME_WAIT, and says so plainly if they never do. Logs wiped before each launch, and a podium line only counted if the file was written after the launch began. Without that the check read the PREVIOUS run's log: the first shakedown reported two valid samples in one second, from a feeder that had not run at all. And the feeder invoked with an argument ARRAY, because `powershell -File` cannot take a switch in the -Switch:$value form - it arrives as a string, the callee refuses it, and the launch silently does not happen. That is what produced the one-second samples. It also refuses to start unless page heap is really on, reading IFEO's GlobalFlag out of HKLM - which needs no elevation, unlike gflags itself, and which is the bit that decides (gflags -hpa clears GlobalFlag and leaves PageHeapFlags behind, so PageHeapFlags alone tells you nothing). Shakedown: two launches, 4 pods, real races both times, scores 543..1253 so they drove and collided, 4 of 4 on the podium each time, no fault. Launch 2 - the one that used to stall - came up clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
74948fdd10 |
The podium survives a full page heap, four pods deep
First run of this rig that was actually holding the instrument it claims. Four pods under real full page heap - flags 0x3 in every cdb log, set by elevated gflags, not the PEB trick that has stopped working - driven hard enough to score 553..997, podium on. Everything the diagnosis points at ran: four placed on the stand, the local pod given its exterior (the podium's second MakeEntityRenderables pass, which is the path the dumps finger), the full Application::Shutdown teardown, four clean exits. No exception on any pod. That is worth more than the earlier clean runs by some distance. Full page heap traps a double free at the second free AND an overrun at the guard page, in the culprit's own stack rather than in the victim's. Neither fired. So on four pods over loopback with one teardown each, this bug does not happen. It also settles the address-space question the rig kept running into: 2 works, 4 works, 6 dies. The population that actually crashed and the instrument that would catch it cannot be had at the same time on one machine. Left to try, in order of what is cheap: repetition, because a single sample of an intermittent fault is nearly nothing; then three real machines, where the podium and its teardown land at genuinely different moments per pod instead of loopback's near-simultaneous nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
021ad36951 |
The podium rig stops rather than lie about its heap
The no-elevation page-heap trick the rig was built on is dead on Windows 26200, and it fails silently - which is the dangerous half. Measured at the create-process event: the write lands (dd $peb+68 reads back 02001000), then one g later ntdll has zeroed NtGlobalFlag again, so no verifier.dll, no page heap, and a run that looks exactly like an instrumented one right down to the clean exits. It was verified working on 26100; the machine has moved on. A six-pod driven run this morning went the whole way - all six placed on the stand, full teardown, six clean exits - before the cdb logs turned out to carry no page-heap line at all. That result proves nothing and is recorded as such. So feeder.ps1 now aborts when it cannot confirm page heap on every pod, and prints the elevated gflags recipe instead. -AllowNoPageHeap runs anyway and says in the log that a clean result is not evidence of absence, because the one thing this rig must never do is bank a negative it did not earn. This makes gflags the only route to a heap instrument on this machine, which costs nothing that was not already true: full page heap was already the discriminating test for the overrun reading of the dumps, and light page heap could never have caught an overrun anyway. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3b5eb992f1 |
The corrected refinery, wellhead and door sounds go live
Twenty-two effects replaced with the corrected versions: the Refinery beds, the Wellhead drills and devices, the two engine drones, the combo and both door movements. These are not the pitch corrections from the soundbank recovery. Every one declares the same sample rate and the same length as the file it replaces - it is the samples themselves that were edited. Measured against the originals the residue runs -19 to -39 dB with difference peaks as high as -0.5 dBFS, so they are substantial edits rather than polish, and the values cluster tightly across unrelated files (ten within 0.1 dB of -20.8) which says one consistent treatment rather than per-file work. The files each carry a LIST chunk the originals did not, which is where the extra 34 bytes went. Harmless: the loader is libsndfile, which walks the RIFF chunks properly rather than assuming data follows fmt. The originals are kept in wavcorections\orginal\ as well as in this commit's history, so putting any of them back is a copy either way. Verified: all 22 live files hash to the corrections and all 22 archived files hash to the originals, with nothing crossed over; AUDIO still holds 395 wavs and the preset table still resolves all 395 with none missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
54738d3259 |
The log keeps the whole night
Every launch truncated rpl4.log, so a tester who played six races and hit trouble in the second had nothing to send: the evidence was overwritten by the races that came after. Collecting it meant copying the file aside between games, which nobody remembers to do while five other people are waiting to start - and the logs that did arrive were a scatter of rpl4 (2).log through rpl4 (16).log that had to be sorted out afterwards. It appends now. Each run opens with a rule and the wall-clock time it started, because that is what a report is made of - "the third race, about nine" - and nothing else in the log carries the time of day. Play the evening, send one file. It rolls once to rpl4.prev.log past 64 MB rather than growing without limit; only the per-frame diagnostics can manage that in an evening, but a log nobody can open is no more use than one that was overwritten. RP412LOGAPPEND=0 restores the old behaviour. Verified: three launches in a scratch install take the file 1521 -> 3042 -> 4563 bytes with three stamped separators, and RP412LOGAPPEND=0 puts it back to one run of 1368. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d02117d87c |
Answer the door before knocking on anyone else's
Players kept failing to get a race started - the host watching pods that never reported ready. It is not the transport: both wires are already reliable and ordered, TCP on one path and Steam's reliable channel on the other, and every failure in the playtest logs is on the game mesh port, none on the console port. The eggs arrive fine. It is the order the mesh is built in. Each pod walks the host list from the egg, connecting out to everyone ahead of it and listening for everyone after - so in list order the connects all come first, and the listener was created lazily on the first host of the second kind. That is to say AFTER every outbound connect had already been made. A connect to a pod that is not ready blocks for RP412CONNECTWAIT, three times over, so a pod could spend a minute unable to answer its own door while the very peers it was waiting on were knocking on it. Their connects to it then timed out, and the failure went round the ring. The logs say it plainly: six of eighteen pods never reached "listening on engine port 1502" at all, and the same pair of players connects in one round and times out in the next, in both directions. It is not one bad network - it is a queue. So the walk now only builds the hosts, and the outbound connects happen after it. The listener still opens exactly where it did, on the first host that will be calling us, but nothing has blocked by then so it is up in the first moments rather than a minute in. A peer that still does not answer is tried once more, because that is the other thing the logs show - the slower pod catching up between attempts. Bounded deliberately: the transport already makes three tries inside each call, so a round here is expensive and a loop of them would turn a bad connection into a hung game. RP412MESHRETRY sets it. And when a peer is genuinely unreachable, say so. Every one of them is counted in remoteHostCount, so the mesh can never report itself complete and the pod is never told to load the mission - it just sits there looking to the host like a player who never got ready. The log now names who could not be reached and says the mission cannot start. Not done here: what SHOULD happen then. Dropping that pod out, or racing with a hole in the mesh, is a decision about the game rather than the network. An earlier attempt at this opened the listener unconditionally before the walk, which was wrong: Connect and Listen both bind localGamePort, so the pod at the END of the list - which listens for nobody - bound it twice and its one outgoing connection fought its own listener. Deferring the connects gets the same ordering with no extra socket. Verified: two-pod loopback green, both pods scoring 1000, mesh completing, stale 0, no listener on the last pod any more. Cadence measured against the previous build over three runs and unchanged - an earlier reading that looked like a regression was harness variance, the pods being parked and the numbers heartbeat-dominated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
d9149a6a8c |
A quiet pod coasts a second, then waits to be told
Build 2's first two items, L4 and L5, picked ahead of the rest of the latency tier because the field data argues for them: a five-pod race on 4.12.233 kept time well - send interval median and p95 both ~32 ms - but every pod saw gaps of one to two seconds, and corrections as large as 2702 metres against a mean under a metre. That is not a timing problem, it is a pod flying most of the way across the map on stale velocity and being yanked back through whatever it passed. L5, the extrapolation clamp. Both dead reckoners carried lastPerformance - lastUpdate into the projection unbounded once past the expected update. They now stop at kMaximumExtrapolationSeconds, one second, so a replicant coasts and then parks. A pod that stops and snaps once reads as the dropped connection it is; a pod sliding confidently through scenery reads as a broken game, and is far more expensive to collide with. It matters more in the accelerated reckoner, which also carries a*t*t/2 and so grows the error as the SQUARE of the silence. RP412NETCOAST sets the seconds, 0 restores the unbounded coast for an A/B on one connection. MUST MATCH across a race, same class as RP412NETPREDICT - it moves replicants, so it decides where they collide. L4, the stale-record guard. Simulation now remembers the sender's stamp on the last record it accepted, and the record walk skips one stamped earlier - such a record winds lastUpdate backwards and has the reckoner extrapolate from a position the sender has already left. A regression larger than five seconds is not a late record but a different stream (a rejoin, a restart, a clock that was set), so that resyncs instead. The counter NetRaceStats::staleCount has been sitting there reading zero with a comment saying "until the stale guard ships"; it is now fed. The guard is asked by the caller rather than done inside ReadUpdateRecord, because that is virtual and not every override chains to the base. Verified: clean Release build; two-pod loopback race green, both pods scoring, and stale 0 on both - which is the reading that says the guard does not fire on an ordered stream. The clamp is NOT demonstrated by that run: the harness parks its pods, so there is no velocity to coast on. Pod B did see a 9.167 s gap in it, which on a moving pod is the shape of the field's kilometre corrections. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
835580ab91 |
A pilot leaving mid-refresh no longer takes the room with them
From the playtest dump: an access violation reading address zero inside strncpy, two minutes into a session, dispatched from a window message - WM_TIMER, wParam 7. Seven is kLobbyTimerId. That timer runs every 750ms while hosting or seated, and its work is RefreshRoster -> RPL4Lobby_RosterLines -> CollectMembers, which reads each member's row out of the lobby. CollectMembers took Steam's answers straight as strings: eleven strncpy and atoi calls fed directly from GetLobbyMemberData with nothing between. Steam answers NULL - not "" - for a member it no longer knows, and the roster is polled on a timer, so somebody leaving between GetNumLobbyMembers and the read of their row is ordinary rather than exotic. strncpy then walks off address zero, which is exactly the faulting instruction. The file already knew this: LobbyText() has guarded the lobby-wide reads from the start, and all six other direct readers test for NULL. Only the member rows went unguarded. So they get the matching accessor, MemberText(), and CollectMembers goes through it - which covers the two atoi calls as well, since atoi(NULL) reads address zero just as happily. Not confirmed frame by frame: the dump is a tester's own build (D:\Games\RP412, stamped 13 Aug 16:33) and no matching pdb came with it, so the frames resolve only as offsets. What is symbol-independent is the message number and the timer id, read straight off the stack; from there this is the one path in that handler that does an unguarded strncpy on a pointer Steam is allowed to return NULL for. The call depth after inlining matches too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
aaedbd1043 |
Join a lobby and Steam is not suddenly missing
Joining somebody's lobby correctly greys HOST STEAM GAME - there is one lobby to be in and it is not yours to open - but it also put STEAM NOT RUNNING above it, told to a player who is at that moment sitting in a Steam lobby. The button collects several unrelated reasons to go dim: the wire never came up, you are already hosting, you are seated as a member. They all landed in one `dead` flag, and the notice was drawn on `dead`. Only the first of those is about Steam being absent, and only the first is worth explaining. So the wire test keeps its own name and the notice is drawn on that, while `dead` goes on collecting the rest for the dimming. Verified both ways round: with Steam asked for in environ.ini but SteamAPI_Init failing, both buttons are dim and the notice is there as before, with LAUNCH still lit for a solo race. The reported case needs two machines to see, but the notice is now gated on exactly the condition it names, so it cannot fire while the wire is up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e9603f4218 |
The host's LAUNCH moves to the middle of the panel
The commit board - the roster of who is connecting, loading and READY, with the LAUNCH button that arms once they all are - was pinned to the top right of the game window, which puts it over the upper right MFD. It belongs on the upper centre one. The host is reading down that roster and then reaching for LAUNCH, and the middle of the panel is where they are already looking. The right-hand corner had it out at the edge of vision, furthest from everything else they are doing. One placement, so one line: the board is centred on the game window instead of inset from its right edge, and clamped for a window narrower than the board itself. Not seen on screen here - the board only exists once a remote pod has joined (a solo commit launches itself and draws no board), so watching it land needs the second machine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6467a5f930 |
The teardown looks at the segments before it calls through them
The podium crash dies in SocketIterator::DeletePlugs, calling through a segment whose vtable dword has been replaced by a small float. The three dumps prove the segment is wrong BY teardown; nothing in them says when it went wrong, and six configurations of the local repro rig - parked pods, driven pods, light and full page heap, two, four and six pods - reached the podium and tore down clean. So the next real playtest becomes the instrument. RP412SEGCHECK walks the segment table in ~JointedMover before the delete, guarded-reads each segment's first dword, and if one does not match the vtable captured from the very first segment ever built it writes the forensics into rpl4-fail.log, which is closed on the way down and survives the abort - rpl4.log does not. The report carries the entity and whether it was the local pod, which index went bad and what is in it, the first two rows of the object as hex and float, and the heap deltas to its neighbours on either side. Three bracket calls in the winners' circle answer the question the dumps cannot: at podium entry, and either side of the second MakeEntityRenderables on the own pod. Whichever fires first is recorded and travels inside the teardown report, so the log says whether the race broke the segment or the podium did. It deliberately does not skip the delete or repair the pointer. The ownership bug is unfixed and a guard would cost exactly the evidence this is here to collect - it stops on the same object, one step earlier, holding the forensics. On by default, a handful of pointer compares per pod per race; RP412SEGCHECK=0 turns it off, and the environ.ini template says so. tools/podium-repro is the rig itself, banked with what the dumps already established: page heap turned on through the PEB without gflags or elevation, N sandboxed installs, and a feeder that drives full races through them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
51be84e89c |
Back on Spacewar until our own AppID is cleared
5108000 is Red Planet's and it works - dropped into a dist it runs exactly as 480 does, the client just names Red Planet on the friends list instead of Spacewar. The permissions on it are not settled with Steam yet, so the packaged builds go back to Valve's public 480 in the meantime. The number now sits in one variable in pack-dist.ps1 with the real one beside it in a comment, because nothing in the code knows it - the whole mechanism is a text file next to the exe - and this will be flipped back the moment Steam is happy. The docs that name it follow: the handbook, the environ.ini template, the README, and the three-machine test doc, whose Steam Input instruction points at a library entry named after the AppID and so says Spacewar again. The Steamworks 1.65 upgrade is unaffected and stays. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
49d6d93414 |
The new AppID changes the name on the friends list, nothing else
The AppID commit claimed the switch off Spacewar meant a Steam account now had to have Red Planet on it, and that an account without it would drop to the TCP fallback. That is wrong, and it was never tested - it was reasoned from how AppIDs work in general and written down as fact, in the handbook, the environ.ini template, the README and the three-machine test doc. Cyd checked it the direct way: put 5108000 into an older dist and ran it. It behaved exactly as it had under 480. SteamAPI_Init does not check entitlement; steam_appid.txt only tells the API which app it is. The one real difference is that the Steam client now names Red Planet instead of Spacewar. So all four places now say that, and say nothing about entitlement. The part that was always true is kept: a copy started from its own folder needs the file, and without it Steam networking cannot start and the game falls back to TCP. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0c8c62a73b |
Red Planet plays as itself on Steam now, on SDK 1.65
Two things that travel together, because the packaging script carries both. STEAMWORKS 1.65 replaces 1.64. steam_api.dll goes 10.51.08.25 -> 10.87.46.89. Vendored the same way - headers and the win32 redistributable only, examples and other platforms left out - and 1.64 is gone rather than left beside it, since nothing referenced it any more: both include paths, the library path, the DLL the packaging script copies, and the docs that name the folder all moved over. The vendoring rules also gained a line they always needed. The redistributable DLL is caught by the global *.dll rule at the repo root, so it only ever got in by a git add -f, and a fresh checkout could not produce a package without noticing the DLL was missing. It is now re-included by name. OUR OWN APPID, 5108000, replaces Spacewar's 480. steam_appid.txt is what tells the Steam client which game this is when a build is started straight from its folder, which is how every tester runs it. The consequence worth knowing before the next test night: 480 was Valve's public one and every account effectively had it. 5108000 is ours, so the Steam account has to actually have Red Planet on it. An account without it gets the plain TCP fallback - the same quiet symptom as a missing appid file - so the handbook, the environ.ini template, the README and the three-machine test doc all now say to check that first. Nothing in the code hardcodes the number; the file is the whole story. Retail builds launched BY the Steam client need no such file at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7d485c9672 |
The wire keeps what it could not send
Cyd asked for an analysis of the networking stack and what would make the simulation feel better over the internet. The analysis found something more urgent than latency: the transport has been losing data silently since the arcade, and nothing in the game could see it happen. Every send result was discarded - L4NET, the console, all of it. On the 1ms arcade LAN the socket buffer never filled, so it never mattered. Over the internet it matters twice. A peer stalled in its own 10-30 second mission load stops reading, its window closes, and our nonblocking send starts answering would-block, which threw the message away; or worse, answering a PARTIAL count, and since framing on that stream is recovered purely from each message's length prefix, the bytes that never followed sheared it for good. Both are reachable in an ordinary race, because every race has a load in it. So sends go through a bounded per-connection queue now. What the wire will not take is kept, byte-exact, and retried at three flush points - before the render (the present blocks on vsync, and this frame's state should be travelling while it does), at the top of the receive pump, and before a connect sequence. Nothing is ever dropped from the middle: these are reliable ordered messages carrying entity creation, damage and race control, so a queue that overflows its 256K declares the connection dead and lets the disconnect path run rather than quietly desyncing the stream. RP412NETSENDQ=0 restores the old behaviour and still logs what it would have lost, which is the honest way to A/B it. On Steam the same queue finally surfaces k_EResultLimitExceeded, which the old code collapsed into -1 and discarded - that was backpressure, unlogged. The receive side gained the check the release build never had. The length prefix is untrusted input; Verify() compiles away in release, so a corrupt one went to memmove as a negative, or copied 4096 bytes of assembled packet into a 1600-byte stack buffer, or named a size the pad could never complete and wedged the connection forever. It is now validated against the same bounds the sender works to, and a stream that fails them is dropped like any other lost peer. And a fry that never ends: drop zones are map entities dealt round-robin at load, ownership transfer is not implemented, so a leaver's pads stay in the DropZones group. The respawn request dispatched to one goes to a host that is gone - dropped at the send, the 'no host N in the table' path - and the two-second retry re-dispatches to the same dead owner forever. The pad scan now skips zones whose owner has left, and re-validates one assigned earlier before reusing it. The rest is measurement, because the symptoms this work exists to chase are all reported in prose and none of them are in any log. Sixteen logs from the six-player night contain zero player-facing latency lines. A race now ends with a NetLog summary: per remote pod, how many updates arrived and how evenly (median and p95 out of a log2 histogram), the widest gap, how many gaps were long enough to mean a quiet sender versus short enough to mean OUR loop stalled, how often its motion snapped instead of blending, and how far arriving updates moved it. Per peer, whether the clock alignment ever had to step mid-race - which is the input for deciding if it needs slewing, rather than guessing. The mission t0 tick goes in the log too, alongside the console's per-pod RunMission send ticks, because nothing has ever measured how far apart the machines actually start; the clockwork doors inherit that skew directly. RP412NETSTATS adds the transport's own view - per connection: messages, bytes, wire writes, partials, refusals, how much sat queued - and on Steam the first read this codebase has ever taken of GetConnectionRealTime Status. Ping, quality, pending and unacked bytes, and one route description per connection at teardown. The API was vendored and never called; there was no RTT number anywhere in the game. Finally, rpl4opt -spoolstats reads any recording offline. The data was already in every spool ever made and nothing read it that way: the recorder restamps each packet with local arrival time while the update records inside keep the sender's sim-grid stamp, so the difference is clock offset plus one-way delay, and the same running-minimum estimator the game runs live separates them. It prints delay above the per-host minimum, and decomposes each entity's gaps into sender pacing versus delivery jitter - which no live counter can do. It lives in the game exe rather than RPL4TOOL because the tool is deliberately not /Zp1 and would misread every struct in the file. Verified on the two-pod loopback harness: mesh up, egg fed, 60s raced, stopped on command, scores collected, and both summaries reading exactly what a pair of PARKED pods should read - heartbeat cadence, one snap per heartbeat, sub-quarter-metre corrections, no clock steps. The t0 ticks and the netclock offsets agree with each other to the two seconds the pods launched apart. The latency tier is deliberately NOT here. TCP_NODELAY, the Steam NoNagle flag, per-frame coalescing and the pre-sim receive drain are all scoped and all wait on this build's numbers, because the point of shipping measurement first is to find out whether the thing we would fix is the thing that hurts. Nagle is still on. Interest management is still inert. The wire format is untouched, so this build and the last one still race each other. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3d94ac7158 |
Linux is Proton's problem to solve, not ours to port to
Cyd asked what it would take to run on Linux. The answer is filed rather than acted on: we are mid-playtest, and this is a decision for the end of it. Proton is the recommendation. The build is 32-bit fixed-function D3D9, which is the most-travelled path Proton has, and Valve keeps i386 alive in the Steam runtime containers specifically to carry it; Wine 10 fixed child-window Vulkan rendering, which is the one thing that would have stopped the cockpit dead, since the 3D scene presents into a child STATIC with GDI panes clipped over it. Deck Verified is achievable from a Windows-only build and is what comparable Win32 titles ship. The native port is scoped in the doc rather than dismissed, because the engine is more portable than it looks: RP is clean C++, MUNGA is ~90% clean, single-threaded, with one timing seam and one file seam already named. What stops it is mechanical and total - 1,800 of 1,844 include lines cite a filename in a case no case-sensitive filesystem will find - and then strategic: the wire format and .RES carry size_t under /Zp1, so a 64-bit build silently changes the protocol while a 32-bit one targets a runtime Valve is walking away from. Five things need doing to the Windows build either way, and they are all improvements to it on their own: the Dynamic Lighting mirror imports windowsapp.lib statically, so its careful try/catch degradation never runs under Wine - the image fails to load first; the plasma window and the exploded MFD view are extra top-level windows, which is what gamescope handles worst; the viewscreen relies on the stock STATIC proc returning HTTRANSPARENT for mouse fall-through rather than saying so; the 1080 canvas downscales 1.5x on the Deck, against a 9px legibility floor; and the front end is mouse-driven, which Deck Verified will not pass. Nothing here has been run on Linux. The first step when this resumes is an afternoon with a Linux box confirming the child-window Present, before anything is promised. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |