sots-re/findings/subsystems/loader-prototypes.md

34 KiB
Raw Blame History

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/<addr>.c; post-rename verification decompiles in /srv/re-lab/handoff/loader-decompiles/verify/<addr>.c.

Conventions: thiscall = this in ECX, args pushed right-to-left, callee pops (RET n); cdecl = all on stack, caller pops. std::string = MSVC-2010 layout (16-byte SSO buffer / pointer, size @+0x10, capacity @+0x14, 0x18 bytes). "Case-insensitive" always means MSVCR100 _stricmp (ASCII fold).


Application spine (coordinator request)

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<std::string> 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)

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<key, GlobalConst*> 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<const char*, GlobalConst*, stricmp_less> (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)

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 <file string> to enumerate.


M2 — manifest / id registry (_weapons.txt, _shipsections.txt)

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/<Race>
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<WeaponDef*> 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/<file>"
  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<SectionDef*> 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):

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)

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 &quotedFlag, 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)

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:

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

This is the text back-end of the Mars::Stream serializer (the same interface the save game uses through StreamableHelper<T>). 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
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, <int @0x00af6a24>, 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

struct FileSystemSet {                 // /SOTS/FileSystemSet; singleton g_gobioInstance @0x00b2e274, g_gobio @0x00b2e270 -> it
  std::vector<IFileSystem*> 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<std::string>* 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<path,entry> (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<std::string>* out) union over all file systems (used for TechTree/*.tech, effect lists…)
0x008d7010 Mars::gobio::Init cdecl void (std::vector<std::string>* mountPaths) one-shot (guard 0x00b2e288) constructs the singleton, sets g_gobio
0x008d6d60 Mars::gobio::FileSystemSet::FileSystemSet thiscall FileSystemSet* (this, std::vector<std::string>* 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<N>=<path> 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<N> 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).