Ship S1: one hull record, and a hull names its visual

A mod ship file now holds a HullData at mod-hull:<id> and the ShipAuthoring
visual package at mod-ship:<id>. HullData.Visual (key 32) is the typed ref
between them; ShipAuthoring.Hull (key 1) is retired.

- One joint validator over (hull, visual) is the only semantic check; Load
  owns the file structure (one hull, one visual, deterministic keys).
- Compose copies the mod store's records verbatim, so the derived catalog
  holds one hull per mod ship.
- ship_cc.py edits the hull record's slots directly; the body[HULL_SLOT]
  indirection is gone.
- The package fixture is generated from ShipAuthoringTests.Fixture() (glTF
  scene included) and exported when AETHERIA_SHIP_FIXTURE_DIR is set.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-30 03:06:43 +02:00
commit 1702e0549f
13 changed files with 622 additions and 319 deletions

View file

@ -530,6 +530,11 @@ public class HullData : EquippableItemData
[Inspectable, JsonProperty("canTow"), Key(29)]
public bool CanTow;
// The hull's visual: a ShipAuthoring record holding its model package, or unset for a Unity-prefab hull. A hull
// names exactly one of Prefab and Visual. Keys 30 and 31 are EquippableItemData's, so 32 is the next free key.
[JsonProperty("visual"), Key(32)]
public CultRecordRef<ShipAuthoring> Visual;
[IgnoreMember]
public Shape InteriorCells
{

View file

@ -6,13 +6,13 @@ using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
// A mod ship's authoring record. Hull is the existing simulation design, embedded here so a
// proof ship has one writable source; the shipped catalog contains no copy of that ship.
// A mod ship's visual package: model, anchors, schematic lines. The hull semantics (cells, hardpoints, stats) live in
// the ship's own HullData record beside this one, and HullData.Visual is the only binding between the two.
[CultDocument("aetheria.ship_authoring", "1"), MessagePackObject]
public sealed class ShipAuthoring
{
[CultName, Key(0)] public string Id;
[Key(1)] public HullData Hull;
// Key 1 held the embedded HullData until S1; it is retired and never reused.
[Key(2)] public string ModelAsset;
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
@ -54,7 +54,7 @@ public static class ShipAuthoringStore
var cache = new CultCache();
try
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(ShipAuthoring) });
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path, !writable), new[] { typeof(HullData), typeof(ShipAuthoring) });
return cache;
}
catch
@ -64,29 +64,55 @@ public static class ShipAuthoringStore
}
}
public static ShipAuthoring Read(string path)
// Writes one ship's two records under their deterministic keys, the shape Load expects.
public static void Write(CultCache cache, HullData hull, ShipAuthoring visual)
{
using var cache = Open(path);
var records = cache.GetAll<ShipAuthoring>().ToArray();
if (records.Length != 1)
throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {records.Length}.");
Validate(records[0]);
return records[0];
cache.UpsertAsync(typeof(HullData), hull, ShipModCatalog.HullKey(visual.Id)).GetAwaiter().GetResult();
cache.UpsertAsync(typeof(ShipAuthoring), visual, ShipModCatalog.AuthoringKey(visual.Id)).GetAwaiter().GetResult();
}
public static void Validate(ShipAuthoring ship)
// The structure of a ship file, without judging its content (drafts load): exactly one hull and one visual, each at
// its deterministic key, the hull naming that visual by its typed ref.
public static (HullData Hull, ShipAuthoring Visual) Load(string path)
{
using var cache = Open(path);
var hulls = cache.GetAll<HullData>().ToArray();
var visuals = cache.GetAll<ShipAuthoring>().ToArray();
if (visuals.Length != 1)
throw new InvalidOperationException($"{path} must hold exactly one ship authoring record, found {visuals.Length}.");
if (hulls.Length != 1)
throw new InvalidOperationException($"{path} must hold exactly one hull record, found {hulls.Length}.");
var (hull, visual) = (hulls[0], visuals[0]);
if (!cache.TryGetHandle(hull).Value.Key.Equals(ShipModCatalog.HullKey(visual.Id)) ||
!cache.TryGetHandle(visual).Value.Key.Equals(ShipModCatalog.AuthoringKey(visual.Id)))
throw new InvalidOperationException($"{path}: {visual.Id}: records must be stored under {ShipModCatalog.HullKey(visual.Id).Value} and {ShipModCatalog.AuthoringKey(visual.Id).Value}.");
return (hull, visual);
}
public static (HullData Hull, ShipAuthoring Visual) Read(string path)
{
var (hull, visual) = Load(path);
Validate(hull, visual);
return (hull, visual);
}
// The one semantic check every path shares: the hull and its visual, judged together.
public static void Validate(HullData hull, ShipAuthoring ship)
{
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
var hull = ship.Hull ?? throw new InvalidOperationException($"{ship.Id}: hull data is required.");
if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required.");
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))
throw new InvalidOperationException($"{ship.Id}: schematic must contain at least one cell.");
// A hull names exactly one visual: a Unity prefab (shipped hulls) or a ShipAuthoring record (mod ships).
if (!string.IsNullOrEmpty(hull.Prefab))
throw new InvalidOperationException($"{ship.Id}: a mod ship cannot name a Unity prefab.");
if (!hull.Visual.Key.Equals(ShipModCatalog.AuthoringKey(ship.Id)))
throw new InvalidOperationException($"{ship.Id}: the hull must name its visual record {ShipModCatalog.AuthoringKey(ship.Id).Value}.");
if (string.IsNullOrWhiteSpace(ship.ModelAsset) || Path.IsPathRooted(ship.ModelAsset) ||
ship.ModelAsset.Replace('\\', '/').Split('/').Any(part => part == ".."))
throw new InvalidOperationException($"{ship.Id}: model asset must be a relative package path.");

View file

@ -12,7 +12,8 @@ public static class ShipModCatalog
{
public sealed class Package
{
public ShipAuthoring Ship;
public HullData Hull;
public ShipAuthoring Visual;
public string ModelPath;
public Dictionary<string, uint> NodeIndices;
}
@ -32,13 +33,13 @@ public static class ShipModCatalog
if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset.");
var ships = new List<ShipAuthoring>();
var packages = new List<Package>();
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
{
var path = Path.Combine(directory, "ship.cc");
if (!File.Exists(path)) continue;
// ReadPackage pins each ID to its directory name, so IDs within one mods root are already unique.
ships.Add(ReadPackage(path).Ship);
packages.Add(ReadPackage(path));
}
Directory.CreateDirectory(Path.GetDirectoryName(output));
@ -52,8 +53,9 @@ public static class ShipModCatalog
File.Copy(source, temporary);
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
{
foreach (var ship in ships)
foreach (var package in packages)
{
var (hull, ship) = (package.Hull, package.Visual);
var hullKey = HullKey(ship.Id);
var authoringKey = AuthoringKey(ship.Id);
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
@ -61,18 +63,18 @@ public static class ShipModCatalog
// CultCache indexes names per concrete type and lets the last writer win, and GetByName over a base
// type throws on a match across subtypes. A mod hull named like any shipped item, or another mod's
// hull (upserted earlier in this loop), would make name lookups wrong or throw.
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, ship.Hull.Name, StringComparison.Ordinal)))
throw new InvalidOperationException($"{ship.Id}: hull name '{ship.Hull.Name}' collides with an existing catalog item.");
if (cache.GetAll<ItemData>().Any(item => string.Equals(item.Name, hull.Name, StringComparison.Ordinal)))
throw new InvalidOperationException($"{ship.Id}: hull name '{hull.Name}' collides with an existing catalog item.");
if (cache.GetAll<ShipAuthoring>().Any(existing => string.Equals(existing.Id, ship.Id, StringComparison.Ordinal)))
throw new InvalidOperationException($"{ship.Id}: ship ID collides with an existing catalog ship authoring record.");
cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult();
cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult();
// Verbatim: the mod store's own two records, under the keys it already holds them at.
ShipAuthoringStore.Write(cache, hull, ship);
}
cache.FlushAsync().GetAwaiter().GetResult();
}
if (File.Exists(output)) File.Replace(temporary, output, null);
else File.Move(temporary, output);
return ships.Count;
return packages.Count;
}
finally
{
@ -84,7 +86,7 @@ public static class ShipModCatalog
{
var path = Path.GetFullPath(shipPath);
var directory = Path.GetDirectoryName(path);
var ship = ShipAuthoringStore.Read(path);
var (hull, ship) = ShipAuthoringStore.Read(path);
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal))
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
@ -95,8 +97,8 @@ public static class ShipModCatalog
foreach (var anchor in ship.Anchors)
if (!modelNodes.ContainsKey(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
CultRecordRefs.Validate(ship.Hull);
return new Package { Ship = ship, ModelPath = modelPath, NodeIndices = modelNodes };
CultRecordRefs.Validate(hull);
return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes };
}
// GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer.