Source of grammar: re_probes/runtime_87400/module.bin (running, extracted NewColossus_x64vk.exe, file offset == RVA). Tool: tools/probe_save_structure.py. Corpus: re_probes/save_backups (5 real save states) and the three candidates, all read-only. Writing was done exclusively to re_probes/save_structure/.
Summary
- The defect is a stale length prefix. Candidates v1 and v2 have extended the card name, but left the preceding
u32length unchanged. Atcheckpoint.datoffset0xdf, there stands 17; it should stand 24. Incheckpoint_mapstart.dat, this occurs six times (0x14,0x8d,0x106: 17 instead of 24;0x44,0xbd,0x136: 14 instead of 21). - Second, independent defect in v1/v2: the
checksumline ingame.detailsremained at2007466474; the correct value is499283768. - v3 fixes both. A candidate reconstructed from scratch based on the grammar read from code is byte-identical to v3 in all 14 files. 33 of 33 checks pass green.
- Not claimed: that v3 loads. "File Corrupt" is not observable offline. The reliable statement is: structurally consistent according to these tests, runtime unconfirmed.
- Correction on the task: Branch (a) from
saveverify.md(Read(rumpf) != length-4at0x8b47c4) is a pure short-read test on the file and cannot trigger here — the file delivers exactlyLength-4bytes. The consumption error lies one level deeper, in the component reader. See Section 5.
1. The Grammar — from code, not guessed
Three functions carry everything necessary:
| RVA | Role |
|---|---|
0x11b9980 |
idGameLocal::RestoreCheckpointInfo — header of checkpoint.dat |
0x11a5610 / 0x11a5710 |
idGameMapMemoryManager::ReadFromFile — card block |
0x11db420 |
Writer of the tag-free block in checkpoint_mapstart.dat |
The primitives (calls via serializer vtable):
[vtbl+0x28] Read(puffer, n) Raw bytes
[vtbl+0x30] Write(puffer, n)
[vtbl+0xe8] Serialize(idStr&) u32-LE length, then characters, no NUL
[vtbl+0xf0] SerializeCheck(tag, file, line) writes/checks exactly such an idStr
Endianness: Integers and floats are big-endian — the reader rotates them after each manual Read(4) (0x11a57d4–0x11a57f1, pattern 0↔3, 1↔2). The length prefixes of idStr are little-endian, they are written without rotation (0x66ab58–0x66ab77). This exact mixture confuses the format at first glance.
checkpoint.dat, header (0x11b99e4–0x11b9e4e)
u32 LE saveFormatVersion must be <= 0x444D0005, otherwise FatalError
u32 LE Subversion here 0x26
u8 Flag must be 1 (0x11b9dee, otherwise FatalError)
u8 Flag
if Subversion >= 0x26: idStr, idStr (Stand ID, SHA1-Hex)
if Subversion > 0x21: 6 x u32
tag "idGameLocal_CheckpointInfo:10"
<idGameMapMemoryManager::ReadFromFile>
tag "idGameLocal_CheckpointInfo:11"
... from here, opaque component stream
idGameMapMemoryManager::ReadFromFile
tag "idGameMapMemoryManager:10"
u32 BE Magic == 0x0060FFCD otherwise "Invalid Magic Number in idGameMapMemoryManager"
u32 BE Version
u32 BE Count of card entries
per entry:
tag "idGameMapMemoryManager:20"
idStr Card name <== the edited location
u32 BE Count of sub-entries
... sub-entries (0x30 bytes each; in backup always 0 — unmeasured)
tag "idGameMapMemoryManager:30"
In the original from SLOT0, this fits perfectly: Tag at 0xb6… Magic 0xb6, Version 0xba, Count 0xbe = 1, Tag :20 at 0xc2, Card name at 0xdf (Length 17, characters 0xe3–0xf3), Sub-count 0xf4 = 0, Tag :30 at 0xf8.
checkpoint_mapstart.dat — complete
This file is tag-free and fully parseable:
4-Byte header, followed by k identical blocks:
u32 BE Magic 0x0060FFCD
u32 BE Version (3)
u32 BE u32 BE
idStr Card Name
idStr idStr idStr idStr (empty in inventory)
u32 BE n + n x idStr Layer
u32 BE m + m x idStr
u32 BE u32 BE u32 BE u32 BE
float BE float BE float BE
Block size at SLOT0: 107 bytes, three blocks, 4 + 3*107 = 325 = file size.
At SLOT2 it is 451 bytes, also exact. This is the branch where the parser can definitively state how many bytes the reader has consumed.
2. The Defect, Named
| File | Offset | Is Present | Should Be Present |
|---|---|---|---|
checkpoint.dat |
0xdf |
11 00 00 00 (17) |
18 00 00 00 (24) |
checkpoint_mapstart.dat |
0x14, 0x8d, 0x106 |
17 | 24 |
checkpoint_mapstart.dat |
0x44, 0xbd, 0x136 |
14 | 21 |
game.details |
checksum= |
2007466474 |
499283768 |
The build step replaced game/dlc/c02/c2v1 (17 bytes) with
game/dlc/c02/c2v1_custom (24 bytes) and correctly grew the file by 7 bytes — only the u32 preceding it remained at the old value. The 4-byte MD5 folding header was then recalculated, so the file branch (b) of the load path parses without issue. A valid header over an inconsistent body.
What the Reader Does With It
idGameMapMemoryManager::ReadFromFile reads length 17 at 0xdf, takes game/dlc/c02/c2v1, and then becomes _cus…. The next read operation is the BE32 count of sub-entries — it reads the ASCII bytes 5f 63 75 73 as 1 600 353 651. The game subsequently attempts to reserve a list with 1.6 billion elements × 48 bytes (roughly 77 GB). The parser reports exactly this value when v2 is presented:
@0xf0: 1600353651 Sub-entries - Grammar unmeasured
In checkpoint_mapstart.dat it fails even earlier: there, the field behind the card name itself is again an idStr, and the reader retrieves the "length" 1 937 072 991 (_cus as u32 LE).
3. The Test Tool and Its Counter-tests
python tools/probe_save_structure.py parse <file.dat>
python tools/probe_save_structure.py check <folder> [--original <folder>]
python tools/probe_save_structure.py build --source … --target … \
--alt-map … --new-map … --alt-layer … --new-layer …
python tools/probe_save_structure.py --selftest
The self-test is constructed so that it can fail:
- Positive: all 5 unchanged game states from
re_probes/save_backupsare accepted;checkpoint_mapstart.datconsumes exactly the file length there (325 or 4×451). - Negative, real: v1 and v2 are rejected with the specific location in the message. This is the exact counter-test requested by the order — if the tester accepts both, it measures nothing.
- Negative, synthetic:
- Card name extended, prefix stale, header recalculated → header is valid (branch (b) notices nothing), structure parse fails.
- a flipped byte → header fails.
- an inserted null byte before the tag
:30→ tag comparison fails. - Identity renaming (same name) → byte-identical file.
Result: 0 errors, Exit code 0.
During renaming, the parser accesses the idStr fields at their parsed offsets and writes prefix and characters together. The defect of v1/v2 is thus constructively excluded, not just checked retroactively.
4. The Corrected Candidate
re_probes/save_structure/FC2-WOLF0-c2v1_custom_struct, built from
re_probes/save_backups/SLOT0_before_c2custom. 33 tests, all green:
| Test | Scope |
|---|---|
| MD5 Header (Branch b) | all 7 .dat files |
| Structure parsing | checkpoint.dat, checkpoint_mapstart.dat, both _alt.dat files |
| Consumption == File Length | everywhere where the grammar is complete |
game.details CRC32 |
yes |
| Strings == Original + Renaming | per file, element-wise |
| Unparsed End == Original | checkpoint.dat, from byte 317 or 310 onwards |
| No framed old path anymore | all .dat files |
.verify unchanged |
all 7 |
It is byte-for-byte identical to candidate v3 of the other Masters in all 14 files. This is an independent confirmation: v3 was created via framed text replacement, while this one was created via the grammar read from disassembly — two paths leading to the same result.
Regarding the Unparsed Remainder of checkpoint.dat
Of the 476,231 bytes parsed by the tool, 317 are consumed and 475,914 remain unparsed; the grammar of the component stream behind it (1,036 length-prefixed strings, 577 idQuickSaveStream tags with Magic 0x3832E21A, Havok Cloth blocks) is not measured. Instead of an excuse, there is a test: the remainder is byte-for-byte identical to that of the original, and the parser proves that the reader arrives at exactly the same position there (317 instead of 310, i.e., exactly +7). The original loads successfully. Therefore, the remainder does not need its own grammar.
5. What Has Been Refuted
- "Branch (a) at
0x8b47c4is the trigger." No. That location only checks whetherRead(rumpf)provides as many bytes asLength()-4— a short read test on the file. The candidate provides exactlyLength-4. That location cannot fire here. The consumption error sits one level deeper, inidGameMapMemoryManager::ReadFromFile, which can set its own error code at[stream+0x184]or via[vtbl+0xe0]— a path that the scan aftermov dword [r15+0x270], 4does not see. The direction of the instruction ("the deserializer consumes a different number of bytes than the file provides") was correct, only the specific code location was wrong. - "Somewhere there is still an old total length / an offset table." No. In
checkpoint.dat, nou32and nou64exist at any position, neither LE nor BE, holding the value of the file or buffer length (476,224 / 476,220, old or new) — 0 hits. The 52 values in the range 470,000–480,000 are, except for ten outliers, a monotonically increasing float series at0x5d5c5, and the file end (last 256 bytes) consists of Havok floats, not a table. The block grammar of the map storage has no size field — the length of the name stands exactly once, in its own prefix. - "The map name is additionally stored as a hash in the file." No. Checked against
game/dlc/c02/c2v1: CRC32, Adler32, FNV-1a 32/64, idStr-Hash, MD5/SHA-1/SHA-256 (first 4 or 8 bytes respectively), each LE and BE. 0 hits incheckpoint.dat. Forcheckpoint_mapstart.dat, the question becomes moot: there every byte is covered by the grammar. - "All map names in a savegame must be identical." No — measured at SLOT2: the map storage in
checkpoint.datregularly also carries the previous map (game/wolf/c01/c01p1next togame/wolf/c02/c02p0), andGAME-TRANSITIONeven has threemapstartblocks with two different maps. Such a consistency check would be incorrect; instead, the tool compares against the original plus renaming. .verifyremains untouched — confirmed bysaveverify.md, here only rechecked to ensure the files were byte-identically copied over.
6. Open
- Runtime Proof. Whether the game
FC2-WOLF0-c2v1_custom_struct(= v3) accepts is undecidable offline. To determine this, one would need to save the state in%USERPROFILE%\Saved Gamesand launch the game — both of which are excluded here. Before attempting such a test: the backups underre_probes/save_backups/are the only copies of the real states. - Sub-entries in the Card Storage. The branch with
Anzahl > 0(elements at 0x30 bytes) does not exist in the entire inventory; the parser explicitly rejects it rather than guessing. - Component Stream from Byte ~0x2142.
idQuickSaveStreamwith Magic0x3832E21Abefore each day, non-fatal tag comparison (idQuickSaveStream::SerializeCheck: tag mismatch: is: %s, should be %s,0x66aa60) and compressed heap sections. Excessive — whoever intends to modify munitions or upgrades there must start here.