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