Compose validated mod ships into a derived catalog

This commit is contained in:
Meta Vens 2026-09-26 18:30:02 +02:00
commit 4ba4bbaf2e
9 changed files with 161 additions and 6 deletions

View file

@ -6,7 +6,7 @@ using GameCult.Caching.MessagePack;
// Aetheria's stores: one home store per type, composed here and nowhere else.
public static class AetheriaStores
{
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] CatalogTypes = { typeof(ItemData), typeof(ShipAuthoring), typeof(Faction), typeof(FactionProductData), typeof(PersonalityAttribute), typeof(NameFile), typeof(InputLayout), typeof(Loadout) };
public static readonly Type[] RunTypes = { typeof(OrbitData), typeof(BodyData), typeof(SavedZone), typeof(SavedGame), typeof(ProvenanceLedger) };
public static readonly Type[] PlayerTypes = { typeof(PlayerSettings) };

View file

@ -78,6 +78,9 @@ public static class ShipAuthoringStore
{
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 (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
if (hull.Shape?.Cells == null || hull.Shape.Width < 1 || hull.Shape.Height < 1 ||
@ -91,12 +94,15 @@ public static class ShipAuthoringStore
var anchors = ship.Anchors ?? throw new InvalidOperationException($"{ship.Id}: anchors are required.");
var ids = new HashSet<string>(StringComparer.Ordinal);
var nodeIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var anchor in anchors)
{
if (anchor == null || string.IsNullOrWhiteSpace(anchor.Id) || !ids.Add(anchor.Id))
throw new InvalidOperationException($"{ship.Id}: anchor IDs must be present and unique.");
if (string.IsNullOrWhiteSpace(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: anchor {anchor.Id} needs a model node ID.");
if (!nodeIds.Add(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: model node {anchor.ModelNodeId} is claimed by more than one anchor.");
}
foreach (var role in new[] { "map-icon", "hull-collider", "shield", "tractor" })
if (anchors.Count(anchor => anchor.Role == role) != 1)

View file

@ -0,0 +1,100 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using GameCult.Caching;
using Newtonsoft.Json.Linq;
using System.Text;
// Builds the one catalog snapshot CultCache needs. Sources remain the shipped catalog and each
// mod-owned ship.cc; this output is disposable and never becomes an authoring surface.
public static class ShipModCatalog
{
public static CultRecordKey HullKey(string id) => new CultRecordKey("mod-hull:" + id);
public static CultRecordKey AuthoringKey(string id) => new CultRecordKey("mod-ship:" + id);
public static int Compose(string shippedCatalog, string outputCatalog, string modsRoot)
{
var source = Path.GetFullPath(shippedCatalog);
var output = Path.GetFullPath(outputCatalog);
var root = Path.GetFullPath(modsRoot);
if (!File.Exists(source)) throw new FileNotFoundException("Shipped catalog is missing", source);
if (!Directory.Exists(root)) throw new DirectoryNotFoundException($"Mod root is missing: {root}");
if (string.Equals(source, output, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The derived catalog cannot replace the shipped catalog.");
if (output.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("The derived catalog cannot replace a mod source or asset.");
var ships = new List<ShipAuthoring>();
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var directory in Directory.GetDirectories(root).OrderBy(path => path, StringComparer.Ordinal))
{
var path = Path.Combine(directory, "ship.cc");
if (!File.Exists(path)) continue;
var ship = ShipAuthoringStore.Read(path);
if (!string.Equals(Path.GetFileName(directory), ship.Id, StringComparison.Ordinal) ||
!ids.Add(ship.Id))
throw new InvalidOperationException($"{path}: ship ID must uniquely match its package directory name.");
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
if (!modelPath.StartsWith(directory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package.");
var modelNodes = ReadNodeIds(modelPath);
foreach (var anchor in ship.Anchors)
if (!modelNodes.Contains(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
CultRecordRefs.Validate(ship.Hull);
ships.Add(ship);
}
Directory.CreateDirectory(Path.GetDirectoryName(output));
var temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp";
try
{
File.Copy(source, temporary);
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
{
foreach (var ship in ships)
{
var hullKey = HullKey(ship.Id);
var authoringKey = AuthoringKey(ship.Id);
if (cache.AllStoredDocuments.Any(record => record.Key.Equals(hullKey) || record.Key.Equals(authoringKey)))
throw new InvalidOperationException($"{ship.Id}: a mod key collides with an existing catalog record.");
cache.UpsertAsync(typeof(HullData), ship.Hull, hullKey).GetAwaiter().GetResult();
cache.UpsertAsync(typeof(ShipAuthoring), ship, authoringKey).GetAwaiter().GetResult();
}
cache.FlushAsync().GetAwaiter().GetResult();
}
if (File.Exists(output)) File.Replace(temporary, output, null);
else File.Move(temporary, output);
return ships.Count;
}
finally
{
if (File.Exists(temporary)) File.Delete(temporary);
}
}
// GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer.
public static HashSet<string> ReadNodeIds(string path)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream);
if (stream.Length < 20 || reader.ReadUInt32() != 0x46546C67 || reader.ReadUInt32() != 2 ||
reader.ReadUInt32() != stream.Length)
throw new InvalidOperationException($"{path}: invalid GLB 2 header.");
var chunkLength = reader.ReadUInt32();
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var node in json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>())
{
var id = (string)node["extras"]?["aetheria.id"];
if (string.IsNullOrEmpty(id)) continue;
if (!ids.Add(id))
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
}
return ids;
}
}

View file

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 708d45e8a4144a6e89bcf40e96b0975a

View file

@ -85,6 +85,43 @@ semantic validator. The Blender capture command writes only line data and the
still need to be built. The current gameplay catalog and prefab loader do not
read these mod records.
## Runtime catalog seam
`CultCache` routes `ItemData` to one home backing store, and
`ActionGameManager.CultCache` holds that store for the entire play session.
Attaching each mod's `.cc` as another item store would violate the cache's
one-home invariant. `ship-authoring compose` instead derives one runtime catalog from
the shipped catalog and validated mod packages, preserving all shipped record
keys. Each mod's stable `ShipAuthoring.Id` yields separate deterministic keys
for its copied `HullData` and its runtime authoring record. The generated
catalog is disposable; the shipped catalog and each mod's source `.cc` remain
the owners. A player session opens one catalog snapshot and cannot hot-swap its
designs beneath existing entities.
Each immediate child of the mods directory has an ID matching its directory
name and contains `ship.cc` plus the GLB at the record's relative `ModelAsset`
path. Every referenced anchor uses an `aetheria.id` custom property exported
into a GLB node's `extras`. Compose checks the asset, unique IDs, and every
reference before atomically replacing the derived catalog. For example:
```text
dotnet run --project tools/AetherDb -- ship-authoring compose GameData/Aetheria.cc GameData/Aetheria.modded.cc GameData/Mods
```
This command does not yet make the game open `Aetheria.modded.cc`; the runtime
loader and a complete playable ship are still outstanding. In the Quiet probe,
206 shipped records became 208 derived records: one `mod-hull:quiet` and one
`mod-ship:quiet`. A missing GLB node ID was rejected without changing the
previous derived file. The probe's four anchors and zero hardpoints are only
a data-path smoke, not a playable Quiet design.
The construction boundary will resolve a mod hull's authoring record from its
derived key, load the package's compiled GLB at runtime, and build the
`ShipInstance` references from validated stable node IDs. It must fail before
scene construction when an asset or anchor is absent. [Unity glTFast's runtime
import](https://github.com/Unity-Technologies/com.unity.cloud.gltfast/blob/main/Packages/com.unity.cloud.gltfast/Documentation~/ImportRuntime.md)
is a candidate for the GLB step; it has not been added to the project.
## Proof gates
1. A typed `.cc` record round-trips between C# and Blender's Python runtime,
@ -94,7 +131,9 @@ read these mod records.
not a second copy of hull semantics.
3. A command-line validator rejects malformed cells, overlapping or dangling
hardpoints, missing model nodes, and unresolved asset references with
precise errors. It changes no live catalog or scene on failure.
precise errors. It changes no live catalog or scene on failure. The
composer now performs this check for a package before replacing its
derived catalog.
4. A packaged mod ship loads in a built Unity player without Unity Editor or
Blender installed. Runtime assembly creates the same components the
current `ShipInstance` expects and uses the existing simulation rules.

View file

@ -1,6 +1,6 @@
# Ship collection authoring
Status: authoring contract for new ships. Longinus and Djinni predate this contract and keep their current prefabs.
Status: FBX-to-Unity-prefab contract for ships using the current catalog path. Longinus and Djinni predate this contract and keep their current prefabs. The parallel mod-owned `.cc` path is specified in [moddable-ship-authoring.md](moddable-ship-authoring.md).
## Objective and ownership

View file

@ -38,7 +38,7 @@ public static class Program
case "targeting-catalog-6d": return TargetingCatalog6d(args.Contains("apply"));
case "ship-authoring": return ShipAuthoringCommands.Run(args.Skip(1).ToArray());
default:
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate");
Console.WriteLine("commands: census, factions, station-fit, hardpoint-fit, loadout [seed], save, settings, settings-dump, dangling [clear <Type.Member>]... [apply], shield-migrate [apply], brownout-migrate [apply], roles-migrate [apply], firing-arc-migrate [apply], targeting-catalog [apply], targeting-catalog-6c [apply], targeting-catalog-6d [apply], ship-authoring create|inspect|validate|compose");
return 1;
}
}

View file

@ -10,11 +10,17 @@ public static class ShipAuthoringCommands
{
if (args.Length == 0)
{
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>");
return 1;
}
try
{
if (args[0] == "compose" && args.Length == 4)
{
var count = ShipModCatalog.Compose(args[1], args[2], args[3]);
Console.WriteLine($"Composed {count} mod ships into {Path.GetFullPath(args[2])}");
return 0;
}
if (args[0] == "inspect" && args.Length == 2)
{
using var cache = ShipAuthoringStore.Open(args[1]);
@ -50,7 +56,7 @@ public static class ShipAuthoringCommands
Console.WriteLine($"Created draft {path}; fill its model bindings and schematic in Blender.");
return 0;
}
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc>");
Console.Error.WriteLine("ship-authoring create <ship.cc> <stable-id> <name> | inspect <ship.cc> | validate <ship.cc> | compose <shipped.cc> <derived.cc> <mods-dir>");
return 1;
}
catch (Exception ex)

2
tools/blender/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
__pycache__/
*.pyc