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.
34 KiB
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.0fine, colours must be quoted"48 29 2"(the colour parser gets the whole quoted token); an unquoted0 0 0would parse0and then choke on the stray0 0as unknown keys; //comments,{}blocks and quoted tokens follow theMars::Scripttokenizer 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 "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,\nonly; - 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 forNext, 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 valuepair 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 exactstrcmp, 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):
- if no path is
.,./or.\, pushNativeFileSystem("")(current directory) first; - then, in list order:
.-style →NativeFileSystem("");PathIsDirectoryA(path)→NativeFileSystem(path); otherwise →ZipFileSystem(path)(Invalid mount path: %sfor 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).