2026-09-26 18:30:02 +02:00
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using GameCult.Caching ;
using Newtonsoft.Json.Linq ;
using System.Text ;
// Builds the one catalog snapshot CultCache needs. Sources remain the shipped catalog and each
// mod-owned ship.cc; this output is disposable and never becomes an authoring surface.
public static class ShipModCatalog
{
2026-09-26 19:10:44 +02:00
public sealed class Package
{
2026-09-30 03:06:43 +02:00
public HullData Hull ;
public ShipAuthoring Visual ;
2026-09-26 19:10:44 +02:00
public string ModelPath ;
public Dictionary < string , uint > NodeIndices ;
2026-10-12 03:54:30 +02:00
// The package's own products, read from its ship.cc; empty for a package taken from the catalog, where gameplay reads products.
public FactionProductData [ ] Products = Array . Empty < FactionProductData > ( ) ;
2026-09-26 19:10:44 +02:00
}
2026-09-26 18:30:02 +02:00
public static CultRecordKey HullKey ( string id ) = > new CultRecordKey ( "mod-hull:" + id ) ;
public static CultRecordKey AuthoringKey ( string id ) = > new CultRecordKey ( "mod-ship:" + id ) ;
2026-10-12 03:54:30 +02:00
public static CultRecordKey ProductKey ( string id , CultRecordKey maker ) = > new CultRecordKey ( "mod-product:" + id + ":" + maker . Value ) ;
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
// A key the mods own. The shipped catalog and its authored records never reference one: a mod may be uninstalled.
public static bool IsModKey ( CultRecordKey key ) = >
2026-10-12 03:54:30 +02:00
key . IsSet ( ) & & ( key . Value . StartsWith ( "mod-hull:" , StringComparison . Ordinal ) | | key . Value . StartsWith ( "mod-ship:" , StringComparison . Ordinal ) | | key . Value . StartsWith ( "mod-product:" , StringComparison . Ordinal ) ) ;
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
// A package left out of the composition, named by its directory, with every reason it was left out.
public sealed class Exclusion
{
public string Package ;
public string Reason ;
public override string ToString ( ) = > $"{Package}: {Reason}" ;
}
public sealed class Composition
{
// Ship IDs of the packages composed into the derived catalog, in directory order.
public string [ ] Included ;
// Packages that failed on their own or collided with the catalog or with each other, in directory order.
public Exclusion [ ] Excluded ;
}
// One bad package never stops the rest. Each package is read and validated on its own; one that fails is excluded and
// named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved,
// since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages.
// Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed.
2026-10-10 06:50:18 +02:00
public static Composition Compose ( string shippedCatalog , string outputCatalog , string modsRoot , CultDocumentRegistry registry = null )
2026-09-26 18:30:02 +02:00
{
var source = Path . GetFullPath ( shippedCatalog ) ;
var output = Path . GetFullPath ( outputCatalog ) ;
var root = Path . GetFullPath ( modsRoot ) ;
if ( ! File . Exists ( source ) ) throw new FileNotFoundException ( "Shipped catalog is missing" , source ) ;
if ( ! Directory . Exists ( root ) ) throw new DirectoryNotFoundException ( $"Mod root is missing: {root}" ) ;
if ( string . Equals ( source , output , StringComparison . OrdinalIgnoreCase ) )
throw new InvalidOperationException ( "The derived catalog cannot replace the shipped catalog." ) ;
if ( output . StartsWith ( root + Path . DirectorySeparatorChar , StringComparison . OrdinalIgnoreCase ) )
throw new InvalidOperationException ( "The derived catalog cannot replace a mod source or asset." ) ;
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 reasons = new SortedDictionary < string , List < string > > ( StringComparer . Ordinal ) ;
void Exclude ( string package , string reason )
{
if ( ! reasons . TryGetValue ( package , out var list ) ) reasons [ package ] = list = new List < string > ( ) ;
list . Add ( reason ) ;
}
var packages = new List < ( string Directory , Package Package ) > ( ) ;
2026-09-26 18:30:02 +02:00
foreach ( var directory in Directory . GetDirectories ( root ) . OrderBy ( path = > path , StringComparer . Ordinal ) )
{
var path = Path . Combine ( directory , "ship.cc" ) ;
2026-10-10 07:35:08 +02:00
if ( ! File . Exists ( path ) ) continue ;
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 name = Path . GetFileName ( directory ) ;
// A package can fail in any way a hostile or broken file can, so its failure is quarantined whatever it is.
try { packages . Add ( ( name , ReadPackage ( path ) ) ) ; }
catch ( Exception error ) { Exclude ( name , error . Message ) ; }
2026-09-26 18:30:02 +02:00
}
Directory . CreateDirectory ( Path . GetDirectoryName ( output ) ) ;
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
// The store keeps a lock file beside every file it opens, so the working copy gets its own directory and the
// whole directory goes away, whatever the store left in it.
var workspace = output + "." + Guid . NewGuid ( ) . ToString ( "N" ) + ".tmp" ;
var temporary = Path . Combine ( workspace , "catalog.cc" ) ;
2026-09-26 18:30:02 +02:00
try
{
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
Directory . CreateDirectory ( workspace ) ;
2026-09-26 18:30:02 +02:00
File . Copy ( source , temporary ) ;
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
Composition composition ;
2026-10-10 06:50:18 +02:00
using ( var cache = AetheriaStores . Open ( temporary , catalogWritable : true , registry : registry ) )
2026-09-26 18:30:02 +02:00
{
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
// Collisions are judged against the shipped records alone, before any package is written, so the
// verdict does not depend on directory order. CultCache indexes names per concrete type and lets the
// last writer win, and GetByName over a base type throws on a match across subtypes: a mod hull named
// like any shipped item, or like another mod's hull, would make name lookups wrong or throw.
var keys = new HashSet < CultRecordKey > ( cache . AllStoredDocuments . Select ( record = > record . Key ) ) ;
var itemNames = new HashSet < string > ( cache . GetAll < ItemData > ( ) . Select ( item = > item . Name ) , StringComparer . Ordinal ) ;
var shipIds = new HashSet < string > ( cache . GetAll < ShipAuthoring > ( ) . Select ( existing = > existing . Id ) , StringComparer . Ordinal ) ;
2026-10-12 03:54:30 +02:00
var factionKeys = new HashSet < CultRecordKey > ( cache . GetAll < Faction > ( ) . Select ( faction = > cache . TryGetHandle ( faction ) . Value . Key ) ) ;
var productNames = new HashSet < string > ( cache . GetAll < FactionProductData > ( ) . Select ( product = > product . Name ) , StringComparer . Ordinal ) ;
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
foreach ( var ( directory , package ) in packages )
2026-09-26 18:30:02 +02:00
{
2026-09-30 03:06:43 +02:00
var ( hull , ship ) = ( package . Hull , package . Visual ) ;
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
if ( keys . Contains ( HullKey ( ship . Id ) ) | | keys . Contains ( AuthoringKey ( ship . Id ) ) )
Exclude ( directory , $"a mod key collides with an existing catalog record." ) ;
if ( itemNames . Contains ( hull . Name ) )
Exclude ( directory , $"hull name '{hull.Name}' collides with an existing catalog item." ) ;
if ( shipIds . Contains ( ship . Id ) )
Exclude ( directory , $"ship ID collides with an existing catalog ship authoring record." ) ;
2026-10-12 03:54:30 +02:00
foreach ( var product in package . Products )
{
if ( ! factionKeys . Contains ( product . Manufacturer . Key ) )
Exclude ( directory , $"product '{product.Name}' names a manufacturer the shipped catalog holds no faction for." ) ;
if ( keys . Contains ( ProductKey ( ship . Id , product . Manufacturer . Key ) ) )
Exclude ( directory , $"a mod product key collides with an existing catalog record." ) ;
if ( productNames . Contains ( product . Name ) )
Exclude ( directory , $"product name '{product.Name}' collides with an existing catalog product." ) ;
}
2026-09-26 18:30:02 +02:00
}
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
foreach ( var claim in packages . GroupBy ( package = > package . Package . Hull . Name , StringComparer . Ordinal ) . Where ( group = > group . Count ( ) > 1 ) )
foreach ( var ( directory , package ) in claim )
Exclude ( directory , $"hull name '{claim.Key}' is also claimed by " +
string . Join ( ", " , claim . Where ( other = > other . Directory ! = directory ) . Select ( other = > other . Directory ) ) + "." ) ;
2026-10-12 03:54:30 +02:00
foreach ( var claim in packages . SelectMany ( package = > package . Package . Products . Select ( product = > ( package . Directory , product . Name ) ) )
. GroupBy ( entry = > entry . Name , StringComparer . Ordinal ) . Where ( group = > group . Select ( entry = > entry . Directory ) . Distinct ( ) . Count ( ) > 1 ) )
foreach ( var directory in claim . Select ( entry = > entry . Directory ) . Distinct ( ) )
Exclude ( directory , $"product name '{claim.Key}' is also claimed by " +
string . Join ( ", " , claim . Select ( entry = > entry . Directory ) . Where ( other = > other ! = directory ) . Distinct ( ) ) + "." ) ;
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 included = packages . Where ( package = > ! reasons . ContainsKey ( package . Directory ) ) . Select ( package = > package . Package ) . ToArray ( ) ;
foreach ( var package in included )
2026-10-12 03:54:30 +02:00
{
// Verbatim: the mod store's own records, under the keys it already holds them at.
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
ShipAuthoringStore . Write ( cache , package . Hull , package . Visual ) ;
2026-10-12 03:54:30 +02:00
foreach ( var product in package . Products ) ShipAuthoringStore . WriteProduct ( cache , package . Visual . Id , product ) ;
}
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
// Shipped hulls too: the derived catalog must not hold a hull that names two bodies.
foreach ( var hull in cache . GetAll < HullData > ( ) )
ShipAuthoringStore . RequireOneBody ( hull , hull . Name ) ;
2026-09-26 18:30:02 +02:00
cache . FlushAsync ( ) . GetAwaiter ( ) . GetResult ( ) ;
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
composition = new Composition
{
Included = included . Select ( package = > package . Visual . Id ) . ToArray ( ) ,
Excluded = reasons . Select ( entry = > new Exclusion { Package = entry . Key , Reason = string . Join ( "; " , entry . Value ) } ) . ToArray ( )
} ;
2026-09-26 18:30:02 +02:00
}
if ( File . Exists ( output ) ) File . Replace ( temporary , output , null ) ;
else File . Move ( temporary , output ) ;
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
return composition ;
2026-09-26 18:30:02 +02:00
}
finally
{
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
if ( Directory . Exists ( workspace ) ) Directory . Delete ( workspace , true ) ;
2026-09-26 18:30:02 +02:00
}
}
2026-09-26 19:10:44 +02:00
public static Package ReadPackage ( string shipPath )
{
var path = Path . GetFullPath ( shipPath ) ;
2026-10-12 03:54:30 +02:00
var ( hull , ship , products ) = ShipAuthoringStore . Read ( path ) ;
return Bind ( hull , ship , products , Path . GetDirectoryName ( path ) , path ) ;
2026-09-30 06:43:52 +02:00
}
// The package a catalog hull names: its two records come from the catalog, and only the GLB comes from the mod
// directory (<modsRoot>/<id>/<ModelAsset>). Gameplay reads a mod ship through this, never through its ship.cc.
public static Package PackageOf ( CultCache catalog , HullData hull , string modsRoot )
{
var ship = catalog . Get ( hull . Visual ) ? ?
throw new InvalidOperationException ( $"{hull.Name}: the catalog holds no visual record {hull.Visual.Key.Value}." ) ;
ShipAuthoringStore . Validate ( hull , ship ) ;
var directory = Path . Combine ( Path . GetFullPath ( modsRoot ) , ship . Id ) ;
2026-10-12 03:54:30 +02:00
return Bind ( hull , ship , Array . Empty < FactionProductData > ( ) , directory , Path . Combine ( directory , "ship.cc" ) ) ;
2026-09-30 06:43:52 +02:00
}
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
// The one game-side choice of catalog: the shipped file when no package is installed or none survives composition,
// otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every
// call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu.
2026-10-10 06:50:18 +02:00
public static ( string Catalog , Exclusion [ ] Excluded ) ResolveCatalog ( string shippedCatalog , string derivedCatalog , string modsRoot ,
CultDocumentRegistry registry = null )
2026-09-30 06:43:52 +02:00
{
2026-10-10 07:35:08 +02:00
if ( ! Directory . Exists ( modsRoot ) | |
! Directory . GetDirectories ( modsRoot ) . Any ( directory = > File . Exists ( Path . Combine ( directory , "ship.cc" ) ) ) )
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
return ( shippedCatalog , Array . Empty < Exclusion > ( ) ) ;
2026-09-30 10:06:16 +02:00
Composition composition ;
// A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot.
2026-10-10 06:50:18 +02:00
try { composition = Compose ( shippedCatalog , derivedCatalog , modsRoot , registry ) ; }
2026-09-30 10:06:16 +02:00
catch ( Exception error ) { return ( shippedCatalog , new [ ] { new Exclusion { Package = "(all mods)" , Reason = error . Message } } ) ; }
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
return ( composition . Included . Length = = 0 ? shippedCatalog : derivedCatalog , composition . Excluded ) ;
2026-09-30 06:43:52 +02:00
}
2026-10-16 21:47:38 +02:00
// The one uninstall: presence under Mods is the only enablement (MR1), so a package leaves by moving, never by deletion,
// to <parent of modsRoot>/RemovedMods/<package> ('<package> (2)' and on when taken). Moving it back reinstalls it.
// Returns the destination. Compose and ResolveCatalog never read RemovedMods.
public static string RemovePackage ( string modsRoot , string package )
{
2026-10-16 22:49:44 +02:00
var root = Path . GetFullPath ( modsRoot ) . TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) ;
// Containment, not a blacklist: the package must resolve to a strict child of the mods folder whose final name is
// the name given. A name the filesystem rewrites ('...', 'a.', 'a ', 'CON', 'a:b') resolves to something else.
if ( ! IsPackageName ( package ) ) throw new ArgumentException ( "Not a mod package name." , nameof ( package ) ) ;
var source = ChildOf ( root , package ) ? ? throw new ArgumentException ( "Not a mod package name." , nameof ( package ) ) ;
var info = new DirectoryInfo ( source ) ;
if ( ! info . Exists ) throw new DirectoryNotFoundException ( "The package is not a directory under the mods folder." ) ;
if ( info . Attributes . HasFlag ( FileAttributes . ReparsePoint ) )
throw new ArgumentException ( "A package that is a link is not removed." , nameof ( package ) ) ;
2026-10-16 21:47:38 +02:00
var removed = RemovedRoot ( modsRoot ) ;
Directory . CreateDirectory ( removed ) ;
2026-10-16 22:49:44 +02:00
var destination = ChildOf ( removed , package ) ;
for ( var copy = 2 ; destination ! = null & & ( Directory . Exists ( destination ) | | File . Exists ( destination ) ) ; copy + + )
destination = ChildOf ( removed , $"{package} ({copy})" ) ;
if ( destination = = null ) throw new ArgumentException ( "Not a mod package name." , nameof ( package ) ) ;
2026-10-16 21:47:38 +02:00
Directory . Move ( source , destination ) ;
return destination ;
}
2026-10-16 22:49:44 +02:00
private static readonly string [ ] ReservedDeviceNames =
{ "CON" , "PRN" , "AUX" , "NUL" , "COM1" , "COM2" , "COM3" , "COM4" , "COM5" , "COM6" , "COM7" , "COM8" , "COM9" ,
"LPT1" , "LPT2" , "LPT3" , "LPT4" , "LPT5" , "LPT6" , "LPT7" , "LPT8" , "LPT9" } ;
2026-10-16 23:16:05 +02:00
// A name Windows keeps as written: no separator, stream colon or wildcard, no trailing dot or space, and
2026-10-16 22:49:44 +02:00
// not a device name (with or without an extension). Held on every platform so a mod moves the same way everywhere.
private static bool IsPackageName ( string name ) = >
2026-10-16 23:16:05 +02:00
! string . IsNullOrEmpty ( name ) & & name ! = "(all mods)" & &
2026-10-16 22:49:44 +02:00
name . IndexOfAny ( Path . GetInvalidFileNameChars ( ) ) < 0 & & name . IndexOfAny ( new [ ] { ':' , '*' , '?' , '"' , '<' , '>' , '|' , '/' , '\\' } ) < 0 & &
name . All ( character = > character > = ' ' ) & & ! name . EndsWith ( '.' ) & & ! name . EndsWith ( ' ' ) & &
! ReservedDeviceNames . Contains ( name . Split ( '.' ) [ 0 ] . TrimEnd ( ' ' ) , StringComparer . OrdinalIgnoreCase ) ;
// The full path of `name` directly under `root`, or null when the filesystem would resolve it anywhere else or under
// another name. The comparison is of resolved path segments, never of a string prefix ('Mods2' is not inside 'Mods').
private static string ChildOf ( string root , string name )
{
var full = Path . GetFullPath ( Path . Combine ( root , name ) ) ;
return string . Equals ( Path . GetDirectoryName ( full ) , root , StringComparison . Ordinal ) & &
string . Equals ( Path . GetFileName ( full ) , name , StringComparison . Ordinal ) ? full : null ;
}
2026-10-16 21:47:38 +02:00
private static string RemovedRoot ( string modsRoot ) = >
2026-10-16 22:49:44 +02:00
Path . Combine ( Path . GetDirectoryName ( Path . GetFullPath ( modsRoot ) . TrimEnd ( Path . DirectorySeparatorChar , Path . AltDirectorySeparatorChar ) ) , "RemovedMods" ) ;
2026-10-16 21:47:38 +02:00
2026-10-12 03:54:30 +02:00
private static Package Bind ( HullData hull , ShipAuthoring ship , FactionProductData [ ] products , string directory , string path )
2026-09-30 06:43:52 +02:00
{
2026-09-26 19:10:44 +02:00
if ( ! string . Equals ( Path . GetFileName ( directory ) , ship . Id , StringComparison . Ordinal ) )
throw new InvalidOperationException ( $"{path}: ship ID must match its package directory name." ) ;
var modelPath = Path . GetFullPath ( Path . Combine ( directory , ship . ModelAsset ) ) ;
Pin Compose, ReadPackage and ReadNodeIds; refuse name collisions (S-Soul F2, F6)
Compose, ReadPackage and ReadNodeIds had no test. ShipModCatalogTests now
covers the derived catalog, the atomic replace of an existing one, every
failure path leaving the previous derived file byte-identical, the unsafe
source/output refusals, package binding (directory name, GLB existence and
extension, anchor node presence), and the GLB reader (each header guard, the
JSON chunk boundary, node index accounting, duplicate ids).
Writing them exposed three defects in Compose, fixed here:
- F6: AetheriaStores.CatalogTypes routes ShipAuthoring to the shipped
catalog, and CultCache indexes names per concrete type with last writer
winning while GetByName over a base type throws on a cross-subtype match.
Compose checked keys only, so a mod hull named like a shipped item, or like
another mod's hull, silently shadowed it or broke lookups. It now refuses
a hull name matching any catalog ItemData and a ship ID matching an
existing ShipAuthoring, naming the ship.
- The backing store writes a .lock file beside every file it opens, so each
Compose left <output>.<guid>.tmp.lock behind, success or failure, once per
boot. The working copy now lives in its own directory that is deleted whole.
- Deleted: the duplicate-ID check (ReadPackage already pins ID to directory
name, so it could not fire) and ReadPackage's containment StartsWith
(Validate already refuses rooted and `..` model paths on the same Read).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 23:47:13 +02:00
// Containment is ShipAuthoringStore.Validate's job: it refuses rooted and `..` model paths before this line.
2026-10-10 06:50:18 +02:00
if ( ! File . Exists ( modelPath ) | | ! string . Equals ( Path . GetExtension ( modelPath ) , ".glb" , StringComparison . Ordinal ) )
2026-09-26 19:10:44 +02:00
throw new InvalidOperationException ( $"{ship.Id}: model asset must name an existing GLB inside its package." ) ;
var modelNodes = ReadNodeIds ( modelPath ) ;
foreach ( var anchor in ship . Anchors )
if ( ! modelNodes . ContainsKey ( anchor . ModelNodeId ) )
throw new InvalidOperationException ( $"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}." ) ;
2026-10-16 21:37:51 +02:00
// Assemble reads a renderer or collider mesh from these anchors' nodes and refuses without one, and reads the shield
// node's scale as three positive radii (ruling thrusters-radiators-are-meshes). These refusals stand in for Assemble's
// throws, so a package Compose includes is one the importer's assembler can build; the exhaust emission surface and
// the glowing radiator are meshes by that same ruling. A weapon mount and a tractor may be empties.
2026-10-03 17:07:08 +02:00
foreach ( var anchor in ship . Anchors )
2026-10-16 21:37:51 +02:00
if ( ( anchor . Role = = "thruster-emitter" | | anchor . Role = = "radiator-mesh" | | anchor . Role = = "map-icon" | | anchor . Role = = "hull-collider" ) & &
! modelNodes [ anchor . ModelNodeId ] . HasMesh )
2026-10-03 17:07:08 +02:00
throw new InvalidOperationException ( $"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none." ) ;
2026-10-16 21:37:51 +02:00
foreach ( var anchor in ship . Anchors )
if ( anchor . Role = = "shield" & & ! modelNodes [ anchor . ModelNodeId ] . PositiveScale )
throw new InvalidOperationException ( $"{ship.Id}: shield anchor {anchor.Id} needs three positive scale radii on model node {anchor.ModelNodeId}, with no matrix." ) ;
2026-10-10 05:23:02 +02:00
BindRig ( ship , modelNodes ) ;
2026-09-30 03:06:43 +02:00
CultRecordRefs . Validate ( hull ) ;
2026-10-03 17:07:08 +02:00
return new Package
{
2026-10-12 03:54:30 +02:00
Hull = hull , Visual = ship , Products = products , ModelPath = modelPath ,
2026-10-03 17:07:08 +02:00
NodeIndices = modelNodes . ToDictionary ( node = > node . Key , node = > node . Value . Index , StringComparer . Ordinal )
} ;
2026-09-26 19:10:44 +02:00
}
2026-10-10 05:23:02 +02:00
// The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's
2026-10-12 03:38:29 +02:00
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a radiator
// mesh sits under no joint.
2026-10-16 21:37:51 +02:00
private static void BindRig ( ShipAuthoring ship , Dictionary < string , ( uint Index , bool HasMesh , int Parent , bool PositiveScale ) > nodes )
2026-10-10 05:23:02 +02:00
{
var joints = new HashSet < string > ( ( ship . Joints ? ? new List < ShipJoint > ( ) ) . Select ( joint = > joint . Id ) , StringComparer . Ordinal ) ;
var idAt = nodes . ToDictionary ( node = > ( int ) node . Value . Index , node = > node . Key ) ;
// Parent is already the nearest ancestor that carries an id, so the walk up is the chain of those.
string JointAbove ( string nodeId )
{
// glTF forbids cycles; the bound keeps a malformed model from hanging the binder.
var parent = nodes [ nodeId ] . Parent ;
for ( var steps = 0 ; parent > = 0 & & steps < = nodes . Count ; steps + + , parent = nodes [ idAt [ parent ] ] . Parent )
if ( joints . Contains ( idAt [ parent ] ) ) return idAt [ parent ] ;
return null ;
}
foreach ( var joint in ship . Joints ? ? new List < ShipJoint > ( ) )
{
if ( ! nodes . ContainsKey ( joint . Id ) )
throw new InvalidOperationException ( $"{ship.Id}: model has no node with aetheria.id={joint.Id} for a joint." ) ;
if ( ! string . Equals ( JointAbove ( joint . Id ) ? ? "" , joint . Parent ? ? "" , StringComparison . Ordinal ) )
throw new InvalidOperationException ( $"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent." ) ;
}
2026-10-12 21:31:14 +02:00
foreach ( var anchor in ship . Anchors . Where ( anchor = > RigChains . Actuates ( anchor . Role ) | | anchor . Role = = "radiator-mesh" ) )
2026-10-10 05:23:02 +02:00
{
var above = JointAbove ( anchor . ModelNodeId ) ;
if ( string . Equals ( above ? ? "" , anchor . Joint ? ? "" , StringComparison . Ordinal ) ) continue ;
2026-10-12 03:38:29 +02:00
throw new InvalidOperationException ( anchor . Role = = "radiator-mesh"
? $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model."
: $"{ship.Id}: {(anchor.Role == " weapon - mount " ? " weapon mount " : " thruster emitter ")} {anchor.Id} must name the nearest joint above its model node as its Joint." ) ;
2026-10-10 05:23:02 +02:00
}
}
2026-10-16 21:37:51 +02:00
// No matrix, and every scale component (default 1) above zero: what Assemble's shield radii need.
private static bool PositiveScale ( JObject node ) = >
node [ "matrix" ] = = null & & ( node [ "scale" ] = = null | | node [ "scale" ] . Select ( component = > ( double ) component ) . All ( component = > component > 0 ) ) ;
2026-10-10 05:23:02 +02:00
// GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index
2026-10-16 21:37:51 +02:00
// (-1 for none) are read here, plus whether the node's scale is three positive radii with no matrix; geometry belongs to the runtime importer.
public static Dictionary < string , ( uint Index , bool HasMesh , int Parent , bool PositiveScale ) > ReadNodeIds ( string path )
2026-09-26 18:30:02 +02:00
{
using var stream = File . OpenRead ( path ) ;
using var reader = new BinaryReader ( stream ) ;
if ( stream . Length < 20 | | reader . ReadUInt32 ( ) ! = 0x46546C67 | | reader . ReadUInt32 ( ) ! = 2 | |
reader . ReadUInt32 ( ) ! = stream . Length )
throw new InvalidOperationException ( $"{path}: invalid GLB 2 header." ) ;
var chunkLength = reader . ReadUInt32 ( ) ;
if ( reader . ReadUInt32 ( ) ! = 0x4E4F534A | | chunkLength > stream . Length - stream . Position )
throw new InvalidOperationException ( $"{path}: missing GLB JSON chunk." ) ;
var json = JObject . Parse ( Encoding . UTF8 . GetString ( reader . ReadBytes ( ( int ) chunkLength ) ) ) ;
2026-10-16 21:37:51 +02:00
var ids = new Dictionary < string , ( uint Index , bool HasMesh , int Parent , bool PositiveScale ) > ( StringComparer . Ordinal ) ;
2026-10-10 05:23:02 +02:00
var nodes = ( json [ "nodes" ] ? . OfType < JObject > ( ) ? ? Enumerable . Empty < JObject > ( ) ) . ToArray ( ) ;
var parents = Enumerable . Repeat ( - 1 , nodes . Length ) . ToArray ( ) ;
for ( var parent = 0 ; parent < nodes . Length ; parent + + )
foreach ( var child in nodes [ parent ] [ "children" ] ? . Select ( token = > ( int ) token ) ? ? Enumerable . Empty < int > ( ) )
if ( child > = 0 & & child < nodes . Length ) parents [ child ] = parent ;
2026-09-26 19:10:44 +02:00
uint index = 0 ;
foreach ( var node in nodes )
2026-09-26 18:30:02 +02:00
{
var id = ( string ) node [ "extras" ] ? [ "aetheria.id" ] ;
2026-09-26 19:10:44 +02:00
if ( string . IsNullOrEmpty ( id ) ) { index + + ; continue ; }
2026-10-16 21:37:51 +02:00
if ( ! ids . TryAdd ( id , ( index , node [ "mesh" ] ! = null & & node [ "mesh" ] . Type ! = JTokenType . Null , IdentifiedAncestor ( parents , nodes , ( int ) index ) , PositiveScale ( node ) ) ) )
2026-09-26 18:30:02 +02:00
throw new InvalidOperationException ( $"{path}: duplicate GLB node aetheria.id={id}." ) ;
2026-09-26 19:10:44 +02:00
index + + ;
2026-09-26 18:30:02 +02:00
}
return ids ;
}
2026-10-10 05:23:02 +02:00
private static int IdentifiedAncestor ( int [ ] parents , JObject [ ] nodes , int index )
{
var parent = parents [ index ] ;
for ( var steps = 0 ; parent > = 0 & & steps < = nodes . Length ; steps + + , parent = parents [ parent ] )
if ( ! string . IsNullOrEmpty ( ( string ) nodes [ parent ] [ "extras" ] ? [ "aetheria.id" ] ) ) return parent ;
return - 1 ;
}
2026-09-26 18:30:02 +02:00
}