Three bodies, one reason: the corpus grew from 22 saves to 43 and started
carrying content the shapes did not name.
ProjectName read `usnc` as a count of ONE item, on the strength of a comment
saying "usnc is 0 in every save available". Game::SpecialProjectNameGen::Write
(0x008147e0) says otherwise: each 88-byte record opens with a 32-byte table of
per-suffix use counts, `usnc` is the number of NON-ZERO slots, and the loop then
writes, for every non-zero slot, its INDEX as `usp` and its count byte -- movzx
widened -- as `usc`. Both go through WriteInt, so both are i32 on the wire.
Twelve corpus saves have one such slot and were round-tripping exactly 12 bytes
short: one i32 item, 4 length + 3 tag + 4 value + 1 pad.
`Sprj` is a polymorphic frame and both halves of its mapping are now measured.
ServerPlayer::Write emits `SprjT` from the plain member at project+0x3c, and
ServerPlayer::Read feeds that value to the factory at 0x008610a0, whose table at
0x008611e8 is 0 BackEngProject / 1 MonitorProject / 2 JewelsProject / 3
TechOfferProject -- and each of those constructors stores its own index back at
+0x3c. Only SprjT 0 is exercised by any save; the other three arms are typed
from the recovered schema and labelled as the hypotheses they are, and an
unknown SprjT falls to rest() so it shows up as opaque coverage rather than
being mis-read in silence.
FieldTemplate carried its points because no save had ever put one on the wire.
Six now do, so Game::FieldTemplate::Point is typed and bound. FTPPosX/PosY/Sqd
read 0 in every observed point, so their i32 disk type is still the schema's
word and not the corpus's, and the comment says so.
All 43 saves now round-trip byte-identically; the wire-schema conformance test
binds six new shapes with 0 MISMATCH and every one matching item for item.
Both early-outs return without touching the generator: p <= 0 false, p >= 1
true, otherwise exactly one word compared with a strict <. The zero-cost cases
decide stream alignment wherever a caller's probability climbs -- the spy
counter-mission adds 0.2f per failed turn and stops drawing entirely from the
fifth. A model without the early-outs drifts one word from there on, for ever.
NaN takes neither early-out in the original, so it draws and returns false;
reproduced rather than smoothed over.
Coverage is indexed by shape and answers 'does a field name this item'. Two
saves carrying content nobody had modelled scored 100% anyway, because the
novelty landed in fields that were already typed. This asks what the corpus has
ever actually put in each field.
Over 22 saves: 724 fields observed, 490 vary, 234 constant. A third of the
format we call 99.99% typed has been seen holding exactly one value.
The corpus went 11 -> 19 saves and named coverage fell to 97.6% against a 99.99
bar. Rule 27 says type the content. Typed, all from the recovered wire schema
plus the records the new trade/spy saves finally carry:
Game::CombatPlayerReport the <rest:prep> tail -- ncls run of six per ship
class, nsec run of two per section, ndam, srep
Game::CombatShipReport srep elements (caps2 is an i64; dami/damt f32)
Game::TacReport TRnc is a COUNT and TRships/TRsats/TRshipsL are one
loop body, INTERLEAVED on the wire -- not three
trailing runs. Two lanes could not settle this
because TRnc was 0 in every save until tonight.
Game::TacReportEvents TRby / TRto
Game::FleetLayout Lay: a FieldTemplate frame then a count and its ids
Game::TradeRoute rt -- a container write with NO count word, read as
an uncounted run keyed on the tag
Game::ServerTradeSector::FreighterWarning fwarn elements
Game::SpyCraft spy elements
Game::WeaponGroups Dwg, and Game::GunBankSelection under it
Corrections found on the way, both silent until a shape was bound to the table:
* Game::CombatReport: auto and cdst are BOOLs, dur/cdt/cdi are FLOATS. All
five were ints here. Four are 0/1 everywhere so the bytes never moved; cdt
is not, and was being read as 1070805848 instead of 1.598.
* Game::CombatWeaponReport: the damage quartet is flat and dami/damt are
floats. The old shape reached them through obj_flex, modelling a nested
`dams` frame the binary does not write. That also made CoverageArchive
charge one phantom typed item per weapon report -- exactly 8/13/21/32 on the
four affected saves -- so the pre-fix coverage figures were slightly
optimistic as well as too low.
FTPnts stays carried: it is the one item the recovery itself marks unresolved,
its count is 0 in every save, and element framing is a property of the helper --
the SysMem / mts / nalat trap. The workload that settles it is a save with a
stored fleet tactical formation.
Named coverage, per save, before -> after:
human-turn5-traderoutes 99.0003% -> 99.9951%
human-turn8-traderoutes 98.6349% -> 99.9955%
human-turn11-spytechs 97.6382% -> 99.9959%
human-turn15-spyprogram 96.9412% -> 99.9964%
the other 16 saves unchanged, 99.9940-99.9953%
corpus 99.5021% -> 99.9949%
All 20 saves are above the ratchet; the only opaque items left anywhere are the
two of the RNG blob. 14 new shapes bound to the wire schema, every one a full
match: 100 shapes, 966 items, 0 MISMATCH. ctest 58/58 over all 20 saves, both
round trips byte-identical, 0 errors and 0 warnings.
A per-turn RNG budget is only as good as the entry-point table, and ours had
three of the seven. Adds the two that are modellable and documents the rest.
float_range(lo, hi) exactly one word. Narrows TWICE -- the scaled product
is stored to a 4-byte float before lo is added, and the
sum is stored again. Evaluating in double and narrowing
once disagrees on a measurable fraction of words, and the
test asserts the two models are distinguishable so the
shortcut cannot creep back.
int_range_bell(lo, hi) AT LEAST TWO words. Triangular, not uniform: the span is
split into h/2 and h - h/2 (truncating toward zero) and
each half drawn inclusively, first half first. The bounds
reach the draw as unsigned, so an inverted range yields a
huge first bound rather than an empty one; reproduced, not
corrected.
Documented but deliberately not modelled: a truncated-normal integer range built
on rejection sampling around a Box-Muller pair. It costs TWO WORDS PER ATTEMPT
and the attempt count is unbounded, and predicting its stream position needs log,
sqrt and cos to agree bit for bit with the original CRT. Nothing in the strategic
turn reaches it. It is recorded so a ledger that meets it does not score its two
words as one draw.
Also recorded in docs/mars-rng.md, because each is a way a word budget goes wrong:
* two calling conventions for one generator -- three entry points take the state
block (the object plus four bytes) and four take the object itself, and one
caller uses both within forty bytes of itself;
* two different divisors in the same image, 1/(2^32 - 1) for the unit draw and
2^-32 (with a +0.5 offset on the word) for the normal path;
* the unit draw is inlined at twenty-eight sites across eleven functions, so any
budget assembled by counting calls is a LOWER BOUND. Exactly one of those
eleven is reachable from the strategic turn driver.
Host ctest 36/36 and tools/clean_room_check.sh run as separate commands, both
clean. No src/shim change, so no cross-build is implicated.
The last opaque block of the save format. Reconciles lane W's provable negative
against lane O's issued-order saves: the two disagreed because three different
things were being compared as one.
Read out of Game::TurnCommands::Write (0x00842540, 764 bytes, no loops of its
own). Two halves:
* a PROLOGUE of six flag-gated groups. Each is a WriteBool on a member,
followed only when set by that command's payload. Write order is NOT offset
order -- each gate sits after its payload in the struct -- so the class is
one of the 89 whose offset-sorted layout view cannot be aligned to the wire.
That, not a branch, is why the sorted view showed an i32 where the save has
a bool at item 4.
* TWENTY-SEVEN std::list<T> members (0x70..0x1a8, stride 0xc, allocator-last),
each written by its own helper as WriteInt(size) then size element records.
All 27 are always written, so an empty list still costs one zero int.
So the recovered 44 items are 17 member writes plus ONE ITEM PER CONTAINER CALL
SITE: the linear recovery keeps the call site, guesses its kind from an element
field it could resolve, and drops the count word. 44 - 17 = 27 = the number of
lists. The "27 trailing ints with only 22 i32 slots" objection is that same 27
seen from both sides -- a kind mismatch in the table, not a structural
impossibility. And 8 prologue items + 27 zero counts = the 35-item block every
no-orders save carries bit-identically.
Item arithmetic closes to the unit on all five distinct workloads (35/38/41/61/
123). One correction to the provisional layout: the fleet-move element ends in a
COUNTED route vector {fleetId, nHops, nHops x systemId}, not a fixed quadruple --
with a four-item element the writer would need 26 lists on one save and 28 on
another, and it has 27.
Conformance: the generic check cannot be used here. Every tag is "." so the LCS
degenerates to a strict positional compare in which any primitive disagreement is
fatal, and the table's tail describes elements where the wire has counts. A
dedicated check states what is checkable instead: the 17-item prologue item for
item (17/17, and SchemaProbe takes every branch, so this is real evidence that
the conditional structure read from the instruction stream is the one the
recovery flattened) and the tail count (27 lists vs 27 table items). The tail is
reported as wire-only, never claimed as matched.
86 shapes / 838 items -> 87 / 856, still 0 MISMATCH.
Twenty-two of the 27 lists are HYPOTHESES: the scalar sequence comes straight off
the helper, but no save exercises them. Nested element bodies not otherwise
modelled here (ShipDesignDef, FleetLayout, WeaponGroups, DefenceLayout,
RaidTargets) are carried as opaque Nodes rather than guessed at, so a wrong body
cannot desynchronise a reader. select() matches the exact ".TurnCommands_v5"
suffix, so a future _v6 falls back to the carried Node.
SECOND DEFECT, present at main and unrelated to this block: zuul-turn23-fleet23
is the first save with a non-empty NVs list, and its element's leading id was
typed positionally as "." where the real tag is PID. The typed round trip on that
save differed at 0x89c14. Rule 6 exactly -- a path no save exercised was a
hypothesis flying as a fact. Fixed, and independently corroborated: the recovered
table for Game::ServerSystem names that item PID, and the Sys row moves from
102 matched / 1 wire-only / 3 shape-only to 103 / 0 / 2.
Coverage (CoverageArchive typed-vs-carried, not round-trip success): all eleven
saves 99.7-99.9% -> 100.0%; opaque items 37/43/63/132 -> 2 everywhere, and those
two are the deliberately-carried MT19937 block. Nothing else in any save we hold
is untyped. Ratchet 99.8 -> 99.99. Round trip byte-identical on all eleven
(newly so on zuul-turn23-fleet23).
test_save now asserts, on every real save, that each TurnCommands_v5 block is
consumed by the prologue plus the 27 lists with nothing left over -- the
item-granular statement a wrong list count or element width breaks first.
clean-room OK; host ctest 36/36; test_save 11 saves 0 failures. src/shim/ not
touched, so no cross-build was needed.
Game::StrategyAIAgent::Streamable and the ten shapes under it. The whole
writer is unconditional -- the branch the decompiler shows around lnat is an
inlined vector destructor whose operator delete is marked noreturn, and both
paths converge -- so the recovered sequence and a single record are the same
sequence, and all 36 items match with 0 wire-only and 0 shape-only.
CD blocks are now selected by the CDT id at the same ordinal, in both
directions; the one .TurnCommands_v5 block per save still falls to a Node.
Also: Sim's Attrib was not an empty frame, it was an AttribMap holding a count
of 0, and typing it closes those two items too. And StreamableEnum<T> writes a
frame containing one int, not a bare int, so SysMem/mts/nalat are arrays of
one-int frames -- byte-neutral, since all three have count 0 in every save,
but the previous typing was wrong.
Conformance 74 shapes/769 items -> 86/838, still 0 MISMATCH. Round trip
byte-identical on all four saves; ratchet 97.5 -> 99.8. Every container that
is empty in all four saves is named as such in the notes; the new unit test
populates each one, since nothing else exercises them.
SvSctOb is a StreamableHelper<SVScriptObject> -- a polymorphic pointer holding a
Game::SVSOSots, which writes two variant lists each dispatched by the key item
before it (xscn -> xsc, EncID -> EncObj). Neither map is on the wire; both were
read out of the game's factories (see the notes repo). The shapes apply the key
in both directions, so a body goes back out as whatever it came in as, and an
unmodelled key still round-trips as a Node. 18 new shapes: SVSOSots, the four
scenario bodies (traps / crowdefs / indsys / gmtrigger -- indsys really does
serialize nothing, its Read and Write are both the shared `ret 4` stub) and the
eight encounter bodies the saves exercise. The four factory ids no save carries
(7 SystemKiller, 8 PuppetMaster, 14 Locust, 21 Ortgay) are deliberately NOT
typed: their serializers are recovered but nothing could check a shape for them.
DOpts and SVSOVonNeumann::trev are VectorHelper<Mars::String>, so read_elem /
write_elem / SchemaBuilder::carr grew the std::string branch lane G listed as
missing. spies2 is VectorHelper<int>: the TYPE is certain from the helper's own
decorated name, but the count is 0 in all 28 systems of all four saves, so no
element value has ever been observed -- the shape is a hypothesis about
behaviour even though it is a fact about type. Same for SysMem and mts.
Conformance 56 shapes / 657 items -> 74 / 769, still 0 MISMATCH, and every new
binding is 0 wire-only and 0 shape-only. Coverage 97.1/97.2/97.2/97.6 ->
98.0/98.0/98.0/98.4 with the byte-identical round trip preserved; ratchet
95.0 -> 97.5. CD is now the only remaining region of size, and it stays opaque:
the recovered 44-item Game::TurnCommands sequence cannot be aligned to the
save's 35 items even as a subsequence (item 4 is 8 bytes, so a bool where the
recovery says i32; and the 27 trailing ints have only 22 i32 slots to come
from), which proves the no-orders diagnosis rather than assuming it.
Two unit tests added that need no saves: the string-array element branch
(including the empty string, which is four zero bytes and so looks like int 0)
and the SvSctOb variant dispatch round trip.
ctest 34/34, clean_room_check OK, test_save skips cleanly with SOTS_SAVES_DIR
unset. sots_stream_schema.h unchanged: streams.py and gen_stream_schema.py were
re-run and the output is byte-identical apart from the provenance line.
The serializer recovery reaches this repo as a generated wire schema
(include/generated/sots_stream_schema.h, 386 classes / 2042 items): for each
class, the ordered sequence of items its Write puts on the stream. Facts only —
no field offsets, no sizeof, no strides. This engine reads and writes the on-disk
format; it does not inherit the original's memory layout.
The table is a specification, not a program: the recovery is a linear pass over
Write, so it cannot see Write's branches (StarShip's BQ2 is gated on hbq but
listed unconditionally) and it flattens container loops. A codec driven off it
would desynchronise. The hand-written io() shapes stay the codec; SchemaProbe
(probe.h) walks them with every branch taken and test_wire_schema LCS-aligns
that against the table — 56 shapes bound, 657 items matched, 0 mismatches.
Four defects the check found, all invisible to a round-trip test:
- SystemParams field 1 is a string, not an int. It is the empty string in
every save, and an empty string is four zero bytes — byte-identical to the
int 0, so it round-tripped by luck. A named planet would have desynced.
- ObservedTech/ObservedWeapon odet is a bool, not an int. Byte-safe only
because a 4-char tag makes a bool item and an int item both 12 bytes.
- SpeciesRatios nv and ShipRecords srbd are counts, not fields.
CoverageArchive separates items a field names from items a Node merely carries,
because a byte-identical round trip is not a coverage claim. Typed coverage of a
real save goes 37.9% -> 97.1% (97.2/97.2/97.6 on the others) with the round trip
still byte-identical, by typing TechTree (both NumTechs sections), Events,
ShipRecs, sprjs, civr, comms, spy2, spymgr, aid, Ojvs, AIEnf, FNG, trdmgr and
the Des section/gun-bank tree. Ratchet at 95%.
trdmgr resolves a recorded trap: ServerTradeManager's Read/Write really are the
inherited no-op, but the call is virtual and ServerTradeManagerImpl has the real
serializer. Same shape resolves IServerSpyManager -> ServerSpyManager.
ctest 34/34, clean_room_check OK, test_save skips cleanly with SOTS_SAVES_DIR unset.