# Data-loader prototypes — Sword of the Stars.exe (GOG 1.8.1) Ghidra round 2026-09-07, program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, MSVC-2010 32-bit. All addresses are image VAs (runtime = `GetModuleHandle(NULL) + (VA - 0x00400000)`). Machine-readable copy of every entry: `/srv/re-lab/handoff/addresses-loaders.json` (same schema as `ghidra/addresses.json`). Everything below was **written back into the Ghidra project** (function names in the `Mars::`/`Game::` namespaces, prototypes with calling convention, plate comments, `/SOTS/*` structs, `g_*` data labels). Scripts: `/srv/re-lab/handoff/scripts/LoaderRecon.java` (request-driven decompile dump) and `/srv/re-lab/handoff/scripts/LoaderWriteBack.java` (idempotent write-back + verification decompiles). Raw decompiles (before rename) are in `/srv/re-lab/handoff/loader-decompiles/recon/.c`; post-rename verification decompiles in `/srv/re-lab/handoff/loader-decompiles/verify/.c`. Conventions: `thiscall` = `this` in ECX, args pushed right-to-left, callee pops (`RET n`); `cdecl` = all on stack, caller pops. `std::string` = **0x1c bytes**: 16-byte SSO buffer / pointer `_Bx` @+0, `_Mysize` @+0x10, `_Myres` @+0x14, `_Alval` (empty allocator, never read or written) @+0x18. *Corrected 2026-09-08 — this line said 0x18, which is the extent of the fields the code touches, not the size of the object; see `observedtech-append.md` §9.* "Case-insensitive" always means MSVCR100 `_stricmp` (ASCII fold). --- ## Application spine (coordinator request) {#application} | VA | name | convention | prototype | status | |---|---|---|---|---| | 0x008a0e50 | `Mars::Application::Initialize` (was `AppStartup_ReadConfig`) | **thiscall, 1 stack arg, `RET 4`** | `bool Initialize(Application* this, AppStartup* startup)` | verified | | 0x0089f5b0 | `Mars::Application::Run` | thiscall, 0 stack args | `void Run(Application* this)` | verified | | 0x0089a640 | `Game::DemoApp::OnTick` | thiscall, 0 stack args | `bool OnTick(DemoApp* this)` | verified | **Why the M0 detour crashed:** `Initialize` takes one stack parameter and cleans it itself. Prologue `MOV EBX,[EBP+0x8]; MOV EDI,ECX` (0x008a0e7e/0x008a0e81); Ghidra stack purge = 4. WinMain (0x0089dd30) calls it as `if (Initialize(&startup)) Run();` where `startup` is a stack struct built in WinMain: ``` struct AppStartup { // built at WinMain local_44 HINSTANCE hInst; // +0 (param_1 of WinMain) std::vector args; // +4 command line tokens (0x00460fa0 tokeniser) int unk_10; // +0x10 int nShow; // +0x14 (WinMain param_4) const char* wndClass; // +0x18 "Kerberos_SwordOfTheStars_WndCls" int iconId; // +0x1c 0x66 }; ``` A `__thiscall` wrapper declared as `void (Application*)` returns with a plain `RET`, leaving 4 bytes on the stack ⇒ the caller's frame is off by 4 and the very next thing (the SEH frame / cookie restore in WinMain) faults. Declare the hook as `bool __thiscall Initialize_hook(Application* self, AppStartup* startup)` and call the original with the same argument. The return value is a `bool` in AL (`cVar1 = Initialize(&local_44); if (cVar1 != 0) Run();`). EDX is not read. Inside, the order that matters for the loaders is: `MountModules()` (0x008a0a20, gobio init) → display/sound config → … → "Initializing GlobalConsts" `GlobalConsts::LoadAll()` (0x008b76d0) → textures/effects/sprites/keymap/font → `IApplication::OnStartup()` (vft+0x10). `Run` (0x0089f5b0): `PUSH EBX/ESI/EDI; MOV ESI,ECX` — no `[ESP+…]` argument reads, plain `RET`; EDX is overwritten before use. Return value is meaningless (EAX = last `PumpMessages()`), WinMain ignores it. `OnTick` (0x0089a640): `PUSH EBX/ESI; MOV ESI,ECX`, plain `RET`; returns `0` when `this+0x1b2` (quit flag) is set, else `1` (tail `return 1`). Safe to wrap with `bool __thiscall (DemoApp*)`. --- ## M1 — flat `KEY value` config loader (GlobalConsts) {#m1} ### The static-init stub (~600 of them, 0x009a4720 … 0x009c1400) ``` 009ba5b0 PUSH 0xa238f8 ; "Data/Strategy/StrategyVars.txt" (file) 009ba5b5 PUSH 0x8b7000 ; parser fn (int) 009ba5ba PUSH 0xa26a80 ; "KEY" 009ba5bf PUSH 0xb23e24 ; &g_KEY (storage word the game reads) 009ba5c4 MOV ECX,0xb2484c ; &cfgvar (16-byte GlobalConst object) 009ba5c9 CALL 0x008b76a0 ; GlobalConst::GlobalConst 009ba5ce RET ``` The earlier note (`strategic-turn-internals.md §0`) missed the **fourth argument, the file name**; the parser is the third. `Data/globals.txt` keys use the lower-case string `"data/globals.txt"` @0x009e56f4 in some stubs and `"Data/globals.txt"` @0x009e8f40 in others — both are the same file for the case-insensitive VFS. ### Object and functions ``` struct GlobalConst { // /SOTS/GlobalConst, 0x10 bytes, one static per key void* storage; // +0 int* / float* / float[4]; the code reads *storage (this is the "PTR_g_KEY" slot) const char* key; // +4 matched case-insensitively GlobalConstParseFn parser; // +8 void __cdecl (*)(void* storage, const char* valueToken) const char* file; // +c "Data/Strategy/StrategyVars.txt" | "Data/globals.txt" | "Data/Species.txt" | "Data/encounters.txt" | … }; ``` | VA | name | conv | prototype | notes | |---|---|---|---|---| | 0x008b76a0 | `Mars::GlobalConst::GlobalConst` | thiscall, `RET 0x10` | `GlobalConst* (GlobalConst* this, void* storage, const char* key, GlobalConstParseFn parser, const char* file)` | stores the 4 words, calls `Register(this)` | | 0x008b7610 | `Mars::GlobalConst::Register` | cdecl | `void (GlobalConst* g)` | insert into `g_GlobalConstRegistry`; logs `GlobalConsts: %s already registered. Overriding previous definition.` / `%s being registered after loading const table.` | | 0x008b7000 | `Mars::GlobalConst::ParseInt` | cdecl | `void (int* storage, const char* text)` | `sscanf(text,"%d",storage)` | | 0x008b7020 | `Mars::GlobalConst::ParseFloat` | cdecl | `void (float* storage, const char* text)` | `sscanf(text,"%f",storage)` | | 0x008b70e0 | `Mars::GlobalConst::ParseFloatScaled` | cdecl | `void (float* storage, const char* text)` | `%f` then `*storage *= *(float*)0x00af5210` (angle → radians style constant) | | 0x008b7110 | `Mars::GlobalConst::ParseColour` | cdecl | `void (float rgba[4], const char* text)` | `sscanf(text,"%d %d %d %d")` with all four defaulted to **255**; each `/255.0` and clamped to 0..1 → **storage is `float[4]`**; `"r g b"` gives alpha 1.0 | | 0x008b76d0 | `Mars::GlobalConsts::LoadAll` | cdecl | `void (void)` | called **once from `Application::Initialize`** (only caller). Builds the set of distinct `file` names over the registry, then for each file builds `std::map` of that file's keys and calls `LoadFile(file, &map)`; finally `g_GlobalConstsLoaded = 1` | | 0x008b73c0 | `Mars::GlobalConsts::LoadFile` | cdecl | `void (const char* file, GlobalConstMap* consts)` | **the file-level read to hook** (see below) | | 0x00b2d740 | `g_GlobalConstsLoaded` | data | `bool` | | | 0x00b2d744 | `g_GlobalConstRegistry` | data | `std::map` (header node ptr at +4 = 0x00b2d748, size at +8) | node: `{left,parent,right, const char* key @+0xc, GlobalConst* @+0x10, color @+0x14, isnil @+0x15}` | **Type decision:** the *stub* decides the type by which parser it pushes — there is no runtime sniffing of the value text. int (`%d`), float (`%f`), scaled float, or colour (`float[4]`). So a reimplementation needs the key → parser map, which is exactly `turn-decompiles/turn2/constants.txt` (parser column) — 0x008b7000/0x008b7020/ 0x008b70e0/0x008b7110. ### `LoadFile` behaviour (verify decompile `verify/008b73c0.c`) ```c Script::Script(&s); if (!Script::Open(&s, file)) { log("GlobalConsts: Could not open %s.\n", file); return; } tok.type = 0; for (;;) { if (Script::Next(&s, &tok) != 0) break; // EOF (or last pair without trailing newline) if (tok.type == 1) { // KEY value node = consts->lower_bound(tok.key); // FUN_008c8ff0: _stricmp ordering if (node == end || _stricmp(tok.key, node->key) < 0) log(2, "[%s] %s not recognized or is multiply defined.\n", file, tok.key); else { g = node->value; g->parser(g->storage, tok.value); consts->erase(node); // FUN_008b8d20 log(4, "[%s] %s = %s\n", file, tok.key, tok.value); } } else if (tok.type == 2) { if (Script::SkipBlock(&s, 1) != 0) break; } // a "name {" block is skipped } Script::Close(&s); for (node in consts) log("[%s] %s expected but not found.\n", file, node->value->key); ``` Leniency facts the shim must reproduce: * keys are matched **case-insensitively**; unknown keys are logged and ignored; * a key is consumed on first sight ⇒ **first occurrence wins**, a duplicate logs "multiply defined" (the Python `flat_kv.parse_kv(on_dup='last')` default is the wrong way round for this loader); * the value is **one token**: `TRADE_SECTOR_SIZE 10.0` fine, colours must be quoted `"48 29 2"` (the colour parser gets the whole quoted token); an unquoted `0 0 0` would parse `0` and then choke on the stray `0 0` as unknown keys; * `//` comments, `{}` blocks and quoted tokens follow the `Mars::Script` tokenizer rules (M3); * keys that are registered but absent from the file keep the image default (logged "expected but not found"). Files seen in the stubs: `Data/Strategy/StrategyVars.txt` (0x00a238f8), `Data/globals.txt` / `data/globals.txt`, `Data/Species.txt` (0x009f93e8), `Data/encounters.txt` (0x009f0254). `Data/Combat/*.txt` use the same mechanism (same parsers, different file strings); grep the stub region for `PUSH ` to enumerate. --- ## M2 — manifest / id registry (`_weapons.txt`, `_shipsections.txt`) {#m2} There is **no separate manifest parser or id→name map object**. The manifests are read with `Script::ReadToken` pairs straight inside the dictionary constructors, and the id is stored on the definition object itself. | VA | name | conv | prototype | |---|---|---|---| | 0x0059a4c0 | `Game::WeaponDictionary::Init` | thiscall | `void (WeaponDictionary* this)` | | 0x0059a230 | `Game::WeaponDictionary::LoadWeapon` | thiscall (`RET 0xc`) | `void (WeaponDictionary* this, const char* path, int id, WeaponDef** out)` | | 0x00598a20 | `Game::WeaponDef::WeaponDef` | thiscall | `WeaponDef* (WeaponDef* this, int index, int id, const char* path)` (sizeof 0x278) | | 0x00590a10 | `Game::WeaponDictionary::FindByName` | thiscall | `WeaponDef* (this, const char* name)` — linear `_stricmp` on `def->name` (+0x40) | | 0x00591e30 | `Game::WeaponDictionary::FindByNameSorted` | thiscall | `WeaponDef* (this, const char* name)` — binary search; logs `Weapon not found: "%s" - Was it added to the index file?` | | 0x00576f40 | `Game::SectionDictionary::SectionDictionary` | thiscall | `SectionDictionary* (this, TechTree* tree)` | | 0x00576cd0 | `Game::SectionDictionary::LoadSection` | thiscall (`RET 0xc`) | `SectionDef* (this, const char* path, int species, int id)` | | 0x00574020 | `Game::SectionDef::SectionDef` | thiscall | `SectionDef* (this, int index, int species, int id)` (sizeof 0x3d8) | | 0x00545ec0 | `Game::Species::GetDirName` | cdecl | `const char* (uint species)` — species 0..6 → `Species/` | | 0x00899280 | `Game::DemoApp::LoadGameData` | thiscall | `void (DemoApp* this)` — **call site**: `new MasterTechTree` → `this+0x110`; `new WeaponDictionary` (ctor 0x005917e0) → `+0x118`, then `Init()`; `new SectionDictionary(tree)` → `+0x114` | | 0x00899870 | `Game::DemoApp::EnsureGameData` | thiscall | `void (DemoApp* this)` — runs `LoadGameData` once (flag `+0x10d`); called from `DemoApp::OnTick` when `+0x10c` is set, i.e. **deferred to the first tick after startup**, not inside `Initialize` | Structures: ``` struct WeaponDictionary { // 0x1c bytes int unk0; // +0 TechTree* tree; // +4 passed to WeaponDef::ParseScript std::vector defs; // +8 begin, +c end, +10 cap — sorted after load (0x00597850, comparator 0x00590b00), then def->index rewritten to position WeaponDef* emt_light; // +18 FindByName("emt_light") }; struct WeaponDef (partial, /SOTS/WeaponDefPartial) { int index; // +0 position in dictionary vector int id; // +4 stable id from Weapons/_weapons.txt <-- the "registry" std::string path; // +8 "Weapons/" std::string name; // +40 'name' key (used for lookups, _stricmp) void* payload; // +5c behaviour sub-block object (bolt/beam/…) int damagetype; // +70 default 7 int weaponclass; // +84 enum; name table 0x00a02760 (stride 0xc, name @+8), 0x27 entries int weaponfamily; // +88 int turretclass; // +b8 0x18 = not specified (warned) int turretsize; // +bc 4 = not specified (warned) int munitionsize; // +c0 int cpoints; // +c4 int cost; // +c8 float hpbonus; // +cc int payload_kind; // +264 index in the 17-entry behaviour-name table (0x0058f230) }; struct SectionDictionary { // 0x18 bytes int cur; // +0 (-1) TechTree* tree; // +4 std::vector defs; // +8/+c/+10 }; SectionDef: +4 = species?, +0x28 = flag, +0x17c std::string localisation token ('@SECTIONNAME_…'), id/index set by ctor. ``` `WeaponDictionary::Init` (verify `0059a4c0.c`): ```c Script::Script(&s); if (!Script::Open(&s, "Weapons/_weapons.txt")) { log("WeaponDictionary: Couldn't open %s\n", …); } else for (;;) { if (Script::ReadToken(&s, tok, 0x100) == 0) id = atoi(tok); // note: id keeps its previous value if this read fails if (Script::ReadToken(&s, tok, 0x100) != 0) break; // file name token; break at EOF path = std::string("Weapons/") + tok; LoadWeapon(this, path.c_str(), id, NULL); } sort(defs); for i: defs[i]->index = i; emt_light = FindByName("emt_light"); ``` `// DELETED - n` lines are ordinary `//` comments to the tokenizer, so deleted ids simply never appear. Because `ReadToken` returns 2 (end) when the token it just read ends exactly at EOF, **a manifest whose last line has no trailing newline loses its last entry** — the shipped files end with CRLF. Non-numeric first token → `atoi` = 0 (no error). Missing files (`SectionDictionary: Couldn't load [%s]`) are logged and skipped, matching the 10 dangling `_shipsections.txt` ids. `SectionDictionary::SectionDictionary` loops `species = 0..6`, path = `Species::GetDirName(species) + "/" + "_shipsections.txt"`, same token-pair loop, `LoadSection(dir + "/" + file, species, id)`, `push_back` if non-NULL. After all seven it checks every def's `@` localisation token against the string table (`Section name %s not localized.` / `Section description %s not found.`). --- ## M3 — Mars brace-block parser (`Mars::Script`) {#m3} Not a tree builder: it is a **pull tokenizer**; every consumer (`WeaponDef::ParseScript`, `SectionDef::ParseScript`, `MasterTechTree::ParseTech`, GUI `.script` callbacks, `GlobalConsts::LoadFile`) drives it with `Next()` and an `_stricmp` if/else chain, and skips what it does not understand with `SkipBlock(1)`. There is no generic node model to fill — the shim reimplements per-consumer fills against the same token stream. ``` struct Script { // /SOTS/Script, 0x28 bytes, vtable Mars::Script::vftable void* vftable; // +0 std::string path; // +4 .. +0x1b int pad; // +1c IBuffer* buf; // +20 whole file from gobio::ReadFile (NULL when closed) uint cursor; // +24 offset into buf->data }; struct ScriptToken { // /SOTS/ScriptToken, 0x804 bytes, lives on the caller's stack int type; // +0 0 none, 1 "key value", 2 "key {", 3 "}" char key[1024]; // +4 char value[1024]; // +404 value, or "{" }; ``` | VA | name | conv | prototype | notes | |---|---|---|---|---| | 0x008cd700 | `Mars::Script::Script` | thiscall | `Script* (Script* this)` | | | 0x008cd720 | `Mars::Script::~Script` | thiscall | `void (Script* this)` | releases `buf` | | 0x008cd770 | `Mars::Script::ScalarDeletingDtor` | thiscall | `Script* (Script* this, byte flags)` | | | 0x008cd7d0 | `Mars::Script::Open` | thiscall | `bool (Script* this, const char* path)` | `path` → std::string; releases old buffer; `gobio::ReadFile(path, &this->buf)`; **cursor is NOT reset here** (Close/ctor do it) | | 0x008cd6c0 | `Mars::Script::Close` | thiscall | `void (Script* this)` | release buffer, clear path, cursor = 0 | | 0x008cd2f0 | `Mars::Script::ReadToken` | thiscall | `int (Script* this, char* out, uint outMax)` | 0 = token read, 1 = no buffer / cursor past end, 2 = cursor reached end of buffer (also right after the *last* token) | | 0x008cd3e0 | `Mars::Script::Next` | thiscall | `int (Script* this, ScriptToken* tok)` | see below | | 0x008cd4b0 | `Mars::Script::SkipBlock` | thiscall | `int (Script* this, int depth)` | consumes tokens counting `{`/`}` until depth 0; returns 0, or nonzero at EOF | | 0x008cd1f0 | `Mars::Script::ScanToken` | custom regs | EAX cursor, ECX "edFlag, EDX out; stack end, outEnd, &len | internal | | 0x008cd180 | `Mars::Script::ScanBareword` | custom regs | | copies until whitespace | | 0x008cd2b0 | `Mars::Script::SkipLineComment` | custom regs | | to after next `\r`/`\n` run | | 0x008cd820 | `Mars::ConfigParser::ParseFile` | cdecl | `void (IConfigCallback* cb, const char* path, const char* blockName, bool* found)` | GUI `.script` / startup config entry: `blockName` empty → `cb->vft[1](&script)` once; else for each top-level block `_stricmp(tok.key, blockName)==0 → cb->vft[1](&script)` else `SkipBlock(1)` | | 0x008a7230 | `Mars::RefCounted_Release` | fastcall (ECX) | `int (IBuffer* obj)` | `--refcount`, `vft[0](1)` at zero | ### Tokenizer rules (from `ReadToken`/`ScanToken`/`ScanBareword`, `recon/008cd2f0.c`, `008cd1f0.c`, `008cd180.c`) * whitespace = `' '`, `\t`, `\r`, `\n` only; * a token that starts with `//` (after whitespace) is a comment: skip to the end of the line and read again. The check is done on the extracted token, so `//` glued to a preceding word is *not* a comment, and (edge case) a *quoted* token whose content starts with `//` is also treated as a comment; * quotes: an opening `"`, `'` or `` ` `` starts a quoted token that ends at the **same** character; no escape processing, the quotes are stripped, whitespace inside is kept; an unterminated quote runs to EOF; * barewords end **only at whitespace** — braces are not delimiters, so `weapon{` is one token (`weapon {` is required — which is what every shipped file does); `key{value}` style never occurs; * output is truncated silently at `outMax-1` (1023 for `Next`, 255 in the manifest loops); * no `/* */`, no `=`, no BOM handling (a UTF-8 BOM would become part of the first token). ### `Next()` (verify `008cd3e0.c`, disasm `recon/disasm_008cd3e0.txt`) ```c tok->type = 0; tok->key[0] = tok->value[0] = 0; rc = ReadToken(this, tok->key, 0x400); if (rc != 0) return rc; // EOF: type stays 0 if (strcmp(tok->key, "}") == 0) { tok->type = 3; return 0; } rc = ReadToken(this, tok->value, 0x400); // value may be "" at EOF tok->type = (strcmp(tok->value, "{") == 0) ? 2 : 1; return rc; // <- status of the SECOND read ``` Consequences (all observed in the shipped data and matching `data-parsers.md`): * callers loop `while (Next(&s,&tok) == 0)`, so **EOF anywhere simply ends parsing** — an unclosed outer `{` is fine (the 11 broken shipsections); * a stray top-level `}` yields type 3, which top-level loops ignore (`CrPropaganda.shipsection`); * a final `key value` pair whose value token touches EOF (no trailing newline) returns 2 and is **dropped**; * `"bare quoted item"` on its own (systemnames.txt) is read as key = the quoted text and value = the next token — consumers that expect that format handle it themselves; * the brace/`}` tests are exact `strcmp`, everything else in the consumers is `_stricmp`. ### How the catalog loaders consume it `Game::WeaponDef::ParseScript` 0x00599070 — thiscall `bool (WeaponDef* this, Script* script, int* rc, TechTree* tree)`, called by `LoadWeapon` right after `Next()` returned the `weapon {` token: ```c this->turretclass = 0x18; this->turretsize = 4; // "unset" sentinels while (*rc == 0) { *rc = Script::Next(script, &tok); if (*rc != 0) break; if (tok.type == 1) { // 66 keys, all _stricmp, unknown keys silently ignored if (!_stricmp(tok.key,"hidden")) flags ^= (ParseBool(tok.value)<<1 ^ flags) & 2; // 0x008ccfc0 bool parser else if (!_stricmp(tok.key,"name")) this->name = ResolveStringToken(tok.value); // 0x008c97a0: '@TOKEN' -> Strings.csv else if (!_stricmp(tok.key,"weaponclass")) { if (LookupClass(tok.value,&p) < 1) { log("Weapon::ParseScript: Unrecognized weapon class \"%s\"\n"); class = 0; } else class = *p; } else if (!_stricmp(tok.key,"weaponfamily")) … // 0x0058fad0, 0 if unknown else if (!_stricmp(tok.key,"weapondamagetype")) … // 0x0058fba0, 7 if unknown else if (!_stricmp(tok.key,"icon_file")) … "icon_rect" (0x008ccf60 rect parser) "turretclass" (0x0058f7a0) "turretsize"/"munitionsize" (0x0058f7e0) else if (!_stricmp(tok.key,"cpoints")) this->cpoints = atoi(tok.value); else if (!_stricmp(tok.key,"cost")) { this->cost = atoi(v); if (cost < 0) log("…savings cost < 0"); } else if (!_stricmp(tok.key,"hpbonus")) this->hpbonus = (float)atof(v); … (range, range_planet, recharge_time, burst_volleys, volley_period, requires -> tree lookup, …) } else if (tok.type == 2) { // sub-block k = index of tok.key in the 17-name behaviour table (0x0058f230: bolt, beam, torpedo, rider, missile, chainlightning, col, mine, disintegrator, grapple, projectedshield, mirv, nodecannon, siege, mesonprojector, spyship, wraith; _stricmp) if (k == 17) *rc = Script::SkipBlock(script, 1); // unknown block (e.g. anim) skipped else { delete old payload ("deleting old payload"); this->payload = CreatePayload(tok.key) /*0x00597ae0*/; this->payload_kind = k; *rc = this->payload->vft[1](script); } // payload parses its own block (bolt{rangetable{…}} etc.) } else if (tok.type == 3) break; // closing '}' of weapon{} } if (turretclass == 0x18) log("[%s] Required 'turretclass' not specified.\n"); if (turretsize == 4) log(… 'turretsize' …); return true; ``` Numbers are `atoi`/`atof` (so `.5`, `-.8`, `7e+8` are fine, `"8"` quoted also parses — quoting never changes typing, unlike the Python reader). Booleans go through 0x008ccfc0 (`true/false/1/0`, case-insensitive). `@TOKEN` strings are resolved through the string table at parse time (0x008c97a0). `Game::SectionDef::ParseScript` 0x005744e0 — same signature and shape (87 keys; sub-blocks `bank`→0x00572bf0, `mount`, `thruster`→0x00570f80, `option`/`optiondef`, `anim`, `netforcelimits`→0x00573d70). Scalar and block forms of `option` coexist because type 1 and type 2 are separate branches. `Game::MasterTechTree::LoadTechFile` 0x0058b770 (thiscall `void (MasterTechTree* this, const char* path, int flag)`) loops top-level blocks, `tech` → `ParseTech` 0x0058b050 (thiscall `void (this, Script*, int flag)`, details in `strategic-turn-internals.md §2.5`), anything else `SkipBlock(1)`. `MasterTechTree::MasterTechTree` 0x0058b870 builds the file list starting from `TechTree/MasterTechList.tech`. ### `.effect` `TXT` / `BEGIN`–`END` reader — a different class: `Mars::TextFileStream` {#effect} This is the text back-end of the `Mars::Stream` serializer (the same interface the save game uses through `StreamableHelper`). Vtable 0x00a428c0 (RTTI `.?AVTextFileStream@Mars@@`), object 0x420 bytes: ``` struct TextFileStream { // /SOTS/TextFileStream void* vftable; // +0 bool label_missing_reported; // +4 int mode; // +8 1 read (gobio buffer), 2 write (FILE*) IBuffer* buf; // +c uint cursor; // +10 FILE* file; // +14 write mode int indent; // +18 write mode tab depth char line[1024]; // +1c current line bool line_pushed_back; // +41c the last line did not match; re-use it for the next label }; ``` | VA | name | conv | prototype | |---|---|---|---| | 0x008cfcb0 | `Mars::Stream::OpenFile` | cdecl | `Stream* (const char* path)` — sniffs the `TXT` magic through gobio (0x008cf940/0x008cf8c0) and returns a `new TextFileStream` opened for read, `NULL` on failure | | 0x0091e460 | `Mars::TextFileStream::TextFileStream` | thiscall | `TextFileStream* (this)` | | 0x008cfb90 | `Mars::TextFileStream::Open` | thiscall | `bool (this, const char* path, int mode /*must be 1*/)` — `gobio::ReadFile`; buffer must start with `"TXT"` (0x00af6718), then `\r`/`\n` skipped | | 0x008cfa30 | `Mars::FileStream::ReadLine` | thiscall | `uint (this, char* out, int max)` — copies through `\n`, NUL over the `\n` | | 0x0091e760 | `Mars::TextFileStream::MatchLabel` | thiscall | `const char* (this, const char* line, const char* label)` — first word `END` → NULL; `label==NULL` → accept; else skip blanks, **exact case-sensitive** label followed by blank/EOL → pointer to the value text; mismatch → logs `TextFileStream: missing label: %s` once, returns NULL | | 0x0091ec90 | `ReadString` vft[1] | thiscall | `bool (this, const char* label, char* out, int outMax)` — value = text between the first `"` pair, backslash escapes `\n \t \r \b \f \v`, other `\x` → `x` | | 0x0091eba0 | `ReadBool` vft[2] | thiscall | `bool (this, const char* label, bool* out)` — `0/F/f` false, `1/T/t` true, else untouched | | 0x0091eb30 | `ReadFloat` vft[3] | thiscall | `bool (this, const char* label, float* out)` — `sscanf %f` | | 0x0091eab0 | `ReadInt` vft[4] | thiscall | `bool (this, const char* label, int* out, int dflt)` — `sscanf %d`, `*out = 0` if unparsable | | 0x0091e8e0 | `ReadNested` vft[5] | thiscall | `void (this, const char* label, IStreamable* obj)` — label line, then `BEGIN` … `END`; `obj == NULL` skips the block counting nested BEGIN/END; else `obj->vft[1](this)` | | 0x0091ee80/0x0091e6c0/0x0091e630/0x0091e5a0/0x0091e4c0 | `WriteString/WriteBool/WriteFloat/WriteInt/WriteNested` vft[6..10] | thiscall | writers: `LABEL "str"`, `LABEL TRUE|FALSE`, `LABEL %f`, `LABEL %d`, `LABEL` + `BEGIN`/`END` with tab indent | | 0x008b9d90/0x008b9d20/0x008b9c00/0x008b9bc0 | `Mars::Stream::ReadString/ReadInt/ReadBool/ReadFloat` | cdecl | `void (Stream* s, const char* label, T* out, int flag)` — thin wrappers used by the `*::Read` functions | | 0x008db650 | `Mars::ParticleSystem::Read` | thiscall | `void (ParticleSystem* this, Stream* s)` — the `.effect` body | | 0x008dad60 | `Mars::ParticleSystem::Write` | thiscall | `void (this, Stream* s)` — gives the canonical label order | | 0x008b42b0 | `Mars::EffectDictionary::Load` (probable) | thiscall | `void* (this, const char* name)` — `"effects/" + name`, `Stream::OpenFile`, 0x1c0-byte effect object | Semantics for the reimplementation: every read is `label → typed value`; **labels are matched in order and are case-sensitive**; a missing label leaves the default and the same line is retried for the next label (so labels may be omitted but not reordered); `END` terminates a nested block; `BEGIN`/`END` are always balanced (the writer emits them); nested objects are `PARTICLEDATATYPE n` followed by `CREATION`/`VARIATION`/`OVERLIFE` blocks (`OVERLIFE` only when `PARTICLEDATATYPE != 0`). The label list (from `Write`): `NAME, ALIGNTOMOTION, FORCESINGLE, USEWORLDSPACE, MOMXFER, MESHFILENAME, TEXTUREFILENAME, SHADERFILENAME, , RANDOMIZESEED, BACKTOFRONTDRAW, PENNANTTRAIL, CREATIONRATE{…}, COLOR_R, COLOR_G, COLOR_B, COLOR_A, NUMINITIALPARTICLES, NUM_TRACKS, per track (PARTICLEDATATYPE, CREATION{…}, VARIATION{…}, [OVERLIFE{…}]), NUM_ATTRACTORS, ATTRACTORINDEX*`. --- ## M4 — `gobio` virtual file system {#m4} ``` struct FileSystemSet { // /SOTS/FileSystemSet; singleton g_gobioInstance @0x00b2e274, g_gobio @0x00b2e270 -> it std::vector fs; // +0 begin, +4 end, +8 cap int pad; // +c int current; // +10 index of the file system that has the currently open file, -1 }; IFileSystem vtable (RTTI .?AVIFileSystem@gobio@Mars@@, abstract 0x00a39870): [0] dtor(flags) [1] bool Open(const char* path) [2] void Close() [3] uint Size() [4] uint Read(void* dst, uint n) [5] void ListFiles(const char* pattern, std::set* out) [6] bool Exists(const char* path) NativeFileSystem (vtable 0x00a398ac, 0x28 bytes): +4 valid, +8 std::string root ("" = cwd, else "dir/"), +24 FILE* [0] 0x008d5220 [1] 0x008d4ef0 fopen(root+path,"rb") [2] 0x008d4ba0 [3] 0x008d4bc0 [4] 0x008d4c30 fread [5] 0x008d6730 _findfirst [6] 0x008d4f60 ZipFileSystem (vtable 0x00a39934, 0x34 bytes): +4 std::string archive, +20 unz handle, +28 std::map (PathCompareCI) [0] 0x008d6d30 [1] 0x008d56a0 Locate+unzOpenCurrentFile [2] 0x008d4af0 [3] 0x008d4b00 [4] 0x008d4b40 unzRead loop [5] 0x008d64d0 PathMatchSpecA [6] 0x008d56e0 struct IBuffer / gobio::Buffer (vtable 0x00a39898, 0x10 bytes): +0 vft, +4 refcount, +8 data, +c size [0] dtor [1]=[2] 0x00682d50 Data() [3] 0x008d4c80 Size() ``` | VA | name | conv | prototype | notes | |---|---|---|---|---| | 0x008d5140 | `Mars::gobio::ReadFile` | cdecl | **`bool (const char* path, IBuffer** out)`** | **"read whole file by relative path"**: `g_gobio->Open(path)`; `n = fs->Size()`; `Buffer::Create(n)`; `fs->Read(buf->data, n) == n ? *out = buf, true : release, false`; `fs->Close()`. Used by `Script::Open`, `TextFileStream::Open`, textures, sounds, shaders (14 callers). Release with `RefCounted_Release(buf)` (0x008a7230, ECX). | | 0x008d5060 | `Mars::gobio::FileSystemSet::Open` | thiscall | `bool (FileSystemSet* this, const char* path)` | closes any open file, then the **first** file system in vector order whose `Open` succeeds wins | | 0x008d50d0 | `Mars::gobio::Open` | cdecl | `bool (const char* path)` | `g_gobio->Open` | | 0x008d4d20 / 0x008d4d50 / 0x008d4d80 | `gobio::Close / Size / Read` | cdecl | `void()`, `uint()`, `uint(void* dst, uint n)` | streaming access to the currently open file | | 0x008d50f0 | `Mars::gobio::Exists` | cdecl | `bool (const char* path)` | any file system | | 0x008d71d0 / 0x008d7090 | `gobio::ListFiles` / `FileSystemSet::ListFiles` | cdecl / thiscall | `void (const char* pattern, std::vector* out)` | union over all file systems (used for `TechTree/*.tech`, effect lists…) | | 0x008d7010 | `Mars::gobio::Init` | cdecl | `void (std::vector* mountPaths)` | one-shot (guard 0x00b2e288) constructs the singleton, sets `g_gobio` | | 0x008d6d60 | `Mars::gobio::FileSystemSet::FileSystemSet` | thiscall | `FileSystemSet* (this, std::vector* mounts)` | mount order, see below | | 0x008d57a0 | `Mars::gobio::NativeFileSystem::NativeFileSystem` | thiscall | `(this, const char* root)` | logs `Mounting current directory...` / `Mounting subdirectory %s...`; `PathIsDirectoryA` check; appends `/` | | 0x008d6c40 | `Mars::gobio::ZipFileSystem::ZipFileSystem` | thiscall | `(this, const char* zipPath)` | opens the `.gob` (zip) and indexes entries | | 0x008d4a50 | `Mars::gobio::PathCompareCI` | custom regs | | `\` == `/`, ASCII case-fold ⇒ **zip entry lookup is case- and slash-insensitive** (native side relies on NTFS being case-insensitive) | | 0x008a0a20 | `Mars::Application::MountModules` | cdecl | `void (void)` | called from `Application::Initialize` first thing after the CPU-affinity check | | 0x008991c0 | `Game::GetDefaultMounts` | cdecl | `void (int* count, const char*** table)` | `count = 3` (0x00b2d514), table `g_DefaultMountTable` 0x00a35e98 (`"sots_local_en.gob"` @0x00a35ea4, `"sots.gob"` @0x00a35eb8, …) | ### How the `.gob` list is registered and the override order `MountModules` reads `sots.ini` (`GetPrivateProfileSectionA("Modules", …)` relative to the current directory, 0x008e7a50) and collects every `Mount=` key sorted by `N`; with no entries it falls back to `GetDefaultMounts()`. The list goes to `gobio::Init(paths)` → `FileSystemSet::FileSystemSet(paths)`: 1. if no path is `.`, `./` or `.\`, push `NativeFileSystem("")` (**current directory**) **first**; 2. then, in list order: `.`-style → `NativeFileSystem("")`; `PathIsDirectoryA(path)` → `NativeFileSystem(path)`; otherwise → `ZipFileSystem(path)` (`Invalid mount path: %s` for empty strings). `FileSystemSet::Open` walks that vector in order and stops at the first hit ⇒ **loose files in the game directory override archived ones, and earlier `Mount` entries override later ones** (native-first, then gobs in ini order). `ListFiles` merges all of them. --- ## Startup / call-site summary (what the shim hooks and when) ``` WinMain 0x0089dd30 new Game::DemoApp (0x1b8) -> g_pDemoApp Mars::Application::Initialize(app, &startup) 0x008a0e50 [thiscall, RET 4, bool] Application::MountModules() 0x008a0a20 sots.ini [Modules] -> gobio::Init (VFS live from here) ... display/audio config via ConfigParser::ParseFile(cb, "sots.cfg"/…) GlobalConsts::LoadAll() 0x008b76d0 -> GlobalConsts::LoadFile(file, map) per data file [M1 hook] textures / effects (Stream::OpenFile -> TextFileStream) / sprites / keymap / font IApplication::OnStartup() (vft+0x10) Mars::Application::Run(app) 0x0089f5b0 main loop DemoApp::OnTick() 0x0089a640 if (+0x10c) DemoApp::EnsureGameData() 0x00899870 (once) DemoApp::LoadGameData() 0x00899280 new MasterTechTree 0x0058b870 TechTree/MasterTechList.tech -> LoadTechFile -> ParseTech WeaponDictionary::Init 0x0059a4c0 Weapons/_weapons.txt -> LoadWeapon -> WeaponDef::ParseScript [M2/M3 hooks] new SectionDictionary(tree) 0x00576f40 Species/*/_shipsections.txt -> LoadSection -> SectionDef::ParseScript GUI scripts via ConfigParser::ParseFile ``` Every file read on these paths bottoms out in `gobio::ReadFile(path, &buf)` 0x008d5140 — hooking that one function (cdecl, two stack args) intercepts every data file the game opens, with the relative path exactly as the game spells it (mixed case, `/` separators).