sots-re/findings/subsystems/loader-prototypes.md
alex f965c8c769 lane S: std::string is 0x1c binary-wide; ObservedTech element fully mapped
Settles the 0x18-vs-0x1c contradiction lane X raised. 0x1c is right, everywhere,
and there is exactly one std::string instantiation in this binary:
_Bx@0, _Mysize@0x10, _Myres@0x14, _Alval@0x18.

ObservedTech+0x24 is that string's trailing empty-allocator word, not the
unaccounted data field it was read as. Three complete enumerations of the element
each skip it: ObservedTech::Write 0x00817cf0, the ctor 0x008562a0, and the copy
ctor inlined at 0x0079a184. Generalised with a new scanner, tools/strfootprint.py,
which recovers every (base, disp, tag) handed to the Mars::Stream string helpers:
65 std::string members off a non-stack base across every serializer in the exe,
ZERO with a sibling member inside the 0x1c span, and 51 of the 52 measurable
inter-member gaps exactly 0x1c. Corroborated by the vector<string> walk stride
(add esi,0x1c @0x00699c29), PostEvent's by-value strings at [ebp+8]/[ebp+0x24]
with RET 0x4c, and MoraleEvent 0x50 = name@0x34 + 0x1c.

Blast radius: zero recovered struct tables were wrong. Every string-bearing layout
already used 0x1c spans and 0x1c gaps -- ServerPlayer::pswd @0x2dc..0x2f7, the row
flagged for re-checking, included. Only prose carried the 0x18 number: the
loader-prototypes conventions line, the GlobalConst_ParseString prototype, and the
ObservedTech element table. struct-recovery S0 additionally had _Mysize/_Myres
transposed (size@0x14, res@0x18) while every table in the same file used the
correct offsets; fixed.

ObservedTech's four on-disk fields are now mapped rather than guessed, by reading
the serializer as lane X suggested: +0x04 uint16 otnF, +0x06 uint16 otnL, +0x08
bool odet (ONE BYTE, WriteBool), +0x0c std::string otch (0x1c), +0x28 int owith
= 0x2c exactly. That matches save_reader.py's on-disk order already. Game::
ObservedWeapon (0x00817bc0/0x00817b10) is the same element with tag owep.

Oracles unaffected and re-run: save_reader 36/36 and --strict exit 0 on all three
real saves; state_checksum 38 tests OK, coverage PROVED byte-for-byte on turn1 and
turn3. sots-engine wip/strings 32d3e36 syncs the header and corrects two stale
"unpinned" comments: clean_room_check OK, host ctest 33/33.

Standing rule this produced: never size a struct member from the offsets the code
touches. This build's STL puts the empty allocator LAST in both string (0x1c) and
vector (0x10), and an empty allocator is never loaded or stored, so a touch-scan
undercounts by exactly 4 every time. Size from an enumeration instead.
2026-09-08 05:09:33 -04:00

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 = 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)

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).