verify: Streamable save reader + writer stub + tests + SAVE_FORMAT
This commit is contained in:
parent
bb01d8e655
commit
15ecd22aa6
22 changed files with 2 additions and 2203 deletions
|
|
@ -30,5 +30,5 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
|
|||
| `Game::StrategyServer` (sim block) | object | mapped | high | 80% | 2026-09-07 | top-level sim state; read FUN_007d27a0 / write FUN_0079fa70; IStreamable +0 |
|
||||
| stream primitive API | subsystem | mapped | high | 100% | 2026-09-07 | IStreamable vft: +0x18 string, +0x1c bool, +0x20 float, +0x24 int, +0x28 nested, +0x30 raw; FUN_00816490 = NetworkObject handle id |
|
||||
| real save for verification | verify | in-progress | — | 0% | 2026-09-07 | need the game running (DXVK+lavapipe on VM140) -> autosave -> parse with recovered layouts |
|
||||
| save_reader.py | verify | in-progress | — | 0% | 2026-09-07 | Streamable reader (gzip, name-tagged, BEEFBEEF framing) from reference + recovered layouts |
|
||||
| save_reader.py | verify | mapped | high | 90% | 2026-09-07 | verify/save-reader/: walker+schema, 26 tests pass on synthetic fixtures (both padding modes); SAVE_FORMAT.md. 'verified' only after a real save parses --strict |
|
||||
| Ghidra type write-back | meta | verified | high | 100% | 2026-09-07 | structs saved in project (ServerSystem 87f, ServerPlayer 110f, StarFleet, StarShip, StrategyServer partial, 22 nested); 52 serializers + primitives + ~60 spine fns renamed; decompile shows field names |
|
||||
|
|
|
|||
|
|
@ -22,3 +22,4 @@ Each links to the finding that raised it. Promoted to backlog or closed by **re-
|
|||
- **Resolved (R1/R2 contradictions)** — `Bats2`/`rcex` are int64 (R2 wrong); `Abdn`/`Dstyd` bools, `ltis` int; `TRM`/`CstR/E/T`/`shrm`/`RefCap`/`RepCap`/PlayerView `Infra` are floats; `pswd` string; `TShn`/`ETS`/diplomacy counters int16 in memory, int32 on disk; `Nexp` carries `xid/xmin/xmax/xper`; `FtOrig` is Vector3. (from [[struct-recovery]])
|
||||
- **Battle-load, narrowed** — sim/combat load run on the main thread; the only other threads are net watchdog, TIME_CRITICAL audio streaming (`g_musicCS`), and a star-map mesh builder. Hypothesis: audio-thread critical-section contention or D3D9 runtime/driver threads on many cores. Needs a dynamic profile (x32dbg / ETW) under the software-GPU stack. (from [[turn-spine]])
|
||||
- **Spine leftovers** — `StrategyServer` struct partial (41 fields); static-initialiser region 0x009be000–0x009c1400 undisassembled; `Mars::Stream` vftable not located; several small ProcessTurn phase fns unnamed. (from [[turn-spine]])
|
||||
- **Save framing ambiguities (settle on first real save)** — padding joint `[len][name][value][pad]` vs split; bool vs int for names with len%4==0 (no type byte); on-disk tags for Summary/CreateParameters and count/element tags inside framed arrays unknown (positional for now). A real 3-char bool tag (`NPC`,`Dep`,`hsp`) settles padding. (from [[SAVE_FORMAT]])
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,98 +0,0 @@
|
|||
"""effect_txt.py -- reader for Effects/*.effect (particle-effect definitions).
|
||||
|
||||
NOT the brace-block format. Layout:
|
||||
|
||||
TXT # magic first line
|
||||
KEY value # scalar (number, TRUE/FALSE, "quoted")
|
||||
KEY # group: KEY on its own line, then
|
||||
BEGIN
|
||||
...nested KEY value / groups...
|
||||
END
|
||||
|
||||
Order matters: 'PARTICLEDATATYPE n' is followed by the CREATION /
|
||||
VARIATION / OVERLIFE curves that belong to that datatype, and 'MODIFIER'
|
||||
repeats once per type. So each level is returned as an ordered list of
|
||||
[key, value] pairs (value = scalar or nested list). to_dict() gives a
|
||||
dict view (repeats -> lists) when order is not needed.
|
||||
|
||||
Quirks handled: one file has CRLF; 'NAME "New Emitter"' values contain
|
||||
spaces; indentation is cosmetic (tabs); the format is line-based.
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from flat_kv import split_tokens, strip_comment
|
||||
from mars_data import coerce
|
||||
|
||||
__all__ = ["parse", "parse_file", "to_dict", "EffectSyntaxError"]
|
||||
|
||||
Pairs = list # list[[key, value]]
|
||||
|
||||
|
||||
class EffectSyntaxError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def parse(text: str, *, typed: bool = True) -> Pairs:
|
||||
lines = text.splitlines()
|
||||
if not lines or lines[0].strip() != "TXT":
|
||||
raise EffectSyntaxError("missing TXT magic")
|
||||
stack: list[Pairs] = [[]]
|
||||
pending_key: str | None = None
|
||||
for lineno, raw in enumerate(lines[1:], 2):
|
||||
line = strip_comment(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
if line == "BEGIN":
|
||||
if pending_key is None:
|
||||
raise EffectSyntaxError(f"line {lineno}: BEGIN without a key")
|
||||
grp: Pairs = []
|
||||
stack[-1].append([pending_key, grp])
|
||||
stack.append(grp)
|
||||
pending_key = None
|
||||
continue
|
||||
if line == "END":
|
||||
if len(stack) == 1:
|
||||
raise EffectSyntaxError(f"line {lineno}: END without BEGIN")
|
||||
stack.pop()
|
||||
continue
|
||||
if pending_key is not None:
|
||||
raise EffectSyntaxError(f"line {lineno}: key {pending_key!r} not followed by BEGIN")
|
||||
toks = split_tokens(line)
|
||||
key = toks[0][0]
|
||||
if len(toks) == 1:
|
||||
pending_key = key
|
||||
continue
|
||||
vals = [coerce(t) if (typed and not q) else t for t, q in toks[1:]]
|
||||
stack[-1].append([key, vals[0] if len(vals) == 1 else vals])
|
||||
if len(stack) != 1:
|
||||
raise EffectSyntaxError(f"{len(stack) - 1} unclosed BEGIN group(s)")
|
||||
if pending_key is not None:
|
||||
raise EffectSyntaxError(f"trailing key {pending_key!r} without BEGIN")
|
||||
return stack[0]
|
||||
|
||||
|
||||
def to_dict(pairs: Pairs) -> dict:
|
||||
d: dict = {}
|
||||
for key, val in pairs:
|
||||
if isinstance(val, list) and val and isinstance(val[0], list) and len(val[0]) == 2 and isinstance(val[0][0], str):
|
||||
val = to_dict(val)
|
||||
if key in d:
|
||||
if not isinstance(d[key], list) or not getattr(d[key], "_rep", False):
|
||||
d[key] = _Rep([d[key]])
|
||||
d[key].append(val)
|
||||
else:
|
||||
d[key] = val
|
||||
return d
|
||||
|
||||
|
||||
class _Rep(list):
|
||||
_rep = True
|
||||
|
||||
|
||||
def parse_file(path, **kw) -> Pairs:
|
||||
with open(path, "rb") as f:
|
||||
return parse(f.read().decode("cp1252"), **kw)
|
||||
|
|
@ -1,127 +0,0 @@
|
|||
"""flat_kv.py -- readers for the flat 'KEY value' tuning tables and the
|
||||
whitespace-positional tables under Data/, Weapons/, Badges/, Avatars/, GUI/.
|
||||
|
||||
Two shapes exist:
|
||||
|
||||
parse_kv(text) KEY value -> {KEY: value}
|
||||
one constant per line; value is a bareword or a
|
||||
"quoted string"; '//' comments; colors are quoted
|
||||
"r g b" (use color()). Files: Data/globals.txt,
|
||||
Data/species.txt, Data/Strategy/StrategyVars.txt,
|
||||
Data/Combat/*.txt (most), Data/encounters.txt, ...
|
||||
|
||||
parse_rows(text) tok tok tok ... -> [[tok, ...], ...]
|
||||
one record per line, whitespace separated, quoted
|
||||
tokens may contain spaces; '//' comments. Files:
|
||||
Weapons/_turrets.txt, Weapons/_defaultweapons.txt,
|
||||
Data/Combat/damfx*.txt, Data/Strategy/playercolors.txt,
|
||||
Badges/BadgeTable.txt, Avatars/AvatarTable.txt,
|
||||
GUI/WeaponIconPlacements.txt
|
||||
|
||||
Quirks handled: '//' inside a quoted value is not a comment; a quoted
|
||||
value may be empty (""); keys repeat in a few files (kept as list);
|
||||
duplicate-key detection is exposed via parse_kv(..., on_dup=).
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mars_data import coerce
|
||||
|
||||
__all__ = ["parse_kv", "parse_rows", "duplicates", "color", "strip_comment",
|
||||
"split_tokens", "parse_kv_file", "parse_rows_file"]
|
||||
|
||||
_TOK_RE = re.compile(r'"([^"]*)"|(\S+)')
|
||||
|
||||
|
||||
def strip_comment(line: str) -> str:
|
||||
"""Remove a trailing // comment, ignoring // inside double quotes."""
|
||||
in_q = False
|
||||
i = 0
|
||||
n = len(line)
|
||||
while i < n:
|
||||
c = line[i]
|
||||
if c == '"':
|
||||
in_q = not in_q
|
||||
elif c == "/" and not in_q and line.startswith("//", i):
|
||||
return line[:i]
|
||||
i += 1
|
||||
return line
|
||||
|
||||
|
||||
def split_tokens(line: str) -> list[tuple[str, bool]]:
|
||||
"""Split a line into (token, was_quoted) pairs."""
|
||||
out = []
|
||||
for m in _TOK_RE.finditer(line):
|
||||
if m.group(1) is not None:
|
||||
out.append((m.group(1), True))
|
||||
else:
|
||||
out.append((m.group(2), False))
|
||||
return out
|
||||
|
||||
|
||||
def parse_rows(text: str, *, typed: bool = True) -> list[list[Any]]:
|
||||
rows = []
|
||||
for raw in text.splitlines():
|
||||
line = strip_comment(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
toks = split_tokens(line)
|
||||
rows.append([coerce(t) if (typed and not q) else t for t, q in toks])
|
||||
return rows
|
||||
|
||||
|
||||
def _pairs(text: str, typed: bool):
|
||||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||||
line = strip_comment(raw).strip()
|
||||
if not line:
|
||||
continue
|
||||
toks = split_tokens(line)
|
||||
key = toks[0][0]
|
||||
vals = [coerce(t) if (typed and not q) else t for t, q in toks[1:]]
|
||||
val: Any = None if not vals else (vals[0] if len(vals) == 1 else vals)
|
||||
yield lineno, key, val
|
||||
|
||||
|
||||
def parse_kv(text: str, *, typed: bool = True, on_dup: str = "last") -> dict:
|
||||
"""KEY value per line -> dict. A value made of several unquoted tokens
|
||||
is kept as a list. on_dup: 'last' (later line wins), 'first', 'error'.
|
||||
Use duplicates() to find repeated keys."""
|
||||
d: dict = {}
|
||||
for lineno, key, val in _pairs(text, typed):
|
||||
if key in d:
|
||||
if on_dup == "error":
|
||||
raise ValueError(f"line {lineno}: duplicate key {key}")
|
||||
if on_dup == "first":
|
||||
continue
|
||||
d[key] = val
|
||||
return d
|
||||
|
||||
|
||||
def duplicates(text: str) -> dict[str, list[int]]:
|
||||
"""key -> line numbers, for keys that appear more than once."""
|
||||
seen: dict[str, list[int]] = {}
|
||||
for lineno, key, _ in _pairs(text, False):
|
||||
seen.setdefault(key, []).append(lineno)
|
||||
return {k: v for k, v in seen.items() if len(v) > 1}
|
||||
|
||||
|
||||
def color(value: str) -> tuple:
|
||||
"""'r g b' or 'r g b a' -> tuple of numbers."""
|
||||
return tuple(coerce(t) for t in value.split())
|
||||
|
||||
|
||||
def _read(path) -> str:
|
||||
with open(path, "rb") as f:
|
||||
return f.read().decode("cp1252")
|
||||
|
||||
|
||||
def parse_kv_file(path, **kw) -> dict:
|
||||
return parse_kv(_read(path), **kw)
|
||||
|
||||
|
||||
def parse_rows_file(path, **kw) -> list:
|
||||
return parse_rows(_read(path), **kw)
|
||||
|
|
@ -1,121 +0,0 @@
|
|||
"""manifest.py -- the numbered id manifests and the '#'-commented CSVs.
|
||||
|
||||
parse_manifest(text) -> Manifest
|
||||
Weapons/_weapons.txt and Species/<Race>/sections/_shipsections.txt:
|
||||
<int-id> <filename> one per line
|
||||
// DELETED - <id> retired id (still reserved)
|
||||
Ids are the persistent network / savegame ids. Filenames are matched
|
||||
case-insensitively (the shipped manifests have 'DEWar.SHIPSECTION',
|
||||
'CRAIC.Shipsection' etc. against lower-case files -- Windows FS).
|
||||
|
||||
parse_csv(text) -> list[list[str]]
|
||||
Rows with '#' or '//' as first non-blank char are comments; blank rows
|
||||
dropped; RFC-4180 quoting honoured (Strings.csv has one multi-line cell
|
||||
and quoted commas). Header rows that start with '#' (aitechpri.csv,
|
||||
"# species" in stock_diplomacy_messages.csv) are returned separately
|
||||
via parse_csv_with_header().
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["Manifest", "parse_manifest", "parse_manifest_file",
|
||||
"parse_csv", "parse_csv_file", "parse_csv_with_header", "read_text"]
|
||||
|
||||
_DELETED_RE = re.compile(r"//\s*DELETED\s*-\s*(\d+)", re.I)
|
||||
_ENTRY_RE = re.compile(r"^\s*(\d+)\s+(\S+)\s*$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class Manifest:
|
||||
entries: list[tuple[int, str]] = field(default_factory=list) # (id, filename)
|
||||
deleted: list[int] = field(default_factory=list)
|
||||
problems: list[str] = field(default_factory=list)
|
||||
|
||||
def by_id(self) -> dict[int, str]:
|
||||
return dict(self.entries)
|
||||
|
||||
def by_name(self) -> dict[str, int]:
|
||||
"""lower-cased filename -> id"""
|
||||
return {n.lower(): i for i, n in self.entries}
|
||||
|
||||
|
||||
def parse_manifest(text: str) -> Manifest:
|
||||
m = Manifest()
|
||||
seen: dict[int, int] = {}
|
||||
for lineno, raw in enumerate(text.splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
d = _DELETED_RE.search(line)
|
||||
if d:
|
||||
m.deleted.append(int(d.group(1)))
|
||||
continue
|
||||
if line.startswith("//"):
|
||||
continue
|
||||
e = _ENTRY_RE.match(line)
|
||||
if not e:
|
||||
m.problems.append(f"line {lineno}: unrecognised {line!r}")
|
||||
continue
|
||||
i, name = int(e.group(1)), e.group(2)
|
||||
if i in seen:
|
||||
m.problems.append(f"line {lineno}: duplicate id {i} (first at line {seen[i]})")
|
||||
seen[i] = lineno
|
||||
m.entries.append((i, name))
|
||||
for i in m.deleted:
|
||||
if i in seen:
|
||||
m.problems.append(f"id {i} is both DELETED and assigned")
|
||||
return m
|
||||
|
||||
|
||||
def read_text(path) -> str:
|
||||
with open(path, "rb") as f:
|
||||
return f.read().decode("cp1252")
|
||||
|
||||
|
||||
def parse_manifest_file(path) -> Manifest:
|
||||
return parse_manifest(read_text(path))
|
||||
|
||||
|
||||
def _is_comment(row: list[str]) -> bool:
|
||||
if not row:
|
||||
return True
|
||||
first = row[0].lstrip()
|
||||
if first.startswith("#") or first.startswith("//"):
|
||||
return True
|
||||
return all(c.strip() == "" for c in row)
|
||||
|
||||
|
||||
def parse_csv(text: str, *, strip: bool = True) -> list[list[str]]:
|
||||
rows = []
|
||||
for row in csv.reader(io.StringIO(text, newline="")):
|
||||
if _is_comment(row):
|
||||
continue
|
||||
rows.append([c.strip() for c in row] if strip else row)
|
||||
return rows
|
||||
|
||||
|
||||
def parse_csv_with_header(text: str) -> tuple[list[str] | None, list[list[str]]]:
|
||||
"""Return (header, rows). Header = the first '#'-prefixed row that
|
||||
contains a comma (e.g. '# <tech>,<human-pri>,...'), with the '#' and
|
||||
any '<>' stripped; None when there is no such row."""
|
||||
header = None
|
||||
for row in csv.reader(io.StringIO(text, newline="")):
|
||||
if not row:
|
||||
continue
|
||||
first = row[0].lstrip()
|
||||
if first.startswith("#") and len(row) > 1:
|
||||
header = [c.strip().lstrip("#").strip().strip("<>") for c in row]
|
||||
break
|
||||
if not _is_comment(row):
|
||||
break
|
||||
return header, parse_csv(text)
|
||||
|
||||
|
||||
def parse_csv_file(path, **kw):
|
||||
return parse_csv(read_text(path), **kw)
|
||||
|
|
@ -1,211 +0,0 @@
|
|||
"""mars_data.py -- reader for the Mars engine's brace-block key/value format.
|
||||
|
||||
Covers: *.weapon, *.shipsection, *.tech, *.combat, *.def, *.script and the
|
||||
block-form *.txt files (scenarios, tutorial, credits, systemnames, skydefs,
|
||||
ctechvars, shipai).
|
||||
|
||||
Grammar (as observed in every shipped SOTS1 file):
|
||||
|
||||
body := (block | pair | item)*
|
||||
block := NAME '{' body '}'
|
||||
pair := NAME value
|
||||
item := QUOTED # bare quoted string inside a block
|
||||
value := QUOTED | BAREWORD
|
||||
comment := '//' .* EOL
|
||||
|
||||
A file's top level is itself a body (scenario .txt files mix top-level pairs
|
||||
and player{} blocks; catalog files hold one or many named blocks).
|
||||
|
||||
Result shape: plain dicts. A key seen once maps to its value; a key seen
|
||||
more than once maps to a list (use get_list() when you want a list always).
|
||||
Bare quoted items are collected under the key "_items".
|
||||
|
||||
Quirks handled (all seen in the real data, see parsers-report.md):
|
||||
* keys are case-insensitive to the engine ("Requires"/"requires",
|
||||
"badge"/"Badge") -> keys are lower-cased unless keep_case=True
|
||||
* a block may open on the same line as a preceding pair
|
||||
("turretsize small mount {") and a block name may sit on the same line
|
||||
as its brace ("weapon {")
|
||||
* backslashes inside quoted strings are literal (Windows paths); there is
|
||||
no escape syntax
|
||||
* '//' inside a quoted string is not a comment
|
||||
* CRLF and LF line endings, cp1252 bytes (decoded losslessly)
|
||||
* numbers use C float syntax: ".5", "-.8", "7e+8"
|
||||
|
||||
Stdlib only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Iterator
|
||||
|
||||
__all__ = ["parse", "parse_file", "coerce", "get_list", "MarsSyntaxError"]
|
||||
|
||||
|
||||
class MarsSyntaxError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
# --- tokenizer -------------------------------------------------------------
|
||||
|
||||
_TOKEN_RE = re.compile(
|
||||
r"""
|
||||
(?P<ws>\s+)
|
||||
| (?P<comment>//[^\n]*)
|
||||
| (?P<open>\{)
|
||||
| (?P<close>\})
|
||||
| (?P<quoted>"[^"]*")
|
||||
| (?P<bad_quote>")
|
||||
| (?P<bare>[^\s{}"]+)
|
||||
""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _tokenize(text: str) -> Iterator[tuple[str, str, int]]:
|
||||
"""Yield (kind, value, line). kind in {open, close, quoted, bare}."""
|
||||
line = 1
|
||||
pos = 0
|
||||
n = len(text)
|
||||
while pos < n:
|
||||
m = _TOKEN_RE.match(text, pos)
|
||||
if m is None: # pragma: no cover - regex is exhaustive
|
||||
raise MarsSyntaxError(f"line {line}: cannot tokenize {text[pos:pos+20]!r}")
|
||||
kind = m.lastgroup
|
||||
tok = m.group()
|
||||
pos = m.end()
|
||||
if kind == "ws":
|
||||
line += tok.count("\n")
|
||||
continue
|
||||
if kind == "comment":
|
||||
continue
|
||||
if kind == "bad_quote":
|
||||
raise MarsSyntaxError(f"line {line}: unterminated string")
|
||||
if kind == "quoted":
|
||||
yield kind, tok[1:-1], line
|
||||
line += tok.count("\n")
|
||||
else:
|
||||
yield kind, tok, line
|
||||
|
||||
|
||||
# --- parser ----------------------------------------------------------------
|
||||
|
||||
_INT_RE = re.compile(r"[+-]?\d+$")
|
||||
_FLOAT_RE = re.compile(r"[+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?$")
|
||||
|
||||
|
||||
def coerce(tok: str) -> Any:
|
||||
"""Bareword -> int / float / bool when it looks like one, else str."""
|
||||
if _INT_RE.match(tok):
|
||||
return int(tok)
|
||||
if _FLOAT_RE.match(tok):
|
||||
return float(tok)
|
||||
low = tok.lower()
|
||||
if low == "true":
|
||||
return True
|
||||
if low == "false":
|
||||
return False
|
||||
return tok
|
||||
|
||||
|
||||
def _add(d: dict, key: str, value: Any) -> None:
|
||||
if key in d:
|
||||
cur = d[key]
|
||||
if isinstance(cur, list):
|
||||
cur.append(value)
|
||||
else:
|
||||
d[key] = [cur, value]
|
||||
else:
|
||||
d[key] = value
|
||||
|
||||
|
||||
def get_list(d: dict, key: str) -> list:
|
||||
"""Always return a list for a key (missing -> [], single -> [x])."""
|
||||
v = d.get(key)
|
||||
if v is None:
|
||||
return []
|
||||
return v if isinstance(v, list) else [v]
|
||||
|
||||
|
||||
class _Parser:
|
||||
def __init__(self, text: str, typed: bool, keep_case: bool, strict: bool, warnings: list | None):
|
||||
self.toks = list(_tokenize(text))
|
||||
self.i = 0
|
||||
self.typed = typed
|
||||
self.keep_case = keep_case
|
||||
self.strict = strict
|
||||
self.warnings = warnings if warnings is not None else []
|
||||
|
||||
def _warn(self, msg: str) -> None:
|
||||
if self.strict:
|
||||
raise MarsSyntaxError(msg)
|
||||
self.warnings.append(msg)
|
||||
|
||||
def _peek(self):
|
||||
return self.toks[self.i] if self.i < len(self.toks) else None
|
||||
|
||||
def _next(self):
|
||||
t = self.toks[self.i]
|
||||
self.i += 1
|
||||
return t
|
||||
|
||||
def _key(self, name: str) -> str:
|
||||
return name if self.keep_case else name.lower()
|
||||
|
||||
def body(self, depth: int) -> dict:
|
||||
d: dict = {}
|
||||
while True:
|
||||
t = self._peek()
|
||||
if t is None:
|
||||
if depth:
|
||||
# 11 shipped shipsections never close their outer block;
|
||||
# the engine treats EOF as closing every open block.
|
||||
self._warn(f"end of file inside block (depth {depth})")
|
||||
return d
|
||||
kind, val, line = t
|
||||
if kind == "close":
|
||||
self._next()
|
||||
if not depth:
|
||||
# CrPropaganda.shipsection has one '}' too many.
|
||||
self._warn(f"line {line}: stray '}}' at top level")
|
||||
continue
|
||||
return d
|
||||
if kind == "open":
|
||||
raise MarsSyntaxError(f"line {line}: '{{' without a block name")
|
||||
self._next()
|
||||
if kind == "quoted":
|
||||
# bare string item (systemnames.txt lists) -- never a key
|
||||
_add(d, "_items", val)
|
||||
continue
|
||||
nxt = self._peek()
|
||||
if nxt is None or nxt[0] == "close":
|
||||
# lone bareword at end of block: treat as flag item
|
||||
_add(d, "_items", val)
|
||||
continue
|
||||
if nxt[0] == "open":
|
||||
self._next()
|
||||
_add(d, self._key(val), self.body(depth + 1))
|
||||
continue
|
||||
nkind, nval, _ = self._next()
|
||||
if nkind == "bare" and self.typed:
|
||||
nval = coerce(nval)
|
||||
_add(d, self._key(val), nval)
|
||||
|
||||
|
||||
def parse(text: str, *, typed: bool = True, keep_case: bool = False,
|
||||
strict: bool = False, warnings: list | None = None) -> dict:
|
||||
"""Parse brace-block text into nested dicts.
|
||||
|
||||
typed -- convert bareword numbers/bools (quoted strings stay str)
|
||||
keep_case -- keep key case instead of lower-casing
|
||||
strict -- raise on unbalanced braces instead of recovering the way
|
||||
the engine does (EOF closes open blocks, stray top-level
|
||||
'}' ignored); pass warnings=[] to collect the recoveries
|
||||
"""
|
||||
return _Parser(text, typed, keep_case, strict, warnings).body(0)
|
||||
|
||||
|
||||
def parse_file(path, **kw) -> dict:
|
||||
with open(path, "rb") as f:
|
||||
raw = f.read()
|
||||
return parse(raw.decode("cp1252"), **kw)
|
||||
|
|
@ -1,634 +0,0 @@
|
|||
"""verify.py -- parse every shipped SOTS1 data file, cross-link the catalogs,
|
||||
and emit normalized JSON artifacts.
|
||||
|
||||
usage: python3 verify.py <gob-extract-dir> <out-dir>
|
||||
|
||||
Prints a markdown report to stdout; writes to <out-dir>:
|
||||
tech_tree.json weapons.json shipsections.json strings.json
|
||||
schema_stats.json crosslink.json tech_tree.dot
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import effect_txt
|
||||
import flat_kv
|
||||
import manifest
|
||||
import mars_data
|
||||
from mars_data import get_list
|
||||
|
||||
RACES = ["Human", "Zuul", "Hiver", "Tarkas", "Liir", "Morrigi"]
|
||||
PLAYABLE = RACES
|
||||
ALL_RACE_DIRS = RACES + ["_NPC"]
|
||||
|
||||
BRACE_TXT = {"Data/tutorial.txt", "Data/credits.txt", "Data/Strategy/systemnames.txt",
|
||||
"Data/Combat/ctechvars.txt", "Data/Combat/shipai.txt",
|
||||
"Models/Skysphere/skydefs.txt", "Models/Skysphere/NodeSpace-skydefs.txt"}
|
||||
ROWS_TXT = {"Weapons/_turrets.txt", "Weapons/_defaultweapons.txt", "Data/Combat/damfx.txt",
|
||||
"Data/Combat/damfx_levels.txt", "Data/Strategy/playercolors.txt",
|
||||
"Badges/BadgeTable.txt", "Avatars/AvatarTable.txt", "GUI/WeaponIconPlacements.txt"}
|
||||
PROSE = {"Locale/EN/ChatTrans.txt"}
|
||||
|
||||
|
||||
def kind_of(rel: str) -> str:
|
||||
ext = rel.rsplit(".", 1)[-1].lower()
|
||||
base = os.path.basename(rel)
|
||||
if ext in ("weapon", "shipsection", "tech", "combat", "def", "script"):
|
||||
return "brace:" + ext
|
||||
if ext == "effect":
|
||||
return "effect"
|
||||
if ext == "csv":
|
||||
return "csv"
|
||||
if ext in ("fx", "fxh"):
|
||||
return "hlsl"
|
||||
if ext == "txt":
|
||||
if base in ("_weapons.txt", "_shipsections.txt"):
|
||||
return "manifest"
|
||||
if rel.startswith("Scenarios/") or rel in BRACE_TXT:
|
||||
return "brace:txt"
|
||||
if rel in ROWS_TXT:
|
||||
return "rows"
|
||||
if rel.startswith("Locale/EN/Desc") or rel in PROSE:
|
||||
return "prose"
|
||||
return "kv"
|
||||
return "other"
|
||||
|
||||
|
||||
def walk(root):
|
||||
for dp, _, fn in os.walk(root):
|
||||
for f in sorted(fn):
|
||||
p = os.path.join(dp, f)
|
||||
yield p, os.path.relpath(p, root).replace(os.sep, "/")
|
||||
|
||||
|
||||
# --- schema stats ------------------------------------------------------------
|
||||
|
||||
def schema_walk(node, path, stats):
|
||||
"""Count key occurrences per block path, and which keys are blocks."""
|
||||
st = stats.setdefault(path, {"blocks": 0, "keys": collections.Counter(), "sub": collections.Counter()})
|
||||
st["blocks"] += 1
|
||||
for k, v in node.items():
|
||||
vals = v if isinstance(v, list) else [v]
|
||||
for x in vals:
|
||||
if isinstance(x, dict):
|
||||
st["sub"][k] += 1
|
||||
schema_walk(x, path + "." + k, stats)
|
||||
else:
|
||||
st["keys"][k] += 1
|
||||
|
||||
|
||||
# --- tech tree ---------------------------------------------------------------
|
||||
|
||||
_RP_RE = re.compile(r"^RP:(\d+)$", re.I)
|
||||
_PCT_RE = re.compile(r"^(\w+):(\d+)$")
|
||||
|
||||
|
||||
def parse_allows(s: str):
|
||||
toks = s.split()
|
||||
child = toks[0]
|
||||
rp = None
|
||||
pct = {}
|
||||
extra = []
|
||||
for t in toks[1:]:
|
||||
m = _RP_RE.match(t)
|
||||
if m:
|
||||
rp = int(m.group(1))
|
||||
continue
|
||||
m = _PCT_RE.match(t)
|
||||
if m and m.group(1) in RACES:
|
||||
pct[m.group(1)] = int(m.group(2))
|
||||
continue
|
||||
extra.append(t)
|
||||
return child, rp, pct, extra
|
||||
|
||||
|
||||
def main(root: str, out: str) -> int:
|
||||
os.makedirs(out, exist_ok=True)
|
||||
rep = []
|
||||
P = rep.append
|
||||
|
||||
# ---- 1. parse everything ---------------------------------------------
|
||||
ok = collections.Counter()
|
||||
fail = collections.Counter()
|
||||
fails = []
|
||||
warns = []
|
||||
parsed = {} # rel -> object
|
||||
for p, rel in walk(root):
|
||||
k = kind_of(rel)
|
||||
try:
|
||||
if k.startswith("brace"):
|
||||
w = []
|
||||
obj = mars_data.parse_file(p, warnings=w)
|
||||
# strict re-parse to record the recovery
|
||||
if w:
|
||||
warns.append((rel, w))
|
||||
elif k == "effect":
|
||||
obj = effect_txt.parse_file(p)
|
||||
elif k == "csv":
|
||||
obj = manifest.parse_csv_file(p)
|
||||
elif k == "manifest":
|
||||
obj = manifest.parse_manifest_file(p)
|
||||
if obj.problems:
|
||||
raise ValueError("; ".join(obj.problems))
|
||||
elif k == "rows":
|
||||
obj = flat_kv.parse_rows_file(p)
|
||||
elif k == "kv":
|
||||
txt = manifest.read_text(p)
|
||||
obj = flat_kv.parse_kv(txt)
|
||||
d = flat_kv.duplicates(txt)
|
||||
if d:
|
||||
warns.append((rel, [f"duplicate keys {d}"]))
|
||||
else:
|
||||
continue
|
||||
parsed[rel] = obj
|
||||
ok[k] += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
fail[k] += 1
|
||||
fails.append((rel, repr(e)))
|
||||
|
||||
P("## Parse results")
|
||||
P("")
|
||||
P("| reader | file kind | files | parsed | failed |")
|
||||
P("|---|---|---|---|---|")
|
||||
reader_of = {"brace": "mars_data", "effect": "effect_txt", "csv": "manifest.parse_csv",
|
||||
"manifest": "manifest.parse_manifest", "rows": "flat_kv.parse_rows", "kv": "flat_kv.parse_kv"}
|
||||
for k in sorted(set(ok) | set(fail)):
|
||||
P(f"| {reader_of[k.split(':')[0]]} | {k} | {ok[k] + fail[k]} | {ok[k]} | {fail[k]} |")
|
||||
P("")
|
||||
P(f"Total: {sum(ok.values())} parsed, {sum(fail.values())} failed "
|
||||
f"(skipped: {sum(1 for _, r in walk(root) if kind_of(r) in ('hlsl', 'prose', 'other'))} "
|
||||
f"HLSL/prose files that are not data).")
|
||||
if fails:
|
||||
P("")
|
||||
P("Failures:")
|
||||
for rel, e in fails:
|
||||
P(f"- `{rel}`: {e}")
|
||||
if warns:
|
||||
P("")
|
||||
P("Lenient recoveries (engine-compatible; strict=True would reject these):")
|
||||
for rel, w in warns:
|
||||
P(f"- `{rel}`: {'; '.join(w)}")
|
||||
P("")
|
||||
|
||||
# ---- 2. schema stats ---------------------------------------------------
|
||||
stats = {}
|
||||
for rel, obj in parsed.items():
|
||||
k = kind_of(rel)
|
||||
if k in ("brace:weapon", "brace:shipsection", "brace:tech", "brace:combat", "brace:def", "brace:script"):
|
||||
schema_walk(obj, k.split(":")[1], stats)
|
||||
schema_json = {path: {"blocks": st["blocks"],
|
||||
"keys": dict(st["keys"].most_common()),
|
||||
"subblocks": dict(st["sub"].most_common())}
|
||||
for path, st in sorted(stats.items())}
|
||||
json.dump(schema_json, open(os.path.join(out, "schema_stats.json"), "w"), indent=1)
|
||||
|
||||
P("## Schema stats (key frequency per block type)")
|
||||
P("")
|
||||
P("Full table in `schema_stats.json`. Block paths with instance counts and the")
|
||||
P("keys seen in them (count = number of block instances carrying the key):")
|
||||
P("")
|
||||
for path in ["weapon.weapon", "shipsection.shipsection", "tech.tech"]:
|
||||
st = stats[path]
|
||||
P(f"### `{path}` ({st['blocks']} instances)")
|
||||
P("")
|
||||
P("keys: " + ", ".join(f"{k}:{n}" for k, n in st["keys"].most_common()))
|
||||
P("")
|
||||
P("sub-blocks: " + ", ".join(f"{k}:{n}" for k, n in st["sub"].most_common()))
|
||||
P("")
|
||||
P("All block paths: " + ", ".join(f"`{p}`({st['blocks']})" for p, st in sorted(stats.items())))
|
||||
P("")
|
||||
|
||||
# ---- 3. build catalogs -------------------------------------------------
|
||||
techs = parsed["TechTree/MasterTechList.tech"]["tech"]
|
||||
tech_by = {t["name"].lower(): t for t in techs}
|
||||
groups = collections.defaultdict(list)
|
||||
for t in techs:
|
||||
if "group" in t:
|
||||
groups[str(t["group"]).upper()].append(t["name"])
|
||||
|
||||
strings_rows = parsed["Locale/EN/Strings.csv"]
|
||||
strings = {}
|
||||
string_dups = []
|
||||
for r in strings_rows:
|
||||
k, v = r[0], (r[1] if len(r) > 1 else "")
|
||||
if k in strings:
|
||||
string_dups.append((k, strings[k], v))
|
||||
strings[k] = v
|
||||
strings_lc = {k.lower(): v for k, v in strings.items()}
|
||||
|
||||
def s(key):
|
||||
return strings_lc.get(key.lower())
|
||||
|
||||
weapons = {} # stem -> record
|
||||
for rel, obj in parsed.items():
|
||||
if kind_of(rel) != "brace:weapon":
|
||||
continue
|
||||
stem = os.path.basename(rel)[:-7]
|
||||
w = dict(obj["weapon"])
|
||||
weapons[stem.lower()] = {"stem": stem, "file": rel,
|
||||
"scope": "NPC" if rel.startswith("Species/_NPC") else "player",
|
||||
"id": None, **w}
|
||||
wman = parsed["Weapons/_weapons.txt"]
|
||||
for i, name in wman.entries:
|
||||
key = name.lower()[:-7]
|
||||
if key in weapons and weapons[key]["scope"] == "player":
|
||||
weapons[key]["id"] = i
|
||||
|
||||
sections = {} # (race, stem) -> record
|
||||
for rel, obj in parsed.items():
|
||||
if kind_of(rel) != "brace:shipsection":
|
||||
continue
|
||||
race = rel.split("/")[1]
|
||||
stem = os.path.basename(rel)[:-12]
|
||||
sections[(race, stem.lower())] = {"race": race, "stem": stem, "file": rel, "id": None, **obj["shipsection"]}
|
||||
for race in ALL_RACE_DIRS:
|
||||
m = parsed[f"Species/{race}/sections/_shipsections.txt"]
|
||||
for i, name in m.entries:
|
||||
key = (race, name.lower()[:-12])
|
||||
if key in sections:
|
||||
sections[key]["id"] = i
|
||||
section_stems = collections.defaultdict(list) # stem.lower -> [races]
|
||||
for (race, st), rec in sections.items():
|
||||
section_stems[st].append(race)
|
||||
|
||||
# ---- 4. cross-links ----------------------------------------------------
|
||||
X = {}
|
||||
P("## Cross-link results")
|
||||
P("")
|
||||
|
||||
def tech_exists(name):
|
||||
n = name.lower()
|
||||
if n in tech_by:
|
||||
return True
|
||||
if n.startswith("grp_") and n[4:].upper() in groups:
|
||||
return True
|
||||
return False
|
||||
|
||||
# weapon.requires -> tech (repeated `requires` lines = AND of techs)
|
||||
dang = []
|
||||
case_mismatch = []
|
||||
nref = 0
|
||||
multi = 0
|
||||
for w in weapons.values():
|
||||
reqs = get_list(w, "requires")
|
||||
multi += len(reqs) > 1
|
||||
for r in reqs:
|
||||
nref += 1
|
||||
r = str(r)
|
||||
if not tech_exists(r):
|
||||
dang.append((w["file"], r))
|
||||
elif r.lower() in tech_by and tech_by[r.lower()]["name"] != r:
|
||||
case_mismatch.append((w["file"], r))
|
||||
no_req = [w["file"] for w in weapons.values() if "requires" not in w]
|
||||
X["weapon_requires_dangling"] = dang
|
||||
X["weapon_requires_case_mismatch"] = case_mismatch
|
||||
X["weapon_without_requires"] = no_req
|
||||
P(f"- weapon `requires` -> tech: {nref} refs in {len(weapons) - len(no_req)} weapons ({multi} weapons list 2+ techs), "
|
||||
f"{len(dang)} dangling, {len(case_mismatch)} case-mismatched; {len(no_req)} weapons have no `requires` "
|
||||
f"(NPC: {sum(1 for f in no_req if f.startswith('Species/_NPC'))}, player: "
|
||||
f"{[os.path.basename(f) for f in no_req if not f.startswith('Species/_NPC')]}).")
|
||||
for f, r in dang:
|
||||
P(f" - DANGLING `{f}` requires `{r}`")
|
||||
for f, r in case_mismatch:
|
||||
P(f" - case: `{f}` requires `{r}` (tech is `{tech_by[r.lower()]['name']}`)")
|
||||
|
||||
# shipsection.requires / option -> tech
|
||||
dang = []
|
||||
case_mm = []
|
||||
opt_dang = []
|
||||
scalar_opts = []
|
||||
nreq = 0
|
||||
nopt = 0
|
||||
for rec in sections.values():
|
||||
for r in get_list(rec, "requires"):
|
||||
nreq += 1
|
||||
if not tech_exists(str(r)):
|
||||
dang.append((rec["file"], r))
|
||||
elif str(r).lower() in tech_by and tech_by[str(r).lower()]["name"] != r:
|
||||
case_mm.append((rec["file"], r))
|
||||
for blk_key in ("option", "optiondef"):
|
||||
for blk in get_list(rec, blk_key):
|
||||
# a few files write a bare `option TECH` at section level
|
||||
# instead of wrapping it in option { }
|
||||
opts = get_list(blk, "option") if isinstance(blk, dict) else [blk]
|
||||
if not isinstance(blk, dict):
|
||||
scalar_opts.append((rec["file"], blk))
|
||||
for o in opts:
|
||||
nopt += 1
|
||||
if not tech_exists(str(o)):
|
||||
opt_dang.append((rec["file"], o))
|
||||
X["shipsection_requires_dangling"] = dang
|
||||
X["shipsection_requires_case_mismatch"] = case_mm
|
||||
X["shipsection_option_dangling"] = opt_dang
|
||||
P(f"- shipsection `requires` -> tech: {nreq} refs, {len(dang)} dangling, {len(case_mm)} case-mismatched.")
|
||||
for f, r in dang:
|
||||
P(f" - DANGLING `{f}` requires `{r}`")
|
||||
for f, r in case_mm:
|
||||
P(f" - case: `{f}` requires `{r}`")
|
||||
X["shipsection_scalar_option"] = scalar_opts
|
||||
P(f"- shipsection `option{{option T}}`/`optiondef` -> tech: {nopt} refs, {len(opt_dang)} dangling. "
|
||||
f"Two forms coexist: `option {{ option A option B }}` (a mutually-exclusive choice group) and a bare "
|
||||
f"section-level `option T` ({len(scalar_opts)} occurrences in {len(set(f for f, _ in scalar_opts))} files, "
|
||||
f"e.g. `option DRV_PlsmFoc` on engine sections) -- both merge under the key `option`, so consumers must "
|
||||
f"accept str-or-dict list members.")
|
||||
for f, r in sorted(set(opt_dang)):
|
||||
P(f" - DANGLING `{f}` option `{r}`")
|
||||
|
||||
# tech.ship.section -> shipsection
|
||||
dang = []
|
||||
nsec = 0
|
||||
for t in techs:
|
||||
for blk in get_list(t, "ship"):
|
||||
for sname in get_list(blk, "section"):
|
||||
nsec += 1
|
||||
if str(sname).lower() not in section_stems:
|
||||
dang.append((t["name"], sname))
|
||||
X["tech_ship_section_dangling"] = dang
|
||||
P(f"- tech `ship{{section}}` -> shipsection: {nsec} refs, {len(dang)} dangling "
|
||||
f"(matched against the union of all race catalogs, case-insensitive).")
|
||||
for t, sname in dang:
|
||||
P(f" - DANGLING tech `{t}` unlocks section `{sname}`")
|
||||
|
||||
# tech.weapon.filename -> file
|
||||
disk = {rel.lower() for _, rel in walk(root)}
|
||||
dang = [(t["name"], w["filename"]) for t in techs for w in get_list(t, "weapon") if w["filename"].lower() not in disk]
|
||||
X["tech_weapon_filename_dangling"] = dang
|
||||
nw = sum(len(get_list(t, "weapon")) for t in techs)
|
||||
P(f"- tech `weapon{{filename}}` -> file: {nw} refs, {len(dang)} dangling.")
|
||||
|
||||
# tech.requires / allows -> tech
|
||||
dang_req = [(t["name"], r) for t in techs for r in get_list(t, "requires") if not tech_exists(str(r))]
|
||||
edges = []
|
||||
dang_allow = []
|
||||
bad_allow = []
|
||||
for t in techs:
|
||||
for a in get_list(t, "allows"):
|
||||
child, rp, pct, extra = parse_allows(a)
|
||||
if extra or rp is None:
|
||||
bad_allow.append((t["name"], a))
|
||||
if child.lower() not in tech_by:
|
||||
dang_allow.append((t["name"], child))
|
||||
edges.append({"from": t["name"], "to": child, "rp": rp, "pct": pct})
|
||||
X["tech_requires_dangling"] = dang_req
|
||||
X["tech_allows_dangling"] = dang_allow
|
||||
X["tech_allows_unparsed"] = bad_allow
|
||||
P(f"- tech `requires` -> tech/GRP_: {sum(len(get_list(t, 'requires')) for t in techs)} refs, {len(dang_req)} dangling. "
|
||||
f"Groups: {dict((g, len(v)) for g, v in groups.items())}.")
|
||||
for t, r in dang_req:
|
||||
P(f" - DANGLING tech `{t}` requires `{r}`")
|
||||
P(f"- tech `allows` edges: {len(edges)}, {len(dang_allow)} point at unknown techs, {len(bad_allow)} unparsable.")
|
||||
for t, c in dang_allow:
|
||||
P(f" - DANGLING tech `{t}` allows `{c}`")
|
||||
roots = [t["name"] for t in techs if not any(e["to"].lower() == t["name"].lower() for e in edges)]
|
||||
P(f"- techs never allowed by anything (roots/orphans): {len(roots)}: {', '.join(roots)}")
|
||||
dup_names = [n for n, c in collections.Counter(t["name"].lower() for t in techs).items() if c > 1]
|
||||
P(f"- duplicate tech names: {dup_names or 'none'}")
|
||||
|
||||
# manifests <-> files
|
||||
P("- id manifests <-> files:")
|
||||
man_rep = {}
|
||||
wfiles = {os.path.basename(rel).lower() for rel in parsed if rel.startswith("Weapons/") and rel.endswith(".weapon")}
|
||||
listed = {n.lower() for _, n in wman.entries}
|
||||
man_rep["Weapons"] = {"ids": len(wman.entries), "deleted": wman.deleted,
|
||||
"listed_but_no_file": sorted(listed - wfiles), "file_but_unlisted": sorted(wfiles - listed)}
|
||||
for race in ALL_RACE_DIRS:
|
||||
m = parsed[f"Species/{race}/sections/_shipsections.txt"]
|
||||
files = {os.path.basename(rel).lower() for rel in parsed if rel.startswith(f"Species/{race}/sections/") and rel.endswith(".shipsection")}
|
||||
listed = {n.lower() for _, n in m.entries}
|
||||
man_rep[race] = {"ids": len(m.entries), "deleted": m.deleted,
|
||||
"listed_but_no_file": sorted(listed - files), "file_but_unlisted": sorted(files - listed)}
|
||||
X["manifests"] = man_rep
|
||||
for k, v in man_rep.items():
|
||||
P(f" - `{k}`: {v['ids']} ids (deleted {v['deleted'] or 'none'}); "
|
||||
f"listed-but-no-file {len(v['listed_but_no_file'])}; file-but-unlisted {len(v['file_but_unlisted'])}")
|
||||
for n in v["listed_but_no_file"]:
|
||||
P(f" - MISSING FILE for id {[i for i, nn in (wman if k == 'Weapons' else parsed[f'Species/{k}/sections/_shipsections.txt']).entries if nn.lower() == n][0]}: `{n}`")
|
||||
for n in v["file_but_unlisted"]:
|
||||
P(f" - UNLISTED file `{n}` (no network/save id)")
|
||||
npc_weapons_unlisted = sorted(w["file"] for w in weapons.values() if w["scope"] == "NPC")
|
||||
P(f" - `Species/_NPC/weapons/*.weapon` ({len(npc_weapons_unlisted)} files) have no manifest at all; "
|
||||
f"they are referenced by filename from `_NPC` shipsection `bank{{weapon}}` lines.")
|
||||
|
||||
# strings
|
||||
P("- localization:")
|
||||
P(f" - `Strings.csv`: {len(strings_rows)} data rows -> {len(strings)} keys. {len(string_dups)} keys occur twice "
|
||||
f"because one copy carries a trailing space (parse_csv strips cells; the later row wins):")
|
||||
for k, a, b in string_dups:
|
||||
P(f" - `{k}`: {a!r} then {b!r}")
|
||||
miss_tn = [t["name"] for t in techs if s("TECHNAME_" + t["name"]) is None]
|
||||
miss_td = [t["name"] for t in techs if s("TECHDESC_" + t["name"]) is None]
|
||||
P(f" - TECHNAME_/TECHDESC_ for {len(techs)} techs: {len(miss_tn)} / {len(miss_td)} missing. {miss_tn} {miss_td}")
|
||||
stems = sorted(section_stems)
|
||||
miss_sn = [st for st in stems if s("SECTIONNAME_" + st) is None]
|
||||
miss_sd = [st for st in stems if s("SECTIONDESC_" + st) is None]
|
||||
P(f" - SECTIONNAME_/SECTIONDESC_ for {len(stems)} distinct section stems: {len(miss_sn)} / {len(miss_sd)} missing.")
|
||||
for label, miss in (("SECTIONNAME_", miss_sn), ("SECTIONDESC_", miss_sd)):
|
||||
npc_only = [st for st in miss if section_stems[st] == ["_NPC"]]
|
||||
other = [st for st in miss if st not in npc_only]
|
||||
P(f" - missing {label}: {len(npc_only)} are `_NPC`-only stems (never shown in the design UI); "
|
||||
f"player-race stems: {len(other)} {other}")
|
||||
miss_wn = [(w["file"], w.get("name")) for w in weapons.values()
|
||||
if isinstance(w.get("name"), str) and w["name"].startswith("@") and s(w["name"][1:]) is None]
|
||||
unnamed = [w["file"] for w in weapons.values() if "name" not in w]
|
||||
P(f" - weapon `name @TOKEN`: {len(miss_wn)} unresolved of {sum(1 for w in weapons.values() if 'name' in w)}; "
|
||||
f"{len(unnamed)} weapons carry no `name`.")
|
||||
for f, n in miss_wn:
|
||||
P(f" - UNRESOLVED `{f}` name `{n}`")
|
||||
# every @token anywhere in brace files
|
||||
at_missing = collections.Counter()
|
||||
at_total = 0
|
||||
for rel, obj in parsed.items():
|
||||
if not kind_of(rel).startswith("brace"):
|
||||
continue
|
||||
for tok in re.findall(r"@([A-Za-z0-9_]+)", manifest.read_text(os.path.join(root, rel))):
|
||||
at_total += 1
|
||||
if s(tok) is None:
|
||||
at_missing[(rel, tok)] += 1
|
||||
P(f" - all `@TOKEN` refs in brace-block files: {at_total} refs, {len(at_missing)} unresolved.")
|
||||
for (rel, tok), n in sorted(at_missing.items()):
|
||||
P(f" - UNRESOLVED `{rel}` `@{tok}`")
|
||||
X["strings"] = {"missing_techname": miss_tn, "missing_techdesc": miss_td,
|
||||
"missing_sectionname": miss_sn, "missing_sectiondesc": miss_sd,
|
||||
"unresolved_weapon_name": miss_wn, "unresolved_at_tokens": sorted(f"{r}:@{t}" for r, t in at_missing)}
|
||||
|
||||
# turrets
|
||||
turrets = parsed["Weapons/_turrets.txt"]
|
||||
|
||||
def last_lc(d, key):
|
||||
v = get_list(d, key)
|
||||
return str(v[-1]).lower() if v else None
|
||||
|
||||
tpairs = {(str(r[1]).lower(), str(r[2]).lower()) for r in turrets} # (weapon-size, class)
|
||||
tslots = {(str(r[0]).lower(), str(r[2]).lower()) for r in turrets} # (mount size, class)
|
||||
wpairs = collections.Counter((last_lc(w, "turretsize"), last_lc(w, "turretclass")) for w in weapons.values())
|
||||
w_unfit = sorted((p, n) for p, n in wpairs.items() if p not in tpairs)
|
||||
bpairs = collections.Counter()
|
||||
nobank = 0
|
||||
dupkeys = 0
|
||||
for rec in sections.values():
|
||||
for b in get_list(rec, "bank"):
|
||||
if "turretsize" not in b:
|
||||
nobank += 1
|
||||
continue
|
||||
if isinstance(b.get("turretsize"), list) or isinstance(b.get("turretclass"), list):
|
||||
dupkeys += 1
|
||||
bpairs[(last_lc(b, "turretsize"), last_lc(b, "turretclass"))] += 1
|
||||
b_unfit = sorted((p, n) for p, n in bpairs.items() if p not in tslots)
|
||||
X["turrets"] = {"turret_rows": len(turrets), "weapon_size_class_pairs_without_turret": w_unfit,
|
||||
"bank_size_class_pairs_without_turret": b_unfit,
|
||||
"banks_without_turretsize": nobank, "banks_with_repeated_size_or_class": dupkeys}
|
||||
P(f"- `_turrets.txt` ({len(turrets)} rows; size/class values compared case-insensitively -- the data mixes "
|
||||
f"`Large`/`large`, `Missile`/`missile`, `Standard`/`standard`):")
|
||||
P(f" - weapon (turretsize,turretclass) pairs with no turret row: {w_unfit or 'none'}")
|
||||
P(f" - section bank (turretsize,turretclass) pairs with no turret row: {b_unfit or 'none'}")
|
||||
P(f" - banks with no turretsize at all (NPC fixed-weapon banks): {nobank}; banks that repeat "
|
||||
f"turretsize/turretclass inside one bank{{}} (last value taken): {dupkeys}")
|
||||
|
||||
# NPC bank{weapon} refs
|
||||
dang = []
|
||||
n = 0
|
||||
for rec in sections.values():
|
||||
for b in get_list(rec, "bank"):
|
||||
for wf in get_list(b, "weapon"):
|
||||
n += 1
|
||||
if str(wf).lower() not in disk:
|
||||
dang.append((rec["file"], wf))
|
||||
X["bank_weapon_dangling"] = dang
|
||||
P(f"- shipsection `bank{{weapon <file>}}` -> file: {n} refs, {len(dang)} dangling.")
|
||||
for f, w in dang:
|
||||
P(f" - DANGLING `{f}` -> `{w}`")
|
||||
|
||||
# default weapons
|
||||
dw = parsed["Weapons/_defaultweapons.txt"]
|
||||
dang = [r for r in dw if ("weapons/" + str(r[2])).lower() not in disk]
|
||||
P(f"- `_defaultweapons.txt`: {len(dw)} rows, {len(dang)} name a missing weapon file. {dang or ''}")
|
||||
|
||||
# AI tables
|
||||
def csv_col(rel, col):
|
||||
return [r[col] for r in parsed[rel] if len(r) > col and r[col]]
|
||||
ai = {}
|
||||
for rel in ("Data/Strategy/AI/aitechpri.csv", "Data/Strategy/AI/aitechgrp.csv", "Data/Strategy/AI/aitechmode.csv"):
|
||||
rows = parsed[rel]
|
||||
bad = [t for t in csv_col(rel, 0) if t.lower() not in tech_by]
|
||||
ai[rel] = bad
|
||||
if not rows:
|
||||
P(f"- `{rel}`: 0 data rows -- the shipped file is a comment-only template (schema documented in its "
|
||||
f"header, no entries); the AI's tech priorities must therefore come from code.")
|
||||
else:
|
||||
P(f"- `{rel}`: {len(rows)} rows; col0 not a tech: {bad or 'none'}")
|
||||
bad = [x for x in csv_col("Data/Strategy/AI/affinity_section.csv", 0) if x.lower() not in section_stems]
|
||||
ai["affinity_section_unknown"] = bad
|
||||
P(f"- `AI/affinity_section.csv`: {len(parsed['Data/Strategy/AI/affinity_section.csv'])} rows; unknown sections: {bad or 'none'}")
|
||||
bad = [x for x in csv_col("Data/Strategy/AI/raider_sections.csv", 0) if x.lower() not in section_stems]
|
||||
P(f"- `AI/raider_sections.csv`: unknown sections: {bad or 'none'}")
|
||||
wr = parsed["Data/Strategy/AI/weapon_replacements.csv"]
|
||||
bad = [x for r in wr for x in r if x and x.lower() not in weapons]
|
||||
ai["weapon_replacements_unknown"] = bad
|
||||
P(f"- `AI/weapon_replacements.csv`: {len(wr)} rows; unknown weapon stems: {bad or 'none'}")
|
||||
fams = collections.Counter(str(w.get("weaponfamily")) for w in weapons.values() if "weaponfamily" in w)
|
||||
aw = csv_col("Data/Strategy/AI/affinity_weapon.csv", 0)
|
||||
bad = [x for x in aw if x not in fams]
|
||||
P(f"- `AI/affinity_weapon.csv`: families {sorted(set(aw))}; not a weaponfamily in any .weapon: {bad or 'none'}. "
|
||||
f"weaponfamily values in data: {dict(fams)}")
|
||||
# scenarios
|
||||
for rel in sorted(parsed):
|
||||
if rel.startswith("Scenarios/") and rel.endswith("Templates.csv"):
|
||||
bad = [(r[0], x) for r in parsed[rel] for x in r[1:4] if x.lower() not in section_stems]
|
||||
P(f"- `{rel}`: {len(parsed[rel])} rows; unknown sections: {bad or 'none'}")
|
||||
if rel.startswith("Scenarios/") and rel.endswith("Techs.csv"):
|
||||
bad = [x for x in csv_col(rel, 0) if x.lower() not in tech_by]
|
||||
P(f"- `{rel}`: {len(parsed[rel])} rows; unknown techs: {bad or 'none'}")
|
||||
X["ai"] = ai
|
||||
json.dump(X, open(os.path.join(out, "crosslink.json"), "w"), indent=1)
|
||||
P("")
|
||||
|
||||
# ---- 5. artifacts ------------------------------------------------------
|
||||
nodes = []
|
||||
for t in techs:
|
||||
strat = get_list(t, "strategy")
|
||||
inc = [x for b in strat for x in get_list(b, "inc")]
|
||||
dec = [x for b in strat for x in get_list(b, "dec")]
|
||||
nodes.append({
|
||||
"name": t["name"],
|
||||
"display_name": s("TECHNAME_" + t["name"]),
|
||||
"description": s("TECHDESC_" + t["name"]),
|
||||
"family": t.get("family"),
|
||||
"family_inferred": t["name"].split("_", 1)[0].upper(),
|
||||
"type": t.get("type"),
|
||||
"threat": t.get("threat"),
|
||||
"group": t.get("group"),
|
||||
"option_cost": t.get("option_cost"),
|
||||
"requires": [str(r) for r in get_list(t, "requires")],
|
||||
"benefits_inc": inc,
|
||||
"benefits_dec": dec,
|
||||
"sections": [str(x) for b in get_list(t, "ship") for x in get_list(b, "section")],
|
||||
"weapons": [w["filename"] for w in get_list(t, "weapon")],
|
||||
"allows": [e["to"] for e in edges if e["from"] == t["name"]],
|
||||
})
|
||||
tech_tree = {
|
||||
"_about": "SOTS1 MasterTechList.tech normalized. family is only written on ~half the nodes; "
|
||||
"family_inferred is the name prefix (IND/WEP/DRV/...). edges[].pct: per-race availability % as written; "
|
||||
"a race absent from pct has no override in the file (the engine default -- believed to be 100 -- "
|
||||
"is code-owned, not asserted here). rp = research-point cost of the edge. "
|
||||
"requires may name GRP_<group>, satisfied by any tech with group <group>.",
|
||||
"races": RACES,
|
||||
"groups": dict(groups),
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
}
|
||||
json.dump(tech_tree, open(os.path.join(out, "tech_tree.json"), "w"), indent=1)
|
||||
|
||||
def norm_weapon(w):
|
||||
d = dict(w)
|
||||
d["display_name"] = s(w["name"][1:]) if isinstance(w.get("name"), str) and w["name"].startswith("@") else w.get("name")
|
||||
return d
|
||||
json.dump({"_about": "All *.weapon files (Weapons/ = player catalog with ids from _weapons.txt; Species/_NPC/weapons = NPC, no ids). "
|
||||
"Keys lower-cased; repeated keys -> lists; bareword numbers typed.",
|
||||
"weapons": [norm_weapon(w) for w in sorted(weapons.values(), key=lambda w: (w["scope"], w["stem"].lower()))]},
|
||||
open(os.path.join(out, "weapons.json"), "w"), indent=1)
|
||||
|
||||
def norm_section(r):
|
||||
d = dict(r)
|
||||
d["display_name"] = s("SECTIONNAME_" + r["stem"])
|
||||
d["description"] = s("SECTIONDESC_" + r["stem"])
|
||||
d["unlocked_by"] = [t["name"] for t in techs for b in get_list(t, "ship") if r["stem"].lower() in [str(x).lower() for x in get_list(b, "section")]]
|
||||
return d
|
||||
json.dump({"_about": "All Species/<race>/sections/*.shipsection; id from the race's _shipsections.txt (null = unlisted). "
|
||||
"Keys lower-cased; repeated keys (bank, option, thruster, requires) -> lists.",
|
||||
"sections": [norm_section(r) for r in sorted(sections.values(), key=lambda r: (r["race"], r["stem"].lower()))]},
|
||||
open(os.path.join(out, "shipsections.json"), "w"), indent=1)
|
||||
|
||||
json.dump(strings, open(os.path.join(out, "strings.json"), "w"), indent=1, ensure_ascii=False)
|
||||
|
||||
with open(os.path.join(out, "tech_tree.dot"), "w") as f:
|
||||
f.write("digraph sots_tech {\n rankdir=LR; node [shape=box, fontsize=9];\n")
|
||||
fam_color = {"IND": "#f4d03f", "NRG": "#e74c3c", "SLD": "#3498db", "DRV": "#9b59b6", "TRP": "#e67e22",
|
||||
"WAR": "#c0392b", "BAL": "#7f8c8d", "BIO": "#2ecc71", "CCC": "#1abc9c", "DRN": "#95a5a6", "XNC": "#d35400"}
|
||||
for n in nodes:
|
||||
col = fam_color.get(str(n["family"]), "#ffffff")
|
||||
label = n["display_name"] or n["name"]
|
||||
f.write(f' "{n["name"]}" [label="{label}\\n{n["name"]}", style=filled, fillcolor="{col}"];\n')
|
||||
for e in edges:
|
||||
lab = f"{e['rp']}" if e["rp"] is not None else ""
|
||||
if e["pct"]:
|
||||
lab += "\\n" + " ".join(f"{r[:2]}{v}" for r, v in e["pct"].items())
|
||||
f.write(f' "{e["from"]}" -> "{e["to"]}" [label="{lab}", fontsize=7];\n')
|
||||
f.write("}\n")
|
||||
|
||||
P("## Artifacts")
|
||||
P("")
|
||||
for fn in ("tech_tree.json", "weapons.json", "shipsections.json", "strings.json", "schema_stats.json", "crosslink.json", "tech_tree.dot"):
|
||||
P(f"- `{fn}` ({os.path.getsize(os.path.join(out, fn)) // 1024} KB)")
|
||||
P(f"- tech_tree.json: {len(nodes)} nodes, {len(edges)} edges; weapons.json: {len(weapons)}; "
|
||||
f"shipsections.json: {len(sections)}; strings.json: {len(strings)} keys")
|
||||
print("\n".join(rep))
|
||||
return 0 if not fails else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1], sys.argv[2]))
|
||||
|
|
@ -1,550 +0,0 @@
|
|||
# Sword of the Stars 1 (SOTS1) — Save-File Struct Reference (Community RE)
|
||||
|
||||
Harvested from two community save-game editors for cross-checking against binary-recovered
|
||||
serializable types. SOTS1 uses a self-describing "Streamable" serialization system: **field
|
||||
order + type matter**, and most values are preceded by their own name string.
|
||||
|
||||
Target game version: **SOTS1 v1.8+** (both editors target the 1.8.x line; repo2 says "1.80 +19").
|
||||
|
||||
## Sources
|
||||
- **[R1] BardezAnAvatar/Sots.Sots1.SavedGameEditor** — C#. A *complete, ordered, typed*
|
||||
Streamable parser. Migrated from SourceForge `sots-sge`. This is the authoritative layout
|
||||
evidence (read/write methods reproduce exact on-disk order). Key files under
|
||||
`Bardez.Project.SwordOfTheStars.DataStructures/`:
|
||||
- `BaseSaveStructures.cs` (primitives + framing), `SharedSaveStructures.cs` (coords/colors),
|
||||
`SaveGameDataStructure.cs` (top level), `SummarySaveStructures.cs`,
|
||||
`CreateParametersSaveStructures.cs`, `SimulationSaveStructures.cs` (11,974 lines — the bulk),
|
||||
`CdTableSaveStructures.cs` (combat/AI table). IO: `Bardez.Project.SwordOfTheStars.IO/{Gzip,SaveFileIO}.cs`.
|
||||
- **[R2] ghbplayer/SOTSedit** — C#/WPF. A *name-tag scanner* (does NOT model layout; it searches
|
||||
the decompressed blob for length-prefixed field-name strings and reads the value that follows).
|
||||
Value comes from `Parse.cs` + the field catalog `SOTSEdit.cfg` (friendly-name ⇄ serialized-name
|
||||
⇄ type mapping) and race/semantic hacks. Written 2011 "to learn C#"; author calls the parser weak.
|
||||
|
||||
Both editors independently confirm the same primitives, framing markers, race IDs, and color IDs.
|
||||
|
||||
---
|
||||
|
||||
## 1. File format & serialization mechanics
|
||||
|
||||
### 1.1 Container
|
||||
- **Whole `.sav` file = gzip stream.** Decompress first (`GZipStream`, standard gzip). [R1 Gzip.cs, R2 gzip.cs]
|
||||
- Editors work on the **decompressed** byte stream (R1 names its test artifacts `*.sav.inflate.dat`).
|
||||
- No separate magic/version header is decoded by either editor beyond the top-level Summary block;
|
||||
version is implied by scenario/field presence, not a numeric version field. Endianness: **little-endian**
|
||||
throughout (`BitConverter` on x86).
|
||||
|
||||
### 1.2 Decompressed top-level order [R1 SaveGameData.ReadFromStream]
|
||||
```
|
||||
SaveGameData:
|
||||
1. SummarySaveStruct summary
|
||||
2. CreateParametersSaveStruct createParams
|
||||
3. SimSaveStruct sim <-- the giant one (players/systems/fleets/etc.)
|
||||
4. CdTable cdTable <-- combat / AI state table
|
||||
```
|
||||
|
||||
### 1.3 Primitive encodings [R1 BaseSaveStructures.cs]
|
||||
Text encoding is **windows-1252** (R1) / ASCII (R2).
|
||||
|
||||
- **StringStruct** (raw string): `Int32 length` + `length` bytes (no NUL terminator counted).
|
||||
R2 calls this a "BStr": 4-byte little-endian length prefix + ASCII bytes.
|
||||
- **Padding rule (critical):** every *basic* value struct is **NUL-padded to a 4-byte boundary**.
|
||||
`PaddingSize = 4`. Padding is computed over `(sizeof(Int32 desc-length) + description.Length + valueBytes)`.
|
||||
- **Named value fields** (`BasicSaveStruct` subclasses) are each laid out as:
|
||||
`StringStruct description` (a field-name tag, often the non-descriptive `"."`) → then the value →
|
||||
then NUL padding to 4 bytes. Concrete leaf types:
|
||||
| Struct | Payload after description tag |
|
||||
|---|---|
|
||||
| `Int32SaveStruct` | 4-byte Int32 |
|
||||
| `Int64SaveStruct` | 8-byte Int64 |
|
||||
| `FloatSaveStruct` | 4-byte IEEE Single |
|
||||
| `BooleanSaveStruct` | 1 byte (0/1), padded to 4 |
|
||||
| `StringSaveStruct` | nested StringStruct (len+bytes) |
|
||||
| `ByteArraySaveStruct` | raw bytes (length externally known) |
|
||||
|
||||
So the on-disk shape of a named scalar is: `[len][name-ascii][pad] [value] [pad]`. R2 exploits exactly
|
||||
this: it locates a field by searching for `[len][name]` and reads the value immediately after.
|
||||
|
||||
### 1.4 Complex-struct framing (the "BEEFBEEF" envelope) [R1 ComplexSaveStruct]
|
||||
Every **complex** structure is framed:
|
||||
```
|
||||
StringStruct description (NUL-padded to 4)
|
||||
UInt32 0xBEEFBEEF (begin marker)
|
||||
... body (ordered child fields) ...
|
||||
UInt32 0x41104110 (end marker = bitwise NOT of 0xBEEFBEEF)
|
||||
```
|
||||
`0xBEEFBEEF` / `~0xBEEFBEEF (0x41104110)` bracket every complex object — a reliable resync/validation
|
||||
signature when scanning the binary. (`ISotsStructure` leaf types are NOT framed; only `ComplexSaveStruct`.)
|
||||
|
||||
### 1.5 Array conventions [R1 BaseSaveStructures.cs]
|
||||
- **ComplexArraySaveStruct<T>**: framed (has description + BEEFBEEF), body = `Int32SaveStruct count`
|
||||
then `count` × T.
|
||||
- **NonComplexArraySaveStruct<T>**: NOT framed; body = `Int32SaveStruct count` then `count` × T.
|
||||
(Distinguishing which arrays are framed vs. not is itself layout evidence — see per-struct notes.)
|
||||
|
||||
### 1.6 Conditional & polymorphic reads (watch for these in the binary)
|
||||
- **Optional-by-flag:** a boolean/int gate precedes an optional sub-object.
|
||||
- `SimPlayerColorSaveStruct`: `Int32 colorIndex`; **iff `colorIndex == -1`**, an `RgbColorInt32`
|
||||
(custom RGB) follows. Otherwise palette index only.
|
||||
- `SimPlayerDesignDw2SaveStruct` (weapon slot): `Boolean bId`; if true → `Int32 wId`, else →
|
||||
`StringSaveStruct wfn` (weapon full resource path); then `Int32 dId`.
|
||||
- `SimFleetShipDetails`: `Boolean hbq` gates `bq`; `Boolean hsp` gates `sp`. Fleet flight-plan/lay
|
||||
gated by `hfPlan` / `hLay` booleans.
|
||||
- `SimSystemDetailNvo.isInd` gates independent-colony sub-block; `SimSystemDetailsIndi.hindi`,
|
||||
`SimSystemDetailsVonNeumann.vnh` similar boolean gates.
|
||||
- **Polymorphism by string tag:** `SimSvSctObXscn` reads `StringSaveStruct xcsn`, then switches:
|
||||
`"crowdefs"`, `"gmtrigger"`, `"traps"`, `"indsys"`/default → different body subclass.
|
||||
- **Polymorphism by fixed position:** `SimScSctObEncObjArray` (grand-menace/encounter objects) reads a
|
||||
count then dispatches subclass **by index 0..8** in fixed order:
|
||||
`0 Infest, 1 Dsn, 2 AsteroidMonitor, 3 TD, 4 WD, 5 Hives, 6 Rsuc, 7 Dfts, 8 Ini2`.
|
||||
|
||||
### 1.7 Write-time quirks worth knowing (R2)
|
||||
- R2 edits in place and cannot safely change string length (it truncates/space-pads to the original
|
||||
length). R1 rewrites the whole stream and re-pads. If the binary stores string lengths, the game
|
||||
reads them dynamically (R1 proves round-trip works when re-padded).
|
||||
- R2 planet OID/PID hack: on-disk **`OID = PID * 16`** (R2 divides by 16 to show a "PlayerID").
|
||||
i.e. the raw owner id field is the player index shifted left 4 bits.
|
||||
|
||||
---
|
||||
|
||||
## 2. Enums / ID tables (agreed by both editors)
|
||||
|
||||
### 2.1 Species / race ID [R2 Parse.cs addRace(); R1 PlayerSlot.FxSp comment]
|
||||
| ID | Species |
|
||||
|---|---|
|
||||
| 0 | Human |
|
||||
| 1 | Hiver |
|
||||
| 2 | Tarka(s) |
|
||||
| 3 | Liir |
|
||||
| 4 | `_NPC` / AI-rebellion / grand-menace player (R1: "??? AI Rebellion") |
|
||||
| 5 | Zuul |
|
||||
| 6 | Morrigi |
|
||||
|
||||
### 2.2 Player color ID (palette index) [R1 PlayerSlot.FxCrId & SimPlayerColor]
|
||||
`01 Red, 02 Yellow, 03 Blue, 04 Pink/Magenta, 05 Orange, 06 Green, 07 Aqua, 08 Gray,
|
||||
09 Dark Green, 10 Purple`. Value **-1 ⇒ custom RGB triplet follows** (see §1.6).
|
||||
|
||||
### 2.3 Difficulty [R1 PlayerSettings.Difficulty]
|
||||
`0 Easy, 1 Normal, 2 Difficult`.
|
||||
|
||||
### 2.4 Sentinel values seen in fields
|
||||
`0x7FFFFFFF (Int32.MaxValue)` used as "tag"/unset (PlayerSlot.tag);
|
||||
`team = -1 (0xFFFFFFFF)` = no team; R2: value `-1` = field absent in this save.
|
||||
No named C# enums exist for tech IDs / weapon families — techs and weapons are **string resource
|
||||
names** (e.g. tech `tNm`, weapon `wfn`), not numeric enums. Weapon *family* enumeration lives only in
|
||||
the CdAi combat block as `aiSitWepFams` (opaque int set).
|
||||
|
||||
---
|
||||
|
||||
## 3. Summary block [R1 SummarySaveStructures.cs] (complex)
|
||||
|
||||
### SummarySaveStruct (fields in on-disk order)
|
||||
1. `StringSaveStruct gameName`
|
||||
2. `Int32 turn`
|
||||
3. `Int32 numSys` (system count)
|
||||
4. `Int32 checkSum`
|
||||
5. `ComplexArray<PlayerSlotWrapper> players`
|
||||
6. `SessionSaveStruct session`
|
||||
7. `Int32 mapShape`
|
||||
8. `Int32 incMod` (income modifier)
|
||||
9. `Int32 resMod` (research modifier)
|
||||
10. `Boolean alliances`
|
||||
11. `Boolean teams`
|
||||
12. `Boolean encounters`
|
||||
13. `StringSaveStruct scenario`
|
||||
|
||||
### PlayerSlotWrapper (complex): `{ PlayerSlotSaveStruct slot; Int32 rank; }`
|
||||
|
||||
### PlayerSlotSaveStruct (complex) — new-game slot definition, ordered:
|
||||
`Boolean isPlay, isDead, isReq, isRec, isFxNm` → `String fxNm` (fixed name) →
|
||||
`Boolean isFxSp` → `Int32 fxSp` (species, §2.1) → `Boolean isFxCr` →
|
||||
`NestedInt32 fxCrId` (color, §2.2) → `Boolean isFxBd` → `String fxBd` (badge) →
|
||||
`Boolean isFxAv` → `String fxAv` (avatar) → `Int32 tag` (often 0x7FFFFFFF) →
|
||||
`Int32 pwd` (password) → `Int32 team` (-1=none) → `PlayerSettingsSaveStruct settings`.
|
||||
|
||||
### PlayerSettingsSaveStruct (complex):
|
||||
`Int32 initialTreasury, initialColonies, initialTechnologies, difficulty (§2.3)`.
|
||||
|
||||
### SessionSaveStruct (complex) → `TmrsSaveStruct tmrs`:
|
||||
`Int32 tstl (0x7F7FFFFF), tctl (0x42700000), tqtl (0x7F7FFFFF), tqtle (0)` — timer limits (float bit-patterns stored as int).
|
||||
|
||||
---
|
||||
|
||||
## 4. CreateParameters block [R1 CreateParametersSaveStructures.cs] (complex)
|
||||
|
||||
### CreateParametersSaveStruct (ordered):
|
||||
`String name; Int32 id; Int32 rSeed (random seed); Int32 aid; String key;`
|
||||
`MapPSaveStruct mapP;` `Int32 mapS; Int32 mapF; Int32 nSys;` `Float rEnc (random-encounter rate);`
|
||||
`Int32 sDist;` `Float sSize; Float sRes;` `Int32 sSuit; Int32 maxP; Int32 aSpec;`
|
||||
`Boolean bAlly; Int32 nTeam; Boolean tmgrp;`
|
||||
`Int32 pSav (start savings); Int32 pCol (start colonies); Int32 pTech (start techs);`
|
||||
`Float incM; Float resM; ScrpSaveStruct scrp;`
|
||||
|
||||
### MapPSaveStruct (complex) — initial map/galaxy generation:
|
||||
1. `Int32 unknown1`
|
||||
2. `ComplexArray<PlanetSaveStruct> planetArray`
|
||||
3. `NonComplexArray<ComplexArray<Int32>> players` (per-player int arrays; "non-complex array of players")
|
||||
4. `ComplexArray<MapPNpc> npcArray` (≈ players − 1; independents/NPCs)
|
||||
|
||||
### PlanetSaveStruct (complex) — initial star node geometry:
|
||||
`SpatialCoordinate coordinates (x,y,z floats)`, `Int32 unknown1..4`
|
||||
(values seen: `0x7FFFFFFF`, `0x7F7FFFFF`). NOTE: this is the *map-generation* planet record; the
|
||||
*live* planet/colony state lives in `SimSystemDetailsSaveStruct` (§8).
|
||||
|
||||
### MapPNpc (complex): `Int32 unknown1, unknown2`. ### ScrpSaveStruct (complex): `Int32 spc`.
|
||||
|
||||
### Shared value types [R1 SharedSaveStructures.cs]
|
||||
- `SpatialCoordinateSaveStruct` (complex): `Float x, y, z`.
|
||||
- `RgbColorFloat` (leaf): `Float r,g,b`. `RgbaColorFloat` (leaf): `RgbColorFloat rgb; Float a`.
|
||||
- `RgbColorInt32` (leaf): `Int32 r,g,b`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Simulation block — top level [R1 SimulationSaveStructures.cs]
|
||||
|
||||
### SimSaveStruct (complex) — the master world state, ordered:
|
||||
```
|
||||
String keyPath
|
||||
Int32 nMsz, nMlc, nMnx (next-id / size counters)
|
||||
NonComplexArray<Int32> playerIds, designIds, systemIds, fleetIds, shipIds, tradeIds
|
||||
Int32 modCount, frame, gameId
|
||||
AttributeSaveStruct attribute
|
||||
RngSaveStruct rng (ByteArray unknownData ~2500 bytes: RNG state)
|
||||
String gameName
|
||||
Int32 map, incMod, resMod, enAl, enTm, gOTurn
|
||||
NestedInt32 gOWinPly
|
||||
Int32 npcm, npco, npci, npcv, npca, szad, rsad, suad
|
||||
ComplexArray<ResearchSaveStruct> sprjs (shared/special research projects)
|
||||
Float randEncAdjustment
|
||||
Int32 cmbtid (next combat id)
|
||||
ComplexArray<TurnPly> turnstats (per-turn per-player history)
|
||||
NonComplexArray<SimCrepSaveStruct> creps (combat reports)
|
||||
Int32 ninv, allExc1, allExc2, allExcCF
|
||||
NonComplexArray<SimPlayerSaveStruct> players <-- EMPIRES (§6)
|
||||
SimSpeciesArraySaveStruct species (galaxy species list)
|
||||
NonComplexArray<SimSystemSaveStruct> systems <-- STAR SYSTEMS (§8)
|
||||
SimNodeGrid2 ndgr2 (node-line / warp graph)
|
||||
SimTradeManager trdmgr (§9)
|
||||
SimSpyManager spymgr (Int32 xsid, nspy)
|
||||
NonComplexArray<SimFleet> flt <-- FLEETS (§10)
|
||||
NonComplexArray<Int32> acts
|
||||
SimSvSctOb svSctOb (scenario/encounter objects, §11)
|
||||
Int32 zdsc, zdsi, zdst (Zuul/system-destroyer counters)
|
||||
```
|
||||
|
||||
### Small shared sim types
|
||||
- `SimPopGSaveStruct` (complex): `Int32 popT; Int32 popS; Int64 popC` (pop type / species / civ count).
|
||||
- `ResearchSaveStruct` (leaf): `NonComplexArray<ResearchOptionalSaveStruct> us; String nm; String ntg`.
|
||||
`ResearchOptionalSaveStruct`: `Int32 usc, usp`.
|
||||
- `TurnPly` (leaf): `Int32 ply; PlyHistSaveStruct hist`.
|
||||
- `PlyHistSaveStruct` (complex): `Int32 ply; PlyHistStatsSaveStruct[] stats`.
|
||||
- `PlyHistStatsSaveStruct` (complex): `Int64 pop; ComplexArray<PlyHistStatsSacq> sacq, slost;`
|
||||
`Int32 trn, almem, inc, tdinc, sav, col, bat, tch; NonComplexArray<PlyHistClsSaveStruct> cls`.
|
||||
- `PlyHistStatsSacq` (complex): `Int32 set, ses, seop, senp; NonComplexArray<Int32> seo`.
|
||||
- `PlyHistClsSaveStruct` (leaf): `Int32 cls, shpt, shpl, shpk, satt, satl, satk` (ship/sat built/lost/killed by class).
|
||||
|
||||
---
|
||||
|
||||
## 6. Player / Empire [R1 SimulationSaveStructures.cs]
|
||||
|
||||
### SimPlayerSaveStruct (leaf wrapper): `Int32 playerId; SimPlayerDetailsSaveStruct details`.
|
||||
|
||||
### SimPlayerDetailsSaveStruct (complex) — the empire record, on-disk order:
|
||||
```
|
||||
SimPlayerTechTree techTree <-- TECH TREE (§7)
|
||||
Int32 homeSystem, playerIndex
|
||||
String playerName
|
||||
Int32 species (§2.1)
|
||||
SimPlayerColorSaveStruct colorId (palette idx or -1 + RGB, §1.6/§2.2)
|
||||
String badge, avatar
|
||||
Int32 team, sav (savings)
|
||||
Float idealSuit, suitTolerance, maxOH
|
||||
Float resRate, resModifier, resScl (research)
|
||||
Int32 trm, trp, tra
|
||||
Float outMod, rebOutMod, scOutMod, incMod, popMod, terraMod (economy multipliers)
|
||||
Boolean aMine; Float minPure, minRate; Int32 ngts, prGtTrf, gTraf
|
||||
Int32 cstR, cstE, cstT, maint, shrm, status, elim
|
||||
Boolean npc, rebAi, reqCL
|
||||
SimPlayerTeamSaveStruct teamStruct (Int32 alid, al, na, cf)
|
||||
Int32 hasVac, hasImm, npTrak, hasDisc, hasDiscSp, hasDiscCl, hasEnc, hasEng
|
||||
SimPlayerEventsSaveStruct events (Int32 evNxId; ComplexArray<SimPlayerEvent>)
|
||||
NestedInt32 fngNum
|
||||
Int32 pvSav, pvMA, aibN
|
||||
Boolean cnTrd, cnRad, hgs, hadvs, harcc, cnVItl
|
||||
Float pddm
|
||||
Int32 bankWrn, bankTrn, bankPr, bankEl
|
||||
SimPlayerShipRecsEventsSaveStruct shipRecs
|
||||
Int32 nextPrjId, plcy, pswd, lret, nmeid
|
||||
Boolean cdp
|
||||
SimPlayerSpySaveStruct spy2 (Int32 defc2, rtc, evc, ttc)
|
||||
SimPlayerCivrSaveStruct civR (Float smx; ComplexArray<SimPlayerCivrSpeSpVa {Int32 sp, va2}>)
|
||||
Int32 aidf
|
||||
Boolean srn; Int32 srcTo, lboid, lcid2
|
||||
String resTnm
|
||||
Boolean resErrRoll
|
||||
SimPlayerModsSaveStruct conMods (3× SimPlayerConModSaveStruct {Float conMod, savMod})
|
||||
NonComplexArray<Int32> ownerIds
|
||||
NonComplexArray<SimPlayerDesignEntrySaveStruct> designs <-- SHIP DESIGNS (§7.2)
|
||||
NonComplexArray<SimPlayerDesignEntrySaveStruct> droneDesigns
|
||||
NonComplexArray<SimPlayerNote> notes (Int32 ntSys; String ntTxt; Int32 ntTrn)
|
||||
NonComplexArray<SimPlayerPr> pr (Float prm; Int32 prbt)
|
||||
Boolean hasAiRebellion, cta
|
||||
NestedInt32 aienf
|
||||
NonComplexArray<SimPlayerDetailsSpecialProjectT> nSprj
|
||||
Int32 nexp, nWeapXcl
|
||||
ComplexArray<SimPlayerDetailsOjv> ovjs (objectives: Int32 id; Bool cmp; Int32 spr; String dsc; Int32 nid)
|
||||
ComplexArray<SimPlayerDipStat> dipStats (diplomacy; see below)
|
||||
ComplexArray<SimPlayerComm> comms (Int32 msgt; SimPlayerCommMsg msg)
|
||||
ComplexArray<SimPlayerPrepSaveStruct> preps
|
||||
ComplexArray<SimPlayerOdesSaveStruct> odes
|
||||
ComplexArray<SimPlayerOwepSaveStruct> owep
|
||||
ComplexArray<SimPlayerOtchSaveStruct> otch
|
||||
NestedInt32 aid
|
||||
Int32 ndeflay, rdtc, tnc
|
||||
```
|
||||
- `SimPlayerDipStat` (complex): `Int32 other; SimPlayerDipStatDetail nap, ally, cf; Int32 deadhome`.
|
||||
`SimPlayerDipStatDetail` (leaf): `Int32 last_, last_bty, bkn_, bty_`.
|
||||
- `SimPlayerCommMsg` (complex): `Int32 cid2, snd, rcp, exp, sent, rcpt, sys`.
|
||||
- `SimPlayerPrepSaveStruct` (complex): `Int32 oid, pid, flds, sav, home, ncol, mpwr, mcls, mmsl, nshp, nsat`.
|
||||
- `SimPlayerOdes/Owep/Otch` (complex, "old design/weapon/tech" build history):
|
||||
`Odes {Int32 ontF, otnL, odid, opid}`, `Owep {Int32 ontF, otnL, odet; String owep; Int32 owith}`,
|
||||
`Otch {Int32 ontF, otnL, odet; String otch; Int32 owith}`.
|
||||
- `SimPlayerDetailsSpecialProjectT` (leaf): `Int32 sprjT; SimPlayerDetailsSpecialProjectDetails sprj`.
|
||||
`...Details` (complex): `Int32 stp; SpecialProjectSpi spi; tail (polymorphic AsMon | Tech)`.
|
||||
`...Spi` (complex): `Int32 sPid, sts; Float cst; Int32 mxC; String name; Int32 trns`.
|
||||
tail `AsMon`: `Int32 rDn, sys, rMn, rMx; Float rMd`; tail `Tech`: `Int32 rDn; Float aOdd, aInc; String tch; Int32 rCst`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Tech tree & ship designs
|
||||
|
||||
### 7.1 Tech tree [R1]
|
||||
- `SimPlayerTechTree` (complex): `NonComplexArray<SimPlayerTechTreeBranch> tree; NonComplexArray<SimPlayerTechTreeTech> techs`.
|
||||
- `SimPlayerTechTreeBranch` (leaf): `String tNm` (tech name); `NonComplexArray<String> branches` (child tech names).
|
||||
- `SimPlayerTechTreeTech` (leaf), ordered:
|
||||
`String tNm` (tech name) → `Int32 st` → `Int32 tResCost` (research cost) →
|
||||
`Int32 tResDone` (progress) → `Int32 tAcq` (turn acquired) → `Int32 tiAcq` (turns-to-acquire) →
|
||||
`Int32 tbd` → `Boolean tfc` → `Int32 tUnlck` (unlocked flag).
|
||||
**Techs are identified by string name, not a numeric enum.**
|
||||
|
||||
### 7.2 Ship designs [R1]
|
||||
- `SimPlayerDesignEntrySaveStruct` (leaf): `Int32 designId; SimPlayerDesignSaveStruct design`.
|
||||
- `SimPlayerDesignSaveStruct` (complex), ordered:
|
||||
`Boolean faiDes; Boolean dHide; Int32 dWep; String dName;` `SimPlayerDesignSectionArray sections;`
|
||||
`Int32 dtc; NonComplexArray<SimPlayerDesignDwg> dwgv`.
|
||||
- `SimPlayerDesignSectionArray` (leaf): `Int32 count` + `count` × `SimPlayerDesignSectionEntrySaveStruct`.
|
||||
- `SimPlayerDesignSectionEntrySaveStruct` (complex): `SimPlayerDesignSectionSaveStruct dsec` (Int32 unknown1, unknown2);
|
||||
`ComplexArray<SimPlayerDesignUnknown1SaveStruct> dgbnk2;` `ComplexArray<String> dOpts`.
|
||||
- `SimPlayerDesignUnknown1SaveStruct` (complex) → `SimPlayerDesignDw2SaveStruct dw2` (weapon slot,
|
||||
conditional — see §1.6): `Boolean bId; (bId? Int32 wId : String wfn); Int32 dId`.
|
||||
- `SimPlayerDesignDwg` (complex): `NonComplexArray<SimPlayerDesignGng> wgng`;
|
||||
`SimPlayerDesignGng` (leaf): `Int32 wgid; SimPlayerDesignDwgWgb wgb` (complex: `Int32 unknown1..3`).
|
||||
|
||||
---
|
||||
|
||||
## 8. Star systems & planets [R1]
|
||||
|
||||
- `SimSystemSaveStruct` (leaf): `Int32 sysId; SimSystemDetailsSaveStruct details`.
|
||||
- **`SimSystemDetailsSaveStruct` (complex)** — the live system+colony record, on-disk order:
|
||||
```
|
||||
SpatialCoordinate pos
|
||||
RgbaColorFloat starColor
|
||||
Int32 idx
|
||||
Int32 size (1-10)
|
||||
Float suit (climate hazard)
|
||||
Int32 res, aRes, mRes (resources / asteroid / extra)
|
||||
Boolean noRebAi
|
||||
Int32 tRes, pop
|
||||
ComplexArray<SimPopGSaveStruct> popG
|
||||
Float infra
|
||||
Int32 pvPop; ComplexArray<SimPopG> pvPopG; Float pvInfra, pvSuit; Int32 pvRes, pvARes2, pvMRes; Bool pvNoRebAi (previous-turn snapshot)
|
||||
SimSystemDetailRtsSaveStruct rts (Float sRs, sRt, sRsc, sRtf, sRi, sRoh, sRnr — IO allocations)
|
||||
Int32 abdn; Boolean dstyd; Int32 tnsOh
|
||||
Float outMod, repCur, repMax
|
||||
Int32 ntdev, pbon
|
||||
ComplexArray<SimPopG> pbon2
|
||||
Float ibon
|
||||
Int32 ltis, rbfl, rbtn, rbfr, rbwn, hsrg
|
||||
NonComplexArray<SimSystemDetailHalt> halt (Int32 haltt; Bool haltv)
|
||||
SimSystemDetailsVonNeumann vnm (Bool vnh gate → details: Bool vnd, vnex3, vnpex3)
|
||||
String name
|
||||
SimSystemDetailFlags1 flags1 (Int32 vFlags, eFlags, aFlags, fFlags, gFlags)
|
||||
Int64 bats2 (recent battles; larger=more recent)
|
||||
Int64 rcex
|
||||
SimSystemDetailFlags2 flags2 (Int32 mnRFlags, rfRFlags, clkFlags)
|
||||
Int32 eggScio, terrFl, tAcq, tfAcq, tDst
|
||||
ComplexArray<SimPopG> dcs; Int32 dsu
|
||||
ComplexArray<SimSystemDetailCm> cm, pvcm (SimSystemDetailCm: Int32 msp, mv)
|
||||
ComplexArray<SimSystemDetailCme2> cme2 (Int32 mid, mtrT, mn, mtp; ComplexArray<Cm> mfx; String mdsc)
|
||||
ComplexArray<SimSystemDetailSpy> spies
|
||||
Int32 pid (owner player id), defF, defSf
|
||||
SimSystemDetailBq bq (ComplexArray<SimSystemDetailBqOrd> ords; Ord: Int32 desId, con, conleft, sav, ordId)
|
||||
NonComplexArray<SimSystemDetailAdct> adct (Int32 ads, adt)
|
||||
Int32 numPlgs2
|
||||
NonComplexArray<Int32> flts, gfs, snF, mnF (fleets / gates / stations / monitors present)
|
||||
NonComplexArray<SimSystemDetailNvo> nvos (colonies; see below)
|
||||
NonComplexArray<SimSystemDetailVe> nve (Int32 ePid, ets, eid)
|
||||
NonComplexArray<SimSystemDetailVs> nvs (Int32 pid; SimSystemDetailVsPView pview)
|
||||
SimSystemDetailsIndi indi (Bool hindi gate → indsp, SimPlayerColor indcl, String indnm/indav/indba)
|
||||
```
|
||||
- `SimSystemDetailNvo` (leaf): `Int32 pid, tShn, oId; Boolean isInd; SimSystemDetailNvoIndi indi`
|
||||
(independent-colony sub-block gated by `isInd`).
|
||||
- `SimSystemDetailVsPView` (complex — per-player *seen* snapshot of a colony): `Int32 vTrn, pop;`
|
||||
`ComplexArray<SimPopG> pop2; Int32 infra; Float suit; Int32 res, aRes2, mRes; Bool noRebAi;`
|
||||
`Int32 pbon; ComplexArray<SimPopG> pbon2; Float ibon; Int32 terrFl; Bool footer`.
|
||||
|
||||
### 8.1 R2 ⇄ R1 cross-map for planet/colony fields (verifier gold)
|
||||
R2 finds these serialized tags anywhere in the "Planets" region (between markers `NumSys`…`NdGr2`).
|
||||
They correspond to fields inside R1's `SimSystemDetailsSaveStruct` / `...VsPView`:
|
||||
|
||||
| R2 serialized tag | type | meaning | R1 field |
|
||||
|---|---|---|---|
|
||||
| `Idx` | int | planet/system id | `idx` |
|
||||
| `Name` | string | name | `name` |
|
||||
| `Size` | int | 1-10 | `size` |
|
||||
| `Suit` | float | climate hazard | `suit` |
|
||||
| `Res` / `ARes2` / `MRes` | int | resources | `res` / `aRes` / `mRes` |
|
||||
| `Infra` | float | infrastructure | `infra` |
|
||||
| `ibon` | float | infra bonus | `ibon` |
|
||||
| `Pop` | int | imperial pop | `pop` |
|
||||
| `pbon` | int | imperial pop bonus | `pbon` |
|
||||
| `PopC` | long | civilian pop | (SimPopG `popC` Int64) |
|
||||
| `OID` | int | owner id (**= PID×16**) | `pid` (owner) |
|
||||
| `PID` | int | derived player id | (OID/16) |
|
||||
| `SRt/SRsc/SRtf/SRi/SRoh` | int | IO trade/ship/terraform/infra/overharvest | `rts.sRt/sRsc/sRtf/sRi/sRoh` |
|
||||
| `Abdn` | short | abandon order | `abdn` |
|
||||
| `Dstyd` | short | star annihilated | `dstyd` |
|
||||
| `ltis` | short | last-time-seen | `ltis` |
|
||||
| `VFlags/EFlags/AFlags/FFlags/GFlags` | int | state flags | `flags1.*` |
|
||||
| `Bats2` | int | recent combat | `bats2` (Int64 in R1) |
|
||||
| `nadct` | int | addicted (1=yes) | (in `adct` array) |
|
||||
| `NumFlts/NumGFs/NumSnF/NumMnF` | int | fleets/gates/stations/monitors | `flts/gfs/snF/mnF` counts |
|
||||
|
||||
Note the **type disagreements** (verifier flags): R2 reads `Abdn`, `Dstyd`, `ltis` as **short (Int16)**
|
||||
while R1 models them as framed `Int32SaveStruct`; R2 reads `Bats2` as int while R1 uses Int64. R2's
|
||||
name-scan reads the value bytes directly after the tag+pad, so R2's width is the more literal
|
||||
on-value-bytes claim for those specific fields; treat as "value is small, low bytes are the datum."
|
||||
|
||||
---
|
||||
|
||||
## 9. Trade & node grid [R1]
|
||||
- `SimNodeGrid2` (complex): `ComplexArray<SimNodeGridPath> paths; Int32 nextId`.
|
||||
`SimNodeGridPath` (complex): `Int32 npt, npid, npfr(from), npto(to), npctm, npcby, npdtn, npdtf, npenp, npuse, nptf`.
|
||||
- `SimTradeManager` (complex): `NonComplexArray<SimTradeSector> tradeSectors; Float sctSize; List<SimTradeSectorRt> rt`.
|
||||
`SimTradeSector` (leaf): `Int32 tradeId; SimTradeSectorTradeSaveStruct trade`.
|
||||
`SimTradeSectorTradeSaveStruct` (complex): `SpatialCoordinate pos; Int32 tradeSectorGridId;`
|
||||
`SimTradeSectorTradeCtrSaveStruct tsctr(3 floats); Int32 tssec, tsct, tscr, ptssec, ptsct, ptscr;`
|
||||
`ComplexArray<...Fwarn {Int32 pId, ntrns}> fwarn; NonComplexArray<Int32> systems, tsflt`.
|
||||
`SimTradeSectorRt` (complex): `Int32 tro, trfow, trfr, trfrs, trtow, trto, trtos, trtc`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Fleets & ships [R1]
|
||||
- `SimFleet` (leaf): `Int32 fltId; SimFleetDetails flt`.
|
||||
- **`SimFleetDetails` (complex)**, ordered:
|
||||
`SpatialCoordinate pos; Int32 pId, locId; FlightPlanContainer fplan (Bool hfPlan gate);`
|
||||
`String ftName; Int32 ftTrans; SimFleetOrigin ftOrig (Int32 ×3);`
|
||||
`Int32 ftFlag, ftae, ftpae, ftEnc, ftMs, perm; SpatialCoordinate prvPos;`
|
||||
`LayContainer lay (Bool hLay gate); NonComplexArray<SimFleetShip> ships`.
|
||||
- `SimFleetDetailsFlightPlan` (complex): `ComplexArray<Wpt> wpts; Float fPsp2; Int32 fPeta2;`
|
||||
`Fpogn2 (3 floats); SpatialCoordinate fPdpos; Int32 pnd`. `Wpt`: `Int32 wpt, tp; Nrt {Int32 nrp,nrf,nrt}`.
|
||||
- `SimFleetShip` (leaf): `Int32 shipId; SimFleetShipDetails ship`.
|
||||
- **`SimFleetShipDetails` (complex)**, ordered:
|
||||
`Int32 desId (design id), fltId, plrId; Float range; SimFleetShipHealth health (3 floats: command/mission/drive);`
|
||||
`Int32 conCap, refCap, repCap, mineCap, plg, act; Boolean dep, atq; Int32 encId;`
|
||||
`SimFleetShipPrish prish (Int32 prMax; NonComplexArray<Int32> prSp); Int32 lct, tsd, atsp, tblt;`
|
||||
`Boolean hbq; SimFleetShipDetailsBq bq (gated by hbq);`
|
||||
`Boolean hsp; SimFleetShipDetailsSp sp (gated by hsp; two SpPop pop/pPop);`
|
||||
`NonComplexArray<SimFleetShipDetailsTh> th (Float th, thm)`.
|
||||
`SimFleetShipDetailsBqOrd`: `Int32 desId, con, conleft, sav, ordId` (same shape as system BqOrd).
|
||||
|
||||
---
|
||||
|
||||
## 11. Combat reports & scenario/encounter objects [R1]
|
||||
- `SimCrepSaveStruct` (combat report, complex): `Int32 cid, trn; SpatialCoordinate pos; Int32 sid, auto, dur, cow, cdst, cpk, cpt, cdt, cdi;`
|
||||
`ComplexArray<SimCrepPrepSaveStruct> prep; ComplexArray<SimCrepWrepSaveStruct> wrep`.
|
||||
`SimCrepPrepSaveStruct`: `Int32 plr; Bool ai; Int32 ally, status, mxeng, mxcls, mxmsl;`
|
||||
`NonComplexArray<Cls> cls; NonComplexArray<Sec> sec; Int32 ndam; ComplexArray<Srep> srep`.
|
||||
`SimCrepPrepSrepSaveStruct`: `String name; Int32 did, cls; Int64 caps2; Int32 nshp, nfld, nlst, dtak; SimDamsSaveStruct dams`.
|
||||
`SimDamsSaveStruct`: `Int32 dams, damp, dami, damt`. `SimCrepWrepSaveStruct`: `String wep; SimDams dams`.
|
||||
- `SimSvSctOb` (complex): `ScnObjStruct scn; NonComplexArray<SimSvSctObXscn> xscn; SimScSctObEncObjArray encObjs`.
|
||||
`SimSvSctObXscn` = polymorphic-by-string (§1.6): `traps` (ComplexArray<TrapDetails {Int32 sys,pid,trenc,trgenc}>),
|
||||
`gmtrigger` (Int32 gmch), `crowdefs` (Int32 sys; NonComplexArray<Int32> dsys,des; Float drad), `indsys`/default (empty).
|
||||
- **`SimScSctObEncObjArray`** = grand-menace/encounter table, dispatched **by fixed index 0-8** (§1.6).
|
||||
Each subclass carries that menace's state, e.g.:
|
||||
- `EncInfest` (Hiver infestation): `NonComplexArray<Asg> asg; ComplexArray<Infest> infests; Int32 deshive, deslarva`.
|
||||
- `EncHives`: `Int32 qDesignId; ComplexArray<Hive {Int32 hiveId,queenId,nextQ}> hives; ComplexArray<Queen {Int32 queenId,qDstId}> queens; ComplexArray<NestedInt32> sysMem`.
|
||||
- `EncAsteroidMonitor`, `EncTD`, `EncWD`, `EncRsuc`, `EncDfts` (Von Neumann; large), `EncIni2`.
|
||||
|
||||
---
|
||||
|
||||
## 12. CdTable — combat / AI persistence block [R1 CdTableSaveStructures.cs]
|
||||
|
||||
### CdTable (leaf, top of the 4th file-section): `ComplexArray<String> cdt; CdPlayer cdplayer; CdAi[] cdai`.
|
||||
|
||||
### CdPlayer (complex) — **entirely reverse-unlabeled** (fields named `unknown1..35`); shape is known:
|
||||
`Int32 unknown1(=16); Bool unknown2; Float unknown3; Bool unknown4; Int32 unknown4p5; Bool unknown5..8;`
|
||||
`Int32 unknown9,10; NonComplexArray<CdPlayerUnknown11Item {Int32 unknownId, const1, const2, value1}> unknown11;`
|
||||
`Int32 unknown12..14; NonComplexArray<Int32> unknown15,16; NonComplexArray<Unknown17Item{Int32 ×2}> unknown17;`
|
||||
`NonComplexArray<Int32> unknown18; Int32 unknown19..21;`
|
||||
`NonComplexArray<Unknown22Item{Int32×2,Bool}> unknown22; Int32 unknown23..35`.
|
||||
|
||||
### CdAi (complex) — per-AI-player planner state, ordered:
|
||||
`AttributeSaveStruct aiAttr; NestedInt32 aiTurnPris; CdAiSit aiSit; NestedInt32 aiPlyHat;`
|
||||
`ComplexArray<CdAiPrsUnknown {Int32 pid,trn}> prs2; Int32 dsh, nbStab, nmBlst; CdAiAidng aidng;`
|
||||
`Int32 aiHivJ, sdFlT; NestedInt32 nalat; Int32 lnat, lat;`
|
||||
`NonComplexArray<CdAiSys> aiSys; NonComplexArray<CdAiCmbr> cmbR; NonComplexArray<CdAiCl {Int32 clTn,clSyId,clPlId}> cl;`
|
||||
`NonComplexArray<CdAiPrv {Int32 nPrvId; Float nPrvVa}> prv; NonComplexArray<Int32> tecs; Int32 fct;`
|
||||
`ComplexArray<CdAiApr {Int32 sid,tn0,tn1}> apr`.
|
||||
- `CdAiSit` (complex): `NestedInt32 aiSitSecs; NestedInt32 aiSitWepFams` (**weapon-family set** lives here — opaque ints).
|
||||
- `CdAiCmbr` (complex): `Int32 crTrnK; Bool crPce; NestedInt32 crSys; CdAiCmbrCrplSv2 crplSv2`.
|
||||
`...CrplSv2`: `Int32 rpBon, rpBonT, savBonus; Bool maintHf; ComplexArray<TacReport> tacReports`.
|
||||
`TacReport`: `TrStruct trBy, trTo; TacReportDamage damageStruct; NonComplexArray<TacReportShips> ships`.
|
||||
`TrStruct`: `Int32 treHd, treHi, treD, treDp, treDi, treDt, treB`.
|
||||
`TacReportDamage` (leaf): `Int32 tRid, tRal, tRbal, tRlas, tRmis, tRmin, tRnrg, tRbio, tRbrd; Bool tRsld, tRsldd, tRsldc, tRsldi, tRsldr`
|
||||
— **damage-by-weapon-family breakdown**: bal(listic)/las(er)/mis(sile)/min(e)/nrg(=energy)/bio/brd(=boarding); sld=shields.
|
||||
`TacReportShips` (leaf): `Int32 tRships, tRsldr, tRshipL`.
|
||||
|
||||
---
|
||||
|
||||
## 13. Coverage gaps & contradictions
|
||||
|
||||
**Coverage (what's decoded):**
|
||||
- R1 decodes essentially the *entire* file top-to-bottom: summary, create-params/map-gen, full sim
|
||||
(players, tech, designs, systems/colonies incl. per-player fog-of-war snapshots, fleets, ships,
|
||||
trade, node grid, combat reports, grand-menace/encounter objects) and the CdTable AI block. This is
|
||||
the most complete community model and the primary Rosetta source.
|
||||
- R2 decodes only Summary, Player Settings, Players, Species, and Planets (colony) fields, by tag
|
||||
search — but adds *friendly semantics* and confirms the value-bytes width of several planet fields.
|
||||
|
||||
**Known-unknown fields (labelled `unknown*` in R1 — do NOT treat names as authoritative):**
|
||||
- All of `CdPlayer` (`unknown1..35`) and `CdAiAidngDnId`, `SimFleetOrigin`, `SimFleetDetailsFlightPlanFpogn2`,
|
||||
`SimTradeSectorTradeCtr`, `SimPlayerDesignDwgWgb`, `SimSystemDetailNvoIndcl*` bodies.
|
||||
- `SimFleetShipHealth` three floats guessed as command/mission/drive.
|
||||
- `PlanetSaveStruct.unknown1..4` (map-gen) unexplained.
|
||||
|
||||
**Not decoded / thin:**
|
||||
- Tactical/real-time combat geometry: only *reports/summaries* are stored (SimCrep*, CdAiCmbr TacReport).
|
||||
Per-ship in-battle positions/velocities are not in these editors (likely not in the sim save at all).
|
||||
- `SimSystemDetailSpy` body is empty in R1 (marked "needs to be populated"); spy detail unresolved.
|
||||
- RNG state (`RngSaveStruct.unknownData`) is an opaque ~2500-byte blob.
|
||||
- R1 source comments flag `SimSystemDetailSpiesArray`, `SimSvSctObXscnXsc` and
|
||||
`SimScSctObEncObjDetails` as incomplete/"wrong" in places — verify encounter bodies against binary.
|
||||
|
||||
**Contradictions between R1 and R2 (reconcile against binary):**
|
||||
1. **Field widths on planet flags:** R2 reads `Abdn`, `Dstyd`, `ltis` as Int16 and `Bats2` as Int32;
|
||||
R1 models `abdn/ltis` as Int32 and `bats2` as Int64. → The datum is small; check the true stored
|
||||
width in the binary struct.
|
||||
2. **PID vs OID:** R2 asserts `OID = PID*16` (owner id is player index << 4); R1 stores a single `pid`
|
||||
owner field and does not model the ×16 relationship. → Confirm whether the binary owner field is a
|
||||
raw index or a shifted/tagged handle.
|
||||
3. **`_NPC` species id 4:** R2 names it `_NPC`; R1 comment guesses "AI Rebellion". Same numeric id 4,
|
||||
different label — likely a shared "non-player/rogue" species slot.
|
||||
4. R2 treats Players and PlayerSettings as separate flat tab regions bounded by marker strings
|
||||
(`HomeSys`…`ISsp`, `Slot`…`Session`); R1 shows these are actually nested (settings inside the
|
||||
Summary PlayerSlot, live player data inside SimPlayerDetails). R2's region boundaries
|
||||
(`Summary`,`Slot`,`Session`,`HomeSys`,`ISsp`,`NumSys`,`NdGr2`,`PlayerIDs`,`DesignIDs`) are useful
|
||||
**section-marker strings** to locate blocks in the raw binary.
|
||||
|
||||
**High-value binary-scan signatures:**
|
||||
`0xBEEFBEEF` / `0x41104110` complex-struct brackets; length-prefixed ASCII field-name tags
|
||||
(`[int32 len][name]`) preceding every named scalar; section marker strings above.
|
||||
|
|
@ -1,461 +0,0 @@
|
|||
# Struct recovery via save-field-name xrefs — Sword of the Stars (2006)
|
||||
|
||||
Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, 32-bit MSVC. All addresses are VAs.
|
||||
Method: every on-disk field is tagged with its name string; the name strings live in `.rdata`; the
|
||||
functions that reference dozens of a struct's names in sequence are its `IStreamable::Read`/`Write`.
|
||||
Decompiled those, mapped `this+offset` → name → type. Scripts (on CT111 `/root/`): `SerFind.java`
|
||||
(string→xref→function ranking), `SerDump.java`/`SerDump2.java` (decompile with `DAT_` → literal
|
||||
substitution), `VtOwner.java`/`VtOwner2.java` (find the owning vftable + RTTI Complete-Object-Locator
|
||||
offset), `SubWrite.java` (sub-struct Read/Write by class name). Raw decompiles: CT111 `/tmp/serdump/*.c`.
|
||||
|
||||
Reference cross-checked: `save-editor-structs.md` (R1 = Bardez editor, R2 = SOTSedit).
|
||||
|
||||
---
|
||||
|
||||
## 0. Serialization runtime facts (needed to read the tables)
|
||||
|
||||
### IStreamable vftable shape
|
||||
Every streamable class has a 3-slot vftable `{ [0] scalar-deleting dtor, [1] Read(Stream&), [2] Write(Stream&) }`
|
||||
(`Mars::IStreamable::vftable` @ 0x009e22bc = `{0x4f7230, 0x924fb0, 0x924fb0}`). `Mars::StreamableHelper<T>` /
|
||||
`Mars::VectorHelper<T>` are thin adaptors: slot [1]/[2] call the object's virtual Read/Write (or, for
|
||||
POD types like Vector3, a free function).
|
||||
|
||||
### this-adjustment (IMPORTANT for offsets)
|
||||
The serializer is called through the class's **IStreamable sub-vftable**, whose RTTI COL `offset` field
|
||||
gives the sub-object offset. The vftable slots point straight at the functions (no adjustor thunks;
|
||||
prologues verified in the exe: `55 8b ec 6a ff 68 …`), so inside each function `this` = object + COL
|
||||
offset. **Absolute member offset = decompiled offset + COL offset.** Tables below give both.
|
||||
|
||||
| Class | IStreamable vftable (COL offset) | Read | Write | Primary vftable |
|
||||
|---|---|---|---|---|
|
||||
| `Game::ServerSystem` (: StarSystem : StarMapNode) | 0x00a2043c (**+8**) | `FUN_0075d4b0` | `FUN_00749630` | (StarSystem primary 0x00a200e4) |
|
||||
| `Game::StarSystem` / `ClientSystem` (StarMapNode part) | 0x00a200d4 / 0x00a20144 (+8) | `FUN_00727790` | `FUN_00727820` | 0x00a200e4 / 0x00a20154 |
|
||||
| `Game::StarMapNode` | 0x00a1e620 (+8) | `FUN_00727790` | `FUN_00727820` | 0x00a1e630 |
|
||||
| `Game::ServerPlayer` (: StrategyPlayer) | 0x00a32794 (**+0x3a0 = 928**) | `FUN_008804d0` | `FUN_008563e0` | 0x00a327a4 (COL 0, 8 slots) |
|
||||
| `Game::StarShip` | 0x00a31408 (**+8**) | `FUN_00853fa0` | `FUN_008291f0` | 0x00a31418 (2 slots) |
|
||||
| `Game::StarFleet` (: StarMapNode) | 0x00a1d5f8 (**+8**) | `FUN_00702470` | `FUN_00701070` | 0x00a1d608 |
|
||||
| `Game::StrategyServer` (whole sim block) | 0x00a26084 (+0) | `FUN_007d27a0` | `FUN_0079fa70` | 0x00a26034 (COL 4) |
|
||||
| `Game::StarSystem::PlayerView` | 0x00a201ac (+0) | `FUN_00752af0` | `FUN_007492d0` | — |
|
||||
| `Game::StarSystem::OutputRates` (POD, via helper) | helper 0x00a1f884 | `FUN_007472a0` | `FUN_00745190` | — |
|
||||
| `Game::Population` | 0x009f90f0 (+0) | `FUN_005390c0` | `FUN_00537ef0` | — |
|
||||
| `Game::PopulationGroup` | 0x009f8d50 (+0) | `FUN_00536a80` | `FUN_00536af0` | — |
|
||||
| `Game::IndependenceInfo` | 0x00a2005c (+0) | `FUN_00748df0` | `FUN_00748ee0` | — |
|
||||
| `Game::Morale` / `MoraleEvent` | 0x00a1f7c8 / 0x00a2003c | `FUN_00744dd0` / `FUN_007490b0` | `FUN_00744ea0` / `FUN_007491b0` | — |
|
||||
| `Game::ShipBuildOrder(Def)` | 0x00a0c160 / 0x00a0ad08 | `FUN_00813770` | `FUN_00813800` | — |
|
||||
| `Game::PlayerNotes` | 0x00a21948 | `FUN_00813250` | `FUN_008132b0` | — |
|
||||
| `Game::SpyReport` | 0x00a32b2c | `FUN_008843d0` | `FUN_00828ec0` | — |
|
||||
| `Game::PlayerReport` (preps) | 0x00a21440 | `FUN_008200a0` | `FUN_00817480` | — |
|
||||
| `Game::DiplomacyStats` | 0x00a21430 | — | `FUN_00818cb0` | — |
|
||||
| `Game::FlightPlan` / `::Waypoint` / `NodeRoute` | 0x00a1d50c / 0x00a1d39c / 0x00a1cbdc | `FUN_00704c70` / `FUN_00701860` / `FUN_006e2260` | `FUN_00700f60` / `FUN_00700ed0` / `FUN_006e22e0` | — |
|
||||
| `Game::PrisonerHold` | 0x009fe130 | `FUN_0056eb00` | `FUN_0056ec00` | — |
|
||||
| `Game::EventStorage` / `PlayerAlliances` / `ShipHealth` / `PlayerColorID` / `Mars::Vector3` | — | — | `FUN_00825cc0` / `FUN_006d2e10` / `FUN_00813e50` / `FUN_0053c080` / `FUN_008a60d0` | — |
|
||||
|
||||
### Stream primitive API (writer side; `Stream` object vftable, `this` = stream)
|
||||
| call | meaning | wrapper used by serializers |
|
||||
|---|---|---|
|
||||
| vft+0x18 `(name, std::string*)` | write string | `FUN_008b9d70(stream,name,std::string*)` |
|
||||
| vft+0x1c `(name, byte)` | write bool | `FUN_008b9c20(stream,name,bool*)` |
|
||||
| vft+0x20 `(name, float)` | write float | `FUN_008b9be0(stream,name,float*)` |
|
||||
| vft+0x24 `(name, int, default=-1)` | write int32 | `FUN_008b9d50(stream,name,int*)`; `FUN_008b9d00(stream,name,int16*)` (widens short→int) |
|
||||
| vft+0x28 `(name, IStreamable-helper*)` | write nested object (BEEFBEEF frame) | inline `StreamableHelper<T>{vft, 0, T*}` |
|
||||
| vft+0x30 `(name, ptr, nbytes)` | write raw bytes | used for 8-byte Int64s |
|
||||
| `FUN_00816490(stream,name,obj*)` | write **handle id** = `obj ? obj->id(+4) : 0` | NetworkObject id at +4 |
|
||||
| `FUN_008b9c60(stream,name,int64*)` | write int64 (PopC) | |
|
||||
Reader side mirrors: `FUN_008b9bc0` float, `FUN_008b9d20` int, `FUN_008b9c00` bool, `FUN_008b9d90` string,
|
||||
`FUN_008b9c40` int64, `FUN_008b9cd0` short, `FUN_008164d0(stream,name)` handle→object* lookup,
|
||||
stream vft+0x10 int-by-ref (returns found flag), vft+0x14 nested object (NULL helper = skip/legacy).
|
||||
Readers accept legacy tags (`ISuit`, `Income`, `HPop`, `Bats`, `Builds`, `Clr`, `SensMod`, `ExPopSys`,
|
||||
`NShps`, `SysID`, `TrdID`, `Caps`, `GtTrf`, `FtSens`, `FtInc`, `Pris`, `NumPlgs`, `lcid`, `morev`, `cme`)
|
||||
by reading them into scratch/NULL — these are pre-1.8 fields, NOT members.
|
||||
|
||||
Common Mars/MSVC layouts seen: `std::string` = 0x1c bytes (MSVC10 `_Bx` union@0, size@0x14, res@0x18;
|
||||
`FUN_008b9d70` does the `res>=16 ? heap : sso` check); `std::vector<T>` = {begin@0, end@4, cap@8};
|
||||
`std::map/set` node = {left@0, parent@4, right@8, key@0xc, value@0x10, …, color/isnil bytes at tail};
|
||||
`std::list` = {head*@0, size@4}. `Mars::NetworkObject` = {vptr@0, int id@4}.
|
||||
|
||||
---
|
||||
|
||||
## 1. `Game::ServerSystem` (= live star system + colony record; R1 `SimSystemDetailsSaveStruct`)
|
||||
|
||||
Serializers: Write `FUN_00749630` @ 0x00749630 (3453 B), Read `FUN_0075d4b0` @ 0x0075d4b0 (8320 B, has
|
||||
legacy branches). Both begin with `StarMapNode::Write/Read` (`FUN_00727820`/`FUN_00727790`) which
|
||||
emits `Pos`. `this` = obj+8. Base layout: `+0` primary vptr (StarSystem 0x00a200e4), `+4` NetworkObject id,
|
||||
`+8` IStreamable vptr, `+0xc` HandleObject vptr, `+0x10` owner pointer (`*(+0x10)->+0x50` = player-object
|
||||
table indexed by map key), `+0x18` Pos.
|
||||
|
||||
| abs off | rel(this+8) | type | save name | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0x18 | 0x10 | `Mars::Vector3` (3 floats) | `Pos` | via StarMapNode; on disk 3 unnamed floats |
|
||||
| 0x4c | 0x44 | float | `R` | starColor.r |
|
||||
| 0x50 | 0x48 | float | `G` | |
|
||||
| 0x54 | 0x4c | float | `B` | |
|
||||
| 0x58 | 0x50 | float | `A` | |
|
||||
| 0x5c | 0x54 | int | `Idx` | system index |
|
||||
| 0x60 | 0x58 | int | `Size` | 1–10 |
|
||||
| 0x64 | 0x5c | float | `Suit` | climate hazard (legacy `ISuit` discarded) |
|
||||
| 0x68 | 0x60 | int | `Res` | |
|
||||
| 0x6c | 0x64 | int | `ARes2` | (legacy `ARes` read then overwritten) |
|
||||
| 0x70 | 0x68 | int | `MRes` | |
|
||||
| 0x74 | 0x6c | int | `TRes` | |
|
||||
| 0x78..0x7a | 0x70..0x72 | bool[3] | `haltv` | written as `haltc`=3, then 3×(`haltt`=i, `haltv`=v[i]) |
|
||||
| 0x7c | 0x74 | float | `OutMod` | |
|
||||
| 0x80 | 0x78 | int | `TAcq` | |
|
||||
| 0x84 | 0x7c | int | `TFAcq` | |
|
||||
| 0x88..0xa3 | 0x80 | `StarSystem::OutputRates` (0x1c) | `Rts` | see §1.1; nested object |
|
||||
| 0xa4 | 0x9c | `BuildQueue*` | `BQ` | written only if owner (`PID`) non-null |
|
||||
| 0xa8..0xc3 | 0xa0 | `std::string` | `Name` | |
|
||||
| 0xc4 | 0xbc | bool | `Abdn` | **bool in memory** (R2 "short" is just the value byte; R1 Int32 is the framing) |
|
||||
| 0xc5 | 0xbd | bool | `Dstyd` | |
|
||||
| 0xc6 | 0xbe | bool | `vnh` | gate: if true → `vnd`,`vnex3`,`vnpex3` |
|
||||
| 0xc7 | 0xbf | bool | `vnd` | |
|
||||
| 0xc8 | 0xc0 | bool | `vnex3` | |
|
||||
| 0xc9 | 0xc1 | bool | `vnpex3` | |
|
||||
| 0xcc | 0xc4 | int | `VFlags` | written by value (int) |
|
||||
| 0xd0 | 0xc8 | int | `EFlags` | |
|
||||
| 0xd4 | 0xcc | int | `AFlags` | |
|
||||
| 0xd8 | 0xd0 | int | `FFlags` | |
|
||||
| 0xdc | 0xd4 | int | `GFlags` | |
|
||||
| 0xe0 | 0xd8 | int | `MnRFlags` | |
|
||||
| 0xe4 | 0xdc | int | `RfRFlags` | |
|
||||
| 0xe8 | 0xe0 | int | `ClkFlags` | |
|
||||
| 0xf0 | 0xe8 | **int64** | `Bats2` | raw 8 bytes (R1 correct; R2 Int32 reads low half). Legacy `Bats` int |
|
||||
| 0xf8 | 0xf0 | **int64** | `rcex` | raw 8 bytes |
|
||||
| 0x100 | 0xf8 | `ServerPlayer*` | `PID` | owner; written as handle id (`->+4`) |
|
||||
| 0x104..0x117 | 0xfc | `Population` (0x14) | `dcs` | nested |
|
||||
| 0x118 | 0x110 | float | `dsu` | |
|
||||
| 0x11c..0x13b | 0x114 | `Morale` (0x20: vptr + int[7]) | `cm` | nested |
|
||||
| 0x13c..0x147 | 0x134 | `vector<MoraleEvent>` | `cme2` | VectorHelper |
|
||||
| 0x14c..0x16b | 0x144 | `Morale` | `PvCM` | |
|
||||
| 0x16c..0x177 | 0x164 | `vector<StarFleet*>` | `NumFlts` + n×`Flt` | ids via handle |
|
||||
| 0x17c | 0x174 | float | `RepCur` | |
|
||||
| 0x180 | 0x178 | float | `RepMax` | |
|
||||
| 0x184 | 0x17c | int | `EggScio` | |
|
||||
| 0x188 | 0x180 | bool | `NoRebAI` | |
|
||||
| 0x189 | 0x181 | bool | `PvNoRebAI` | |
|
||||
| 0x18c | 0x184 | int | `Pop` | imperial pop |
|
||||
| 0x190 | 0x188 | float | `Infra` | |
|
||||
| 0x194 | 0x18c | int | `pbon` | |
|
||||
| 0x198 | 0x190 | float | `ibon` | |
|
||||
| 0x19c | 0x194 | int | `TerrFl` | |
|
||||
| 0x1a0..0x1b3 | 0x198 | `Population` | `Pop2` | civilian pop groups (R1 `popG`) |
|
||||
| 0x1b4..0x1c7 | 0x1ac | `Population` | `pbon2` | |
|
||||
| 0x1c8 | 0x1c0 | `IndependenceInfo*` | `hindi` + `indi` | `hindi` = ptr!=NULL |
|
||||
| 0x1cc..0x1d7 | 0x1c4 | `vector<int>` | `spies2` | VectorHelper<int> |
|
||||
| 0x1dc | 0x1d4 | int | `rbfl` | written by value |
|
||||
| 0x1e0 | 0x1d8 | bool | `hsrg` | |
|
||||
| 0x1e4..0x1ff | 0x1dc | int[7] | `nadct`,(`ads`=i,`adt`=v) | addiction table: count of non-zero entries then sparse (index,value) pairs |
|
||||
| 0x200 | 0x1f8 | int | `PvPop` | previous-turn snapshot block |
|
||||
| 0x204 | 0x1fc | float | `PvInfra` | |
|
||||
| 0x208 | 0x200 | float | `PvSuit` | |
|
||||
| 0x20c | 0x204 | int | `PvRes` | |
|
||||
| 0x210 | 0x208 | int | `PvARes2` | |
|
||||
| 0x214 | 0x20c | int | `PvMRes` | |
|
||||
| 0x218..0x22b | 0x210 | `Population` | `PvPop2` | |
|
||||
| 0x238 | 0x230 | `StarFleet*` | `DefF` | handle id |
|
||||
| 0x23c | 0x234 | `StarFleet*` | `DefSF` | handle id |
|
||||
| 0x240..0x24b | 0x238 | `vector<obj*>` | `NumGFs` + n×`GF` | gates |
|
||||
| 0x250..0x25b | 0x248 | `vector<obj*>` | `NumSnF` + n×`SnF` | stations |
|
||||
| 0x260..0x26b | 0x258 | `vector<obj*>` | `NumMnF` + n×`MnF` | monitors |
|
||||
| 0x274 / 0x278 | 0x26c / 0x270 | `std::map` head / size | `NVO` + entries | colonies, see §1.2 |
|
||||
| 0x284 / 0x288 | 0x27c / 0x280 | `std::map` head / size | `NVE` + entries | §1.2 |
|
||||
| 0x294 / 0x298 | 0x28c / 0x290 | `std::map` head / size | `NVs` + entries | per-player `pview`, §1.3 |
|
||||
| 0x2a8..0x2b3 | 0x2a0 | `vector<Plague*>` | `NumPlgs2` + n×(`PlgT`=plg->+4, `Plg` obj) | |
|
||||
| 0x2b8 | 0x2b0 | int | `TnsOH` | |
|
||||
| 0x2bc | 0x2b4 | int | `TDst` | |
|
||||
| 0x2c4 | 0x2bc | int | `ntdev` | |
|
||||
| 0x2c8 | 0x2c0 | int | `ltis` | **int** (R2 "short" wrong width) |
|
||||
| 0x2cc | 0x2c4 | int | `rbtn` | |
|
||||
| 0x2d0 | 0x2c8 | int | `rbfr` | by value |
|
||||
| 0x2d4 | 0x2cc | int | `rbwn` | |
|
||||
|
||||
Object size ≥ 0x2d8. On-disk order = R1 §8 exactly (Pos, RGBA, Idx, Size, Suit, Res, ARes2, MRes, NoRebAI,
|
||||
TRes, Pop, Pop2, Infra, PvPop, PvPop2, PvInfra, PvSuit, PvRes, PvARes2, PvMRes, PvNoRebAI, Rts, Abdn, Dstyd,
|
||||
TnsOH, OutMod, RepCur, RepMax, ntdev, pbon, pbon2, ibon, ltis, rbfl, rbtn, rbfr, rbwn, hsrg, halt*, vn*, Name,
|
||||
*Flags, Bats2, rcex, Mn/Rf/ClkFlags, EggScio, TerrFl, TAcq, TFAcq, TDst, dcs, dsu, cm, PvCM, cme2, spies2, PID,
|
||||
DefF, DefSF, BQ, nadct/ads/adt, NumPlgs2…, NumFlts/GFs/SnF/MnF, NVO, NVE, NVs, hindi/indi).
|
||||
|
||||
Members that are only ever read with a NULL/scratch target (not stored): `ISuit`, `Income`, `HPop`, `Builds`
|
||||
(+`Con`,`Sav`,`ConLeft`,`OrID`,`DesID` — old inline build queue), `Slvs`, `dct`, `cme`, `Bats`, `NumPlgs`.
|
||||
|
||||
### 1.1 `Game::StarSystem::OutputRates` (POD, 0x1c) — Write `FUN_00745190`
|
||||
Memory order ≠ disk order: `+0x00 float SRt`, `+0x04 SRsc`, `+0x08 SRtf`, `+0x0c SRi`, `+0x10 SRoh`,
|
||||
`+0x14 SRs`, `+0x18 int SRnr`. Disk order: SRs, SRt, SRsc, SRtf, SRi, SRoh, SRnr. Reader: if `SRs` tag is
|
||||
absent, reads 5 unnamed floats (legacy).
|
||||
|
||||
### 1.2 Colony maps `NVO` / `NVE` (std::map keyed by player-table index)
|
||||
Write emits `NVO`=size, then per node: `PID` = handle id of `owner->+0x50[key]` (player object table),
|
||||
`TShn` = **int16** at node+0x12 (value+2), `OID` = int at node+0x14 (value+4) by value, `isind` bool at
|
||||
node+0x18 (value+8), `indi` = inline `IndependenceInfo` at node+0x1c (value+0xc, 0x70 bytes). Node isnil
|
||||
byte at +0x8d ⇒ value size 0x7c. `NVE` nodes: `EPid` handle (key→player), `ETS` int16 @ node+0x12, `Eid`
|
||||
int @ node+0x14 (isnil @ +0x19 ⇒ value 8 bytes).
|
||||
`OID` is a stored int, distinct from `PID`; the R2 claim "OID = PID×16" is an id-allocation pattern, not
|
||||
a derivation in this code (open question — check the HandleObject id allocator).
|
||||
|
||||
### 1.3 `Game::StarSystem::PlayerView` (per-player seen snapshot) — Write `FUN_007492d0`, Read `FUN_00752af0`
|
||||
Stored inline as map value at node+0x10 (`NVs`; node isnil @ +0xad ⇒ value ≈ 0x9c).
|
||||
`+0 vptr (0x00a201ac)`, `+8 int VTrn`, `+0xc int Pop`, `+0x10 Population Pop2 (0x14)`, `+0x24 float Infra`,
|
||||
`+0x28 float Suit`, `+0x2c int Res`, `+0x30 int ARes2`, `+0x34 int MRes`, `+0x38 bool NoRebAI`, `+0x3c int pbon`,
|
||||
`+0x40 Population pbon2`, `+0x54 float ibon`, `+0x58 int TerrFl`; trailer bool `footer`=1. Reader also accepts
|
||||
legacy `ARes`, `PvPop/PvInfra/PvSuit/PvRes/PvARes/PvARes2/PvMRes/PvNoRebAI` into the same slots.
|
||||
(R1 lists `Int32 infra` — it is a **float**.)
|
||||
|
||||
### 1.4 `Game::Population` (0x14) / `Game::PopulationGroup` (0x18 stride)
|
||||
Population: `+0 vptr`, `+4/+8/+0xc vector<PopulationGroup>`; Write emits `PopNG` = count of groups with
|
||||
PopC>0 (or ≥0 with low word ≠0), then each as nested `PopG`. PopulationGroup: `+4 int PopT`, `+8 int PopS`,
|
||||
`+0x10 int64 PopC` (R1 `popT,popS,popC` ✓).
|
||||
|
||||
### 1.5 `Game::IndependenceInfo` (0x70) — Write `FUN_00748ee0`
|
||||
`+4 int indsp`, `+8 PlayerColorID indcl` (nested), `+0x1c string indnm`, `+0x38 string indav`, `+0x54 string indba`.
|
||||
|
||||
### 1.6 `Game::Morale` / `Game::MoraleEvent`
|
||||
Morale (0x20): `+0 vptr`, `+4 int[7]`; disk: `mnsp`=n then n×(`msp`=index, `mv`=value) (reader tolerates
|
||||
missing `mnsp` → 7 fixed entries, skipping index 4). MoraleEvent: `+4 mid`, `+8 mtr`, `+0xc mn`, `+0x10 mtp`
|
||||
(ints), `+0x14 Morale mfx`, `+0x34 string mdsc`.
|
||||
|
||||
### 1.7 `Game::ShipBuildOrder` (build-queue entry) — Write `FUN_00813800`
|
||||
`+4 int desID`, `+8 int con`, `+0xc int sav`, `+0x10 int conleft`, `+0x14 int ordID`; disk order desID, con,
|
||||
conleft, sav, ordID (R1 ✓).
|
||||
|
||||
### Where is "Planet"?
|
||||
`Game::Planet : Actor` (vft 0x009ef144) is a **render/scene actor** and is not streamed. The colony/planet
|
||||
state the save calls "planet" (R2's Idx/Name/Size/Suit/Res/Infra/Pop/OID…) is entirely in `ServerSystem`
|
||||
above plus `PlayerView`. The CreateParameters `PlanetSaveStruct` (x,y,z + 4 ints) is map-gen input
|
||||
(`StarMapParams`), not touched here.
|
||||
|
||||
---
|
||||
|
||||
## 2. `Game::ServerPlayer` (empire; R1 `SimPlayerDetailsSaveStruct`)
|
||||
|
||||
Serializers: Write `FUN_008563e0` @ 0x008563e0 (4040 B), Read `FUN_008804d0` @ 0x008804d0 (7647 B).
|
||||
IStreamable sub-object at **+0x3a0** (COL offset 928); `this` = obj+0x3a0, so decompiled offsets are
|
||||
negative for most members. Primary vptr @+0 (0x00a327a4, StrategyPlayer shape), NetworkObject id @+4.
|
||||
|
||||
| abs off | rel(this+0x3a0) | type | save name | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0x28 | -0x378 | int | `PlyrIdx` | |
|
||||
| 0x2c | -0x374 | `ServerSystem*` | `HomeSys` | handle id |
|
||||
| 0x30/0x34 | -0x370/-0x36c | `vector<ServerPlayer*>` | `NumOwn` + n×`OwnId` | handle ids (R1 `ownerIds`) |
|
||||
| 0x40..0x5b | -0x360 | `std::string` | `PlryName` | |
|
||||
| 0x5c | -0x344 | int | `Species` | 0 Human … 6 Morrigi |
|
||||
| 0x60 | -0x340 | `PlayerColorID` (4 B) | `ClrID` | nested; §2.1 (legacy `Clr` int skipped) |
|
||||
| 0x74..0x8f | -0x32c | `std::string` | `Bdg` | badge |
|
||||
| 0x90..0xab | -0x310 | `std::string` | `Avt` | avatar |
|
||||
| 0xac | -0x2f4 | int | `Team` | |
|
||||
| 0xb0 | -0x2f0 | float | `IdealSuit` | |
|
||||
| 0xb4 | -0x2ec | float | `SuitTol` | |
|
||||
| 0xb8 | -0x2e8 | float | `MaxOH` | |
|
||||
| 0xbc | -0x2e4 | float | `ResRate` | |
|
||||
| 0xc0 | -0x2e0 | float | `ResMod` | |
|
||||
| 0xc4 | -0x2dc | float | `ResScl` | |
|
||||
| 0xd0 | -0x2d0 | **float** | `TRM` | R1 says Int32 — it is float |
|
||||
| 0xd4 | -0x2cc | int | `TRA` | note memory order TRA before TRP |
|
||||
| 0xd8 | -0x2c8 | int | `TRP` | |
|
||||
| 0xe4/0xe8 | -0x2bc/-0x2b8 | `vector<ShipDesign*>` | `NumDes` + n×(`DesID`=d->+0xa4, `Des` obj) | current designs |
|
||||
| 0xf4 | -0x2ac | `TechTree*` | `TechTree` | first field on disk |
|
||||
| 0xf8 | -0x2a8 | bool | `Elim` | |
|
||||
| 0xfb | -0x2a5 | bool | `NPC` | |
|
||||
| 0xfc | -0x2a4 | bool | `RebAI` | |
|
||||
| 0xfd | -0x2a3 | bool | `ReqCL` | |
|
||||
| 0xfe | -0x2a2 | bool | `AIBn` | |
|
||||
| 0xff | -0x2a1 | bool | `CnTrd` | |
|
||||
| 0x100 | -0x2a0 | bool | `CnRad` | |
|
||||
| 0x101 | -0x29f | bool | `CnVItl` | |
|
||||
| 0x102 | -0x29e | bool | `hgs` | |
|
||||
| 0x103 | -0x29d | bool | `hadvs` | |
|
||||
| 0x104 | -0x29c | bool | `harcc` | |
|
||||
| 0x108 | -0x298 | float | `pddm` | |
|
||||
| 0x10c..0x117 | -0x294 | float[3] | `ConMod` ×3 | interleaved on disk as 3×(ConMod[i], SavMod[i]) |
|
||||
| 0x118..0x123 | -0x288 | float[3] | `SavMod` ×3 | |
|
||||
| 0x124 | -0x27c | float | `OutMod` | |
|
||||
| 0x128 | -0x278 | float | `RebOutMod` | |
|
||||
| 0x12c | -0x274 | float | `ScOutMod` | |
|
||||
| 0x130 | -0x270 | float | `PopMod` | (legacy `SensMod` float, `ExPopSys` int skipped between IncMod/PopMod/TerraMod) |
|
||||
| 0x134 | -0x26c | float | `TerraMod` | |
|
||||
| 0x138 | -0x268 | bool | `AMine` | |
|
||||
| 0x13c | -0x264 | float | `MinPure` | |
|
||||
| 0x140 | -0x260 | float | `MinRate` | |
|
||||
| 0x144 | -0x25c | int | `NGts` | |
|
||||
| 0x148 | -0x258 | int | `PrGtTrf` | |
|
||||
| 0x14c | -0x254 | int | `GTraf` | |
|
||||
| 0x150 | -0x250 | **float** | `CstR` | R1 Int32 → float |
|
||||
| 0x154 | -0x24c | **float** | `CstE` | |
|
||||
| 0x158 | -0x248 | **float** | `CstT` | |
|
||||
| 0x15c | -0x244 | int | `Maint` | (legacy `NShps` skipped) |
|
||||
| 0x160 | -0x240 | **float** | `shrm` | |
|
||||
| 0x164 | -0x23c | int | `Status` | by value |
|
||||
| 0x168..0x177 | -0x238 | `PlayerAlliances` {int ALid, AL, NA, CF} | `Team` (2nd) | nested (R1 `teamStruct`) |
|
||||
| 0x178/0x17c | -0x228/-0x224 | `vector<ShipDesign*>` | `NumLeg` + n×(`DesID`,`Des`) | legacy/drone designs (R1 `droneDesigns`) |
|
||||
| 0x188 | -0x218 | int | `PvSav` | |
|
||||
| 0x18c | -0x214 | bool | `PvMA` | |
|
||||
| 0x19c | -0x204 | int | `HasDisc` | by value |
|
||||
| 0x1a0 | -0x200 | int | `HasDiscSp` | |
|
||||
| 0x1a4 | -0x1fc | int | `HasDiscCl` | |
|
||||
| 0x1a8 | -0x1f8 | int | `HasEnc` | |
|
||||
| 0x1ac | -0x1f4 | int | `HasEng` | |
|
||||
| 0x1b0 | -0x1f0 | `ShipRecords` (inline) | `ShipRecs` | Write `FUN_008176a0` |
|
||||
| 0x1f4 | -0x1ac | `vector<Objective>` | `Ojvs` | VectorHelper |
|
||||
| 0x204/0x208 | -0x19c/-0x198 | `vector<{int xid,xmin,xmax; float xper}>` (16 B) | `Nexp` + n×(`xid`,`xmin`,`xmax`,`xper`) | R1 misses the per-entry body |
|
||||
| 0x214/0x218 | -0x18c/-0x188 | `vector<int>` | `NWeapXcl` + n×`WeapXcl` | |
|
||||
| 0x230 | -0x170 | `vector<DiplomacyStats>` | `dipstats` | §2.2 |
|
||||
| 0x240 | -0x160 | `CommMessageContainer*` | `comms` | |
|
||||
| 0x244 | -0x15c | `vector<PlayerReport>` | `preps` | §2.3 |
|
||||
| 0x254 | -0x14c | `vector<ObservedDesign>` | `odes` | |
|
||||
| 0x264 | -0x13c | `vector<ObservedWeapon>` | `owep` | |
|
||||
| 0x274 | -0x12c | `vector<ObservedTech>` | `otch` | |
|
||||
| 0x284 | -0x11c | int | `Sav` | savings |
|
||||
| 0x288 | -0x118 | int | `HasImm` | by value |
|
||||
| 0x28c | -0x114 | int | `HasVac` | |
|
||||
| 0x290 | -0x110 | int | `NPTrk` | |
|
||||
| 0x294 | -0x10c | `Tech*` (current research) | `ResTNm` | writes `tech ? tech->name(+4) : ""` |
|
||||
| 0x298 | -0x108 | `FleetNameGenerator*` | `FNG` | |
|
||||
| 0x29c | -0x104 | `EventStorage` (inline) | `Events` | {`EvNxID`@+0x14, `Events` vector<TurnEvents>@+4} |
|
||||
| 0x2b8/0x2bc | -0xe8/-0xe4 | `std::list<PlayerNotes>` head/size | `NumNotes` + n×`Nts` | node value at +0x10: `NtSys`@+4,`NtTxt` str@+8,`NtTrn`@+0x24 |
|
||||
| 0x2c4 | -0xdc | int | `BnkWrn` | by value |
|
||||
| 0x2c8 | -0xd8 | int | `BnkTrn` | |
|
||||
| 0x2cc | -0xd4 | int | `BnkEl` | |
|
||||
| 0x2d0 | -0xd0 | int | `BnkPr` | |
|
||||
| 0x2d8 | -0xc8 | int | `plcy` | by value |
|
||||
| 0x2dc..0x2f7 | -0xc4 | **`std::string`** | `pswd` | R1 says Int32 — it is a string |
|
||||
| 0x2f8 | -0xa8 | bool | `Srn` | |
|
||||
| 0x2fc | -0xa4 | `obj*` | `SrnTo` | handle id (R1 `srcTo`) |
|
||||
| 0x300 | -0xa0 | int | `lboid` | |
|
||||
| 0x304 | -0x9c | int | `lcid2` | by value (legacy `lcid`) |
|
||||
| 0x30c | -0x94 | float | `IncMod` | |
|
||||
| 0x310 | -0x90 | `vector<PlayerAid>` | `aid` | |
|
||||
| 0x320/0x324 | -0x80/-0x7c | `vector<DefenceLayout*>` | `ndeflay` + n×`deflay` | |
|
||||
| 0x330 | -0x70 | bool | `cdp` | |
|
||||
| 0x334 | -0x6c | `SpyReport*` | `spy2` | §2.4 |
|
||||
| 0x338/0x33c | -0x68/-0x64 | `vector<RaidTargets>` (0x20 stride) | `rdtc` + n×`rdt` | |
|
||||
| 0x368 | -0x38 | int | `aidf` | by value |
|
||||
| 0x370 | -0x30 | `CivilianRatios` (inline) | `civr` | Write `FUN_0082c740` |
|
||||
| 0x39c | -4 | int | `tnc` | written as max(v,1) |
|
||||
| **0x3a0** | 0 | vptr | — | IStreamable sub-vftable 0x00a32794 |
|
||||
| 0x3a4/0x3a8 | +4/+8 | `vector<{float PRm; int PRBt}>` | `NumPR` + n×(`PRm`,`PRBt`) | |
|
||||
| 0x3b4 | +0x14 | bool | `ResErrRoll` | |
|
||||
| 0x3b5 | +0x15 | bool | `cta` | |
|
||||
| 0x3b8 | +0x18 | `AIRebellion*` | `HasAIR` + `AIR` | gate = ptr!=NULL |
|
||||
| 0x3bc | +0x1c | `AIEncounterFlags*` | `AIEnf` | |
|
||||
| 0x3c0/0x3c4 | +0x20/+0x24 | `vector<SpecialProjectImpl*>` | `NSprj` + n×(`SprjT`=p->+0x3c, `Sprj`) | |
|
||||
| 0x3d0 | +0x30 | int | `NextPrjID` | |
|
||||
| 0x3d4 | +0x34 | int | `lret` | |
|
||||
| 0x3dc | +0x3c | int | `nmeid` | |
|
||||
|
||||
Object size ≥ 0x3e0. Disk order = R1 §6 (TechTree, HomeSys, PlyrIdx, PlryName, Species, ClrID, Bdg, Avt,
|
||||
Team, Sav, IdealSuit, SuitTol, MaxOH, ResRate, ResMod, ResScl, TRM, TRP, TRA, OutMod, RebOutMod, ScOutMod,
|
||||
IncMod, PopMod, TerraMod, AMine, MinPure, MinRate, NGts, PrGtTrf, GTraf, CstR/E/T, Maint, shrm, Status, Elim,
|
||||
NPC, RebAI, ReqCL, Team{ALid,AL,NA,CF}, HasVac, HasImm, NPTrk, HasDisc, HasDiscSp, HasDiscCl, HasEnc, HasEng,
|
||||
Events, FNG, PvSav, PvMA, AIBn, CnTrd, CnRad, hgs, hadvs, harcc, CnVItl, pddm, BnkWrn/Trn/Pr/El, ShipRecs,
|
||||
NextPrjID, plcy, pswd, lret, nmeid, cdp, spy2, civr, aidf, Srn, SrnTo, lboid, lcid2, ResTNm, ResErrRoll,
|
||||
3×(ConMod,SavMod), NumOwn/OwnId, NumDes/DesID/Des, NumLeg/DesID/Des, NumNotes/Nts, NumPR/PRm/PRBt, HasAIR/AIR,
|
||||
cta, AIEnf, NSprj/SprjT/Sprj, Nexp/xid/xmin/xmax/xper, NWeapXcl/WeapXcl, Ojvs, dipstats, comms, preps, odes,
|
||||
owep, otch, aid, ndeflay/deflay, rdtc/rdt, tnc). Note: on-disk `Sav` comes right after `Team` though it
|
||||
lives at 0x284 in memory.
|
||||
|
||||
### 2.1 `Game::PlayerColorID` (4 bytes) — Write `FUN_0053c080`
|
||||
`+0 int8 index; +1,+2,+3 uint8 r,g,b`; writer emits index via `FUN_008b9cb0` (char→int on disk), and
|
||||
**iff index == -1** the three r,g,b bytes (matches R1 §1.6). Same struct used by `IndependenceInfo.indcl`.
|
||||
|
||||
### 2.2 `Game::DiplomacyStats` (0x24) — Write `FUN_00818cb0`
|
||||
`+4 int other`; then **int16** fields at +8 `lastnap`, +0xa `lastnapbty`, +0xc `bknnap`, +0xe `btynap`,
|
||||
+0x10 `lastally`, +0x12 `lastallybty`, +0x14 `bknally`, +0x16 `btyally`, +0x18 `lastcf`, +0x1a `lastcfbty`,
|
||||
+0x1c `bkncf`, +0x1e `btycf`, +0x20 `deadhome` (all widened to int32 on disk; R1 `nap/ally/cf{last_,last_bty,bkn_,bty_}` ✓).
|
||||
|
||||
### 2.3 `Game::PlayerReport` (preps, 0x30) — Write `FUN_00817480`
|
||||
ints `+4 oid, +8 pid, +0xc flds, +0x10 sav, +0x14 home, +0x18 ncol, +0x1c mpwr, +0x20 mcls, +0x24 mmsl, +0x28 nshp, +0x2c nsat` (R1 ✓).
|
||||
|
||||
### 2.4 `Game::SpyReport` — Write `FUN_00828ec0`
|
||||
Four `std::list`s: `+4 list<SpyReportDefences>` (count `defc2`@+8, items `def`), `+0x10 list<SpyReportTrade>`
|
||||
(`rtc`@+0x14, `strd`), `+0x1c list<SpyReportEvents>` (`evc`@+0x20, `evs`), `+0x28 list<SpyReportTechTree>`
|
||||
(`ttc`@+0x2c, `tt`). R1 only kept the four counts.
|
||||
|
||||
### 2.5 `Game::TechTree` — Write `FUN_005890a0` (tags `NumTechs`, `TNm`, `NumBrs`; per-tech body in a
|
||||
sub-writer not decompiled here; logs "TechTree: Tech %d not found saving tech tree").
|
||||
|
||||
---
|
||||
|
||||
## 3. `Game::StarFleet` (R1 `SimFleetDetails`) — Write `FUN_00701070`, Read `FUN_00702470`; `this` = obj+8
|
||||
|
||||
| abs | rel | type | save name | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0x18 | 0x10 | Vector3 | `Pos` | via StarMapNode |
|
||||
| 0x4c | 0x44 | Vector3 | `PrvPos` | |
|
||||
| 0x58 | 0x50 | `ServerPlayer*` | `PID` | handle |
|
||||
| 0x5c..0x77 | 0x54 | string | `FtName` | |
|
||||
| 0x78 | 0x70 | bool | `Perm` | |
|
||||
| 0x7c | 0x74 | `FleetLayout` (inline, ~0x24) | `HLay` + `Lay` | gate = either of its two vectors (+4/+8, +0x14/+0x18) non-empty |
|
||||
| 0xa0 | 0x98 | `StarSystem*` | `LocID` | handle (legacy `SysID`,`TrdID` ints skipped) |
|
||||
| 0xa4/0xa8 | 0x9c/0xa0 | `vector<StarShip*>` | `NShips` + n×(`ShipID` handle, `Ship` obj) | |
|
||||
| 0xc4..0xfb | 0xbc | `FlightPlan` (inline, 0x38) | `HFPlan` + `FPlan` | gate = wpts non-empty; §3.1 |
|
||||
| 0xfc | 0xf4 | int | `FtTrans` | by value (legacy `Caps` int, `GtTrf` short, `FtSens` float, `FtInc` int skipped) |
|
||||
| 0x100 | 0xf8 | Vector3 | `FtOrig` | (R1 "3 ints" → 3 floats) |
|
||||
| 0x10c | 0x104 | int | `FtFlg` | |
|
||||
| 0x110 | 0x108 | int | `Ftae` | |
|
||||
| 0x114 | 0x10c | int | `Ftpae` | |
|
||||
| 0x118 | 0x110 | int | `FtEnc` | |
|
||||
| 0x11c | 0x114 | int | `FtMS` | |
|
||||
|
||||
### 3.1 `Game::FlightPlan` (0x38) / `Waypoint` / `NodeRoute`
|
||||
FlightPlan: `+0 vptr`, `+4 vector<Waypoint> wpts`, `+0x14 float FPsp2`, `+0x18 int FPeta2`, `+0x1c Vector3 FPogn2`,
|
||||
`+0x28 Vector3 FPdpos`, `+0x34 int pnd`. Waypoint: `+4 int Wpt`, `+8 int Tp`, `+0xc NodeRoute nrt`.
|
||||
NodeRoute: `+4 nrp`, `+8 nrf`, `+0xc nrt` (ints). Reader also handles legacy `NumWpt`/`path`/`FPognid`.
|
||||
|
||||
## 4. `Game::StarShip` (R1 `SimFleetShipDetails`) — Write `FUN_008291f0`, Read `FUN_00853fa0`; `this` = obj+8
|
||||
|
||||
| abs | rel | type | save name | notes |
|
||||
|---|---|---|---|---|
|
||||
| 0x10 | 0x8 | `ServerPlayer*` | `PlrID` | handle |
|
||||
| 0x14 | 0xc | `ShipDesign*` | `DesID` | writes `design->+0xa4` (design id) |
|
||||
| 0x20 | 0x18 | float | `Range` | |
|
||||
| 0x24..0x33 | 0x1c | `ShipHealth` {vptr; float[3]} | `Health` | 3 unnamed floats (R1 guess command/mission/drive) |
|
||||
| 0x34 | 0x2c | int | `MineCap` | |
|
||||
| 0x38/0x3c | 0x30/0x34 | `vector<{float th, thm}>` | `NTH` + n×(`TH`,`THM`) | |
|
||||
| 0x48 | 0x40 | int | `Plg` | |
|
||||
| 0x4c | 0x44 | int | `Act` | |
|
||||
| 0x50 | 0x48 | bool | `Dep` | |
|
||||
| 0x51 | 0x49 | bool | `Atq` | |
|
||||
| 0x5c | 0x54 | int | `LCT` | |
|
||||
| 0x60 | 0x58 | int | `tsd` | |
|
||||
| 0x64 | 0x5c | `StarFleet*` | `FltID` | handle |
|
||||
| 0x68 | 0x60 | int | `ConCap` | |
|
||||
| 0x6c | 0x64 | **float** | `RefCap` | R1 Int32 → float |
|
||||
| 0x70 | 0x68 | **float** | `RepCap` | R1 Int32 → float |
|
||||
| 0x7c | 0x74 | int | `EncID` | |
|
||||
| 0x80 | 0x78 | `PrisonerHold` (inline) | `PrisH` | `+0x14 int*` → `[0]=PrMax`, `[2..8]` per-species counts; disk `PrMax`,`PrNSp`,(`PrSp`=idx,`PrNum`) |
|
||||
| 0x98 | 0x90 | `BuildQueue*` | `hbq` + `BQ2` | gate = ptr!=NULL |
|
||||
| 0x9c | 0x94 | `Population*` | `hsp` + `pop` | gate = ptr!=NULL |
|
||||
| 0xa0 | 0x98 | `Population*` | `ppop` | |
|
||||
| 0xa8 | 0xa0 | int | `atsp` | by value |
|
||||
| 0xac | 0xa4 | int | `tblt` | |
|
||||
|
||||
---
|
||||
|
||||
## 5. Top-level sim block — `Game::StrategyServer` Write `FUN_0079fa70` / Read `FUN_007d27a0`
|
||||
Tag order confirms R1 §5: KeyPath, NMSz, NMLc, NMnx, ModCount, Frame, GameID, [AIDifficultyID legacy],
|
||||
Attrib, RNG, GameName, Map, IncMod, ResMod, EnAl, EnTm, GOTurn, GOWinPly, NPCm/o/i/v/a, szadj, rsadj, suadj,
|
||||
sprjs, RandEncAdj, cmbtid, turnstats, numcreps/crep, ninv/invs/inve/invt/invtb, AllExc×6, AllExcCF,
|
||||
AllExcCFp×2, NumPlrs/PlayerID/Player, ISsp, ISsu, NumSys/SysID/Sys, NdGr2, trdmgr, spymgr, NumFlts/FltID/Flt,
|
||||
NumActs/Act, SvSctOb, zdsc, zdsi, zdst. (Offsets not tabulated — out of scope this round.)
|
||||
|
||||
---
|
||||
|
||||
## 6. Corrections to the community reference (R1/R2)
|
||||
- Types: `TRM`, `CstR/E/T`, `shrm`, ship `RefCap/RepCap`, PlayerView `Infra` are **floats**; `pswd` is a
|
||||
**string**; `PvMA`, `AIBn` are bools; `FtOrig` is a Vector3 (3 floats).
|
||||
- Widths: `Bats2`/`rcex` are true int64 (R2 int is wrong); `Abdn`/`Dstyd` are bools, `ltis` an int
|
||||
(R2 "short" is an artefact of reading value bytes); `TShn`, `ETS`, all DiplomacyStats counters are
|
||||
int16 in memory but int32 on disk.
|
||||
- Missing in R1: `Nexp` entries carry (`xid`,`xmin`,`xmax`,`xper`); SpyReport lists have bodies; Morale
|
||||
is a sparse (msp,mv) table; `nadct` is followed by sparse (`ads`,`adt`) pairs over a 7-int table.
|
||||
- `Plg`/`Act`/`EncID`/`OID`/flags are written by value → plain ints (not handles).
|
||||
|
||||
## 7. Confidence & open questions
|
||||
- **High**: all offsets/types in §1–§4 (direct from Write functions; Read functions agree on every
|
||||
member address; COL offsets from RTTI; no adjustor thunks).
|
||||
- **Medium**: nested struct sizes inferred from neighbouring offsets (Population 0x14, Morale 0x20,
|
||||
FlightPlan 0x38, PlayerView ≈0x9c, IndependenceInfo 0x70); which members belong to `StarSystem` vs
|
||||
`ServerSystem` (serializer is `ServerSystem`'s; `ClientSystem` shares the StarSystem vftables and only
|
||||
streams `Pos`).
|
||||
- **Open**: `FUN_008b9cb0` exact on-disk width for `PlayerColorID` (R1 says int32 — plausible);
|
||||
`ServerSystem+0x10` owner type (StrategyServer? its `+0x50` is a player-object table); `OID`
|
||||
allocation (R2's ×16); per-tech body of `TechTree::Write`; `CdPlayer` block not attempted (names are
|
||||
R1's `unknownN`, nothing to xref). No types were written back into Ghidra (notes only).
|
||||
Loading…
Add table
Reference in a new issue