15 KiB
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/<header>.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 "<file>" 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_<g> 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 <Race>: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
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<Violation> 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):
- 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_NPCdefault,applied_techsorder withoptiondeflast, the DOpts round trip, stats sums, the option multiplier, NPC fixed-weapon costing, the default-weapon table andfill_empty_banks, the JSON reader. - 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. - Oracle comparison —
dump_designswrites findings, applied techs and stats per design;oracle/compare.pyrunsdesign_rules.pyon 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)
- A6 class equality — enforced in validation, or only by the designer never offering a mix? Implemented as an error.
- B4 class-accept table —
standard ← missileandstrafe ← standardare data-forced;standard ← grappleis the weakest assumption (the two Zuul grapples would otherwise be unmountable). Whethertinyreally is small-only, and the row-lookup order. - C4 hull-class tech — injected from
section_classwith the station exemption; design-time or build-time check? - Hidden default designs — where the engine builds them and why
requiresis skipped (modelled as warn-level gating onhidden). - Option semantics — default pick per group, auto-upgrade, and what
option_costmultiplies (section cost assumed; build time?). - Cost / mass / speed — weapon cost per bank or per mount; whether
weapons add mass; how
netforcelimits+ mass andftlspeed/nodespeedTECHBEN_STRATSPDbecome speeds.
- Missiles and
WEP_Nukes—mishas norequires; gated in code? faiDes,dWep— carried through asdesigner_made,default_weapons; meaning open.- Empty banks (B2) — can a design be saved with one, or does the
designer always fill from
_defaultweapons.txt(fill_empty_banks)? - Repeated keys — first or last value (
game::datatakes last). - Mixed-species records — the record allows it; modelled as an A5 error for want of any example.