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 > ( ) ;
}
[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,
// weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount).
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 ;
}
// 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-03 14:39:08 +02:00
"map-icon" , "hull-collider" , "shield" , "tractor" , "thruster-emitter" , "weapon-mount" , "weapon-muzzle" , "radiator-mesh" ,
"articulation"
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-09-30 03:06:43 +02:00
cache . AddBackingStore ( new SingleFileMessagePackBackingStore ( path , ! writable ) , new [ ] { typeof ( HullData ) , typeof ( ShipAuthoring ) } ) ;
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-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
// its deterministic key, the hull naming that visual by its typed ref.
public static ( HullData Hull , ShipAuthoring Visual ) 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 ) ; }
catch ( MessagePackSerializationException error )
{
throw new InvalidOperationException ( $"{path}: a record does not decode as its schema: {error.Message}" , error ) ;
}
}
// 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 ) ; }
catch ( MessagePackSerializationException error )
{
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 ;
private static ( HullData Hull , ShipAuthoring Visual ) 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}." ) ;
return ( hull , visual ) ;
}
public static ( HullData Hull , ShipAuthoring Visual ) Read ( string path )
{
var ( hull , visual ) = Load ( path ) ;
Validate ( hull , visual ) ;
return ( hull , visual ) ;
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-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." ) ;
if ( string . IsNullOrWhiteSpace ( ship . Id ) ) throw new InvalidOperationException ( "Ship ID is required." ) ;
2026-09-26 18:30:02 +02:00
if ( ! ( ship . Id [ 0 ] > = 'a' & & ship . Id [ 0 ] < = 'z' | | ship . Id [ 0 ] > = '0' & & ship . Id [ 0 ] < = '9' ) | | ship . Id . Any ( c = >
! ( c > = 'a' & & c < = 'z' | | c > = '0' & & c < = '9' | | c = = '.' | | c = = '_' | | c = = '-' ) ) )
throw new InvalidOperationException ( $"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens." ) ;
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-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-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-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.
if ( ( anchor . Role = = "thruster-emitter" | | anchor . Role = = "radiator-mesh" | | anchor . Role = = "weapon-mount" ) & &
! mounts . ContainsKey ( anchor . Id ) )
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-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." ) ;
}
}
}