# `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/
.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, 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 { optional value; vector 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//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` | `.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` | `.dam_pop/dam_infra/dam_terra` | `PlanetDamage` | whichever behaviour block is present | ### `ShipSectionDef` (`shipsection{}`; `Species//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_` / `SECTIONDESC_`; techs whose `ship{section}` names the stem (tech order) | | `model` (required), `dam_model` | | string | | | `requires`* | | vector | may name `GRP_` | | `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, `: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//sections/` (+ `_shipsections.txt`) and `Species//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".