diff --git a/Assets/Scripts/ServerShared/AetheriaStores.cs b/Assets/Scripts/ServerShared/AetheriaStores.cs index cd2589dc0..60975c38b 100644 --- a/Assets/Scripts/ServerShared/AetheriaStores.cs +++ b/Assets/Scripts/ServerShared/AetheriaStores.cs @@ -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) }; diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index 3eeb4eeed..c19ab153d 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -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(StringComparer.Ordinal); + var nodeIds = new HashSet(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) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs new file mode 100644 index 000000000..eff18b115 --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -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(); + var ids = new HashSet(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 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(StringComparer.Ordinal); + foreach (var node in json["nodes"]?.OfType() ?? Enumerable.Empty()) + { + 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; + } +} diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta b/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta new file mode 100644 index 000000000..c81d1965d --- /dev/null +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 708d45e8a4144a6e89bcf40e96b0975a diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index e5fe66669..b44b7c2a3 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -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. diff --git a/docs/ship-collection-authoring.md b/docs/ship-collection-authoring.md index b4e85f7a1..a92551ccd 100644 --- a/docs/ship-collection-authoring.md +++ b/docs/ship-collection-authoring.md @@ -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 diff --git a/tools/AetherDb/Program.cs b/tools/AetherDb/Program.cs index c392c3bf3..1803a74f4 100644 --- a/tools/AetherDb/Program.cs +++ b/tools/AetherDb/Program.cs @@ -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 ]... [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 ]... [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; } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index ce21cc6ca..061f58e41 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -10,11 +10,17 @@ public static class ShipAuthoringCommands { if (args.Length == 0) { - Console.Error.WriteLine("ship-authoring create | inspect | validate "); + Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); 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 | inspect | validate "); + Console.Error.WriteLine("ship-authoring create | inspect | validate | compose "); return 1; } catch (Exception ex) diff --git a/tools/blender/.gitignore b/tools/blender/.gitignore new file mode 100644 index 000000000..7a60b85e1 --- /dev/null +++ b/tools/blender/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.pyc