2026-09-26 18:14:36 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
using System.Reflection ;
2026-09-26 18:14:36 +02:00
using GameCult.Caching ;
using GameCult.Caching.MessagePack ;
using MessagePack ;
2026-09-30 03:06:43 +02:00
// A mod ship's visual package: model, anchors, schematic lines. The hull semantics (cells, hardpoints, stats) live in
// the ship's own HullData record beside this one, and HullData.Visual is the only binding between the two.
2026-09-26 18:14:36 +02:00
[CultDocument("aetheria.ship_authoring", "1"), MessagePackObject]
public sealed class ShipAuthoring
{
[CultName, Key(0)] public string Id ;
2026-09-30 03:06:43 +02:00
// Key 1 held the embedded HullData until S1; it is retired and never reused.
2026-09-26 18:14:36 +02:00
[Key(2)] public string ModelAsset ;
[Key(3)] public List < ShipAnchor > Anchors = new List < ShipAnchor > ( ) ;
[Key(4)] public List < ShipPolyline > SchematicLines = new List < ShipPolyline > ( ) ;
2026-10-10 05:23:02 +02:00
// The moving parts of the rig, presentation only (ruling rig-is-presentation): no joint value feeds the simulation.
[Key(5)] public List < ShipJoint > Joints = new List < ShipJoint > ( ) ;
}
// A rig joint: a bone of the model's armature. Axes are three floats per degree of freedom, a unit vector in Ship Root
// space at rest (glTF axes, +Y up); Min and Max are that degree of freedom's limits in degrees.
[MessagePackObject]
public sealed class ShipJoint
{
// The bone's aetheria.id, which is also its GLB node id.
[Key(0)] public string Id ;
// The nearest joint above this one in the model, or null for a root joint.
[Key(1)] public string Parent ;
[Key(2)] public float [ ] Axes ;
[Key(3)] public float [ ] Min ;
[Key(4)] public float [ ] Max ;
2026-09-26 18:14:36 +02:00
}
[MessagePackObject]
public sealed class ShipAnchor
{
// Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts.
[Key(0)] public string Id ;
2026-10-03 14:39:08 +02:00
// One of: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount,
2026-10-10 05:23:02 +02:00
// weapon-muzzle, or radiator-mesh.
2026-09-26 18:14:36 +02:00
[Key(1)] public string Role ;
// Stable node ID in the compiled model, never a Blender display name.
[Key(2)] public string ModelNodeId ;
// Only child roles use ParentId; e.g. a muzzle names its weapon hardpoint.
[Key(3)] public string ParentId ;
[Key(4)] public int Order ;
2026-10-10 05:23:02 +02:00
// On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount.
[Key(5)] public string Joint ;
2026-09-26 18:14:36 +02:00
}
// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up
// space. The runtime renderer owns its coordinate conversion and camera projection.
[MessagePackObject]
public sealed class ShipPolyline
{
[Key(0)] public string Layer ;
[Key(1)] public string Material ;
[Key(2)] public float [ ] Points ;
[Key(3)] public float [ ] Radii ;
[Key(4)] public float [ ] Opacities ;
[Key(5)] public bool Cyclic ;
[Key(6)] public float [ ] Color ;
}
public static class ShipAuthoringStore
{
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
// ShipAnchor.Role's vocabulary.
private static readonly HashSet < string > Roles = new HashSet < string > ( StringComparer . Ordinal )
{
2026-10-10 05:23:02 +02:00
"map-icon" , "hull-collider" , "shield" , "tractor" , "thruster-emitter" , "weapon-mount" , "weapon-muzzle" , "radiator-mesh"
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
} ;
2026-10-03 14:39:08 +02:00
// The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor.
private static string MountRole ( HardpointType type ) = >
type = = HardpointType . Thruster ? "thruster-emitter" :
type = = HardpointType . Radiator ? "radiator-mesh" :
IsWeapon ( type ) ? "weapon-mount" : null ;
public static bool IsWeapon ( HardpointType type ) = >
type = = HardpointType . Energy | | type = = HardpointType . Ballistic | | type = = HardpointType . Launcher ;
2026-09-26 18:14:36 +02:00
public static CultCache Open ( string path , bool writable = false )
{
var cache = new CultCache ( ) ;
try
{
2026-10-12 03:54:30 +02:00
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( path , ! writable ) , new [ ] { typeof ( HullData ) , typeof ( ShipAuthoring ) , typeof ( FactionProductData ) } ) ;
2026-09-26 18:14:36 +02:00
return cache ;
}
catch
{
cache . Dispose ( ) ;
throw ;
}
}
2026-09-30 03:06:43 +02:00
// Writes one ship's two records under their deterministic keys, the shape Load expects.
public static void Write ( CultCache cache , HullData hull , ShipAuthoring visual )
{
cache . UpsertAsync ( typeof ( HullData ) , hull , ShipModCatalog . HullKey ( visual . Id ) ) . GetAwaiter ( ) . GetResult ( ) ;
cache . UpsertAsync ( typeof ( ShipAuthoring ) , visual , ShipModCatalog . AuthoringKey ( visual . Id ) ) . GetAwaiter ( ) . GetResult ( ) ;
}
2026-10-12 03:54:30 +02:00
// A package's product by one maker. These two are the only product writers; the key is the ship and the maker, so a
// maker has at most one product of the hull and writing again replaces it.
public static void WriteProduct ( CultCache cache , string shipId , FactionProductData product ) = >
cache . UpsertAsync ( typeof ( FactionProductData ) , product , ShipModCatalog . ProductKey ( shipId , product . Manufacturer . Key ) ) . GetAwaiter ( ) . GetResult ( ) ;
public static bool DeleteProduct ( CultCache cache , string shipId , CultRecordKey maker ) = >
cache . Remove ( ShipModCatalog . ProductKey ( shipId , maker ) ) ;
2026-10-03 17:07:09 +02:00
// A new mod hull that flies like a shipped one. Every member of the reference is copied through the type's own
// MessagePack round trip, so a member added to HullData later is copied too; then the six that are the shipped ship's
// own body and layout are reset: Name, Shape (one occupied cell, so the record loads), Hardpoints, Prefab, Schematic,
// and Visual, which names this mod's visual record.
public static HullData HullLike ( HullData reference , string name , string shipId )
{
var options = CultDocumentMessagePackSerialization . OptionsFor ( typeof ( HullData ) . Assembly ) ;
var hull = MessagePackSerializer . Deserialize < HullData > ( MessagePackSerializer . Serialize ( reference , options ) , options ) ;
hull . Name = name ;
hull . Shape = new Shape ( ) ;
hull . Hardpoints = new List < HardpointData > ( ) ;
hull . Prefab = null ;
hull . Schematic = null ;
hull . Visual = new CultRecordRef < ShipAuthoring > ( ShipModCatalog . AuthoringKey ( shipId ) ) ;
return hull ;
}
2026-09-30 03:06:43 +02:00
// The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at
2026-10-12 03:54:30 +02:00
// its deterministic key, the hull naming that visual by its typed ref, and the hull's products, each at its maker's key.
public static ( HullData Hull , ShipAuthoring Visual , FactionProductData [ ] Products ) Load ( string path )
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
{
RefuseLegacyEmbeddedHull ( path ) ;
try { return LoadRecords ( path ) ; }
2026-10-22 20:23:32 +02:00
catch ( Exception error ) when ( error is InvalidDataException or MessagePackSerializationException )
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
{
throw new InvalidOperationException ( $"{path}: a record does not decode as its schema: {error.Message}" , error ) ;
}
2026-10-22 23:34:26 +02:00
catch ( NotSupportedException error )
{
throw new InvalidOperationException ( $"{path}: not a readable ship file: {error.Message}" , error ) ;
}
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
}
// Key 1 of ShipAuthoring held the whole hull before S1. The type no longer has that member, so a deserialized record
// shows nothing of it, and a file that still carries one would load as a ship silently ignoring its old hull. The
// raw payload is the only place the old shape is visible.
private static void RefuseLegacyEmbeddedHull ( string path )
{
if ( ! File . Exists ( path ) ) return ;
var bytes = File . ReadAllBytes ( path ) ;
if ( bytes . Length = = 0 ) return ;
CultPersistedStoreSnapshot snapshot ;
try { snapshot = CultDocumentMessagePackSerialization . DeserializeSnapshot ( bytes ) ; }
2026-10-22 20:23:32 +02:00
catch ( Exception error ) when ( error is MessagePackSerializationException or NotSupportedException )
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
{
throw new InvalidOperationException ( $"{path}: not a readable ship file: {error.Message}" , error ) ;
}
var schema = typeof ( ShipAuthoring ) . GetCustomAttribute < CultDocumentAttribute > ( ) . SchemaName ;
foreach ( var record in snapshot . Records )
{
if ( snapshot . SchemaCatalog . FirstOrDefault ( entry = > entry . SchemaId = = record . SchemaId ) ? . SchemaName ! = schema ) continue ;
var reader = new MessagePackReader ( record . Payload ) ;
if ( reader . NextMessagePackType ! = MessagePackType . Array ) continue ;
var slots = reader . ReadArrayHeader ( ) ;
for ( var slot = 0 ; slot < slots ; slot + + )
{
if ( slot = = RetiredHullKey & & ! reader . TryReadNil ( ) )
throw new InvalidOperationException ( $"{path}: {record.Key}: legacy embedded hull at retired key {RetiredHullKey} of the ship authoring record. " +
$"Migrate it: write the hull as its own hull record at mod-hull:<id> naming the visual, and rewrite the ship authoring record without key {RetiredHullKey}." ) ;
if ( slot ! = RetiredHullKey ) reader . Skip ( ) ;
}
}
}
private const int RetiredHullKey = 1 ;
2026-10-12 03:54:30 +02:00
private static ( HullData Hull , ShipAuthoring Visual , FactionProductData [ ] Products ) LoadRecords ( string path )
2026-09-26 18:14:36 +02:00
{
using var cache = Open ( path ) ;
2026-09-30 03:06:43 +02:00
var hulls = cache . GetAll < HullData > ( ) . ToArray ( ) ;
var visuals = cache . GetAll < ShipAuthoring > ( ) . ToArray ( ) ;
if ( visuals . Length ! = 1 )
throw new InvalidOperationException ( $"{path} must hold exactly one ship authoring record, found {visuals.Length}." ) ;
if ( hulls . Length ! = 1 )
throw new InvalidOperationException ( $"{path} must hold exactly one hull record, found {hulls.Length}." ) ;
var ( hull , visual ) = ( hulls [ 0 ] , visuals [ 0 ] ) ;
if ( ! cache . TryGetHandle ( hull ) . Value . Key . Equals ( ShipModCatalog . HullKey ( visual . Id ) ) | |
! cache . TryGetHandle ( visual ) . Value . Key . Equals ( ShipModCatalog . AuthoringKey ( visual . Id ) ) )
throw new InvalidOperationException ( $"{path}: {visual.Id}: records must be stored under {ShipModCatalog.HullKey(visual.Id).Value} and {ShipModCatalog.AuthoringKey(visual.Id).Value}." ) ;
2026-10-12 03:54:30 +02:00
var products = cache . GetAll < FactionProductData > ( ) . ToArray ( ) ;
foreach ( var product in products )
{
if ( ! product . Manufacturer . IsSet ( ) | | ShipModCatalog . IsModKey ( product . Manufacturer . Key ) )
throw new InvalidOperationException ( $"{path}: {visual.Id}: a product needs a manufacturer that is a shipped faction." ) ;
var productKey = ShipModCatalog . ProductKey ( visual . Id , product . Manufacturer . Key ) ;
if ( ! cache . TryGetHandle ( product ) . Value . Key . Equals ( productKey ) )
throw new InvalidOperationException ( $"{path}: {visual.Id}: a product must be stored at {productKey.Value}." ) ;
if ( ! product . Design . IsSet ( ) | | ! product . Design . Key . Equals ( ShipModCatalog . HullKey ( visual . Id ) ) )
throw new InvalidOperationException ( $"{path}: {visual.Id}: a product must name the package hull {ShipModCatalog.HullKey(visual.Id).Value} as its design." ) ;
}
return ( hull , visual , products ) ;
2026-09-30 03:06:43 +02:00
}
2026-10-12 03:54:30 +02:00
public static ( HullData Hull , ShipAuthoring Visual , FactionProductData [ ] Products ) Read ( string path )
2026-09-30 03:06:43 +02:00
{
2026-10-12 03:54:30 +02:00
var ( hull , visual , products ) = Load ( path ) ;
2026-10-12 20:00:18 +02:00
ValidateLoaded ( hull , visual , products ) ;
return ( hull , visual , products ) ;
}
// Every judgement a loaded package's records face, in one place: Read, and validate on a draft with no model yet, call it.
public static void ValidateLoaded ( HullData hull , ShipAuthoring visual , FactionProductData [ ] products )
{
2026-09-30 03:06:43 +02:00
Validate ( hull , visual ) ;
2026-10-12 03:54:30 +02:00
ValidateProducts ( hull , visual , products ) ;
}
// What a package may sell: every product named, no name twice, and a price above 0, because generation skips price 0.
2026-10-12 20:00:18 +02:00
private static void ValidateProducts ( HullData hull , ShipAuthoring visual , FactionProductData [ ] products )
2026-10-12 03:54:30 +02:00
{
if ( products . Length = = 0 ) return ;
if ( products . Any ( product = > string . IsNullOrWhiteSpace ( product . Name ) ) )
throw new InvalidOperationException ( $"{visual.Id}: every product needs a name." ) ;
if ( products . Select ( product = > product . Name ) . Distinct ( StringComparer . Ordinal ) . Count ( ) ! = products . Length )
throw new InvalidOperationException ( $"{visual.Id}: product names must be unique within the package." ) ;
if ( hull . Price < = 0 )
throw new InvalidOperationException ( $"{visual.Id}: a hull with products needs a price above 0 (generation skips price 0)." ) ;
2026-09-26 18:14:36 +02:00
}
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
// A hull names one body: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships), never both, so no
// consumer needs a precedence rule. A whitespace-only Prefab is unset. Every path that validates or composes hulls
// calls this.
public static void RequireOneBody ( HullData hull , string label )
{
if ( ! string . IsNullOrWhiteSpace ( hull . Prefab ) & & hull . Visual . IsSet ( ) )
throw new InvalidOperationException ( $"{label}: a hull names one body, but this one names both a Unity prefab and a visual record." ) ;
}
2026-10-10 06:53:03 +02:00
// The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so
// Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value.
public static void RequireShipId ( string id )
{
if ( string . IsNullOrWhiteSpace ( id ) ) throw new InvalidOperationException ( "Ship ID is required." ) ;
if ( ! ( id [ 0 ] > = 'a' & & id [ 0 ] < = 'z' | | id [ 0 ] > = '0' & & id [ 0 ] < = '9' ) | | id . Any ( c = >
! ( c > = 'a' & & c < = 'z' | | c > = '0' & & c < = '9' | | c = = '.' | | c = = '_' | | c = = '-' ) ) )
throw new InvalidOperationException ( "Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens." ) ;
if ( id [ id . Length - 1 ] = = '.' ) throw new InvalidOperationException ( "Ship ID must not end with a dot." ) ;
var stem = id . Split ( '.' ) [ 0 ] ;
if ( stem is "con" or "prn" or "aux" or "nul"
| | stem . Length = = 4 & & ( stem . StartsWith ( "com" ) | | stem . StartsWith ( "lpt" ) ) & & stem [ 3 ] > = '1' & & stem [ 3 ] < = '9' )
throw new InvalidOperationException ( "Ship ID must not be a Windows device name." ) ;
}
2026-09-30 03:06:43 +02:00
// The one semantic check every path shares: the hull and its visual, judged together.
public static void Validate ( HullData hull , ShipAuthoring ship )
2026-09-26 18:14:36 +02:00
{
if ( ship = = null ) throw new InvalidOperationException ( "Ship authoring record is null." ) ;
2026-10-10 06:53:03 +02:00
RequireShipId ( ship . Id ) ;
2026-09-30 03:06:43 +02:00
if ( hull = = null ) throw new InvalidOperationException ( $"{ship.Id}: hull data is required." ) ;
2026-09-26 18:14:36 +02:00
if ( string . IsNullOrWhiteSpace ( hull . Name ) ) throw new InvalidOperationException ( $"{ship.Id}: hull name is required." ) ;
2026-09-30 02:14:10 +02:00
if ( hull . Shape ? . Cells = = null | | ! hull . Shape . Cells . Cast < bool > ( ) . Any ( occupied = > occupied ) )
2026-09-26 18:14:36 +02:00
throw new InvalidOperationException ( $"{ship.Id}: schematic must contain at least one cell." ) ;
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
RequireOneBody ( hull , ship . Id ) ;
2026-10-12 03:54:30 +02:00
if ( hull . HullType = = HullType . Ship & & ! ( hull . Behaviors ? ? new List < BehaviorData > ( ) ) . Any ( behavior = > behavior is VelocityLimitData ) )
throw new InvalidOperationException ( $"{ship.Id}: a ship hull needs a top speed (VelocityLimit)." ) ;
2026-09-30 03:06:43 +02:00
if ( ! hull . Visual . Key . Equals ( ShipModCatalog . AuthoringKey ( ship . Id ) ) )
throw new InvalidOperationException ( $"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}." ) ;
2026-09-26 18:14:36 +02:00
if ( string . IsNullOrWhiteSpace ( ship . ModelAsset ) | | Path . IsPathRooted ( ship . ModelAsset ) | |
ship . ModelAsset . Replace ( '\\' , '/' ) . Split ( '/' ) . Any ( part = > part = = ".." ) )
throw new InvalidOperationException ( $"{ship.Id}: model asset must be a relative package path." ) ;
var anchors = ship . Anchors ? ? throw new InvalidOperationException ( $"{ship.Id}: anchors are required." ) ;
var ids = new HashSet < string > ( StringComparer . Ordinal ) ;
2026-09-26 18:30:02 +02:00
var nodeIds = new HashSet < string > ( StringComparer . Ordinal ) ;
2026-09-26 18:14:36 +02:00
foreach ( var anchor in anchors )
{
if ( anchor = = null | | string . IsNullOrWhiteSpace ( anchor . Id ) | | ! ids . Add ( anchor . Id ) )
throw new InvalidOperationException ( $"{ship.Id}: anchor IDs must be present and unique." ) ;
if ( string . IsNullOrWhiteSpace ( anchor . ModelNodeId ) )
throw new InvalidOperationException ( $"{ship.Id}: anchor {anchor.Id} needs a model node ID." ) ;
2026-09-26 18:30:02 +02:00
if ( ! nodeIds . Add ( anchor . ModelNodeId ) )
throw new InvalidOperationException ( $"{ship.Id}: model node {anchor.ModelNodeId} is claimed by more than one anchor." ) ;
2026-09-26 18:14:36 +02:00
}
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
foreach ( var anchor in anchors )
if ( ! Roles . Contains ( anchor . Role ) )
throw new InvalidOperationException ( $"{ship.Id}: anchor {anchor.Id} has unknown role '{anchor.Role}'." ) ;
2026-09-26 18:14:36 +02:00
foreach ( var role in new [ ] { "map-icon" , "hull-collider" , "shield" , "tractor" } )
if ( anchors . Count ( anchor = > anchor . Role = = role ) ! = 1 )
throw new InvalidOperationException ( $"{ship.Id}: exactly one {role} anchor is required." ) ;
2026-10-03 14:39:08 +02:00
var mounts = new Dictionary < string , HardpointData > ( StringComparer . Ordinal ) ;
2026-09-26 18:14:36 +02:00
var occupiedHardpointCells = new HashSet < ( int x , int y ) > ( ) ;
foreach ( var hardpoint in hull . Hardpoints ? ? new List < HardpointData > ( ) )
{
2026-10-03 14:39:08 +02:00
if ( hardpoint = = null | | string . IsNullOrWhiteSpace ( hardpoint . Transform ) | | mounts . ContainsKey ( hardpoint . Transform ) )
2026-09-26 18:14:36 +02:00
throw new InvalidOperationException ( $"{ship.Id}: hardpoint IDs must be present and unique." ) ;
2026-10-03 14:39:08 +02:00
mounts . Add ( hardpoint . Transform , hardpoint ) ;
Ship S1 fixes: refuse the retired embedded hull, one body per hull, closed validator gaps
Load reads the raw ship authoring payload and refuses a non-nil retired key 1 (the
pre-S1 embedded hull) naming the old shape, so a stale hull is no longer silently
ignored; ship_cc.py refuses the same file on read and therefore on every save.
A hull naming both a Unity prefab and a visual is refused by one predicate
(ShipAuthoringStore.RequireOneBody), called by Validate and by Compose over every hull
in the derived catalog, shipped ones included; a whitespace-only Prefab is unset. This
replaces the mod-ship-only "cannot name a Unity prefab" check.
The validator also refuses non-finite or negative hardpoint Armor and FiringArc, an
undefined HardpointType or ItemRotation, and an anchor Role outside the eight the
schema documents.
Load wraps MessagePack decode failures with the file path. Python replace_layout
refuses non-boolean layout and hardpoint-footprint cells.
Tests: exact Visual key comparison, HullData.Visual at key 32, a save refreshing
stored_at, plus one case per new refusal. HullData.Visual is now [Inspectable] like
Prefab (Unity editor only; not compiled here).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 06:18:20 +02:00
if ( ! Enum . IsDefined ( typeof ( HardpointType ) , hardpoint . Type ) )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown type {(int)hardpoint.Type}." ) ;
if ( ! Enum . IsDefined ( typeof ( ItemRotation ) , hardpoint . Rotation ) )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} has an unknown rotation {(int)hardpoint.Rotation}." ) ;
if ( ! float . IsFinite ( hardpoint . Armor ) | | hardpoint . Armor < 0 | | ! float . IsFinite ( hardpoint . FiringArc ) | | hardpoint . FiringArc < 0 )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} armor and firing arc must be finite and not negative." ) ;
2026-10-12 19:58:22 +02:00
// A thruster's gimbal range is the judge's alone, a row of one included: no thruster swings further than a turn.
if ( hardpoint . Type = = HardpointType . Thruster & & hardpoint . FiringArc > 360f )
throw new InvalidOperationException ( $"{ship.Id}: thruster hardpoint {hardpoint.Transform} firing arc may be at most 360 degrees." ) ;
2026-10-12 03:38:29 +02:00
if ( ! string . IsNullOrEmpty ( hardpoint . Row ) & & hardpoint . Type ! = HardpointType . Thruster )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} is a {hardpoint.Type} hardpoint, and only a Thruster hardpoint may state a row." ) ;
2026-09-26 18:14:36 +02:00
if ( hardpoint . Shape ? . Cells = = null | | ! hardpoint . Shape . Cells . Cast < bool > ( ) . Any ( occupied = > occupied ) )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} has no cells." ) ;
for ( var cellX = 0 ; cellX < hardpoint . Shape . Width ; cellX + + )
for ( var cellY = 0 ; cellY < hardpoint . Shape . Height ; cellY + + )
{
if ( ! hardpoint . Shape . Cells [ cellX , cellY ] ) continue ;
var x = hardpoint . Position . x + cellX ;
var y = hardpoint . Position . y + cellY ;
if ( x < 0 | | y < 0 | | x > = hull . Shape . Width | | y > = hull . Shape . Height | | ! hull . Shape . Cells [ x , y ] )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} lies outside the hull schematic." ) ;
if ( ! occupiedHardpointCells . Add ( ( x , y ) ) )
throw new InvalidOperationException ( $"{ship.Id}: hardpoint {hardpoint.Transform} overlaps another hardpoint." ) ;
}
2026-10-03 14:39:08 +02:00
// Only a hardpoint that shows on the model has a mount anchor, and its type fixes the role that anchor plays.
Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot
- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:30:52 +02:00
var mountAnchor = anchors . FirstOrDefault ( anchor = > anchor . Id = = hardpoint . Transform ) ;
2026-10-03 14:39:08 +02:00
var mountRole = MountRole ( hardpoint . Type ) ;
if ( mountRole = = null & & mountAnchor ! = null )
throw new InvalidOperationException ( $"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} is internal, so no anchor may take its id." ) ;
if ( mountRole ! = null & & mountAnchor = = null )
throw new InvalidOperationException ( $"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} has no model anchor." ) ;
if ( mountRole ! = null & & mountAnchor . Role ! = mountRole )
Ship S2 fixes: structural anchors are not mounts, mod presets refused, one bad mod never bricks the boot
- Validate: a hardpoint's anchor role is fixed by its type (thruster-emitter, radiator-mesh, else articulation); ShipModPlan's
duplicate role check is deleted.
- Loadouts.Commit refuses a preset naming a mod design (mod-hull:/mod-ship:), so the shipped catalog never references a mod.
- Compose quarantines each package: a bad package is excluded and named, a collision excludes every package involved; the
game boots on the rest (or the shipped catalog) and the main menu lists the exclusions.
- RequireDesigns refuses a save with a null item or lot instead of throwing NullReferenceException.
- Tests: per structural role, muzzle id ties, every slot/zone/lot in RequireDesigns, listing order, exclusion cases.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-30 08:30:52 +02:00
throw new InvalidOperationException ( $"{ship.Id}: {hardpoint.Type} hardpoint {hardpoint.Transform} needs a {mountRole} anchor of that id." ) ;
2026-09-26 18:14:36 +02:00
}
2026-10-12 03:38:29 +02:00
foreach ( var row in mounts . Values . Where ( hardpoint = > hardpoint . Type = = HardpointType . Thruster & & ! string . IsNullOrEmpty ( hardpoint . Row ) )
. GroupBy ( hardpoint = > hardpoint . Row , StringComparer . Ordinal ) )
{
var lead = row . First ( ) ;
foreach ( var hardpoint in row )
if ( hardpoint . Rotation ! = lead . Rotation | | hardpoint . FiringArc ! = lead . FiringArc )
throw new InvalidOperationException ( $"{ship.Id}: thruster hardpoints {lead.Transform} and {hardpoint.Transform} are in row {row.Key} and must share Rotation and FiringArc." ) ;
2026-10-12 19:58:22 +02:00
if ( ! ( lead . FiringArc > 0f ) )
throw new InvalidOperationException ( $"{ship.Id}: thruster row {row.Key} must state a gimbal FiringArc above 0." ) ;
2026-10-12 03:38:29 +02:00
}
2026-10-03 14:39:08 +02:00
foreach ( var anchor in anchors )
{
if ( ! string . IsNullOrEmpty ( anchor . ParentId ) & & ! mounts . ContainsKey ( anchor . ParentId ) )
2026-09-26 18:14:36 +02:00
throw new InvalidOperationException ( $"{ship.Id}: anchor {anchor.Id} has unknown hardpoint parent {anchor.ParentId}." ) ;
2026-10-03 14:39:08 +02:00
// A mount-role anchor is the mount of a hardpoint of its type, never a loose node. The hardpoint loop above has
// already refused one whose id names a hardpoint of another type, so here it only has to name a hardpoint.
2026-10-12 21:31:14 +02:00
if ( ( RigChains . Actuates ( anchor . Role ) | | anchor . Role = = "radiator-mesh" ) & & ! mounts . ContainsKey ( anchor . Id ) )
2026-10-03 14:39:08 +02:00
throw new InvalidOperationException ( $"{ship.Id}: {anchor.Role} anchor {anchor.Id} must be the mount of a hardpoint of its type." ) ;
if ( anchor . Role = = "weapon-muzzle" & &
( string . IsNullOrEmpty ( anchor . ParentId ) | | ! IsWeapon ( mounts [ anchor . ParentId ] . Type ) ) )
throw new InvalidOperationException ( $"{ship.Id}: muzzle {anchor.Id} must be parented to a weapon hardpoint's mount." ) ;
}
foreach ( var hardpoint in mounts . Values . Where ( hardpoint = > IsWeapon ( hardpoint . Type ) ) )
if ( ! anchors . Any ( anchor = > anchor . Role = = "weapon-muzzle" & & anchor . ParentId = = hardpoint . Transform ) )
throw new InvalidOperationException ( $"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor." ) ;
2026-10-10 05:23:02 +02:00
ValidateRig ( ship , anchors , mounts , nodeIds ) ;
2026-09-26 18:14:36 +02:00
foreach ( var line in ship . SchematicLines ? ? new List < ShipPolyline > ( ) )
{
var points = line ? . Points ;
if ( points = = null | | points . Length < 6 | | points . Length % 3 ! = 0 | |
points . Any ( value = > float . IsNaN ( value ) | | float . IsInfinity ( value ) ) )
throw new InvalidOperationException ( $"{ship.Id}: schematic line needs finite XYZ points." ) ;
var count = points . Length / 3 ;
if ( line . Radii ! = null & & line . Radii . Length ! = count | |
line . Opacities ! = null & & line . Opacities . Length ! = count )
throw new InvalidOperationException ( $"{ship.Id}: schematic line point attributes have the wrong length." ) ;
if ( line . Radii ! = null & & line . Radii . Any ( value = > float . IsNaN ( value ) | | float . IsInfinity ( value ) | | value < 0 ) | |
2026-09-30 02:14:10 +02:00
line . Opacities ! = null & & line . Opacities . Any ( value = > float . IsNaN ( value ) | | value < 0 | | value > 1 ) )
2026-09-26 18:14:36 +02:00
throw new InvalidOperationException ( $"{ship.Id}: schematic line radius and opacity must be finite and in range." ) ;
if ( line . Color ! = null & & ( line . Color . Length ! = 4 | |
line . Color . Any ( value = > float . IsNaN ( value ) | | float . IsInfinity ( value ) ) ) )
throw new InvalidOperationException ( $"{ship.Id}: schematic line color must be finite RGBA." ) ;
}
}
2026-10-10 05:23:02 +02:00
// A degree of freedom counts as yaw when its axis lies within 5 degrees of Ship Root up (glTF +Y).
private static readonly float YawAxisCos = MathF . Cos ( 5f * MathF . PI / 180f ) ;
// The joints, which anchors ride them, and whether each driven joint's chain can sweep the arc its guns state. The
// joints are presentation, so this judges only that the rig could point the gun where the hardpoint says it may fire.
private static void ValidateRig ( ShipAuthoring ship , List < ShipAnchor > anchors , Dictionary < string , HardpointData > mounts , HashSet < string > nodeIds )
{
var joints = ship . Joints ? ? new List < ShipJoint > ( ) ;
var byId = new Dictionary < string , ShipJoint > ( StringComparer . Ordinal ) ;
foreach ( var joint in joints )
{
if ( joint = = null | | string . IsNullOrWhiteSpace ( joint . Id ) | | byId . ContainsKey ( joint . Id ) )
throw new InvalidOperationException ( $"{ship.Id}: joint IDs must be present and unique." ) ;
if ( nodeIds . Contains ( joint . Id ) )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} is also an anchor's model node." ) ;
byId . Add ( joint . Id , joint ) ;
}
foreach ( var joint in joints )
{
var dofs = joint . Min ? . Length ? ? 0 ;
if ( dofs < 1 | | joint . Max ? . Length ! = dofs | | joint . Axes ? . Length ! = 3 * dofs )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} needs one or more degrees of freedom: Axes holds three values each, Min and Max one each." ) ;
for ( var dof = 0 ; dof < dofs ; dof + + )
{
var ( x , y , z ) = ( joint . Axes [ 3 * dof ] , joint . Axes [ 3 * dof + 1 ] , joint . Axes [ 3 * dof + 2 ] ) ;
var ( min , max ) = ( joint . Min [ dof ] , joint . Max [ dof ] ) ;
if ( ! float . IsFinite ( x ) | | ! float . IsFinite ( y ) | | ! float . IsFinite ( z ) | | ! float . IsFinite ( min ) | | ! float . IsFinite ( max ) )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} axes and limits must be finite." ) ;
if ( MathF . Abs ( MathF . Sqrt ( x * x + y * y + z * z ) - 1f ) > 1e-3f )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} axis {dof} must be a unit vector." ) ;
if ( min < - 180f | | max > 180f | | min > max )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} limits {dof} must satisfy -180 <= Min <= Max <= 180." ) ;
}
if ( ! string . IsNullOrEmpty ( joint . Parent ) & & ! byId . ContainsKey ( joint . Parent ) )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} has an unknown parent joint." ) ;
}
foreach ( var joint in joints )
{
var step = joint ;
for ( var depth = 0 ; ! string . IsNullOrEmpty ( step . Parent ) ; depth + + )
{
if ( depth > = joints . Count )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} is on a parent cycle." ) ;
step = byId [ step . Parent ] ;
}
}
foreach ( var anchor in anchors . Where ( anchor = > ! string . IsNullOrEmpty ( anchor . Joint ) ) )
{
2026-10-12 03:38:29 +02:00
if ( anchor . Role = = "radiator-mesh" )
throw new InvalidOperationException ( $"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint." ) ;
2026-10-12 21:31:14 +02:00
if ( ! RigChains . Actuates ( anchor . Role ) )
2026-10-12 03:38:29 +02:00
throw new InvalidOperationException ( $"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount or a thruster emitter rides one." ) ;
2026-10-10 05:23:02 +02:00
if ( ! byId . ContainsKey ( anchor . Joint ) )
2026-10-12 03:38:29 +02:00
throw new InvalidOperationException ( $"{ship.Id}: {anchor.Role} anchor {anchor.Id} names an unknown joint." ) ;
2026-10-10 05:23:02 +02:00
}
2026-10-12 21:31:14 +02:00
// A joint has one actuator (ruling one-joint-one-actuator), and RigChains.Drives is the only place that says what drives a
// joint: a joint carrying two actuators, or whose reach passes through a joint another actuator drives, is refused here,
// whatever the two actuators are.
var drives = RigChains . Drives ( ship ) . Select ( drive = > ( drive . Joint , Hardpoint : mounts [ drive . Anchor . Id ] ) ) . ToArray ( ) ;
foreach ( var onJoint in drives . GroupBy ( drive = > drive . Joint , StringComparer . Ordinal ) )
{
var lead = onJoint . First ( ) . Hardpoint ;
var other = onJoint . Select ( drive = > drive . Hardpoint ) . FirstOrDefault ( hardpoint = > RigChains . Actuator ( hardpoint ) ! = RigChains . Actuator ( lead ) ) ;
if ( other ! = null )
throw new InvalidOperationException ( $"{ship.Id}: joint {onJoint.Key} carries more than one actuator ({lead.Transform} and {other.Transform}); a joint has one actuator." ) ;
}
var driven = RigChains . Driven ( ship ) ;
foreach ( var joint in driven )
{
var through = RigChains . Chain ( ship , joint ) . SkipLast ( 1 ) . FirstOrDefault ( driven . Contains ) ;
if ( through ! = null )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint} reaches through joint {through}, which another actuator drives; a joint has one actuator." ) ;
2026-10-10 05:23:02 +02:00
}
2026-10-12 21:31:14 +02:00
var ridden = drives . Where ( drive = > IsWeapon ( drive . Hardpoint . Type ) ) . ToArray ( ) ;
foreach ( var ( _ , hardpoint ) in ridden )
2026-10-10 05:23:02 +02:00
if ( ! ( hardpoint . FiringArc > 0f ) )
throw new InvalidOperationException ( $"{ship.Id}: weapon hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0." ) ;
foreach ( var group in ridden . GroupBy ( mount = > mount . Joint , StringComparer . Ordinal ) )
{
var lead = group . First ( ) . Hardpoint ;
2026-10-12 21:31:14 +02:00
foreach ( var ( _ , hardpoint ) in group )
2026-10-10 05:23:02 +02:00
if ( hardpoint . Rotation ! = lead . Rotation | | hardpoint . FiringArc ! = lead . FiringArc )
throw new InvalidOperationException ( $"{ship.Id}: weapon hardpoints {lead.Transform} and {hardpoint.Transform} ride joint {group.Key} and must share Rotation and FiringArc." ) ;
var reach = YawReach ( ship , group . Key ) ;
if ( reach < lead . FiringArc )
throw new InvalidOperationException ( $"{ship.Id}: joint {group.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree firing arc of {lead.Transform}." ) ;
}
2026-10-12 03:38:29 +02:00
// A thruster row is a gimbal: its hardpoints' stated FiringArc is how far the row swings, so a row on a joint rides it
2026-10-12 21:31:14 +02:00
// whole, and the joint's chain must be able to sweep that arc. A hardpoint with no Row is a row of one.
2026-10-12 03:38:29 +02:00
var thrusters = mounts . Values . Where ( hardpoint = > hardpoint . Type = = HardpointType . Thruster ) . ToArray ( ) ;
2026-10-12 21:31:14 +02:00
var tilted = drives . Where ( drive = > drive . Hardpoint . Type = = HardpointType . Thruster ) . ToArray ( ) ;
foreach ( var ( _ , hardpoint ) in tilted )
2026-10-12 03:38:29 +02:00
if ( ! ( hardpoint . FiringArc > 0f ) )
throw new InvalidOperationException ( $"{ship.Id}: thruster hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0; a fixed thruster may not ride a joint." ) ;
2026-10-12 21:31:14 +02:00
foreach ( var onJoint in tilted . GroupBy ( drive = > drive . Joint , StringComparer . Ordinal ) )
2026-10-12 03:38:29 +02:00
{
2026-10-12 21:31:14 +02:00
var lead = onJoint . First ( ) . Hardpoint ;
var members = string . IsNullOrEmpty ( lead . Row ) ? new [ ] { lead } : thrusters . Where ( hardpoint = > hardpoint . Row = = lead . Row ) . ToArray ( ) ;
2026-10-12 03:38:29 +02:00
foreach ( var member in members )
2026-10-12 21:31:14 +02:00
if ( ! tilted . Any ( drive = > drive . Hardpoint = = member & & drive . Joint = = onJoint . Key ) )
2026-10-12 03:38:29 +02:00
throw new InvalidOperationException ( $"{ship.Id}: thruster hardpoint {member.Transform} is in the row of {lead.Transform} on joint {onJoint.Key} and must ride it too; a row rides one joint whole." ) ;
var reach = YawReach ( ship , onJoint . Key ) ;
if ( reach < lead . FiringArc )
throw new InvalidOperationException ( $"{ship.Id}: joint {onJoint.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree gimbal arc of {lead.Transform}." ) ;
}
2026-10-10 05:23:02 +02:00
}
// The yaw a driven joint's chain can sweep: the summed range of every degree of freedom whose axis is yaw, capped at a turn.
private static float YawReach ( ShipAuthoring ship , string jointId )
{
var joints = ship . Joints . ToDictionary ( joint = > joint . Id , StringComparer . Ordinal ) ;
var reach = 0f ;
foreach ( var joint in RigChains . Chain ( ship , jointId ) . Select ( id = > joints [ id ] ) )
for ( var dof = 0 ; dof < joint . Min . Length ; dof + + )
if ( MathF . Abs ( joint . Axes [ 3 * dof + 1 ] ) > = YawAxisCos )
reach + = joint . Max [ dof ] - joint . Min [ dof ] ;
return MathF . Min ( reach , 360f ) ;
}
}
2026-10-12 21:31:14 +02:00
// A rig's actuators and the joints they drive. An actuator is a weapon mount or a thruster emitter that names a joint; a gun
// group (every gun on a joint) or a thruster row is one actuator, and a joint has at most one (ruling one-joint-one-actuator).
// This is the only place that says which anchors are actuators, so the judge, the binder and the plan all follow it.
2026-10-10 05:23:02 +02:00
public static class RigChains
{
2026-10-12 21:31:14 +02:00
public static bool Actuates ( string role ) = > role = = "weapon-mount" | | role = = "thruster-emitter" ;
// Every actuator anchor that names a joint, with the joint it drives.
public static IEnumerable < ( string Joint , ShipAnchor Anchor ) > Drives ( ShipAuthoring ship ) = >
ship . Anchors . Where ( anchor = > Actuates ( anchor . Role ) & & ! string . IsNullOrEmpty ( anchor . Joint ) ) . Select ( anchor = > ( anchor . Joint , anchor ) ) ;
2026-10-10 05:23:02 +02:00
public static HashSet < string > Driven ( ShipAuthoring ship ) = >
2026-10-12 21:31:14 +02:00
new HashSet < string > ( Drives ( ship ) . Select ( drive = > drive . Joint ) , StringComparer . Ordinal ) ;
// The actuator a mount belongs to: all guns on a joint are one, a thruster is its row, and a thruster of no row is its own.
public static string Actuator ( HardpointData hardpoint ) = >
ShipAuthoringStore . IsWeapon ( hardpoint . Type ) ? "weapon" : string . IsNullOrEmpty ( hardpoint . Row ) ? "thruster:" + hardpoint . Transform : "row:" + hardpoint . Row ;
2026-10-10 05:23:02 +02:00
2026-10-12 21:31:14 +02:00
// The joint and its ancestors, root-first. The judge refuses a driven joint whose ancestors another actuator drives,
// so for an accepted ship this is the whole chain the actuator's reach is summed over.
2026-10-10 05:23:02 +02:00
public static string [ ] Chain ( ShipAuthoring ship , string jointId )
{
var joints = ship . Joints . ToDictionary ( joint = > joint . Id , StringComparer . Ordinal ) ;
var chain = new List < string > { jointId } ;
2026-10-12 21:31:14 +02:00
for ( var parent = joints [ jointId ] . Parent ; ! string . IsNullOrEmpty ( parent ) ; parent = joints [ parent ] . Parent )
2026-10-10 05:23:02 +02:00
chain . Add ( parent ) ;
chain . Reverse ( ) ;
return chain . ToArray ( ) ;
}
2026-09-26 18:14:36 +02:00
}