11 KiB
M2 — id-manifest loading (_weapons.txt / _shipsections.txt) old-vs-new on the live game
Result (2026-09-08): the weapon dictionary is verified end to end — trace (tracecmp.py
exit 0), compare 1/1 compared, 0 divergences, and replace reproducing the End-Turn
determinism oracle byte for byte ((Autosave).sav = 978041ac…, (Autosave EndTurn).sav =
bb4fd9ac…) with our loader feeding the game its weapons alone. The section dictionary is
trace-verified only: its 885 definitions and ids match the manifests in the golden trace,
but ours crashes the engine in compare mode — see "Open: the section compare crash". Game
left running on VM140 in hooks=trace at the main menu.
What was hooked
Two functions, both [verified] __thiscall in sots_addresses.h, so they go through the
Hook<Descriptor> template rather than an asm stub (M0's stub caveat covers unverified
prototypes; see "thiscall through the template" below):
hook name (record hook) |
RVA | prototype |
|---|---|---|
Game::WeaponDictionary::Init |
0x0019a4c0 | void (WeaponDictionary*) — Weapons/_weapons.txt |
Game::SectionDictionary::SectionDictionary |
0x00176f40 | SectionDictionary* (SectionDictionary*, TechTree*) — Species/<Race>/sections/_shipsections.txt ×7 |
Both are called once, from DemoApp::LoadGameData on the first OnTick after start-up (not
inside Initialize, unlike M1). Sources: src/shim/hooks/dictionaries.{h,cpp}, installed from
src/shim/main.cpp next to the M1 hook.
There is no registry object to hook: the loaders drive Script::ReadToken themselves and
store the id on each definition. So the hookable boundary is the dictionary loader as a whole,
and the "manifest" is a token-pair stream inside it.
Region model
The definitions do not exist when the hook is entered, so the declared region is the dictionary object itself — 0x1c bytes (weapons) / 0x18 bytes (sections) — with a struct describer that walks the vector at +8 and emits one entry per definition, in vector order:
| region | describer |
|---|---|
dict (weapons, 0x1c) |
{unk0:i32, tree:ptr, n:u32, defs:[{index,id,path,name}], unk14:ptr, emt_light:str} |
dict (sections, 0x18) |
{cur:i32, tree:ptr, n:u32, defs:[{index,species,id,token}], unk14:ptr} |
id (WeaponDef+4 / SectionDef+8) and index (+0) are the words the original writes and
the point of the milestone; path/name/token are the name half of the id↔name assignment,
decoded from the MSVC std::strings at WeaponDef+8/+0x40 and SectionDef+0x17c.
emt_light is emitted by the weapon name it points at, not as a pointer: in compare mode
ours builds its own definition objects, so the pointers necessarily differ while the weapon
they name must not. Every pointer chase in a describer is guarded by a VirtualQuery check —
the section dictionary is hooked at its constructor, so the "before" snapshot is whatever the
fresh heap block held (the scouting trace shows exactly that: cur = 71692504, an unreadable
vector, invalid: true).
Args: weapons this + the manifest path; sections this, tree, and the seven directory names
read from the game's own Species::GetDirName, so a golden log replays offline without the exe.
ours — src/game/config/manifest_loader.{h,cpp} (lib sots_game_config)
- File bytes come from the game's own
gobio::ReadFile(mount order / mods honoured), released withRefCounted_Release; host tests read plain text. manifest_pairs()reproduces the loaders' loop overmars::parse::Script: read a token, and only if that read succeededatoiit into the running id; read the file-name token; a read that is notOkends the loop. Consequences, all tested:// DELETED - nand// REMOVED - nlines are ordinary comments and never appear; a non-numeric id token isatoi's 0; tokens are truncated at 255 bytes (the loaders' 256-byte buffers); and a manifest whose last line has no trailing newline loses its last entry (the value token touches EOF, so it readsAtEnd). The shipped files end with CRLF, so nothing is lost in practice.manifest_atoi()is the CRT's: leading whitespace, optional sign, decimal digits, wrap modulo 2^32, 0 when there is no digit.- Path spelling is the loaders':
"Weapons/" + file,dir + "/" + file. - The pairs are handed to the game's own
LoadWeapon/LoadSection(per-file parsing is M3 scope); the weapon dictionary is then sorted and re-indexed, andemt_lightlooked up, as the original does after its loop. Definitions built during a compare call leak one allocation set (start-up only, like M1's long strings). src/game/config/msvc_sort.hreplays MSVC 2010'sstd::sort(median-of-3/9 three-way introsort, insertion sort ≤ 32, heap-sort fallback) so that ties land where the original puts them. The shipped data turns out to have no tied weapon names, so any correct sort would do; the replica is kept because the tie order is otherwise unspecified and data can change.
thiscall through the template
CallConv::Thiscall was added to src/shim/trace/hook.h (+ SHIM_THISCALL in platform.h):
this is the first Args element, passed in ECX by both the detour and the trampoline call,
the rest on the stack, callee-cleaned — GCC's __attribute__((thiscall)) is exactly MSVC's ABI
on i386. This is safe only because both prototypes are [verified]; M0's asm-stub rule
still stands for anything marked [unverified].
Runs (/bulk-storage/re-lab/shim/traces/, build m2v-3ebc8bf-20260908T0325Z)
| file | mode | calls | result |
|---|---|---|---|
m2-trace-golden.jsonl |
trace | 22 (2 M2 + 19 LoadFile + selftest) | tracecmp.py exit 0, 0 invalid |
m2-compare.jsonl |
compare | 1 compared (weapons) | 0 diverged, 0 errors, exit 0 |
| replace (no log) | replace | weapons | End Turn from ref-turn2.sav: (Autosave).sav 978041ac… / (Autosave EndTurn).sav bb4fd9ac… = oracle |
m2-trace-scout.jsonl |
trace | 22 | the earlier build, kept because it is what exposed the +0x14 layout bug |
m2-shim-final.log, m2-*.png |
run banners and dict: lines; Turn-2 replace screen, final main menu |
What the golden trace establishes against the live game:
- Weapons: 123 definitions, ids exactly the manifest's — 0 id mismatches and 0 manifest
entries missing, cross-checked against
Weapons/_weapons.txt(123 numbered lines, three// DELETEDtombstones correctly ignored as comments). - The dictionary is sorted by
_stricmponname, not by id and not by path, withindexrewritten to the position afterwards (index == positionfor all 123). The shipped data has no tied names, so the MSVC-sort replica is insurance rather than a load-bearing detail. emt_lightresolves to the weapon of that name — the check that confirmed the +0x14 fix (the first build read the unmodelled word and saw text bytes instead).- Sections: 885 definitions, species 0..6 in the order
Human, Hiver, Tarkas, Liir, _NPC, Zuul, Morrigi, counts 145/137/139/136/64/122/142,index == position, ids from the manifests with the 10 dangling ids simply absent. Names arrive already resolved (Assault Shuttle, not@SECTIONNAME_…).
Host suite: ctest 26/26 (game_config_manifest = 71 checks). Cross-build staged in
/srv/re-lab/shim/dist-m2, exports 66 names identical to binkw32.dll; deployed from the
lane-local C:\SOTS\shimdist-m2. clean_room_check.sh OK.
Open: the section compare crash
With hook.Game::SectionDictionary::SectionDictionary=compare the engine dies inside ours
(Mars: Application error encountered, Mars.dmp written) after the weapon hook has already
compared cleanly, before the first species finishes — no dict: [Species/…] line is ever
logged. The weapon path does the same thing (re-run the game's own per-file loader on a scratch
dictionary) without trouble, so the difference is in SectionDictionary::LoadSection, not in
the manifest reader: manifest_pairs is shared by both and is covered by host tests.
Leads for whoever picks this up, cheapest first:
LoadSectionmay append to the dictionary's own vector, in which caseourspush_backs a second time and the two allocators fight — instrument by logging the vector's begin/end around one call before assuming.- It runs after the original has already built all 885 definitions, so the second pass
registers duplicates with the
TechTree/ string table; a fixed-size table overflowing there would look exactly like this. - The trace-mode path is unaffected, so a narrower boundary (hooking
LoadSectionitself, one call per section, rather than the whole constructor) would both isolate the fault and give a finer region model.
Note the diagnosis cost: the crash dialog was hidden behind a Windows Update dialog, so the process looked alive-but-wedged for ~12 minutes. Screenshot before believing a hang.
Gotchas
- The dictionary structs are one word longer than a naive C translation. The
std::vectoris three words (+8 begin, +c end, +10 cap), so avoid*written after it lands at +0x14, not +0x18. The first scouting build made exactly that mistake and reported the unmodelled +0x14 word asemt_light(it holds text bytes —"TION"), which is why the region model above carries an explicitunk14and astatic_asserton each struct size. The region sizes changed as a result, so the scouting trace is not the golden trace. - The
+0x14word is not modelled: it is emitted as an opaque pointer (default policy ignores pointer values) so a change is visible in a trace without creating false divergences. SectionDef+0xcwas tried as an id-ish field and dropped: it holds string bytes, and bytes from a different heap block would diverge for no reason.- Per-call state is not kept between
regions()andours()(unlike M1): each dictionary is loaded exactly once, andrebindonly re-aimsthisat the scratch copy. - Compare mode allocates through the game's own
msvcr100operator new/delete(GetProcAddresson the mangled names) so the vector the section constructor grows can be freed by the game's runtime. CastGetProcAddressresults throughvoid*— MinGW's-Wcast-function-typeis an error in this tree. shim.cfgper-hook lines:hook.Game::WeaponDictionary::Init=compare|replace,hook.Game::SectionDictionary::SectionDictionary=compare|replace. Ready-made configs are on the VM asC:\SOTS\ui\shim.cfg.m2{trace,compare,replace}plusshim.cfg.m2replaceW(weapons in replace, sections left in trace — the config the oracle run used).- The M2 hooks fire on the first
OnTick, i.e. at the main menu, ~40 s after launch — not insideInitializelike M1. Skip the intro with threeescsendkeys before waiting on them.