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