sots-engine/docs/game-data.md

273 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# `game::data` — the typed catalogs
`src/game/data/` turns the parsed data files into the definition objects the
game builds at startup: weapons, ship sections, the turret table, the stable
id registries, the tech tree, the string table, and a `Catalog` that loads a
whole data root, resolves names and cross-checks every reference. It sits on
`mars::parse` (brace blocks) and `mars::text` (rows, manifests, CSV); library
target `game_data` (static, `src/game/data/CMakeLists.txt`), include as
`game/data/<header>.h`. C++17, no dependencies, no exceptions across the API.
Behaviour is specified by the RE findings (`data-model.md` §4,
`data-parsers.md`, `SHIP_DESIGN_RULES.md` §1–3); the normalised JSON catalogs
the reference Python tooling emits are the oracle, and this module is checked
against them value by value (§5).
```
src/game/data/
common.h/.cpp Species (engine index order), Problem, Loaded<T>, case folding
block.h/.cpp Block/Attr: copyable snapshot of a parsed block, typed reads
fields.h internal: typed reads that record Problems
weapon.h/.cpp WeaponDef, RangeTable, BoltDef, FireControl, Ratings; load_weapon
shipsection.h/.cpp ShipSectionDef, OptionGroup, BankDef, MountDef, NetForceLimits; load_shipsection
turrets.h/.cpp TurretTable (_turrets.txt), IdRegistry (_weapons/_shipsections.txt)
techtree.h/.cpp TechTree, TechNode, AllowsEdge, parse_allows
strings.h/.cpp StringTable (Strings.csv)
catalog.h/.cpp Catalog: load_catalog(root), lookups, cross_check()
tests/game_data/
build_and_run.sh plain g++ build; unit tests; real-data facts; oracle comparison
test_*.cpp hand-written samples for every loader and the option normalisation
realdata_test.cpp counts / spot facts / cross-check against the published numbers
dump_catalog.cpp whole catalog -> JSON (typed fields + raw blocks)
oracle/compare.py field-by-field comparison with the reference catalogs
```
## 1. Common conventions
- **Loaders are total.** `load_x(const mars::parse::Document&)` returns
`Loaded<T> { optional<T> value; vector<Problem> problems; }`. Only a file
with no usable top-level block (or a hard syntax error in the `parse_x`
text convenience) yields no value; everything else loads and reports.
`Problem { kind, file, line, key, message }` kinds: `MissingBlock`,
`MissingKey`, `BadValue`, `Unparsed`, `Duplicate`, `IoError`, `Syntax`
(the parser's lenient recoveries are reported as `Syntax` "recovered: …").
- **Typed fields are `optional`.** A key that is absent is `nullopt`; a key
whose value does not have the expected shape is `nullopt` *plus* a
`BadValue` problem, and the text stays reachable in `raw`. Ints need the
int shape; doubles accept int or C-float; bools accept `true`/`false` (any
case) or an int (`hidden 1`, `autonomous 1`, `nodesign 1`).
- **Last occurrence wins** when a scalar key repeats (`mass 1800 mass 1000`
on two engine sections, `crew` on four Zuul sections, `turretsize` in 19
banks). Repeatable keys (`requires`, `exclude`, `compatible_section`,
`option`, `bank`, `mount`, `thruster`, `allows`, `inc`/`dec`, `section`,
`filename`) are vectors in file order.
- **Text kept as written, matched folded.** `turretclass PlanetMissile`,
`section_type Engine`, `requires WEP_HCLAS` are stored verbatim; every
lookup, enum derivation and cross-link comparison is ASCII
case-insensitive, which is how the data itself is consistent.
- **`raw`.** Every def keeps a `Block` copy of its whole block: ordered
`attrs` (key, text, quoted, line), `blocks`, `items`, with `find` (last
wins) / `all` / `block` / `blocks_named` / `str` / `strs` and typed
`Attr::as_int/as_double/as_bool`. The long tail of section role flags and
capacities (`refinery`, `police`, `colonizer_pop`, `mining_rate`, sounds,
`anim{}`) and the non-`bolt` weapon behaviour blocks live there.
- **Species** are indexed in the engine's own order — `Human 0, Hiver 1,
Tarkas 2, Liir 3, _NPC 4, Zuul 5, Morrigi 6` (the save-file species table).
## 2. Schemas as implemented
### `WeaponDef` (`weapon{}`; `Weapons/*.weapon`, `Species/<Race>/weapons/*.weapon`)
| field | key | type | notes |
|---|---|---|---|
| `stem`, `file`, `scope`, `id` | — | | catalog identity; `scope` NPC for anything under `Species/`; `id` from `_weapons.txt` (player only) |
| `display_name` | — | optional string | `name` resolved through the string table (`@X` → `X`); a non-`@` name is its own display name |
| `name` | `name` | string | `@WEAPON_*` token, required |
| `weapon_class`, `weapon_family`, `weapon_damage_type` | `weaponclass` (required), `weaponfamily`, `weapondamagetype` | string | |
| `requires` | `requires`* | vector | AND of techs; 30 weapons have none |
| `compatible_section`* / `exclusive_species` | | vector / string | rider carriers; `zuul` on the two grapples |
| `cost`, `burst_volleys`, `range`, `range_planet`, `muzzle_speed`, `hpbonus`, `dam_est` | | optional int | |
| `turret_size`, `turret_class` | `turretsize`, `turretclass` (both required) | string | |
| `track_speed_mod`, `recharge_time`, `volley_period`, `volley_duration`, `buildup_delay`, `solution_tolerance` | | optional double | |
| `hidden`, `pinpoint`, `blindfire`, `secondary_pd` | | optional bool | |
| `model1..3`, `muzzle_effect`, `muzzle_sound`, `icon_file`, `icon_rect` | | string | |
| `fc` | `fc_requires_los … fc_targets_expire` | 11 optional bools | fire-control flags |
| `ratings` | `rating_frate/dam/acc/range` | optional double | UI stars; fractional on 18 weapons |
| `behavior_kind` | first of `bolt beam torpedo rider missile chainlightning col mine disintegrator grapple projectedshield mirv nodecannon siege mesonprojector spyship wraith` present | string | `behavior()` returns that block from `raw` |
| `bolt` | `bolt{}` | optional `BoltDef` | `rangetable`, `planet` (`dam_pop/infra/terra`), `mass`, `beam_origin`, `beam_length`, `ricochet_mod`, `effect`, `impact_effect`, `expire_effect` |
| `rangetable` | `<behaviour>.rangetable{}` | optional `RangeTable` | present for `bolt` (66) and `torpedo` (19): three bands `point_blank/effective/maximum` of `{range, deviation, damage}` from `pb_/eff_/max_range[_dev|_dam]` |
| `planet_damage` | `<behaviour>.dam_pop/dam_infra/dam_terra` | `PlanetDamage` | whichever behaviour block is present |
### `ShipSectionDef` (`shipsection{}`; `Species/<Race>/sections/*.shipsection`)
| field | key | type | notes |
|---|---|---|---|
| `race`, `species`, `stem`, `file`, `id` | — | | `race` is the directory name; `id` from the race's `_shipsections.txt` |
| `display_name`, `description`, `unlocked_by` | — | | `SECTIONNAME_<stem>` / `SECTIONDESC_<stem>`; techs whose `ship{section}` names the stem (tech order) |
| `model` (required), `dam_model` | | string | |
| `requires`* | | vector | may name `GRP_<group>` |
| `section_type_text` → `section_type` | `section_type` | string → `SectionType {None, Command, Mission, Engine, Other}` | 41 sections have none (riders, NPC hulls) |
| `section_class_text` → `section_class` | `section_class` | string → `SectionClass {None, Destroyer, Cruiser, Dreadnought, Other}` | |
| `design_class`, `entity_class` | | string | `station`, `rider`, … |
| `health`, `mass` (both required) | | optional double | NPC masses are written as floats |
| `cost`, `cpoints`, `crew`, `command_cost`, `maintenance_cost`, `command_quota` | | optional int | |
| `socket_fore/aft`, `dam_socket_fore/aft` | | string | geometry only; `has_sockets()` is the hull-vs-standalone test (design rule A3) |
| `options` | `option T` and `option{ option A … }`* | vector of `OptionGroup { members, scalar, line }` | **one normalised list in file order**: a scalar entry is a one-member group with `scalar = true`, a block keeps its members. 229 scalar entries in 153 files, 1,226 blocks |
| `optiondef` | `optiondef{ option … }` | optional `OptionGroup` | the shield-level group on the 12 shield sections; kept separate because it is a separate key |
| `banks` | `bank{}`* | vector of `BankDef` | `turret_class`, `turret_size` (last if repeated; `repeated_turret_spec` flags the 19 banks that do), `weapon` (fixed weapon file on 404 NPC banks), `show_turrets`, `invincible`, `mounts` (`MountDef { node, min/max_azimuth, min/max_inclination, home_azimuth/inclination }`) |
| `ftlspeed`, `nodespeed`, `range`, `scanrange`, `tactical_sensor_range` | | optional double | |
| `engine_techera` | | string | `fission / fusion / antimatter` |
| `netforcelimits` | `netforcelimits{}` | optional `NetForceLimits` | `force_forward/right/up`, `torque_yaw/pitch/roll`, `speed`, `rotspeed` |
| `thrusters` | `thruster{}`* | vector | `node`, `effect`, `idle_effect` |
| `exclude`* | `exclude "STEM"` | vector | ramscoop command sections |
| `explicit_command_section`, `explicit_engine_section`, `explicit_section` | | string, string, optional bool | station triples |
| `autonomous`, `nodesign` | | optional bool | written as `1` or `true` |
### `TurretTable` (`Weapons/_turrets.txt`) and `IdRegistry`
`TurretRow { mount_size, weapon_size, turret_class, health, track_speed,
azimuth_scale, inclination_scale, model }` from the 8-token positional rows
(a row with any other width is `Unparsed` and skipped). `find(mount, weapon,
class)`, `has_weapon_pair(weapon_size, class)`, `has_bank_pair(mount_size,
class)`, `rows_for_bank(...)` — all case-insensitive.
`IdRegistry` wraps `mars::text::Manifest`: `IdEntry { id, name, stem, line }`,
`deleted()` tombstones, `find(id)` (last assignment wins), `id_of(name or
stem)` case-insensitive (`DEWar.SHIPSECTION`, `dewar`, `DEWar.shipsection`
all resolve), `is_deleted(id)`. Manifest problems map to `Unparsed` /
`Duplicate`.
### `TechTree` (`TechTree/MasterTechList.tech`)
`TechNode { name, family (as written, 155 nodes), family_inferred (upper
name prefix), type, threat, group (as written), option_cost,
unlock_explicitly, requires, benefits_inc, benefits_dec, sections,
weapon_files, allows (edge indices), display_name, description, raw }`.
`AllowsEdge { from, to, rp, pct[7], pct_written[7], unparsed, text, line }` —
`parse_allows` splits the string on whitespace: token 0 is the child,
`RP:n` (any case) the cost, `<Species>:n` a percentage for that species
(any of the seven names, any case); anything else lands in `unparsed` and is
reported. **Unlisted species default to 100** — this is the engine default
the RE notes describe (`data-parsers.md`, "believed 100"); `pct_written`
records which values the file actually spelled out so a consumer can tell
the two apart. `_NPC` is never written and is therefore always 100.
`groups`: upper-cased `group` value → member names (first-seen order);
`group_members("TORPS")` and `group_members("GRP_Torps")` both work;
`requirement_exists(token)` is true for a tech name or a non-empty group.
`strategy{ inc/dec }` is kept as the `TECHBEN_*` token lists — no semantics
are attached here. `edges_to / edges_from / roots()` are graph helpers.
### `StringTable` (`Locale/EN/Strings.csv`)
Column 0 → column 1 of every data row of the commented CSV; keys folded, the
later row wins on a repeat (the four trailing-space duplicates), `resolve()`
strips a leading `@`.
### `Catalog`
`load_catalog(root)` reads `Weapons/*.weapon` + `_weapons.txt` +
`_turrets.txt`, every `Species/<dir>/sections/` (+ `_shipsections.txt`) and
`Species/<dir>/weapons/`, `TechTree/MasterTechList.tech` and, if present,
`Locale/EN/Strings.csv`; missing mandatory files are `IoError` problems, the
rest still loads. Weapons are ordered player-first then by folded stem,
sections by (race, folded stem); ids, display names, descriptions and
`unlocked_by` are filled in; lookups: `weapon(stem)` (player catalog wins on
a stem clash), `weapon_by_file`, `weapon_by_id`, `section(race, stem)`,
`section_by_id`, `sections_named(stem)` (all races), `tech_node`.
`cross_check()` → `CrossCheck` with one vector per link kind. Dangling
(counted by `dangling_count()`, `clean()` = 0): weapon/section `requires` →
tech (a `GRP_x` needs a non-empty group), section `option`/`optiondef`
members → tech, tech `ship{section}` → any race's section, tech
`weapon{filename}` → a loaded weapon file, tech `requires` → tech/group,
`allows` child → tech (and unparsed edges), `bank{weapon}` → weapon file,
manifest id → file and file → manifest id, weapon and bank
(size, class) → a turret row, `@name` → string. Informational: case-only
mismatches, weapons without `requires`, missing `TECHNAME_/TECHDESC_` and
`SECTIONNAME_/SECTIONDESC_` (per distinct stem), roots.
## 3. Normalisation decisions
| decision | why |
|---|---|
| `option` scalar and block forms merge into one ordered list of groups | the engine treats a bare `option T` as a one-member choice (design rule D1); order is preserved through the entries' position in the block so the save-file `DOpts` order (group order) can be reproduced |
| `optiondef` stays a separate optional group | different key, different meaning (shield level), only 12 files |
| repeated scalar → last value, flagged on banks | the design-rule validator takes last; the engine's choice is unknown (open question), so the fact is kept (`repeated_turret_spec`, `raw.all(key)`) rather than hidden |
| numeric fields that do not parse → `nullopt` + `BadValue`, text kept in `raw` | 34 values in the shipped data are typos (`force_right o` ×22, `crew false` ×9, `trackspeed_mod 1.0f`, `min_inclination 0-5`, `max_inclination 90\`); guessing what the engine's converter makes of them would be fiction, so they are reported instead |
| bools accept 0/1 | `hidden 1`, `autonomous 1`, `nodesign 1` coexist with `true` in the data |
| `section_type`/`section_class` enums beside the text | the data spells them in two cases; behaviour code wants the enum, the oracle comparison wants the text |
| species percentages stored for all seven species, default 100 | matches the RE description of the engine default and gives `_NPC` (which has a catalog but never appears in `allows`) a defined value |
| `family_inferred` from the name prefix | half the nodes lack `family`, and where present it is sometimes the *display* branch (`WEP_Dsrptr` has `family "TRP"`) |
| NPC weapons are `scope NPC` with no id | they have no manifest; NPC banks reference them by file path (`bank{weapon}`) |
## 4. Tests
`tests/game_data/build_and_run.sh` (plain `g++ -std=c++17 -Wall -Wextra
-Wpedantic -Werror`):
1. **Unit tests** — 27 cases on hand-written samples: every typed field of a
weapon, `bolt` + `rangetable`, torpedo/beam behaviour detection, missing
keys and bad values as problems, fatal no-block / syntax error, lenient
recovery, last-wins and list order; section fields, the option
normalisation across both forms, `optiondef`, banks with repeated
`turretsize`, mounts, `netforcelimits`, thrusters, `exclude`, enum
parsing, long-tail access through `raw`; turret rows (comment lines,
empty model, short row), case-insensitive fit lookups; id registry with a
tombstone, stem/name lookup, duplicate id; `parse_allows` full / partial
/ bad tokens, tree nodes, groups, `GRP_` resolution, strategy links, ship
/ weapon links, roots, duplicate names; a miniature data root written to
a temp directory exercising `load_catalog`, every lookup, and every
`cross_check` list (each one seeded with exactly one dangling case); a
missing root.
2. **Real-data facts** (`SOTS_DATA_DIR` set, else SKIP) — counts and
published facts (below).
3. **Oracle comparison** — `dump_catalog` writes every def with its typed
fields and its `raw` block rendered in the reference reader's dict shape;
`oracle/compare.py` compares with `weapons.json`, `shipsections.json`,
`tech_tree.json`, `strings.json`, `crosslink.json`.
Canonicalisation in `compare.py`: `raw` bodies are compared exactly and
type-aware (this re-proves the block snapshot); typed fields are compared
against the oracle value under the same key with *last of a repeated key*,
*repeatable keys always lists*, *numbers numerically*, *bools accept the
oracle's 0/1*, *absent = null/""/[]*; option lists are normalised on the
oracle side the same way the loader does it; `allows` edges compare
`{from, to, rp, pct-as-written}` and the effective 7-species percentages
against `pct.get(race, 100)`; the cross-check report is compared list by
list with `crosslink.json` (manifest gaps folded, scalar options recomputed
from the dump). Strings are cp1252 in the files and are decoded to Unicode
by the dumper, since the oracle JSON holds decoded text.
### Results (2026-09-07, owner's `gob-extract`)
| what | ours | published |
|---|---|---|
| weapons | 207 (123 player, 84 NPC) | 207 |
| ship sections | 875 (Human 144, Hiver 137, Morrigi 141, Liir 135, Tarkas 132, Zuul 122, _NPC 64) | 875 |
| tech nodes / `allows` edges / groups | 293 / 354 / 9 | 293 / 354 / 9 |
| turret rows / weapon ids / deleted | 42 / 123 / {36, 58, 59} | same |
| strings | 5,196 | 5,196 |
| banks / mounts / scalar options / repeated-spec banks / banks without size | 3,721 / 7,375 / 229 / 19 / 2 | same |
| lenient parse recoveries | 12 | 12 |
| **oracle comparison** | **229,042 values, 0 differences** | — |
| cross-check dangling | 13 = 10 manifest ids without a file (Tarkas 3, 4, 16, 32, 43, 108, 109; `DEWar` in Human 98, Liir 86, Morrigi 39) + 3 NPC weapon name tokens absent from strings | same |
| every other link | 0 dangling; 18 / 7 case-only mismatches; 30 weapons without `requires`; 67 stems without `SECTIONDESC_`; 12 roots | same |
**Divergences: none in values.** The 34 untyped tokens listed in §3 are
`BadValue` problems on our side and plain strings/bools in the oracle;
`compare.py` requires that set to match exactly (it is spelled out in
`KNOWN_UNTYPED`), so a change in either direction is a failure.
Skips cleanly with `SOTS_DATA_DIR` unset. `tests/game_data/build/` (which
holds the dump) is git-ignored; nothing from the game is committed.
## 5. Open questions
- **Repeated scalar keys** — first or last value (two engine sections'
`mass`, four `crew`, 19 bank `turretsize/turretclass`)? Last is
implemented; `raw.all()` keeps both.
- **Converter leniency** — what the engine makes of `force_right o`,
`crew false`, `trackspeed_mod 1.0f`, `min_inclination 0-5`,
`max_inclination 90\`. Probably `0`, `0`, `1.0`, `0`, `90` if it uses
C-style prefix parsing; not assumed here.
- **`allows` default of 100** for unlisted species is taken from the RE
notes ("believed"), not proven from the binary.
- **`section_type` for typeless sections** — `Other`/`None` here; whether
the engine treats a missing type as "mission" for rider hulls is code.
- **`tech ship{section}`** is kept as data (`sections`, `unlocked_by`) and
is *not* used as a build gate, per `SHIP_DESIGN_RULES.md` §4.
- **Turret fit rules** (standard banks accepting missiles, strafe accepting
standard, grapples) belong to the design validator, not this module;
`TurretTable` only answers "is there a row".