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
using System ;
using System.Collections.Generic ;
using System.IO ;
using System.Linq ;
using System.Security.Cryptography ;
using System.Text ;
using GameCult.Caching ;
2026-09-30 03:06:43 +02:00
using Newtonsoft.Json.Linq ;
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
using Xunit ;
// Compose, ReadPackage and ReadNodeIds: the derived catalog a game would open, and the GLB node ids its anchors bind to.
public sealed class ShipModCatalogTests : IDisposable
{
private readonly TempDirectory _directory = new TempDirectory ( ) ;
private string Shipped = > Path . Combine ( _directory . Path , "Aetheria.cc" ) ;
private string Derived = > Path . Combine ( _directory . Path , "Derived" , "Aetheria.modded.cc" ) ;
private string Mods = > Path . Combine ( _directory . Path , "Mods" ) ;
2026-09-30 03:06:43 +02:00
private static readonly string [ ] FixtureNodes = ShipFixture . Nodes ;
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
public ShipModCatalogTests ( )
{
Directory . CreateDirectory ( Mods ) ;
using var cache = AetheriaStores . Open ( Shipped , catalogWritable : true ) ;
2026-10-10 07:18:00 +02:00
// A populated catalog must hold the VerseGrammar global.
cache . Upsert ( new VerseGrammar { Revision = 1 } ) ;
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
cache . Upsert ( new HullData { Name = "Wasp" , Shape = ShipAuthoringTests . Fixture ( ) . Hull . Shape } ) ;
cache . Upsert ( new GearData { Name = "Widget" } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
public void Dispose ( ) = > _directory . Dispose ( ) ;
[Fact]
public void ComposeDerivesACatalogAndLeavesTheShippedOneUntouched ( )
{
WritePackage ( "mod.skiff" ) ;
Directory . CreateDirectory ( Path . Combine ( Mods , "notes" ) ) ;
var shippedBefore = Hash ( Shipped ) ;
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
Assert . Equal ( 1 , ShipModCatalog . Compose ( Shipped , Derived , Mods ) . Included . Length ) ;
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
Assert . Equal ( shippedBefore , Hash ( Shipped ) ) ;
using var cache = AetheriaStores . Open ( Derived ) ;
Assert . Contains ( "Wasp" , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) ) ;
Assert . Contains ( "Widget" , cache . GetAll < GearData > ( ) . Select ( gear = > gear . Name ) ) ;
var records = cache . AllStoredDocuments . ToDictionary ( record = > record . Key . Value , record = > record . Document ) ;
Assert . Equal ( "Skiff" , Assert . IsType < HullData > ( records [ ShipModCatalog . HullKey ( "mod.skiff" ) . Value ] ) . Name ) ;
Assert . Equal ( "mod.skiff" , Assert . IsType < ShipAuthoring > ( records [ ShipModCatalog . AuthoringKey ( "mod.skiff" ) . Value ] ) . Id ) ;
AssertOnlyTheDerivedFile ( ) ;
}
2026-09-30 03:06:43 +02:00
// Correction 2 of the S0 map: the derived catalog held each mod hull twice, once as a HullData and once inside the
// ship's authoring record. The hull is now one record, and the visual holds no hull.
[Fact]
public void ComposedCatalogHoldsOneHullPerModShip ( )
{
WritePackage ( "mod.skiff" , hullName : "Skiffhull" ) ;
ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
using var cache = AetheriaStores . Open ( Derived ) ;
Assert . Equal ( 1 , cache . GetAll < HullData > ( ) . Count ( hull = > hull . Name = = "Skiffhull" ) ) ;
// The hull's name is stored exactly once in the derived file, so no second copy of the hull hides in another record.
Assert . Equal ( 1 , Occurrences ( File . ReadAllBytes ( Derived ) , Encoding . UTF8 . GetBytes ( "Skiffhull" ) ) ) ;
var visual = cache . GetAll < ShipAuthoring > ( ) . Single ( ) ;
Assert . Equal ( "mod.skiff" , visual . Id ) ;
}
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
[Fact]
public void ComposeRefusesAShippedHullThatNamesTwoBodies ( )
{
WritePackage ( "mod.skiff" ) ;
using ( var cache = AetheriaStores . Open ( Shipped , catalogWritable : true ) )
{
cache . Upsert ( new HullData
{
Name = "Chimera" ,
Shape = ShipAuthoringTests . Fixture ( ) . Hull . Shape ,
Prefab = "Djinni" ,
Visual = new CultRecordRef < ShipAuthoring > ( ShipModCatalog . AuthoringKey ( "mod.skiff" ) )
} ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
Assert . Contains ( "Chimera: a hull names one body" ,
Assert . Throws < InvalidOperationException > ( ( ) = > ShipModCatalog . Compose ( Shipped , Derived , Mods ) ) . Message ) ;
Assert . False ( File . Exists ( Derived ) ) ;
}
2026-09-30 03:06:43 +02:00
[Fact]
public void ComposeCopiesTheModStoresRecordsUnchangedAndTheHullNamesItsVisualByRef ( )
{
WritePackage ( "mod.skiff" ) ;
var ( modHull , modVisual ) = ShipAuthoringStore . Read ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ;
ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
using var cache = AetheriaStores . Open ( Derived ) ;
var hull = cache . Get < HullData > ( ShipModCatalog . HullKey ( "mod.skiff" ) ) ;
var visual = cache . Get < ShipAuthoring > ( ShipModCatalog . AuthoringKey ( "mod.skiff" ) ) ;
Assert . Same ( visual , cache . Get ( hull . Visual ) ) ;
var options = GameCult . Caching . MessagePack . CultDocumentMessagePackSerialization . OptionsFor ( typeof ( ShipAuthoring ) . Assembly ) ;
Assert . Equal ( MessagePack . MessagePackSerializer . Serialize ( modHull , options ) , MessagePack . MessagePackSerializer . Serialize ( hull , options ) ) ;
Assert . Equal ( MessagePack . MessagePackSerializer . Serialize ( modVisual , options ) , MessagePack . MessagePackSerializer . Serialize ( visual , options ) ) ;
}
// The package the Unity smoke (ShipModPreview.Smoke) loads. Set AETHERIA_SHIP_FIXTURE_DIR to have this run leave it
// there: `-shipModPath $AETHERIA_SHIP_FIXTURE_DIR/mod.skiff/ship.cc`.
[Fact]
public void TheFixturePackageIsCompleteAndItsGlbCarriesAScene ( )
{
var directory = ShipFixture . WritePackage ( Mods , "mod.skiff" ) ;
var package = ShipModCatalog . ReadPackage ( Path . Combine ( directory , "ship.cc" ) ) ;
Assert . Equal ( ShipFixture . Nodes . Length , package . NodeIndices . Count ) ;
var glb = File . ReadAllBytes ( package . ModelPath ) ;
var json = JObject . Parse ( Encoding . UTF8 . GetString ( glb , 20 , ( int ) BitConverter . ToUInt32 ( glb , 12 ) ) ) ;
Assert . Equal ( 0 , ( int ) json [ "scene" ] ) ;
Assert . Equal ( Enumerable . Range ( 0 , ShipFixture . Nodes . Length ) , json [ "scenes" ] [ 0 ] [ "nodes" ] . Select ( node = > ( int ) node ) ) ;
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
Assert . Equal ( 1 , ShipModCatalog . Compose ( Shipped , Derived , Mods ) . Included . Length ) ;
2026-09-30 03:06:43 +02:00
var export = Environment . GetEnvironmentVariable ( "AETHERIA_SHIP_FIXTURE_DIR" ) ;
if ( string . IsNullOrEmpty ( export ) ) return ;
var target = Path . Combine ( export , "mod.skiff" ) ;
Directory . CreateDirectory ( target ) ;
foreach ( var name in new [ ] { "ship.cc" , "skiff.glb" } ) File . Copy ( Path . Combine ( directory , name ) , Path . Combine ( target , name ) , true ) ;
}
2026-09-30 10:52:47 +02:00
[Fact]
public void TheFixtureGlbCarriesARealMeshOnEveryAnchorTheAssemblerReadsOneFrom ( )
{
var package = ShipModCatalog . ReadPackage ( Path . Combine ( ShipFixture . WritePackage ( Mods , "mod.skiff" ) , "ship.cc" ) ) ;
var glb = File . ReadAllBytes ( package . ModelPath ) ;
var jsonLength = ( int ) BitConverter . ToUInt32 ( glb , 12 ) ;
var json = JObject . Parse ( Encoding . UTF8 . GetString ( glb , 20 , jsonLength ) ) ;
var binLength = ( int ) BitConverter . ToUInt32 ( glb , 20 + jsonLength ) ;
Assert . Equal ( 0x004E4942 u , BitConverter . ToUInt32 ( glb , 24 + jsonLength ) ) ;
Assert . Equal ( glb . Length , 28 + jsonLength + binLength ) ;
Assert . Equal ( ( int ) json [ "buffers" ] [ 0 ] [ "byteLength" ] , ShipFixture . MeshBytes ( ) . Length ) ;
Assert . True ( binLength > = ShipFixture . MeshBytes ( ) . Length ) ;
var roles = new [ ] { "map-icon" , "hull-collider" , "thruster-emitter" , "radiator-mesh" } ;
var needed = package . Visual . Anchors . Where ( anchor = > roles . Contains ( anchor . Role ) ) . ToArray ( ) ;
Assert . Contains ( needed , anchor = > anchor . Role = = "thruster-emitter" ) ;
foreach ( var anchor in needed )
{
var node = json [ "nodes" ] [ ( int ) package . NodeIndices [ anchor . ModelNodeId ] ] ;
var mesh = json [ "meshes" ] [ ( int ) node [ "mesh" ] ] ;
var accessor = json [ "accessors" ] [ ( int ) mesh [ "primitives" ] [ 0 ] [ "attributes" ] [ "POSITION" ] ] ;
Assert . True ( ( int ) accessor [ "count" ] > = 4 , anchor . Id ) ;
}
Assert . Contains ( package . Visual . Anchors , anchor = > anchor . Role = = "weapon-muzzle" & & anchor . ParentId = = "gun" ) ;
Assert . Contains ( package . Hull . Hardpoints , hardpoint = > hardpoint . Type = = HardpointType . Energy & & hardpoint . Transform = = "gun" ) ;
}
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
[Fact]
public void ComposeCountsEveryPackageAndReplacesAnExistingDerivedCatalog ( )
{
WritePackage ( "mod.skiff" ) ;
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
Assert . Equal ( 1 , ShipModCatalog . Compose ( Shipped , Derived , Mods ) . Included . Length ) ;
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
WritePackage ( "mod.barge" , hullName : "Barge" ) ;
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
Assert . Equal ( 2 , ShipModCatalog . Compose ( Shipped , Derived , Mods ) . Included . Length ) ;
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
using var cache = AetheriaStores . Open ( Derived ) ;
Assert . Equal ( new [ ] { "Barge" , "Skiff" , "Wasp" } , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) . OrderBy ( name = > name ) ) ;
AssertOnlyTheDerivedFile ( ) ;
}
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
// One bad package never stops the game: it is left out and named, and the rest are composed.
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
[Fact]
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
public void ABadPackageIsExcludedAndNamedWhileTheGoodOnesCompose ( )
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
{
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
WritePackage ( "mod.good" , hullName : "Good" ) ;
WritePackage ( "mod.nomodel" , hullName : "Nomodel" , nodes : FixtureNodes . Where ( node = > node ! = "shield" ) . ToArray ( ) ) ;
WritePackage ( "mod.nofile" , hullName : "Nofile" ) ;
File . Delete ( Path . Combine ( Mods , "mod.nofile" , "skiff.glb" ) ) ;
Directory . CreateDirectory ( Path . Combine ( Mods , "mod.garbage" ) ) ;
File . WriteAllText ( Path . Combine ( Mods , "mod.garbage" , "ship.cc" ) , "not a ship file" ) ;
var composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
Assert . Equal ( new [ ] { "mod.good" } , composition . Included ) ;
Assert . Equal ( new [ ] { "mod.garbage" , "mod.nofile" , "mod.nomodel" } , composition . Excluded . Select ( exclusion = > exclusion . Package ) ) ;
Assert . Contains ( "model has no node with aetheria.id=shield" , composition . Excluded . Single ( e = > e . Package = = "mod.nomodel" ) . Reason ) ;
Assert . Contains ( "must name an existing GLB" , composition . Excluded . Single ( e = > e . Package = = "mod.nofile" ) . Reason ) ;
Assert . Contains ( "mod.nofile: " , composition . Excluded . Single ( e = > e . Package = = "mod.nofile" ) . ToString ( ) ) ;
using ( var cache = AetheriaStores . Open ( Derived ) )
Assert . Equal ( new [ ] { "Good" , "Wasp" } , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) . OrderBy ( name = > name ) ) ;
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
AssertOnlyTheDerivedFile ( ) ;
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 name collision leaves out every package that claims the name; the verdict does not depend on directory order.
[Fact]
public void ACollisionExcludesEveryPackageInvolvedAndNoneOfTheRest ( )
{
WritePackage ( "mod.a" , hullName : "Skiff" ) ;
WritePackage ( "mod.b" , hullName : "Skiff" ) ;
WritePackage ( "mod.c" , hullName : "Skiff" ) ;
WritePackage ( "mod.d" , hullName : "Wasp" ) ;
WritePackage ( "mod.e" , hullName : "Fine" ) ;
var composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
Assert . Equal ( new [ ] { "mod.e" } , composition . Included ) ;
Assert . Equal ( new [ ] { "mod.a" , "mod.b" , "mod.c" , "mod.d" } , composition . Excluded . Select ( exclusion = > exclusion . Package ) ) ;
Assert . Equal ( "hull name 'Skiff' is also claimed by mod.b, mod.c." , composition . Excluded [ 0 ] . Reason ) ;
Assert . Equal ( "hull name 'Skiff' is also claimed by mod.a, mod.c." , composition . Excluded [ 1 ] . Reason ) ;
Assert . Equal ( "hull name 'Wasp' collides with an existing catalog item." , composition . Excluded [ 3 ] . Reason ) ;
using var cache = AetheriaStores . Open ( Derived ) ;
Assert . Equal ( new [ ] { "Fine" , "Wasp" } , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) . OrderBy ( name = > name ) ) ;
}
// A package that fails for two reasons names both.
[Fact]
public void APackageThatCollidesTwiceNamesBothReasons ( )
{
WritePackage ( "mod.a" , hullName : "Wasp" ) ;
WritePackage ( "mod.b" , hullName : "Wasp" ) ;
var composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
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
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
Assert . Empty ( composition . Included ) ;
Assert . Equal ( "hull name 'Wasp' collides with an existing catalog item.; hull name 'Wasp' is also claimed by mod.b." ,
composition . Excluded [ 0 ] . Reason ) ;
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
}
[Theory]
[InlineData("mod-hull:mod.skiff", false)]
[InlineData("mod-ship:mod.skiff", true)]
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
public void APackageWhoseKeyAlreadyExistsInTheCatalogIsExcluded ( string key , bool authoring )
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
{
WritePackage ( "mod.skiff" ) ;
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
WritePackage ( "mod.other" , hullName : "Other" ) ;
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
using ( var cache = AetheriaStores . Open ( Shipped , catalogWritable : true ) )
{
if ( authoring )
{
2026-09-30 03:06:43 +02:00
var stranger = ShipAuthoringTests . Fixture ( ) . WithId ( "stranger" ) . Visual ;
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
cache . UpsertAsync ( typeof ( ShipAuthoring ) , stranger , new CultRecordKey ( key ) ) . GetAwaiter ( ) . GetResult ( ) ;
}
else
cache . UpsertAsync ( typeof ( HullData ) , new HullData { Name = "Stranger" , Shape = ShipAuthoringTests . Fixture ( ) . Hull . Shape } ,
new CultRecordKey ( key ) ) . GetAwaiter ( ) . GetResult ( ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
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 composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
Assert . Equal ( new [ ] { "mod.other" } , composition . Included ) ;
Assert . Contains ( "a mod key collides" , Assert . Single ( composition . Excluded ) . Reason ) ;
Assert . Equal ( "mod.skiff" , composition . Excluded [ 0 ] . Package ) ;
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
}
[Theory]
[InlineData(typeof(HullData))]
[InlineData(typeof(GearData))]
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
public void APackageWithAHullNamedLikeAShippedItemIsExcluded ( Type shippedType )
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
{
using ( var cache = AetheriaStores . Open ( Shipped , catalogWritable : true ) )
{
if ( shippedType = = typeof ( HullData ) )
cache . Upsert ( new HullData { Name = "Skiff" , Shape = ShipAuthoringTests . Fixture ( ) . Hull . Shape } ) ;
else
cache . Upsert ( new GearData { Name = "Skiff" } ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
WritePackage ( "mod.skiff" ) ;
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 composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
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
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
Assert . Empty ( composition . Included ) ;
Assert . Equal ( "hull name 'Skiff' collides with an existing catalog item." , Assert . Single ( composition . Excluded ) . Reason ) ;
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
}
[Fact]
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
public void APackageWhoseShipIdAShippedAuthoringRecordAlreadyUsesIsExcluded ( )
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
{
using ( var cache = AetheriaStores . Open ( Shipped , catalogWritable : true ) )
{
2026-09-30 03:06:43 +02:00
cache . UpsertAsync ( typeof ( ShipAuthoring ) , ShipAuthoringTests . Fixture ( ) . Visual , new CultRecordKey ( "shipped:skiff" ) ) . GetAwaiter ( ) . GetResult ( ) ;
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
cache . FlushAsync ( ) . Wait ( ) ;
}
WritePackage ( "mod.skiff" , hullName : "Different" ) ;
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 composition = ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
Assert . Empty ( composition . Included ) ;
Assert . Equal ( "ship ID collides with an existing catalog ship authoring record." , Assert . Single ( composition . Excluded ) . Reason ) ;
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
}
2026-10-10 06:50:18 +02:00
// The loader and the LFS rule (GameData/Mods/**/*.glb) agree: only a lowercase .glb is a model.
[Fact]
public void AModelWithAnUppercaseExtensionIsRefused ( )
{
WritePackage ( "mod.skiff" , modelAsset : "skiff.GLB" ) ;
var excluded = Assert . Single ( ShipModCatalog . Compose ( Shipped , Derived , Mods ) . Excluded ) ;
Assert . Equal ( "mod.skiff" , excluded . Package ) ;
Assert . Contains ( "GLB" , excluded . Reason ) ;
}
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 game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
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
[Fact]
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
public void TheBootCatalogSurvivesBadPackages ( )
{
WritePackage ( "mod.good" , hullName : "Good" ) ;
WritePackage ( "mod.bad" , hullName : "Bad" , nodes : FixtureNodes . Where ( node = > node ! = "shield" ) . ToArray ( ) ) ;
var ( catalog , excluded ) = ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) ;
Assert . Equal ( Derived , catalog ) ;
Assert . Equal ( "mod.bad" , Assert . Single ( excluded ) . Package ) ;
Directory . Delete ( Path . Combine ( Mods , "mod.good" ) , true ) ;
( catalog , excluded ) = ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) ;
Assert . Equal ( Shipped , catalog ) ;
Assert . Equal ( "mod.bad" , Assert . Single ( excluded ) . Package ) ;
}
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
public void ComposeRefusesUnsafeInputsAndOutputs ( )
{
WritePackage ( "mod.skiff" ) ;
Assert . Contains ( "Shipped catalog is missing" , Assert . Throws < FileNotFoundException > ( ( ) = >
ShipModCatalog . Compose ( Path . Combine ( _directory . Path , "absent.cc" ) , Derived , Mods ) ) . Message ) ;
Assert . Contains ( "Mod root is missing" , Assert . Throws < DirectoryNotFoundException > ( ( ) = >
ShipModCatalog . Compose ( Shipped , Derived , Path . Combine ( _directory . Path , "NoMods" ) ) ) . Message ) ;
var before = Hash ( Shipped ) ;
Assert . Contains ( "cannot replace the shipped catalog" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . Compose ( Shipped , Shipped , Mods ) ) . Message ) ;
Assert . Contains ( "cannot replace the shipped catalog" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . Compose ( Shipped , Shipped . ToUpperInvariant ( ) . Replace ( _directory . Path . ToUpperInvariant ( ) , _directory . Path ) , Mods ) ) . Message ) ;
Assert . Contains ( "cannot replace a mod source or asset" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . Compose ( Shipped , Path . Combine ( Mods , "mod.skiff" , "out.cc" ) , Mods ) ) . Message ) ;
Assert . Equal ( before , Hash ( Shipped ) ) ;
Assert . False ( File . Exists ( Path . Combine ( Mods , "mod.skiff" , "out.cc" ) ) ) ;
}
[Fact]
public void ReadPackageBindsTheShipToItsDirectoryAndGlb ( )
{
WritePackage ( "mod.skiff" ) ;
var package = ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ;
2026-09-30 03:06:43 +02:00
Assert . Equal ( "mod.skiff" , package . Visual . Id ) ;
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
Assert . Equal ( Path . GetFullPath ( Path . Combine ( Mods , "mod.skiff" , "skiff.glb" ) ) , package . ModelPath ) ;
2026-09-30 10:54:02 +02:00
Assert . Equal ( new Dictionary < string , uint > { [ "map" ] = 0 , [ "collider" ] = 1 , [ "shield" ] = 2 , [ "tractor" ] = 3 , [ "thruster-port" ] = 4 , [ "gun" ] = 5 , [ "gun-muzzle" ] = 6 } , package . NodeIndices ) ;
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
}
[Fact]
public void ReadPackageRefusesADirectoryNameThatIsNotTheShipId ( )
{
WritePackage ( "mod.skiff" ) ;
Directory . Move ( Path . Combine ( Mods , "mod.skiff" ) , Path . Combine ( Mods , "renamed" ) ) ;
Assert . Contains ( "ship ID must match its package directory name" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "renamed" , "ship.cc" ) ) ) . Message ) ;
}
[Fact]
public void ReadPackageRefusesAMissingOrWronglyNamedModel ( )
{
WritePackage ( "mod.skiff" ) ;
File . Delete ( Path . Combine ( Mods , "mod.skiff" , "skiff.glb" ) ) ;
Assert . Contains ( "must name an existing GLB inside its package" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
Directory . Delete ( Path . Combine ( Mods , "mod.skiff" ) , true ) ;
WritePackage ( "mod.skiff" , modelAsset : "skiff.bin" ) ;
Assert . Contains ( "must name an existing GLB inside its package" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
}
2026-09-30 02:14:10 +02:00
[Fact]
public void ReadPackageAppliesTheCatalogsItemValidationToTheHull ( )
{
WritePackage ( "mod.skiff" , tweak : ship = > ship . Hull . MinimumTemperature = ship . Hull . MaximumTemperature ) ;
Assert . Contains ( "zero-span range" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
}
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
[Fact]
public void ReadPackageRefusesAnAnchorWhoseNodeIsNotInTheModel ( )
{
WritePackage ( "mod.skiff" , nodes : FixtureNodes . Where ( node = > node ! = "thruster-port" ) . ToArray ( ) ) ;
Assert . Contains ( "model has no node with aetheria.id=thruster-port for anchor thruster.port" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
}
2026-10-03 17:07:08 +02:00
// Ruling thrusters-radiators-are-meshes: a thruster's mesh is its emission surface and a radiator's is what glows, so
// either on an empty node is refused before compose; a weapon mount (and shield, tractor) may be an empty.
[Fact]
public void AMeshRoleAnchorOnAnEmptyNodeIsRefused ( )
{
var withoutThrusterMesh = ShipFixture . MeshNodes . Where ( node = > node ! = "thruster-port" ) . ToArray ( ) ;
WritePackage ( "mod.skiff" , meshNodes : withoutThrusterMesh ) ;
Assert . Contains ( "thruster-emitter anchor thruster.port needs a mesh, but model node thruster-port has none" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
Directory . Delete ( Path . Combine ( Mods , "mod.skiff" ) , true ) ;
void AsRadiator ( ShipParts ship )
{
ship . Hull . Hardpoints . Single ( hardpoint = > hardpoint . Transform = = "thruster.port" ) . Type = HardpointType . Radiator ;
ship . Visual . Anchors . Single ( anchor = > anchor . Id = = "thruster.port" ) . Role = "radiator-mesh" ;
}
WritePackage ( "mod.skiff" , tweak : AsRadiator , meshNodes : withoutThrusterMesh ) ;
Assert . Contains ( "radiator-mesh anchor thruster.port needs a mesh" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ) . Message ) ;
// The same radiator on a mesh node, and the weapon mount, shield and tractor on empty nodes, are accepted.
Directory . Delete ( Path . Combine ( Mods , "mod.skiff" ) , true ) ;
WritePackage ( "mod.skiff" , tweak : AsRadiator ) ;
var nodes = ShipModCatalog . ReadNodeIds ( Path . Combine ( Mods , "mod.skiff" , "skiff.glb" ) ) ;
Assert . False ( nodes [ "gun" ] . HasMesh | | nodes [ "shield" ] . HasMesh | | nodes [ "tractor" ] . HasMesh ) ;
Assert . Equal ( "mod.skiff" , ShipModCatalog . ReadPackage ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) . Visual . Id ) ;
}
// AetherDb's validate judges the GLB once it exists, so Blender's Package sees what Compose would refuse.
[Fact]
public void ValidateCommandReadsTheGlbWhenPresent ( )
{
WritePackage ( "mod.skiff" , nodes : FixtureNodes . Where ( node = > node ! = "thruster-port" ) . ToArray ( ) ) ;
var path = Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ;
var ( code , output , error ) = Run ( "validate" , path ) ;
Assert . Equal ( 1 , code ) ;
Assert . Contains ( "model has no node with aetheria.id=thruster-port for anchor thruster.port" , error ) ;
File . Delete ( Path . Combine ( Mods , "mod.skiff" , "skiff.glb" ) ) ;
( code , output , error ) = Run ( "validate" , path ) ;
Assert . Equal ( 0 , code ) ;
Assert . Contains ( "records only" , output ) ;
WritePackage ( "mod.good" ) ;
( code , output , _ ) = Run ( "validate" , Path . Combine ( Mods , "mod.good" , "ship.cc" ) ) ;
Assert . Equal ( 0 , code ) ;
Assert . Contains ( "records and model" , output ) ;
}
2026-10-10 06:53:03 +02:00
[Theory]
[InlineData("con"), InlineData("nul.x"), InlineData("abc.")]
public void CreateRefusesABadIdBeforeTouchingTheDisk ( string id )
{
var target = Path . Combine ( _directory . Path , "GameData" , "Mods" , id , "ship.cc" ) ;
2026-10-10 09:49:39 +02:00
// The display name is itself a valid ship ID, so a rule applied to it instead of the ID would not refuse.
var ( code , output , error ) = Run ( "create" , target , id , "probe" , "--like" , "Djinni" ) ;
2026-10-10 06:53:03 +02:00
Assert . Equal ( 1 , code ) ;
Assert . Contains ( "Ship ID" , error ) ;
Assert . False ( Directory . Exists ( Path . Combine ( _directory . Path , "GameData" ) ) ) ;
}
2026-10-10 09:49:39 +02:00
[Fact]
public void CreateJudgesTheIdAndTakesAnyDisplayName ( )
{
var target = Path . Combine ( _directory . Path , "GameData" , "Mods" , "probe.a" , "ship.cc" ) ;
var ( code , output , error ) = Run ( "create" , target , "probe.a" , "Probe A" ) ;
Assert . Equal ( 0 , code ) ;
Assert . True ( File . Exists ( target ) , error ) ;
}
2026-10-10 21:02:22 +02:00
[Fact]
public void CentreCommandPrintsShapeCenterOfMass ( )
{
// An L: its centre of mass (1.2, 0.2) is not the bounds centre (1.5, 0.5).
var shape = new Shape ( 4 , 2 ) ;
foreach ( var ( x , y ) in new [ ] { ( 0 , 0 ) , ( 1 , 0 ) , ( 2 , 0 ) , ( 3 , 0 ) , ( 0 , 1 ) } ) shape . Cells [ x , y ] = true ;
var target = Path . Combine ( _directory . Path , "GameData" , "Mods" , "probe.l" , "ship.cc" ) ;
Assert . Equal ( 0 , Run ( "create" , target , "probe.l" , "Probe L" ) . Code ) ;
var ( hull , ship ) = ShipAuthoringStore . Load ( target ) ;
hull . Shape = shape ;
using ( var cache = ShipAuthoringStore . Open ( target , writable : true ) )
{
ShipAuthoringStore . Write ( cache , hull , ship ) ;
cache . FlushAsync ( ) . Wait ( ) ;
}
var ( code , output , error ) = Run ( "centre" , target ) ;
Assert . Equal ( 0 , code ) ;
Assert . Equal ( "1.2 0.2" , output . Trim ( ) ) ;
Assert . Equal ( "" , error ) ;
}
2026-10-03 17:07:08 +02:00
private static ( int Code , string Output , string Error ) Run ( params string [ ] args )
{
var ( output , error ) = ( new StringWriter ( ) , new StringWriter ( ) ) ;
var ( previousOut , previousError ) = ( Console . Out , Console . Error ) ;
Console . SetOut ( output ) ;
Console . SetError ( error ) ;
try { return ( ShipAuthoringCommands . Run ( args ) , output . ToString ( ) , error . ToString ( ) ) ; }
finally { Console . SetOut ( previousOut ) ; Console . SetError ( previousError ) ; }
}
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
[Fact]
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne ( )
{
2026-09-30 03:06:43 +02:00
var path = Write ( "nodes.glb" , ShipFixture . Glb ( @"{""asset"":{""version"":""2.0""},""nodes" ":[
2026-10-03 17:07:08 +02:00
{ "" name "" : "" plain "" } , { "" extras "" : { "" aetheria . id "" : "" a "" } } , { "" extras "" : { } } , { "" extras "" : { "" aetheria . id "" : "" "" } } , { "" mesh "" : 0 , "" extras "" : { "" aetheria . id "" : "" b "" } } ] } "));
2026-10-10 05:23:02 +02:00
Assert . Equal ( new Dictionary < string , ( uint , bool , int ) > { [ "a" ] = ( 1 , false , - 1 ) , [ "b" ] = ( 4 , true , - 1 ) } , ShipModCatalog . ReadNodeIds ( path ) ) ;
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
}
[Fact]
public void AGlbWithoutNodesHasNoIds ( ) = >
2026-09-30 03:06:43 +02:00
Assert . Empty ( ShipModCatalog . ReadNodeIds ( Write ( "empty.glb" , ShipFixture . Glb ( @"{""asset"":{""version"":""2.0""}}" ) ) ) ) ;
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
[Fact]
public void ADuplicateNodeIdIsRefused ( ) = >
Assert . Contains ( "duplicate GLB node aetheria.id=a" , Assert . Throws < InvalidOperationException > ( ( ) = >
2026-09-30 03:06:43 +02:00
ShipModCatalog . ReadNodeIds ( Write ( "dup.glb" , ShipFixture . Glb ( @"{""nodes"":[{""extras"":{""aetheria.id"":""a""}},{""extras"":{""aetheria.id"":""a""}}]}" ) ) ) ) . Message ) ;
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
[Fact]
public void AMalformedGlbHeaderIsRefused ( )
{
2026-09-30 03:06:43 +02:00
var good = ShipFixture . Glb ( @"{""nodes"":[]}" ) ;
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
void Refuse ( string name , byte [ ] bytes , string message ) = >
Assert . Contains ( message , Assert . Throws < InvalidOperationException > ( ( ) = > ShipModCatalog . ReadNodeIds ( Write ( name , bytes ) ) ) . Message ) ;
Refuse ( "short.glb" , With ( good . Take ( 19 ) . ToArray ( ) , 8 , 19 ) , "invalid GLB 2 header" ) ;
Refuse ( "magic.glb" , With ( good , 0 , 0x46546C68 ) , "invalid GLB 2 header" ) ;
Refuse ( "version.glb" , With ( good , 4 , 1 ) , "invalid GLB 2 header" ) ;
2026-09-30 02:14:10 +02:00
Refuse ( "longer.glb" , With ( good , 8 , ( uint ) good . Length + 1 ) , "invalid GLB 2 header" ) ;
Refuse ( "shorter.glb" , With ( good , 8 , ( uint ) good . Length - 4 ) , "invalid GLB 2 header" ) ;
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
Refuse ( "chunktype.glb" , With ( good , 16 , 0x004E4942 ) , "missing GLB JSON chunk" ) ;
Refuse ( "chunklength.glb" , With ( good , 12 , ( uint ) good . Length ) , "missing GLB JSON chunk" ) ;
}
[Fact]
public void AGlbWhoseJsonChunkFillsTheFileIsAccepted ( )
{
var json = Encoding . UTF8 . GetBytes ( @"{""nodes"":[{""extras"":{""aetheria.id"":""a""}}]} " ) ;
var bytes = new byte [ 20 + json . Length ] ;
BitConverter . GetBytes ( 0x46546C67 u ) . CopyTo ( bytes , 0 ) ;
BitConverter . GetBytes ( 2 u ) . CopyTo ( bytes , 4 ) ;
BitConverter . GetBytes ( ( uint ) bytes . Length ) . CopyTo ( bytes , 8 ) ;
BitConverter . GetBytes ( ( uint ) json . Length ) . CopyTo ( bytes , 12 ) ;
BitConverter . GetBytes ( 0x4E4F534A u ) . CopyTo ( bytes , 16 ) ;
json . CopyTo ( bytes , 20 ) ;
2026-10-03 17:07:08 +02:00
Assert . Equal ( new Dictionary < string , uint > { [ "a" ] = 0 } , Indices ( ShipModCatalog . ReadNodeIds ( Write ( "full.glb" , bytes ) ) ) ) ;
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
}
2026-09-30 06:43:52 +02:00
// A run store references a mod hull by its key, so the same packages must derive the same records on every launch,
// whatever order the directories were created in.
[Fact]
public void ComposeAtBootIsDeterministic ( )
{
var ids = new [ ] { "mod.a" , "mod.b" , "mod.c" , "mod.d" , "mod.e" , "mod.f" } ;
foreach ( var id in ids . Reverse ( ) ) WritePackage ( id , hullName : "Hull " + id ) ;
var firstMods = Mods ;
var secondMods = Path . Combine ( _directory . Path , "Mods2" ) ;
foreach ( var id in ids ) ShipFixture . WritePackage ( secondMods , id , "Hull " + id ) ;
var second = Path . Combine ( _directory . Path , "Derived2" , "Aetheria.modded.cc" ) ;
ShipModCatalog . Compose ( Shipped , Derived , firstMods ) ;
ShipModCatalog . Compose ( Shipped , second , secondMods ) ;
ShipModCatalog . Compose ( Shipped , Path . Combine ( _directory . Path , "Derived3" , "Aetheria.modded.cc" ) , firstMods ) ;
Assert . Equal ( Records ( Derived ) , Records ( second ) ) ;
Assert . Equal ( Records ( Derived ) , Records ( Path . Combine ( _directory . Path , "Derived3" , "Aetheria.modded.cc" ) ) ) ;
Assert . Contains ( ShipModCatalog . HullKey ( "mod.c" ) . Value , Records ( Derived ) . Keys ) ;
}
[Fact]
public void TheBootCatalogIsTheShippedOneUntilAModIsInstalled ( )
{
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
Assert . Equal ( Shipped , ShipModCatalog . ResolveCatalog ( Shipped , Derived , Path . Combine ( _directory . Path , "absent" ) ) . Catalog ) ;
2026-09-30 06:43:52 +02:00
Directory . CreateDirectory ( Path . Combine ( Mods , "notes" ) ) ;
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
Assert . Equal ( Shipped , ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) . Catalog ) ;
2026-09-30 06:43:52 +02:00
Assert . False ( File . Exists ( Derived ) ) ;
WritePackage ( "mod.skiff" ) ;
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
Assert . Equal ( Derived , ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) . Catalog ) ;
2026-09-30 06:43:52 +02:00
using ( var cache = AetheriaStores . Open ( Derived ) )
Assert . Contains ( "Skiff" , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) ) ;
// Recomposed on every call: a mod removed since the last boot is gone from the file the game opens next.
Directory . Delete ( Path . Combine ( Mods , "mod.skiff" ) , true ) ;
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
Assert . Equal ( Shipped , ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) . Catalog ) ;
2026-09-30 06:43:52 +02:00
WritePackage ( "mod.barge" , hullName : "Barge" ) ;
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
Assert . Equal ( Derived , ShipModCatalog . ResolveCatalog ( Shipped , Derived , Mods ) . Catalog ) ;
2026-09-30 06:43:52 +02:00
using ( var cache = AetheriaStores . Open ( Derived ) )
Assert . Equal ( new [ ] { "Barge" , "Wasp" } , cache . GetAll < HullData > ( ) . Select ( hull = > hull . Name ) . OrderBy ( name = > name ) ) ;
}
2026-09-30 10:06:16 +02:00
// A failure of the composition itself is a mod problem too: the game boots on the shipped catalog and names it.
[Fact]
public void TheBootCatalogSurvivesAComposeThatFails ( )
{
WritePackage ( "mod.skiff" ) ;
var blocker = Path . Combine ( _directory . Path , "blocker" ) ;
File . WriteAllText ( blocker , "a file where the derived catalog's directory should be" ) ;
var ( catalog , excluded ) = ShipModCatalog . ResolveCatalog ( Shipped , Path . Combine ( blocker , "Aetheria.modded.cc" ) , Mods ) ;
Assert . Equal ( Shipped , catalog ) ;
var exclusion = Assert . Single ( excluded ) ;
Assert . Equal ( "(all mods)" , exclusion . Package ) ;
Assert . False ( string . IsNullOrWhiteSpace ( exclusion . Reason ) ) ;
// An invalid output (the shipped catalog itself) is refused the same way, with its reason.
( catalog , excluded ) = ShipModCatalog . ResolveCatalog ( Shipped , Shipped , Mods ) ;
Assert . Equal ( Shipped , catalog ) ;
Assert . Contains ( "cannot replace the shipped catalog" , Assert . Single ( excluded ) . Reason ) ;
}
2026-09-30 06:43:52 +02:00
// Gameplay reads a mod ship's records from the derived catalog; the package directory supplies only the GLB.
[Fact]
public void PackageOfTakesItsRecordsFromTheCatalogAndOnlyTheGlbFromTheMod ( )
{
WritePackage ( "mod.skiff" ) ;
ShipModCatalog . Compose ( Shipped , Derived , Mods ) ;
File . Delete ( Path . Combine ( Mods , "mod.skiff" , "ship.cc" ) ) ;
using var cache = AetheriaStores . Open ( Derived ) ;
var hull = cache . GetAll < HullData > ( ) . Single ( candidate = > candidate . Name = = "Skiff" ) ;
var package = ShipModCatalog . PackageOf ( cache , hull , Mods ) ;
Assert . Same ( hull , package . Hull ) ;
Assert . Same ( cache . Get ( hull . Visual ) , package . Visual ) ;
Assert . Equal ( Path . Combine ( Mods , "mod.skiff" , "skiff.glb" ) , package . ModelPath ) ;
Assert . Equal ( ShipFixture . Nodes . Length , package . NodeIndices . Count ) ;
2026-09-30 06:46:17 +02:00
// The shared validator judges the catalog's records too, not only a ship.cc.
hull . Prefab = "Djinni" ;
Assert . Contains ( "names both a Unity prefab and a visual record" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . PackageOf ( cache , hull , Mods ) ) . Message ) ;
hull . Prefab = null ;
2026-09-30 06:43:52 +02:00
// A hull whose visual the catalog does not hold, and a mod whose GLB is gone, are refused.
Assert . Contains ( "holds no visual record" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . PackageOf ( cache , new HullData { Name = "Ghost" } , Mods ) ) . Message ) ;
File . Delete ( package . ModelPath ) ;
Assert . Contains ( "model asset must name an existing GLB" , Assert . Throws < InvalidOperationException > ( ( ) = >
ShipModCatalog . PackageOf ( cache , hull , Mods ) ) . Message ) ;
}
// Every stored record's key, schema and payload bytes. StoredAt is a write timestamp, not record content.
private static Dictionary < string , string > Records ( string path ) = >
GameCult . Caching . MessagePack . CultDocumentMessagePackSerialization . DeserializeSnapshot ( File . ReadAllBytes ( path ) ) . Records
. ToDictionary ( record = > record . Key , record = > record . SchemaId + ":" + Convert . ToHexString ( record . Payload ) ) ;
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
private void AssertOnlyTheDerivedFile ( )
{
2026-09-30 02:14:10 +02:00
var names = Directory . GetFileSystemEntries ( Path . GetDirectoryName ( Derived ) ) . Select ( entry = > Path . GetFileName ( entry ) ) . ToArray ( ) ;
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
Assert . True ( names . Length = = 1 & & names [ 0 ] = = "Aetheria.modded.cc" , string . Join ( " | " , names ) ) ;
}
private static byte [ ] With ( byte [ ] bytes , int offset , uint value )
{
var copy = ( byte [ ] ) bytes . Clone ( ) ;
BitConverter . GetBytes ( value ) . CopyTo ( copy , offset ) ;
return copy ;
}
private string Write ( string name , byte [ ] bytes )
{
var path = Path . Combine ( _directory . Path , name ) ;
File . WriteAllBytes ( path , bytes ) ;
return path ;
}
2026-09-30 02:14:10 +02:00
private void WritePackage ( string id , string hullName = "Skiff" , string [ ] nodes = null , string modelAsset = "skiff.glb" ,
2026-10-03 17:07:08 +02:00
Action < ShipParts > tweak = null , string [ ] meshNodes = null ) = > ShipFixture . WritePackage ( Mods , id , hullName , nodes , modelAsset , tweak , meshNodes ) ;
2026-10-10 05:23:02 +02:00
private static Dictionary < string , uint > Indices ( Dictionary < string , ( uint Index , bool HasMesh , int Parent ) > nodes ) = >
2026-10-03 17:07:08 +02:00
nodes . ToDictionary ( node = > node . Key , node = > node . Value . Index ) ;
2026-09-30 03:06:43 +02:00
private static int Occurrences ( byte [ ] haystack , byte [ ] needle )
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
{
2026-09-30 03:06:43 +02:00
var count = 0 ;
for ( var start = 0 ; start < = haystack . Length - needle . Length ; start + + )
if ( haystack . AsSpan ( start , needle . Length ) . SequenceEqual ( needle ) ) count + + ;
return count ;
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
}
private static string Hash ( string path ) = > Convert . ToHexString ( SHA256 . HashData ( File . ReadAllBytes ( path ) ) ) ;
}