# `game::design` — ship-design assembly rules `src/game/design/` decides what a valid `ShipDesignDef` is and what it weighs, costs and carries, on top of the `game::data` catalogs. Library target `game_design` (static, links `game_data`), include as `game/design/
.h`. C++17, no dependencies, no exceptions across the API: every finding is a `Violation`. Behaviour is specified by the RE findings (`SHIP_DESIGN_RULES.md`, rules derived from the shipped data and every design in three real saves); the reference Python validator (`design_rules.py`) is the oracle and this module is diffed against it design by design (§5). ``` src/game/design/ design.h/.cpp Slot, WeaponRef, SectionUse, Design (the 5-slot save record); Level, Violation rules.h/.cpp Ruleset::validate; hull_class_tech, applied_techs, options_from_applied, bank_accepts_class, turret_row, weapon_fits_bank, TechSet stats.h/.cpp derive_stats -> DesignStats (mass, cost, health, crew, drive, capacities, turrets) defaults.h/.cpp DefaultWeaponTable (_defaultweapons.txt), fill_empty_banks tests/game_design/ build_and_run.sh plain g++ build; unit tests; real-data test; dump; oracle comparison mini_catalog.cpp hand-written miniature data root (4 species, 12 weapons, 19 turret rows, tech tree) test_structure/banks/tech/stats/json.cpp 43 unit tests, one or more per rule stock_designs.cpp reads the owner's designs extract (game-derived; never in the repo) realdata_test.cpp every save design through the rules; the published counts dump_designs.cpp findings + applied techs + stats per design -> JSON oracle/compare.py runs design_rules.py on the same designs and diffs ``` ## 1. The design record (`design.h`) The save's `Des` record, kept in its own shape: | field | save | notes | |---|---|---| | `name`, `hidden`, `designer_made`, `default_weapons` | `dName`, `dHide`, `faiDes`, `dWep` | the last two are carried through; their meaning is open | | `race` | — | the species catalog the design is validated against; `Unknown` → the first filled slot's species (`effective_race()`) | | `slots[5]` | `DSec[5]` | `Slot::Command 0, Mission 1, Engine 2`; `Reserved3/4` must stay empty (rule A1) | | `SectionUse::species`, `section_id` | `DSec.species_idx`, `section_id` | `(0, 0)` = empty; `section_id` indexes the race's `_shipsections.txt`; a stem / file name in `section` is accepted instead | | `SectionUse::weapons` | `DGbnk2` | one `WeaponRef` per `bank{}` in file order: `by_id` (`bID true, wid`), `by_file` (`bID false, wfn`), `by_stem`, `none`; `rider_design_id` = `did` | | `SectionUse::options` | `DOpts` minus structural techs | the chosen option techs; `Ruleset::strip_applied_techs` turns a raw `DOpts` list into this | | `known_techs` | player `TechTree` `St == 4` | `nullopt` skips tech gating | `Violation { rule, level, message, slot, bank }` with `Level::Error` (invalid), `Warn` (engine accepts it, designer UI would not / only code can settle it), `Info`. `str()` prints `ERROR B4 [mission bank 2]: …`. ## 2. Rule catalog (`rules.h`) Ids match the RE document and the reference validator. "Evidence" is what in the data or the saves forces the rule. ### Structure | id | rule | evidence | |---|---|---| | A1 | slots 3 and 4 are never used | absent in all 127 save designs (our addition; the reference has no such record) | | A2 | the mission slot is always filled | every design and template has one; standalone hulls sit in the mission slot | | A3 | mission section **with** `socket_fore/aft` = hull ship: command **and** engine required; **without** = standalone: both must be empty (an unresolvable partner still counts as filled) | 57–61 hull vs 16–21 standalone mission sections per race; `Light Defense Platform` = `[0,0] DEDefencePlatform [0,0]` | | A4 | `section_type` must match the slot (case-insensitive). A typeless section in the mission slot of a standalone hidden design, or one whose `design_class` is `rider`, is `Info`; anywhere else it is an error | hidden `Default Assault Shuttle` = `[0,0] _AssaultShuttle [0,0]` | | A5 | one species catalog: the race must have a catalog, every section must resolve in it, and a slot naming another species is an error | per-race `sections/` dirs; the same stem differs per race (`DEFission` requires `DRV_Node` for Human, `DRV_Hyper` for Tarkas); no save design mixes species | | A6 | all sections share `section_class` | all 127 designs and 87 templates are class-homogeneous — only code can say whether the engine checks or merely never offers a mix | | A7 | `exclude` list: a section may not be combined with a listed stem | Hiver `DERamScoop exclude {DEFission …}` (ramscoop command sections forbid fission engines) | | A8 | `explicit_command_section` / `explicit_engine_section` on a mission section pin its partners; `explicit_section true` sections are usable only that way | the six `DNStation*` `_Fore`/`_Aft` triples | | A9 | `nodesign` = not offered by the designer, **not** an engine rule → `Warn` (silent on hidden designs) | `DNGuard` is a scenario mission section; NPC designs use nodesign sections throughout | **Sockets are geometry, not a key.** `socket_aft` on a command section and `socket_fore` on a mission section are mesh node names for snapping the models; they need not match (`DECommand CommandNode` + `DEColonizer MissionNode01`). Only their *presence* carries assembly information (A3). ### Banks and weapon fit | id | rule | evidence | |---|---|---| | B1 | exactly one weapon entry per `bank{}` block, in file order | `DGbnk2.n == len(bank{})` on all 197 instances | | B2 | no bank is left empty | every bank in every save design has a `DW2`; `_defaultweapons.txt` exists to fill them | | B3 | a bank with `weapon ""` in data is bound to that weapon; an empty entry takes it | 404 NPC banks; NPC designs store the same file in `wfn` | | B4 | fit = class compatibility **and** a `_turrets.txt` row for `(bank size, weapon size, class)`; a bank without a turret spec takes only its fixed weapon | below | | B5 | the weapon must exist (id, file or stem); NPC-scope weapons only on NPC hulls | player weapons by id, NPC by path | | B6 | `exclusive_species` | `bal_grapple`, `bal_disruptorwhip` are Zuul-only | | B7 | rider weapons (`compatible_section`) need one of those rider sections in the race catalog | `DNCarrier`'s `tarkahunter` bank exists only in the Tarkas catalog | | B8 | `hidden` weapons are engine-only unless the design is hidden or the bank is fixed | `mis_planet*`, `_mis_*_warhead`, Silicoid guns | **B4 in detail.** `bank_accepts_class`: `standard ← {standard, missile, grapple}`, `strafe ← {standard}`, everything else exact. Stock `Armor` puts `mis` (medium/missile) in `DEArmor`'s medium standard bank; no weapon is class `strafe` and the NPC `_HeraldDefender` strafe bank carries a standard-class beamer; grapples have no banks of their own (assumption). `turret_row` looks the row up by the **weapon's** class first (a missile in a standard bank gets `turret_m1missile.x`), then by the bank's class (the strafe rows are the fallback). Consequences: tiny (PD) weapons fit only small standard/strafe banks; a large standard bank takes 54 player weapons. ### Tech gating | id | rule | evidence | |---|---|---| | C1 | every section `requires` must be researched; `GRP_` is satisfied by any researched tech of `group g` (case-insensitive). A token that is neither a tech nor a group is `Warn` | 970 refs; groups PRJCTR/PD/HVYBEAM/SPINAL/TORPS/MINES/BIOMISSILE/JAMMING/SHIELDS | | C2 | every weapon `requires` (AND) must be researched; fixed NPC banks are exempt | 189 refs | | C3 | chosen options must be researched (when the option is a tech node) | turn-1 designs choose none; NPC players (all 293 techs) choose one per group | | C4 | **hull-class tech**: cruiser sections need `IND_CruisCon`, dreadnought `IND_DreadCon`; `design_class station` sections are exempt; destroyers need nothing | `DOpts` carries the tech first for every CR/DN section and omits it for `_AsteroidMonitor*` | | C5 | a tech whose every incoming `allows` edge writes `:0` can never be researched by that race → its sections are unbuildable for it, researched or not | `DRV_Node` Human-only, `DRV_Hyper` Tarkas-only; `_NPC` is never written and defaults to 100 | **Hidden default rider designs** (code rule the data does not show): the engine creates `Default Assault Shuttle` for every race at game start; the Tarkas `_AssaultShuttle` requires `DRN_AdvFrm`, unresearched on turn 1. On `hidden` designs C1–C4 are reported at `Warn`. Not a gate: the tech tree's `ship { section X }` lists are the "new section" UI notice (139 of ~150 stems differ from the sections' own `requires`). ### Options | id | rule | evidence | |---|---|---| | D1 | a chosen option must be a member of one of the section's groups (`option` entries, then `optiondef`); at most one per group | no tech is in two groups of one section; NPC `DOpts` hold one per group | | D2 | `applied_techs = [hull-class tech] + requires + chosen options in group order` is what the save stores | 197/197 instances | ## 3. API ```cpp using namespace game::design; data::Catalog cat = data::load_catalog(root); Ruleset rules(cat); // precomputes the C5 availability index Design d; d.race = data::Species::Human; d.known_techs = player_techs; d.command() = {.., .section = "DECommand", .weapons = {WeaponRef::by_id(8)}}; d.mission().section_id = 37; // by id, like the save ... std::vector v = rules.validate(d); has_errors(v); rules_at(v, Level::Warn); DesignStats st = derive_stats(rules, d); st.mass, st.total_cost_estimate, st.sections[i].banks[j].turret applied_techs(section, chosen) // == DOpts rules.strip_applied_techs(d); // DOpts -> chosen options, in place weapon_fits_bank(cat.turrets, bank, weapon) // Fit { ok, why, row } DefaultWeaponTable t = *load_default_weapons(root).value; fill_empty_banks(d, cat, t); ``` Resolution: a section by `(species, section_id)` through the race's id registry, else by stem / file name; a weapon by id, full path, bare file name or stem (player catalog wins on a stem clash). Everything folds ASCII case. ## 4. Derived stats (`stats.h`) | stat | derivation | status | |---|---|---| | `mass`, `health_total`, `crew`, `cpoints` | sums of the section fields (`crew false` → 0) | data; whether weapons add mass is code | | `section_cost`, `section_cost_with_options` | sum of `cost`, and × the product of the chosen options' `option_cost` | the multiplier is an assumption | | `weapon_cost_per_bank` / `_per_mount` | weapon `cost` once per bank / × mounts | which one the engine uses is code; `total_cost_estimate` uses per-bank | | `banks`, `turrets` | bank count, one turret per mount; each bank's `_turrets.txt` row (model, health, track speed) | data | | `command_cost`, `maintenance_cost`, `command_quota` | sums | data | | `ftlspeed`, `nodespeed`, `engine_techera`, `netforcelimits` | from the engine section, or the mission section of a standalone hull | the strategic/tactical speed formulas are code | | `capacities` | role flags and capacities copied from `raw` (text as written) | data | | `applied_techs` per section | D2 | 197/197 | ## 5. Tests and results `tests/game_design/build_and_run.sh` (plain `g++ -std=c++17 -Wall -Wextra -Wpedantic -Werror`; CMake wiring in `tests/game_design/CMakeLists.txt`): 1. **Unit tests** — 43 cases on the hand-written mini root: every rule in §2 in both directions (valid stock `Armor`, then each way to break it), resolution by id / species / file name, the class-accept table and the row lookup order (weapon class first, bank class fallback), hidden-design downgrades, `GRP_` satisfaction, C5 availability incl. the `_NPC` default, `applied_techs` order with `optiondef` last, the DOpts round trip, stats sums, the option multiplier, NPC fixed-weapon costing, the default-weapon table and `fill_empty_banks`, the JSON reader. 2. **Real-data test** (`SOTS_DATA_DIR` + `SOTS_DESIGNS_JSON`, else SKIP) — every design of every player in the three saves, built from the raw `(species, section_id, weapons, DOpts)` record. 3. **Oracle comparison** — `dump_designs` writes findings, applied techs and stats per design; `oracle/compare.py` runs `design_rules.py` on the same designs and requires identical finding sets `(rule, level, slot, bank)`, identical applied techs and options, and equal stats. ### Results (2026-09-07, owner's `gob-extract` + 127-design extract) | what | result | |---|---| | designs clean on structure / banks / fit / options / gating | **127 / 127** | | section instances whose `DOpts` = `applied_techs()` | **197 / 197** | | tech gating vs the owning player's researched techs | **121 / 127**; the 6 others are warn-only: the hidden Tarkas `Default Assault Shuttle` in each save for both Tarkas players (`_AssaultShuttle requires DRN_AdvFrm`) | | oracle: findings | 62 / 62 identical (50 designs carry findings: A4 info on the 12 hidden riders, C1 warn on the 6 Tarkas ones, A9 warn on NPC nodesign hulls) | | oracle: applied techs, options, stats | 9,673 values, **0 differences** | Skips cleanly with the variables unset; `tests/game_design/build/` is git-ignored; nothing from the game is committed (`tools/clean_room_check.sh` OK). ## 6. Open questions (only code can settle) 1. **A6 class equality** — enforced in validation, or only by the designer never offering a mix? Implemented as an error. 2. **B4 class-accept table** — `standard ← missile` and `strafe ← standard` are data-forced; `standard ← grapple` is the weakest assumption (the two Zuul grapples would otherwise be unmountable). Whether `tiny` really is small-only, and the row-lookup order. 3. **C4 hull-class tech** — injected from `section_class` with the station exemption; design-time or build-time check? 4. **Hidden default designs** — where the engine builds them and why `requires` is skipped (modelled as warn-level gating on `hidden`). 5. **Option semantics** — default pick per group, auto-upgrade, and what `option_cost` multiplies (section cost assumed; build time?). 6. **Cost / mass / speed** — weapon cost per bank or per mount; whether weapons add mass; how `netforcelimits` + mass and `ftlspeed`/`nodespeed` + `TECHBEN_STRATSPD` become speeds. 7. **Missiles and `WEP_Nukes`** — `mis` has no `requires`; gated in code? 8. **`faiDes`, `dWep`** — carried through as `designer_made`, `default_weapons`; meaning open. 9. **Empty banks (B2)** — can a design be saved with one, or does the designer always fill from `_defaultweapons.txt` (`fill_empty_banks`)? 10. **Repeated keys** — first or last value (`game::data` takes last). 11. **Mixed-species records** — the record allows it; modelled as an A5 error for want of any example.