A map record is an Entity::MakeMessage (MUNGA/ENTITY3.h): classToCreate,
owningPlayerID, resourceID, instanceFlags, localOrigin. The origin ends
the 76-byte case, which puts classToCreate at +28, resourceID at +40 and
instanceFlags at +44.
I had been reading +44. That is instanceFlags, and it is 524 on every
scenery record - and 524 happens to be cn3's GaugeImage. So every track
resolved to cn3 repeated a few hundred times, consistently and wrongly,
and every conclusion drawn from that followed: the "one wall bar with a
gate", the ticks, the claim that the LOD machinery is never exercised.
The id at +40 varies per placement. The tracks use cn1, cn3, cn4, cn5,
cn7, br1, br3, ft1, cq1, cq2, md3, md4, the pits, and the score zones
sc50/sc50a/sc500a that gave the amber boxes at each end. A record names
the model's Model List; the GaugeImage is filed under the same model
name, so the name is the join - and models with no gauge image (oao,
snAwork, pz1) are skipped here exactly as DrawStatic skips them.
Found by following the map loader: InterestManager::LoadMapStream reads
the stream as MakeMessages and names the map entity classes, one of which
is 95 in these records - CulturalIconClassID.
They are not a fixed 76 bytes. The first int of each record is its length,
and while most placements are 76, every track also has eight of 140, two
of 80 and one of 336 - 560 on Paingod's. Striding a fixed 76 landed
mid-record on those, and hunting forward for the next plausible
quaternion then locked onto arbitrary bytes: that is where the impossible
class ids came from, and the "resource id" 1065353216, which is
0x3F800000 - float 1.0.
Reading the length instead, all eighteen tracks parse to exactly their
declared instance count with no bytes left over. That is the check that
was missing before.
It does not change what gets drawn, because the extra records were never
drawable anyway. It does mean the parse is no longer guessing.
It was drawing the map mirrored. The X flip came from the setup console's
picture of Brewer's Bane, which is an illustration and not a screenshot -
the game does not draw it that way round.
What the game does: L4GaugeImagePrimitive::Draw plots
MoveToAbsolute(dest->x, dest->z), so the screen axes are view-space X and
Z, and the graphics view's origin is bottom left with Y increasing upward
- BackgroundLine draws endpoints.bottomLeft to endpoints.topRight, and
the port's zero-degree blit is documented with the origin in the bottom
left corner. So +X runs right and +Z runs up.
Also added the heading the real display has. NavDisplay centres on the
vehicle and turns with it, inverting the viewer's transform and taking
yaw only unless rockAndRoll is set; the viewer defaults to heading 0,
which is the north-up case, and Q/E/R turn it. Panning and dragging now
work in what you see rather than in world axes, so up stays up when the
map is turned.
TRACKS.html is left following the console pictures on purpose: nine of
those cards are the console pictures, so the reconstructed nine have to
sit beside them consistently. The two disagree by a mirror and each is
right for what it is, which both READMEs now say.
Rewritten because build_mapview.py imports navmap. This is what tracking
a .pyc costs - it will churn whenever a tool that imports navmap runs,
without ever being a source change. One .gitignore line ends it.
Pan with the arrows, zoom with plus and minus, [ and ] for the next
track. Self-contained HTML with the track data and palette embedded;
nothing here ships, and pack-dist.ps1 does not look at it.
It follows the engine rather than approximating it. NavDisplay derives
metersPerPixel from the zoom and sets LODIndex to it; L4GaugeImage::Draw
takes the first LOD whose scale is at least that value and draws nothing
once the value runs past the largest, so objects vanish rather than
simplify. Both map gauges are here because they disagree - nav is the
448x416 radar screen with LOD following zoom, gps the 125x203 panel whose
config pins LOD at 1.0. The HUD reports what is dropped, and is honest
that this content barely exercises it: every placement in every track is
cn3 with one LOD at scale 1000.
The map is not a phosphor screen. Primitives carry palette indices and
the palette is whichever the port was configured with - for the pod's
secondary port, configure(0,sec,270,0x00ff,clut0,rgb,secpal.pcc). PCC is
PCX, so the palette is the last 769 bytes. Walls are grey because index
51 is #4b4b4b; background is index 0, black; a primitive with colour 0
keeps the display's staticColor, 0x3C. Per-file palettes, not a global
one - 39 of 40 gauge PCCs differ - so the port's configured palette is
the one that counts.
Started as a question about eleven vehicles and seven maps missing from
our resource file. They were in a community 4.11 build; verifying that
file as a strict superset and promoting it brought them back, and most of
what follows came out of having to prove things about the file rather
than guess.
* The cut VTVs restored, and the content pipeline (RPL4TOOL -b) made to
work in this tree for the first time.
* The front end fits 800x600, uses dropdowns, and no longer paints
Windows grey over its own green.
* VTV-PRESETS.html: 38 vehicles, loadouts and six-preset tables decoded
by resource id, tabbed by hull.
* TRACKS.html: all 18 tracks. Nine now show the setup console's own
maps, recovered rather than reconstructed; the other nine are the
course traced through their gates.
* The airlock archive tracked whole, so those promotions stay
checkable, plus the console's track, vehicle and pod art.
Two corrections worth carrying: the vehicles were cut BY 4.11 rather than
never shipped - ALPHA_1/REL410 is a cockpit of the ALPHA wing and its
RPL4.RES is the retail file - and the top-down projection was mirrored,
which the console's picture of Brewer's Bane caught.
Everything outstanding, uncurated: the bytecode cache Python wrote beside
navmap.py while building the pages. It is derived from a tracked source
and tagged to one interpreter (cpython-314), so it will go stale rather
than break anything - one .gitignore line drops it again if it becomes
noise.
A community build of 4.11 from another site. Three things have already
been taken out of it - RPL4.RES and GAUGE/L4GAUGE.CFG promoted into
assets/RP411 in f7c7000, and the console configs copied to
tools/console-config - and with the archive untracked none of that was
checkable. Keep it whole so it is.
Nothing here is read at build time or ships in a release; a README says
so, says what was promoted and why, and warns that rpl4opt.exe in this
folder is the community's 4.11 binary and not what BUILD.md produces.
It costs much less than the 111 MB on disk suggests: 767 of the 998 files
are byte-identical to assets/RP411 and git stores a blob once. The real
additions are the 223 WAVs, every one of which differs from ours, and
four audio files with no counterpart at all. Whether those WAVs are
better masters or just different renderings is unestablished, so they
stay unpromoted. Thumbs.db was already covered by .gitignore.
RPConfig.xml has always named a picture for nine of the eighteen tracks.
The pictures exist after all, so the page reads the mapping straight out
of the config and uses them: score zones, drop zone, the chambers drawn
properly and labelled. No reconstruction beats the real thing. The other
nine keep the course traced from their gates, and each card now says
which of the two it is showing.
The pictures also check the reconstruction. Brewer's Bane is the one
track shaped distinctively enough to be obviously wrong, and it matches
the console picture turn for turn - long leg up one side to Score Zone 1,
the corner, the run out to Score Zone 2, junction chambers spaced along
it. It matched MIRRORED. Seen from above with +Z up the page the engine's
+X runs to the left, and every plan here had been drawn the other way
round. Fixed, so the nine tracks without a picture are drawn the same way
round as the nine with one.
Three sets, added to assets/: the setup console's nine track maps, its
nine vehicle hull pictures, and twenty-four pieces of pod art. The track
maps are the ones RPConfig.xml has always named and pointed at - the
pictures the console showed - and they had been presumed lost.
Drawing what the map screen draws never was going to give a map. Nearly
every placement in every track is one piece, cn3, and its gauge image is
two 25x5 bars at x 19.5..44.5 and -44.5..-19.5 - not a wall along the
route but a wall across it with a 39 unit gate in the middle. The
collision solid agrees exactly. A few hundred of those is a row of ticks.
The gate is the point: cn3's origin sits in the opening, so every
placement marks somewhere the race passes through. Walking the gates
nearest to nearest, from the end furthest out, draws the track itself -
Brewer's Bane comes out as its L with the junction chambers, Zaxxis as a
circuit, and the small arena as the maze it always was.
Guarded, because chaining nearest neighbours across a regular grid
invents a maze-like path out of nothing but visit order. Each track is
tested first on how many neighbours a gate has within 1.6x the typical
spacing: a corridor gives 2, a floor of obstacles gives 4 or more. The
separation is not close - seventeen tracks score 1 or 2, the demolition
arena scores 8 on an exact 100 unit grid and keeps its wall blocks.
Most of the arcade tracks really are near-straight canyon runs, a few
hundred units wide and several thousand long. The plans say so now
rather than implying otherwise.
They are palette variants of machines already in the list, so interleaved
they read as duplicates. The console's own name decides it - eleven of
the thirty-eight are called Black or Blacker something - so they sort
last within a hull tab as well as overall.
Blacker Broccoli's console picture is a Bug, but the game draws it on the
Mule hull like every other Broccoli: the console entry is simply wrong,
and saying so on the card raised a question the card could not answer.
Blacker Tarantula keeps its note, where the console and the game really
do disagree about the hull.
It promised higher ground shown brighter, which the flat masks never
did, and it predated the change that draws each wall bar as one line
rather than a box.
Thirty-eight cards in one run is a lot to read. The first letter of a
vehicle's two-letter art code is the hull the game actually draws it
with, and the console's class names are subdivisions of those five -
Bull and Roadblock are both 'b', Bug and Skeeter both 's' - so grouping
by the letter keeps kin together whatever the console called them.
Particles 6, Bugs 10, Mules 9, Bulls 8, Police 5, which is all of them.
The build prints a warning if any vehicle falls outside the five, so a
new hull cannot go missing from the page quietly. The tabs compose with
the armament filter and the search box rather than replacing them.
Nearly every track is one model repeated: cn3, a wall bar whose gauge
image is two closed 25x5 rectangles. Five metres of wall thickness is
finer than the plan can resolve, so each bar was landing as two parallel
lines plus two end caps - several hundred times over, which is the
hatching that swamped the arenas. Collapse a thin closed quad to the
centreline between its short edges: one stroke for one wall.
Walls stacked to build height coincide seen from above, so draw each
distinct wall once. And drop placements standing alone more than 200
units from any other - fourteen tracks park a single bar at (1200,0,0)
well off the course, and that one placement stretched the frame to
twelve times the width of the track. The four tracks without it are
exactly the four that always framed correctly. A real branch keeps its
neighbours and stays: Paingod's second canyon is sixty bars out at
x=-400.
The first version of these plans was a scatter of scenery positions - an
impression of a track rather than a picture of one. The game already draws
the real thing: the map screen in the pod renders the track from above every
race, so the plans now reconstruct that instead of approximating it.
NavDisplay::DrawStatic walks the static entities, looks up each one's
L4GaugeImage by resource id, and draws it through localToWorld x
worldToView. navmap.py does the same offline. The pieces that made it
possible:
- a map instance carries its model's GaugeImage id at +44, beside the
position at +48 and the quaternion at +60;
- a GaugeImage is a vertex array plus per-LOD polylines through it, in
world units - cn3 is an 89x5 wall segment, pit1 a 500x300 pit;
- a placement whose model has no gauge image is skipped here exactly as
DrawStatic skips it, which is why a card can report fewer placements
carrying map art than the track contains.
The difference is not subtle. Wiseguy's Wake and Paingod's Passage resolve
into twin canyon walls running their length, Brewer's Bane into an L-shaped
route through junction chambers, and both arenas into a lattice of obstacles
inside a boundary wall. What is still missing is the driving surface: the
map draws what lines the route, never the tarmac.
tools/pages/navmap.py carries the reader, and the README documents both the
instance record and the gauge image stream. Regenerating from the committed
generators reproduces the committed page byte for byte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docs/tracks.html joins the roster page: all 18 tracks with a plan view, what
the console calls them, which scenarios offer them, and how big they are.
There are no track maps in the game's files. The console had pictures of
them and those pictures did not survive - RPConfig.xml still points at
"images/red planet maps/Wiseguy's Wake.bmp" and the folder is gone. So the
plans are drawn from the tracks themselves. A map's instance stream places
its scenery: 76-byte records carrying a position at +48 and a unit
quaternion at +60, a few of them longer, so the reader resyncs on an
unexpected class id rather than trusting the stride. The quaternion doubles
as a checksum - a mis-read almost never yields a unit one - and 17 of the 18
decode every instance the header promises. Trough gives up 631 of 633 and
the card says so.
Seen this way the tracks have obvious shapes: Brewer's Bane turns two
corners, Tour De Mars is one 23,000-unit run, and both arenas are a regular
lattice of obstacles rather than a route at all.
The eras come from the resource-file archaeology rather than a guess: 9
tracks shipped in the 4.10 cabinets, headoff and headmf arrived with 4.11,
and 7 were built by the community afterwards. Scenario legality is read out
of the front end's own kMaps and kFootballMaps, so the page cannot claim a
track is offered when the menu does not offer it.
tools/pages carries the generators for both reference pages, with a README
covering the two formats they read and the id-alignment the listing is
needed for. They were scratch scripts until now, which made a committed
page harder to regenerate than to rebuild by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The roster page called vehicles by their resource key and hulls by their
two-letter art code - PUCK, "Hull PA". TeslaConsole's RPConfig.xml is the
only place either is named in words, so it now supplies both: the card says
ARMADILLO with the key beneath it, and the hull says "Armadillo hull" rather
than "Hull PA".
The class comes from the picture the console showed for each machine, so
vehicles sharing a picture share a class, and each hull is named by majority
vote of the vehicles drawn with it - one mis-set picture cannot rename a
whole class. Which matters, because two are mis-set: the console shows a Bug
picture for Blacker Broccoli and Blacker Tarantula, while the game draws
them on the Mule and Bull hulls. The page says so on those two cards instead
of quietly picking a side. Every other vehicle agrees.
RPConfig.xml and RPStrings.xml are copied into tools/console-config: the
front end's catalogs already came from them by hand, the roster page reads
them now, and they were only living inside the airlock archive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page listed 34 of the 38 vehicles in the resource file. The four
missing were community variants, and I had written down that they shared
another vehicle's mapping streams. They do not - they have their own, of 17
to 33 records. The decoder just could not find them.
Streams are stored as resources named plainly L4 and Thrustmaster, so
nothing in a stream says whose it is. The old decoder guessed by taking the
nearest preceding vehicle name in the file, which works while vehicles are
laid out one after another and fails quietly when they are not: it lost vole
outright and mis-attributed four blkr variants.
The file answers exactly if asked properly. A vehicle's ControlsMappings
List holds the resource ids of its two streams. Ids are not quite positional
- this file leaves 53 and 56 unassigned - so the directory walk is aligned
against RPL4TOOL -l, skipping the ids the listing marks Not Used. That gives
1077 ids with zero size mismatches, and every stream lands on its owner.
Subsystem names now come from each vehicle's own Stream of N Subsystems,
checked against the count in its header instead of being pattern-matched out
of the bytes.
Nothing already verified moved: lepton, dark, blkspk and neut decode exactly
as they did when checked against the 4.10 retail file, blkspk still putting
its third booster on the thumb-high in preset 4 and dark still spending
preset 5's HORN slot on its second demo pack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CBS_OWNERDRAWFIXED only hands over the item area, so while the list rows
came out green on black, the closed box kept the system's frame and drop
arrow - a white/grey Windows control sitting in the middle of a black panel.
The closed box is painted here now: black field, dim green border, bright
green text, and a plain green triangle instead of a themed button. The
control keeps doing everything else, including dropping its list, so this is
a subclass over WM_PAINT rather than a reimplementation.
Still system-drawn: the scrollbar inside a dropped list, which only appears
on the two lists longer than twelve rows - vehicle and track.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It was tucked under the vehicle and colour boxes, which put the one
field you type into below three you only click. It now sits at the top
of the second column: who you are, then what you are driving.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every list group is a combo box now: track, time, weather, length, vehicle,
and colour/badge or team/position. Scenario stays as visible buttons because
it decides what the other lists contain, so it should not be hidden behind
one of them.
This ends a problem I had been solving the wrong way. The menu was flat
lists of everything, which was fine when the content was short enough to see
at once - the quality that made it feel like the pod panel. The promoted
resource file roughly doubled it, and I answered with two columns, then
better margins, then a general column flow, each time keeping an idiom whose
justification had already gone. Eight controls replace ninety-odd rows.
At 800x600 the columns go from 131px to 323px, so nothing ellipsizes any
more - the longest name wants 158. The whole menu now needs 310px of the 492
above the buttons there, and 529 of 900 at 1080p, so adding vehicles or maps
cannot crowd it again.
The boxes are owner-drawn - green on black, highlight inverted rather than
tinted - so they read as part of the panel instead of arriving in system
colours. They are rebuilt rather than moved when the scenario changes, since
it swaps two of them outright and reshuffles the track list.
Built and run at 640x480, 800x600, 1280x720 and 1920x1080.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I fixed the wrong column twice. The overflow was never the vehicle list: it
was the settings column - scenario, map, time, weather, length - which sat
within 11px of the bottom at 800x600 before any of this, and went 115px past
it once seven maps were added. At 1080p it wanted 1174 of 1080.
Fixed columns cannot hold this menu any more, so the groups flow: they fill
a column, start the next, and the layout takes as many as the content needs,
sizing them to share the width. Two passes - one to count the columns, one
to place the items - so nothing has to know the count in advance. Adding a
map or a vehicle can no longer push anything off screen, which is the actual
property that was missing.
Every column starts two rows down so the pilot name box has the same home
whichever column ends up last, and the bottom is reserved for LAUNCH and the
lobby buttons. AddGroupItems is gone; the flow places items directly.
Verified by arithmetic at 640x480, 800x600, 1024x768, 1280x720 and 1920x1080
in both scenarios - nothing exceeds its width or its bottom - and by running
the front end at four of those plus a mission at 800x600.
800x600 is honestly dense: the content genuinely needs five columns there,
and the longest few names ellipsize. Lowering the row-height floor does not
buy a column back, so the rows stay at 18px and legible. Dropdowns would end
this class of problem outright and are worth considering.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The wrapped layout inherited the three-column fractions, which are generous
for three and wrong for four. At 800x600 that put the last column hard
against the frame - 19px of slack across the whole right edge, with the
longest vehicle name needing all but 2px of its 160px column.
The wrapped case now spreads its four columns evenly on its own margins,
and the unwrapped case keeps the proportions it always had. At 800x600 the
columns go to 168px with 40px of slack; 1024x768 and 1280x720 land
comfortably too.
640x480 cannot be made to fit: four columns of long names want more width
than there is, so names there are drawn with an ellipsis rather than sliced
through a glyph. That applies everywhere, so any window too narrow for its
content degrades the same readable way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adding eleven vehicles to the setup menu made the column longer than the
window. At 1920x1080 it used to end at 994 against a 1080 client and now
wanted 1324, so the bottom of the list was simply off screen - and the rows
cannot shrink to absorb it, being already at the 18px floor that keeps them
legible.
The list now wraps across two columns when it does not fit, split evenly
rather than filled-then-spilled, and the loadout column - colours and
badges, or team and position - moves one place right along with the pilot
name box and the launch, host and join buttons. A roster short enough for a
single column lays out exactly as it did before, so this only changes the
screen when it has to.
Four columns still fit the width everywhere we ship: the right edge lands at
623 of 640, 1250 of 1280 and 1877 of 1920. The tallest column is 19 rows,
ending at 428, 502 and 754 against those clients.
AddGroupItems grows a 'first' argument so a group can start partway through
its own list. The items stay contiguous in fe->items, so the header still
draws once above the first of them, and each item keeps its true index -
selection and hit-testing already work off item->index rather than position.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The front end's catalogs are hand-written from the console's RPConfig.xml,
and the promoted resource file brought content they did not know about.
Eleven vehicles added - dark, blktrn and neut, which the console names
Blacker Puck, Black Tarantula and Neutrino, plus the eight community Blacker
variants - and seven maps, keeping the console's own convention of bracketing
non-arcade tracks in dashes.
The football map list is deliberately untouched. The console config has its
per-scenario invalid lists commented out, so it says nothing about whether
the new tracks are football-legal, and guessing would put players on a map
with no scoring zones.
A note where the catalog is declared, not a check: validating the keys
against RPL4.RES at menu time crashes, because the front end runs before the
resource file is opened and GetResourceFile has nothing to search yet. That
drift is real - this menu offered blkspk for a while before any vehicle
resource backed it - but the place to catch it is offline against the built
file, not in the boot path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
assets/RP411/RPL4.RES is now the 1.25MB resource from the airlock archive, a
2014 community build, replacing the 785KB file RP412 inherited. It verifies
as a strict superset of ours: nothing is lost but two unnamed Not Used
placeholders, the format version matches, all 26 base vehicles' L4 and
Thrustmaster mapping streams are byte-identical to the ones we shipped, vole
matches resource for resource, and it boots against our own GAUGE, VIDEO and
AUDIO with a log identical to the baseline.
Its L4GAUGE.CFG comes with it. That file is ours plus the new vehicles'
blocks and one fix: dragonInit gains twoBoosterInit, so the dragon's two
boosters finally have gauges - it always had them in its subsystem list and
the panel simply never drew them.
dark, blkspk and blktrn are back with the tables they shipped with in 4.10,
checked against the retail file: blkspk still puts its third booster on the
thumb-high in preset 4 alone, dark still spends preset 5's HORN slot on its
second demo pack. The archive also brings neut, a four-booster Lepton class
with an Eject subsystem, eight community Blacker variants and seven maps.
The black mystery turned out to be a renaming bug rather than a vehicle that
never existed. RP411's gauge config carried a blackInit block nothing could
select, because the lookup is <model>Init and no model is named black. The
airlock config calls the same block - byte-identical body - blktrnInit,
which is the model's real name. disk is now the only genuine orphan: a panel
layout with no vehicle behind it in any resource file, and no entry in the
console's own config either.
The roster page regenerates to 34 vehicles. Four of the eight community
variants have a ControlsMappings List but no streams of their own, pointing
at another vehicle's by id, so they carry no preset table and do not appear.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
RPL4.RES is compiled from authored sources by RPL4TOOL -b, the original 1996
resource tool, which still builds in this tree. That pipeline did not work:
the tool aborted on the very first model, so the resource file could only be
consumed, never regenerated. Content could not be changed at all.
It died in PlugStream_FindEntryAndWriteObjectID resolving resource=
Translocation01 from AUDIO/PLYINT.SCP, a name defined in no file under
CONTENT/RP. The obvious suspect was the AWE32 soundbank path, since
AudioCard::LoadSBK is stubbed to return 1 by the Win32 port - but that is a
red herring. Supplying the banks changes nothing because nothing reads them
at build time. CreateStaticAudioStreamResource opens audio\static.scp, which
declares all 154 patch resources as plain text mapping each name to a bank
and patch number, and CONTENT/RP simply does not have that file. The sda4
developer drive does. Two more scripts included by 27 vehicles, VTVINT.SCP
and VTVEXT.SCP, were missing the same way. All three are kept in recovered/
because they are the keystone and are small.
No engine change was needed. The pipeline was missing content, not code.
build-res.ps1 assembles a build tree from the 4.10 content, those three
scripts, and the soundbanks and ~547 VIDEO files that RP412 ships complete
and the content tree does not. It never overwrites an authored file with a
shipped one, so archival content stays authoritative where it exists.
A model missing a skeleton is dropped SILENTLY - the tool logs and carries
on, producing a resource file with fewer models rather than failing. Check
the model count, which is why the script reports unresolved inputs.
-RestoreCutVehicles uncomments dark, blkspk and blktrn, three vehicles taken
out of the .bld after 4.10 shipped with their model ids left in place. All
three build clean and take the count from 42 to 45, exactly retail's, each
with its full subsystems, segments, damage zones and control mappings.
What this cannot do yet: eleven maps have no source. Five survive only on the
sda4 drive in a 1996 state older than retail, and otto, frstrm, burnt,
brewers, headoff and headmf are gone entirely - the .CAM cameras and .XST
existence boxes are here but the .MAP files are not. So a build from these
sources yields 45 models and zero maps against RP411's nine, and is not yet a
drop-in replacement for assets/RP411/RPL4.RES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VTV-PRESETS.html joins the handbook in the dist: all 26 racable vehicles,
each with its hull plan view, what it carries, and the six control presets
on its stick. The data is decoded from RPL4.RES rather than transcribed -
each vehicle's own subsystem list names its systems, and
VTV::BasicSubsystemCount fixes list index i as subsystem 9+i, so every
subsystem id in all 26 mapping streams resolves with nothing left over.
The gauge config could not have been used for this: it is only artwork,
and it lies. The dragon has boosters and a chute with no art declared,
and the burro's chute sits in a different panel slot than sequence would
suggest. Both come out right this way.
The plan views are each vehicle's own damage-gauge silhouette, redrawn
from the three-colour original as an alpha mask so it takes the page's
colour in either theme. There is no per-vehicle art to use instead:
vehicles are grouped into hull families that share both the silhouette
and the mesh in VIDEO/, which is keyed by the same two-letter code. Each
card says which hull it is and who else races the same one.
The page does not mention the intercom PTT. Its hardware never went past
prototype cockpits, so naming a control nobody can press would only raise
questions; those cells are simply blank. CONTROL-PRESETS.md carries the
full account, because anyone re-decoding the resource will find message
ID 13 on the pinky and needs to know why the tables show it empty. Two
things in the shipped assets settle it: the tool panel's fourth quadrant
is bare where every other system has a legend, and the two finished
intercom station screens are referenced by nothing at all. The edge
strips are referenced - but gated on ModeIntercom, which nothing ever
sets, so they have never been on screen either. The block that would have
wired the buttons is inside #if 0 and still names L4ModeManager, a class
that no longer exists: cut before the RPL4ModeManager rename and never
revisited.
The doc also now explains the mode-mask gate itself, since that is what
makes the dead intercom legible: one 32-bit word, seeded 0x201, read by
both the controls dispatch and the gauge renderer's active/inactive
sorting. A drawable whose bit is never set is parked in inactiveList for
the life of the process and never complains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The six PRESET switches down the map's right flank stored their lamps in
modeLamp[], which holds four. Indices 4 and 5 ran off the end into
presetLamp[0..1], so the whole thing stayed self-consistent by memory
layout and nobody noticed - but it overwrote the four control-mode lamps
made moments earlier, and BASIC/STANDARD/VETERAN/MASTER on the upper-right
MFD were never lit again. presetLamp[], meanwhile, went unused.
The preset pass now fills the array it was always meant to, and the lamp
work moves out of the switch handler into a virtual NotifyOfPresetChange
that PresetEnable announces itself. That closes the second gap in passing:
keyboard 1-6 changed the mappings without touching the lamps, leaving the
flank showing a preset that was no longer in force. Both routes now go
through one place. The lamp arrays are also cleared in the constructor -
only the mapping loops ever filled them, and NOMODES skips those.
Verified by dumping the commanded RIO lamp states out of the running game
(PadRIO, TEST.EGG, at rest in Basic mode). Before and after are identical
except lamp 0x33, BASIC, which goes from 14 dim to 3c lit. The preset
lamps are unchanged: they worked by accident, and now work by
construction.
docs/CONTROL-PRESETS.md is the research behind it. The presets are not a
map feature at all - each is a complete factory layout for the four
mappable stick buttons, one mode-mask bit apiece, with all 26 vehicles
carrying their own six-preset table in RPL4.RES for both the pod RIO and
the Thrustmaster.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both the README and the dialog an expired build puts up pointed at the
Gitea releases page for the next build. That page is empty now - releases
come through another channel - so in a fortnight's time the one message a
tester is guaranteed to read would have sent them somewhere with nothing
on it.
No address in its place: whoever handed them the build is who to ask, and
a URL that goes stale again is worse than no URL. The Source: line stays,
because the repository it names is still there.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The damage model, end to end, read off the authentic surviving RP source
(VTV.cpp, WEAPSYS.cpp, RIVET.cpp, DEMOPACK.cpp, RPPLAYER.cpp) rather than
inferred. Companion to the BT doc of the same name; the shared engine
layers are the same and only summarised here.
The finding it is built around: RP's damage model is a physics and score
economy, not a subsystem-failure simulation. One armor pool at zone 0, no
criticals, no per-zone cascade. Collision armor is not authored at all -
it is calibrated from the vehicle's own mass and MaxImpactSpeed so that a
full-speed hit spends exactly the whole budget - and deathConstant
converts damage to score at that same exchange rate. Every point of
damage is simultaneously a transaction between two players, through a
two-second revenge window. Martian football, not a mech duel.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Crashdmp\ is where a dump sent in by a tester lands. Read it with cdb
against the matching Release\rpl4opt.pdb - the PE timestamp recorded in
the dump says which build it came from, and the symbols mean nothing
unless it matches.
Not tracked, because a minidump is not ours to keep: it carries process
memory and the sender's own file paths.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
From Nathan's crash dump: an access violation reading 8093e920, fourteen
minutes into a session, on 4.12.115.
rpl4opt!PatchLevelOfDetail::SetupPatch+0xbb
rpl4opt!Static3DPatchSource::StartImplementation+0x50
rpl4opt!AudioRenderer::ExecuteBackground+0x9e
The faulting instruction is g_buffers[index] with index = 0x20000000 -
536 million - and the array base in eax at 0093e920, which is exactly the
address it died on. So the index was garbage, and the dump says where the
garbage came from: the stack slot holding info.bufferIndex.
PRESET_getSampleInfo builds a SAMPLEINFO to return when it is asked for a
zone the preset does not have. It sets chan, file, implemented and loop -
and not bufferIndex. Every caller tests bufferIndex >= 0 before using it,
so "no such zone" was meant to be rejected there; instead the test read
whatever was on the stack, and passed whenever that happened to be
positive. AL_getBuffer then indexed the array with it, unchecked.
Why it asked for a zone that is not there: the loop runs to
sourceSet.count, which was fixed when the audio source was built, from
whichever level of detail was selected at the time. SetDistance re-picks
the level of detail by distance on the line immediately before SetupPatch
runs, and the zone counts across the recovered banks are nothing like
uniform - of 200 presets, 46 have no zones at all, and the rest run 1 to
4. So a sound that moved far enough to drop to a quieter patch could ask
that patch for a zone it never had. In the dump: count 3, died asking for
zone 2.
Fixed at all three levels, because any one of them alone would have held:
the default carries bufferIndex = -1 so the existing guard works,
AL_getBuffer returns AL_NONE rather than reading past its array, and
SetupPatch asks for no more zones than the patch it is actually using
has.
Verified: the dump's own numbers reproduce arithmetically, and two full
races run clean. The distance-dependent trigger itself was reasoned from
the dump rather than reproduced here - it needs a sound to cross a level
of detail boundary into a shorter patch - so the belt-and-braces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The volume and bass keys were not on the keyboard diagram, and nothing told
a returning player why the game suddenly sounds different.
Adds a short section on what came back out of the original soundbanks -
pitch, the missing layers, distance, reverb, doppler - written for someone
who wants to know why their collisions have weight now, not for someone
reading the source. It closes on the knobs, because "it is too much" is a
fair reaction and the answer should be next to the explanation.
PgUp, PgDn, Home and End now light up on the keyboard diagram in their own
colour, with a legend entry, rather than sitting there as dead keys. And
volume.cfg and bass.cfg join the list of files in the folder that belong to
the player.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported by a tester and reproduced here: finish a race, come back to the
lobby, start another, and the game dies a few seconds in.
It is a stack overflow, from CockpitShellProc calling itself. The cockpit
subclasses the game window to catch WM_SIZE and re-fit the canvas, and
kept SetWindowLongPtr's return as the proc to chain on to. But the game
window is not the cockpit's - it outlives it, and carries the console
screen from one race to the next - and nothing ever unsubclassed it. So
the second race subclassed an already-subclassed window, SetWindowLongPtr
handed back CockpitShellProc itself as the "original", and from the next
message onwards the proc chained to itself until the stack ran out.
Nothing in the log, because nothing in the game had gone wrong yet.
So the destructor puts the window's own proc back, and the install site
will not subclass the same window twice even if it could not.
While there: the destructor also left activeCockpit pointing at the
object it had just freed, so GetCockpit() handed CockpitShellProc a dead
cockpit to lay out. Harmless until someone resized or maximised the
window at the lobby between races, which is not a hard thing to do. Now
cleared.
This came in with the cockpit resize work in 6b43971, so every build
since has had it.
Verified under cdb: before, the crash is a c00000fd stack overflow with
CockpitShellProc / CallWindowProcA repeating the whole way down. After,
four consecutive races - launch, race, results, CONTINUE, lobby, launch
again - complete with no exception at all, and the process exits only
when asked to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The bank exclusion used AUDIO?.RES, which also swallowed AUDIO.RES - the
one-byte stub that has been in the audio folder since 1995. A '?' in a
Windows wildcard will match zero characters, not just one. Name the two
banks instead of pattern-matching them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The original AWE32 soundbanks are the source the shipped sound effects are
generated from, and they belong in the repo for that, but the game has no
use for them at run time - the LoadSBK path died with the sound cards and
AUDIO.INI's [AudioResources] section is commented out alongside it. They
were going into every download regardless.
Also drops four wav files that nothing references: two leftovers named temp,
one of them empty, and two stale zone files orphaned when the preset table
was regenerated from the banks.
Together about 8MB off the package.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The volume keys wanted a partner, and the bass trim could not be one as it
stood: it scaled the sample data as it loaded, so by the time anyone pressed
a key the audio was already sitting in OpenAL buffers and nothing short of a
restart would move it.
So the trim is now a per-zone gain applied in the mix instead. Each buffer's
depth - how much of the low band it occupies - is still worked out once at
load from its playback rate, but the trim itself is read every frame, which
is what lets Home and End move it while sounds are playing. It is the better
form regardless: no rewriting of sample data, and no quantisation on top of
audio that has already been through one gain stage.
Home raises, End lowers, in steps of 0.05, and the setting is written to
bass.cfg beside the exe exactly as the volume writes volume.cfg. Together
with PageUp and PageDown that is the amplifier and the crossover the
cabinets had in hardware and a desktop does not.
Builds clean, runs, and neither knob fires unprompted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cabinets had no volume control - they ran at unity and left level to an
external amplifier - so a player without that hardware had nowhere to turn
it down but environ.ini and a restart. PageUp and PageDown now step the
master volume by 0.05 while you play, from silent to double, and whatever
you leave it on is written to volume.cfg beside the exe and used from then
on. The environ.ini figure decides where a machine that has never been
touched starts out; the keys are the knob, and a knob stays where it was
left.
Page keys because they produce no typed character, so they cannot collide
with the character-keyed commands the engine already answers to, nothing
else in RP binds them, and they are on every keyboard including tenkeyless.
They are polled rather than read off the key-message path, which is worth
recording because the message path looked like the obvious home for them
and was tried first. RP's keyboard pump only takes WM_KEYUP, WM_SYSKEYUP
and WM_CHAR off the front of the queue, and the front end runs message
loops of its own, so key messages get raced for and lost: six deliberate,
well-spaced presses arrived as two. Fine for the abort chord, useless for
something you tap repeatedly to find a level. Reading key state directly
costs nothing and cannot be dropped. That losses figure is a pre-existing
property of the input path, not something this change introduced, and is
worth knowing before anything else gets bound there.
Builds clean, runs, and does not fire unprompted. The step function itself
is proven - it was driven end to end through the message path before the
switch, stepping the right way, clamping, and persisting. What I could not
test from here is the polling trigger, because Windows would not hand the
game foreground and injecting keys without it would have sprayed them
across whatever else was open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The cabinets ran the game at unity and shaped volume and tone outside it,
in an external amplifier and a 3-way crossover. That is why there is no
master volume anywhere in the original code and none in AUDIO.INI - an
operator turned a knob on an amp. A desktop player has no amp and no
crossover, and the recovered soundbanks are a good deal livelier than what
4.12 shipped with, so the game has to offer the two controls the pod got
from hardware.
RP412AUDIOVOLUME, 0.0 to 4.0, is the amplifier: a listener gain, which the
port had never set at all. RP412AUDIOBASS, 0.0 to 1.0, is the crossover's
low band. Both default to leaving the mix exactly as the pod played it, so
neither changes anything for anyone who does not go looking.
The bass trim is not a filter, and the reason is worth writing down: the
OpenAL we ship is Creative's, not OpenAL Soft, and it implements only
AL_FILTER_LOWPASS. It rejects highpass and bandpass outright. A bandpass
would have been the tidy answer, carrying the authored brightness model on
GAINHF and the trim on GAINLF across the single direct filter a source
gets. It is not on offer.
So the trim scales sample data as it loads, which suits how this low end is
actually built: the weight lives in discrete deep layer zones whose per-zone
tuning bakes out to a very low playback rate - thirteen zones below 8kHz,
three to five octaves under their recorded pitch, against four fifths of the
set at 22kHz and up. Baked rate is a dependable proxy for band, so pulling
down the low-rate zones is a real low-band trim and not a blunt cut. It eases
in below 22kHz and reaches full depth at 5.5kHz.
Caught while building this, and the reason for the probe: EFX_Initialize
checks alGetError after configuring the scratch filter, so asking for a
filter type the driver refuses leaves an error pending and takes the entire
bridge down - reverb included. The bandpass attempt did precisely that and
would have silently killed the reverb and brightness work. Initialize now
survives losing the filter and says so.
Builds clean, runs with both knobs set and with neither.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recovering the soundbanks took voice demand per sound from about one zone to
about two and a half, and the audio path allocated an OpenAL source for every
sound event and destroyed it again on release. Sources are a hard
per-context resource - this driver grants 256 - so that churn doubled at
exactly the moment it got more expensive. Sources are now generated once and
recycled through a free list: measured, three sources generated across
twelve thousand acquisitions.
The BT tree reached the same conclusion the expensive way, from field logs
full of failed acquisitions: raising the source budget is not the fix,
because the ceiling also acts as a governor and more voices mixing is real
CPU during exactly the busiest moments. Recycling is the fix, and it costs
nothing.
Two older bugs were sitting underneath, both reproduced against the driver
rather than assumed:
Releasing a set leaked it. alDeleteSources is atomic - one bad name in the
array and nothing at all is deleted. ReleaseSourceSet handed it the whole
fixed-size array and then parked the slots at -1, so any partial set, and
any double release, leaked every source it held. Sources are now handed back
one at a time and slots park at 0, which is never a valid name.
A source set began life uninitialised. The constructor set only the count,
and the acquire path decided whether a slot was already filled by asking
OpenAL about uninitialised stack garbage. Garbage that happened to match a
live name meant two sounds silently sharing one source. Pooling would have
made that more likely, not less, since it keeps small names in circulation.
Recycled sources are scrubbed before parking - stopped, buffer detached,
looping, gain, pitch, relative flag, position and velocity reset, and the
EFX filter and reverb send dropped. Without that last part a dry cockpit
sound inherits the wet send of whatever 3D source held the name before it.
Verified: a deliberately dirtied source comes back clean.
Builds clean. Runs with memory and handle count flat.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Red Planet's original AWE32 soundbanks are back in the tree, and the game's
sound effects are now generated from them instead of from an incomplete
one-off extraction.
AUDIO1.RES and AUDIO2.RES come from the 1996 release in the TeslaRel410
archive, hash-identical. AUDIO.INI has named them all along - they were
simply never carried into the port. tools/rp_sf2extract.py reads them and
regenerates both the WAV set and RP_L4/WTPresets.cpp, so the assets are
reproducible from the banks rather than hand-maintained.
Two things were wrong with the old set:
Pitch. Every shipped WAV was flat 44100 Hz with the banks' tuning discarded,
so 202 of the 219 zones played at the wrong speed - the worst by nine
semitones. The EMU8000's per-zone root key and tuning are now baked into
each file's declared sample rate, which is exact and needs no engine change.
Layers that were meant to be deep now are: a collision sub-thud that lasted
18 milliseconds at the wrong rate is a 0.66 second one at 1228 Hz.
Missing layers. 93 presets were short of zones and 176 were missing outright,
219 of 395. Nothing was lost recovering them - the 46 preset slots that
disappeared were all empty placeholders. The old files were also over-read,
running past the end of their sample into whatever PCM came next;
WellheadDrill02a was six seconds where the bank says eight hundred
milliseconds. Every one of the 395 files now matches its bank record exactly.
Also baked in: per-zone layer attenuation, and the static resonant low-pass
the EMU8000 applied in hardware.
Measured while doing it, and worth knowing: RP's banks contain no key-splits
at all - every multi-zone preset is a pure layer stack - and no preset has
more than four zones, which is what the engine's own "AWE appears to only
play 1st 4 voices" warning has been asserting since 1995.
Still to do: loop regions and the release fades, which 349 zones ask for and
which need new SAMPLEINFO fields. And voice demand per sound has gone from
about one zone to about two and a half, so the per-event alGenSources and
alDeleteSources churn roughly doubles - the BT tree measured pooling as the
fix for that, and a CPU win besides.
Builds clean. The extreme baked rates, 1228 Hz up to 88200, were checked
through the real path - libsndfile, alBufferData, alSourcePlay - and all
load. Not yet listened to on the pod.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The OpenAL port kept the whole authored audio model and then threw most of
its output away. Every frame the engine computed a distance-attenuation
curve, a high-frequency rolloff, doppler cents, a reverb level and a
front/rear placement, and every one of those consumers had been commented
out when the two AWE32 cards were replaced. What reached the speakers was
OpenAL's own defaults instead: a straight-line fade to silence, no
filtering, doppler at the wrong constants with an inverted velocity, no
reverb, and every cockpit sound dead centre.
Restored, per AUDIO.INI, which is byte-identical to the file that shipped
in August 1995:
- the authored knee/rolloff distance curve, replacing AL_LINEAR_DISTANCE.
This also un-blinds the transient cull, the voice-steal weighting and
the mix ducking, which all key off it and were treating far sources as
full presence
- the CC7 squared volume law; writing the scale linearly ran everything
about 6 dB hot at mid-scale
- brightness and distance muffling, and the wet-exterior/dry-cockpit
reverb split, both through a new OpenAL EFX bridge
- doppler on the moving-source path only, as the original had it
- front/rear placement from the authored position enum
The larger find is that AL_PITCH was never called anywhere in the tree, so
the entire pitch chain was inert - not only doppler but pitch_mix_offset,
which our own sequences author 97 times. Doppler alone would have changed
nothing audible.
Note pitch is applied for parity with the BT engine but is identity here:
our content predates NoteAudioControlID, so every source runs at note 60.
Builds clean on VS2022 Release|Win32. Smoke-tested against vRIO on COM1 -
reaches gameplay and holds a steady frame loop. ALC_EXT_EFX is present on
the build machine with all nine entry points, so the filter and reverb work
is live rather than inert. Not yet listened to on the pod, which is the
real test: the volume law changes the level of everything.
docs/SOUND.md documents the original two-card quadraphonic design, where
the surviving original assets are, and what remains.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BT411's f99003c, brought across. Its playtesters reported the cockpit
lighting going slow or stopping altogether while the 3D view stayed
smooth, and RP412 has the same structure exactly: the on-screen vRIO
buttons light themselves from PadRIO::GetLampState, but what FILLS that
store is lampManager->Update() in GaugeRenderer::ExecuteForeground - once
per full gauge cycle.
Which is the cycle the previous commit was about. Measured on a starved
frame budget it now completes 3.1 times a second, and completed 0.7
times a second before that; either way far too slow to carry a flashing
lamp. So sweep the lamps once per frame from the main render instead,
which runs regardless of how little frame is left over. It is cheap, and
AssertNewLampValue already drops anything unchanged, so this pushes no
extra traffic - it only stops changes arriving late.
Only when a PadRIO is active, i.e. cockpit-less play, and only while a
mission is actually running. With real serial hardware selected the pod
keeps its authentic bandwidth-paced cadence, untouched.
RP412LAMPSWEEP=0 restores the once-per-cycle behaviour.
BT411's other half, 02ce9f5, does not apply. That one is about Windows
throttling WM_TIMER and paint messages for background windows, which
made the glass panels' flash crawl whenever they did not have focus.
RP412 has no timer-driven repaint anywhere - the MFD windows are D3D
devices presented from SVGA16::Update, and the panel strips repaint from
there too - so there is no throttled message path to bypass. That path
was starved rather than throttled, and the previous commit is the fix.
Verified: no regression at either budget, 20.0 display sweeps/s at a
normal frame budget and 3.1/s starved, both unchanged by this commit;
mission runs clean. The lamp win itself is structural - the sweep is now
an unconditional per-frame call - and would want a busy multiplayer
mission to see directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two testers reported the map and the countdown clock freezing, one of
them only on larger, more complex maps, and one of them until a death.
Both details point at the same place.
The gauges and the cockpit displays are redrawn in whatever time is left
after the 3D view. The background loop is guaranteed a single pass per
frame and gets more only while time remains before the frame is due, and
one pass drew exactly one gauge. So a full sweep of ninety-odd gauges
needed ninety-odd passes - free when there is spare frame, but on a busy
map the 3D view eats all of it, the loop drops to its one guaranteed
pass, and a sweep takes ninety-odd FRAMES. Seconds. A death makes the
renderer skip every static object, the budget frees up, and the backlog
drains at once: the display appears to come back to life.
Worse, the copy phase that follows ended after a SINGLE display, so the
map - one of three - came round only every third sweep.
So: draw gauges to a 2ms slice rather than one per pass, which ties the
refresh rate to elapsed time instead of to how much spare frame there
happened to be; and copy every display before reporting the sweep done.
Measured on a deliberately starved frame budget, which reproduces the
reported symptom: 0.7 sweeps/s before, 3.1 after. At a normal budget
20/s, against 18-19 before - no cost to the healthy case. RP412GAUGESLICE
tunes the slice and 0 restores the old behaviour, which reproduces the
0.7 exactly. RP412GAUGEDIAG=1 logs the rate; watching the screen cannot
tell a display that has stopped refreshing from one whose picture simply
is not changing, which is what made this hard to see.
Also fixes the constructor calling Update() three lines before it
initialised mDisplayToUpdate, so the first pass indexed the D3D device
and surface arrays with whatever was on the stack.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tester still racing a two-week-old binary reports things that were
fixed a week ago, and the afternoon spent chasing them is gone. An
expired build now says so and stops: a dialog naming its version and
expiry date, pointing at the releases page, and an exit before anything
else runs. The log carries the same line, so a report from an expired
build identifies itself.
$expireDays at the top of stamp-version.ps1 is the shelf life, sitting
next to the product line it belongs with. It counts from the day a build
was MADE rather than the day the code was written - rebuilding an old
commit to chase something should hand back a usable binary, not one born
stale.
SET IT TO 0 FOR A REAL RELEASE. A shipped build that expires is a
catastrophe, and that one line decides it. It is called out in the
script, in the generated header and in BUILD.md, because it is the kind
of thing that gets noticed exactly once, too late.
The date is what makes rpl4build.h differ from one day to the next, so
the first build of each day recompiles RPL4.CPP and the rest do not.
This is a nudge, not a lock. The date comes from the machine's own clock
and anyone determined can wind it back; the point is to stop an honest
tester wasting a day, not to stop anybody at all. RP412NOEXPIRY=1 waives
it for us and logs that it did, so a waived build is never mistaken for
a current one. It is deliberately absent from environ.ini - a bypass
every tester can see is a bypass every tester will use, and then it never
goes stale for the one person it was meant to stop.
Verified all four ways by backdating the shelf life rather than touching
the clock, which is what a negative $expireDays is for: a fresh build
runs untouched; an expired one raises the dialog, exits 1, and logs
"Build expired on 4 August 2026 - refusing to run"; the same expired
build with RP412NOEXPIRY=1 runs and logs the waiver; and a build with two
days left runs and logs two days left.
Two things that only showed up by running it. Negative days first meant
"never" rather than "already expired", so the refusal path went untested
on the first pass - only 0 means never now. And the days-left count was
anchored at midday, reporting one day fewer than the build had; it is
anchored at the end of the expiry day, which is the rule the check
actually enforces.
The packaged README tells testers the build expires, where to get the
next one, and that unzipping it over the folder keeps their four files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The file-preserving repack landed with the restore before the zip and
skipped entirely under -Zip, to keep somebody's callsign and key
bindings out of a release. It worked, but at the price of -Zip quietly
wiping the settings out of dist\ - captured, then discarded.
Both properties are available at once by moving the restore after the
archive is taken: the zip is built from a folder with none of the
player's files in it, and they go back into dist\ immediately
afterwards. A fresh unzip still looks like a first run, and cutting a
release costs the person cutting it nothing.
Verified: edited all four files, packed with -Zip, and confirmed the
archive contains none of them - 1003 entries, nothing loose at the root -
while all four are still in dist\ with their edits intact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The game stopped shipping environ.ini so a new build could land on an
existing folder without replacing anyone's settings. pack-dist.ps1 then
went on rebuilding dist\ from nothing every time, which threw away
environ.ini, bindings.txt, pilot.cfg and mfd_layout.cfg on every repack -
the one place the promise did not hold, and the folder we do most of our
own testing in.
They are now carried across the rebuild. -Fresh wipes them too, for
checking what a genuine first run does.
Restored last, after the tree is rebuilt, so nothing the pack writes can
land on top of them. Not restored at all under -Zip: the archive is taken
from dist\, so a preserved file would otherwise travel to whoever
downloads the release, and a release should always be built from a folder
with none of them in it.
Also fixes three paragraphs of the packaged README that have been saying
the opposite of the truth since the change: that environ.ini ships, that
it is the only one of the four that does, and that bindings.txt is the
only one never overwritten. Those edits were made at the time with
PowerShell .Replace() calls that silently matched nothing - which is
exactly why the same corrections here are made with an editor that fails
loudly instead.
Verified by editing all four files, repacking, and reading the first line
of each back out of the rebuilt folder; then again with -Fresh to confirm
all four are gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Simulation::ReadUpdateRecord threw away the sender's timestamp and
stamped lastUpdate with its own arrival time. The line carried the
original authors' own note: "HACK - should be based upon
message->timeStamp".
The dead reckoner extrapolates a replicant over
(lastPerformance - lastUpdate), so starting that clock at ARRIVAL rather
than at SEND leaves every remote vehicle exactly one network latency
behind where it should be. On the 1 ms LAN inside an arcade that is
nothing. Over Steam Datagram Relay it is 50-150 ms of positional lag on
every other player - a constant bias, not jitter, and the information
needed to remove it was already in the packet.
The timestamp cannot be used as it stands: both machines run
QueryPerformanceCounter since their own boot, so the two clocks share no
epoch. The offset is estimated per peer instead. Each record gives
sample = ourNow - theirStamp = trueOffset + oneWayLatency
and latency is never negative, so the smallest sample seen is the
closest to the truth. A rolling minimum over 128 samples follows crystal
drift and re-adapts when a route gets slower, rather than being pinned
forever by one lucky packet; a shorter path is believed immediately.
Applied with two clamps: never ahead of our own clock, and never further
back than 500 ms. Past that the packet is stale or the estimate is
wrong, and throwing a vehicle half a second forward does more damage
than the lag being corrected.
Entity::UpdateMessageHandler is the only point on the receive path that
knows whose update this is - records carry a timestamp but not an owner -
so it publishes the sender around the loop, and only for entities
somebody else owns. Offsets are forgotten in CreateMission: the hosts in
the next race are not the hosts in the last one and a HostID gets reused.
RP412NETCLOCK=0 restores the arrival-time behaviour, documented in
environ.ini, so a test machine can compare the two without a rebuild.
The estimate is logged per host when it first settles and whenever it
moves more than 50 ms, which is what a three-machine session should be
read against.
WHAT IS AND IS NOT VERIFIED. A full single-player race runs unchanged -
the path is never entered without replicants, which is the regression
risk that reaches everybody. The behaviour this exists for needs real
latency between real machines and is therefore untested: a two-instance
loopback race would only have exercised the zero-latency case, where the
correction is a no-op by construction. Expect remote vehicles to sit
further forward than before, and watch for overshoot when somebody
changes direction sharply - that is the tradeoff this makes, and the
clamp above is what bounds it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>