Merge pull request 'Mod ships: the catalog gate decides every file-decidable GLB fact once' (#22) from eureka/aetheria-release-mod-ship-glb-gate into master

This commit is contained in:
Nibu 2026-11-13 07:28:20 +00:00
commit b2322c4ce5
6 changed files with 84 additions and 16 deletions

View file

@ -112,6 +112,7 @@ public static class ShipPrefabAuthoring
case "Thruster": case "Thruster":
var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>(); var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>();
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
ShipModShips.Hide(emitter, invisible);
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter; AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
thrusters.Add(node.GetComponent<ThrusterHardpoint>()); thrusters.Add(node.GetComponent<ThrusterHardpoint>());
break; break;

View file

@ -28,6 +28,8 @@ public static class ShipPrefabAuthoringSmoke
ShipPrefabAuthoring.Configure(root); ShipPrefabAuthoring.Configure(root);
var ship = root.GetComponent<ShipInstance>(); var ship = root.GetComponent<ShipInstance>();
Require(ship != null && ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter"); Require(ship != null && ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter");
Require(Array.TrueForAll(ship.ThrusterHardpoints[0].Emitter.sharedMaterials, material => material == ship.InvisibleMaterial),
"thruster emitter drawn with the invisible material only");
Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles"); Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles");
Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order");
Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh");

View file

@ -6,9 +6,10 @@ using GameCult.Caching;
using UnityEngine; using UnityEngine;
using Object = UnityEngine.Object; using Object = UnityEngine.Object;
// Mod ships in the running game. Compose decides which mod hulls exist; Preload builds a prototype for each, keyed by the // Mod ships in the running game. Compose (Bind) is the one verdict on whether a mod hull exists, from the package's files;
// hull's record key, into an inactive assembled copy that Instantiate clones synchronously, so LoadEntity never loads async. // Preload only builds a prototype for each, keyed by the hull's record key, into an inactive assembled copy that Instantiate
// A composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package. // clones synchronously, so LoadEntity never loads async. ImportFault reports what the Unity importer made of a composed hull:
// a composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package.
// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template. // The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template.
public static class ShipModShips public static class ShipModShips
{ {
@ -101,6 +102,7 @@ public static class ShipModShips
node.name = id; node.name = id;
return node; return node;
} }
// An importer report, not a verdict: Bind has already judged the file.
MeshRenderer Renderer(string id) MeshRenderer Renderer(string id)
{ {
var found = Node(id).GetComponentInChildren<MeshRenderer>(true); var found = Node(id).GetComponentInChildren<MeshRenderer>(true);
@ -130,9 +132,6 @@ public static class ShipModShips
var surface = AddOrGet<HullCollider>(hullNode.gameObject); var surface = AddOrGet<HullCollider>(hullNode.gameObject);
var shieldMarker = Node(plan.Shield); var shieldMarker = Node(plan.Shield);
var scale = shieldMarker.localScale;
if (!(scale.x > 0 && scale.y > 0 && scale.z > 0))
throw new InvalidOperationException($"{ship.Id}: the shield anchor scale must have three positive radii.");
var shield = Object.Instantiate(template.Shield.GetComponent<ShieldManager>(), shieldMarker, false); var shield = Object.Instantiate(template.Shield.GetComponent<ShieldManager>(), shieldMarker, false);
Reset(shield.transform); Reset(shield.transform);
AddOrGet<ShieldEnvelope>(shield.gameObject); AddOrGet<ShieldEnvelope>(shield.gameObject);

View file

@ -251,14 +251,15 @@ public static class ShipModCatalog
foreach (var anchor in ship.Anchors) foreach (var anchor in ship.Anchors)
if (!modelNodes.ContainsKey(anchor.ModelNodeId)) if (!modelNodes.ContainsKey(anchor.ModelNodeId))
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}."); throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
// Assemble reads a renderer or collider mesh from these anchors' nodes and refuses without one, and reads the shield // Assemble reads a renderer or collider mesh from these anchors' nodes, and the shield node's scale as three positive
// node's scale as three positive radii (ruling thrusters-radiators-are-meshes). These refusals stand in for Assemble's // radii (ruling thrusters-radiators-are-meshes). These refusals cover everything the package's files say about whether
// throws, so a package Compose includes is one the importer's assembler can build; the exhaust emission surface and // the importer can build a node (skin, dangling or empty mesh, not only "names a mesh") and are Assemble's only judge,
// the glowing radiator are meshes by that same ruling. A weapon mount and a tractor may be empties. // so a package Compose includes is one the importer's assembler can build; the exhaust emission surface and the
// glowing radiator are meshes by that same ruling. A weapon mount and a tractor may be empties.
foreach (var anchor in ship.Anchors) foreach (var anchor in ship.Anchors)
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") && if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") &&
!modelNodes[anchor.ModelNodeId].HasMesh) !modelNodes[anchor.ModelNodeId].HasMesh)
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none."); throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none the importer can build.");
foreach (var anchor in ship.Anchors) foreach (var anchor in ship.Anchors)
if (anchor.Role == "shield" && !modelNodes[anchor.ModelNodeId].PositiveScale) if (anchor.Role == "shield" && !modelNodes[anchor.ModelNodeId].PositiveScale)
throw new InvalidOperationException($"{ship.Id}: shield anchor {anchor.Id} needs three positive scale radii on model node {anchor.ModelNodeId}, with no matrix."); throw new InvalidOperationException($"{ship.Id}: shield anchor {anchor.Id} needs three positive scale radii on model node {anchor.ModelNodeId}, with no matrix.");
@ -309,7 +310,23 @@ public static class ShipModCatalog
private static bool PositiveScale(JObject node) => private static bool PositiveScale(JObject node) =>
node["matrix"] == null && (node["scale"] == null || node["scale"].Select(component => (double)component).All(component => component > 0)); node["matrix"] == null && (node["scale"] == null || node["scale"].Select(component => (double)component).All(component => component > 0));
// GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index // A mesh the importer builds as a plain mesh node: no skin (glTFast makes a skinned node a SkinnedMeshRenderer, which
// Assemble's MeshRenderer/MeshFilter reads cannot see), a mesh index inside meshes, at least one primitive, and every
// primitive with POSITION and a mode absent or 4 (triangles). Geometry accessors and buffers are not read.
private static bool BuildsMesh(JObject json, JObject node)
{
if (node["skin"] != null && node["skin"].Type != JTokenType.Null) return false;
var meshes = json["meshes"] as JArray;
if (node["mesh"] == null || node["mesh"].Type != JTokenType.Integer || meshes == null) return false;
var index = (long)node["mesh"];
if (index < 0 || index >= meshes.Count) return false;
var primitives = (meshes[(int)index] as JObject)?["primitives"] as JArray;
return primitives != null && primitives.Count > 0 && primitives.All(primitive =>
primitive is JObject p && p["attributes"]?["POSITION"] != null &&
(p["mode"] == null || (p["mode"].Type == JTokenType.Integer && (int)p["mode"] == 4)));
}
// GLB is the xenos asset boundary. Only its node extras, whether each node carries a mesh the importer builds as a plain mesh node, and its nearest identified ancestor's node index
// (-1 for none) are read here, plus whether the node's scale is three positive radii with no matrix; geometry belongs to the runtime importer. // (-1 for none) are read here, plus whether the node's scale is three positive radii with no matrix; geometry belongs to the runtime importer.
public static Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> ReadNodeIds(string path) public static Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> ReadNodeIds(string path)
{ {
@ -333,7 +350,7 @@ public static class ShipModCatalog
{ {
var id = (string)node["extras"]?["aetheria.id"]; var id = (string)node["extras"]?["aetheria.id"];
if (string.IsNullOrEmpty(id)) { index++; continue; } if (string.IsNullOrEmpty(id)) { index++; continue; }
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index), PositiveScale(node)))) if (!ids.TryAdd(id, (index, BuildsMesh(json, node), IdentifiedAncestor(parents, nodes, (int)index), PositiveScale(node))))
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
index++; index++;
} }

View file

@ -328,6 +328,54 @@ public sealed class ShipModCatalogTests : IDisposable
Assert.Contains("GLB", excluded.Reason); Assert.Contains("GLB", excluded.Reason);
} }
// Bind is the one verdict on whether a mod hull exists: a package whose GLB names a node mesh the importer cannot build
// (dangling or negative index, no meshes array, non-integer index, no POSITION on any one primitive, non-triangle mode, skinned node, no primitives) never reaches the boot catalog, so Preload has nothing left to refuse on file facts.
[Fact]
public void ABootCatalogHoldsNoHullTheImporterCannotBuild()
{
WritePackage("mod.good", hullName: "Good");
var broken = new Dictionary<string, (string From, string To)>
{
["mod.dangling"] = (@"{""name"":""collider"",""mesh"":0", @"{""name"":""collider"",""mesh"":7"),
["mod.nopos"] = (@"""attributes"":{""POSITION"":0}", @"""attributes"":{}"),
["mod.skinned"] = (@"{""name"":""map"",""mesh"":0", @"{""name"":""map"",""mesh"":0,""skin"":0"),
["mod.noprims"] = (@"""primitives"":[{""attributes"":{""POSITION"":0}}]", @"""primitives"":[]"),
["mod.onebad"] = (@"""primitives"":[{""attributes"":{""POSITION"":0}}]", @"""primitives"":[{""attributes"":{""POSITION"":0}},{""attributes"":{}}]"),
["mod.points"] = (@"""attributes"":{""POSITION"":0}}", @"""attributes"":{""POSITION"":0},""mode"":0}"),
["mod.negative"] = (@"""mesh"":0", @"""mesh"":-1"),
["mod.nomeshes"] = (@"""meshes"":", @"""other"":"),
["mod.fractional"] = (@"""mesh"":0", @"""mesh"":0.0")
};
foreach (var (package, (from, to)) in broken)
{
WritePackage(package, hullName: package.Substring(4));
var json = ShipFixture.GlbJson(FixtureNodes);
Assert.Contains(from, json);
File.WriteAllBytes(Path.Combine(Mods, package, "skiff.glb"), ShipFixture.Glb(json.Replace(from, to), ShipFixture.MeshBytes()));
}
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods);
Assert.Equal(Derived, catalog);
Assert.Equal(broken.Keys.OrderBy(name => name, StringComparer.Ordinal), excluded.Select(e => e.Package).OrderBy(name => name, StringComparer.Ordinal));
Assert.All(excluded, e => Assert.Contains("needs a mesh", e.Reason));
using var cache = AetheriaStores.Open(catalog);
var hulls = cache.GetAll<HullData>().Where(hull => hull.Visual.IsSet()).ToArray();
Assert.Equal(new[] { "Good" }, hulls.Select(hull => hull.Name));
var keys = cache.AllStoredDocuments.Select(record => record.Key.Value).ToHashSet();
foreach (var package in broken.Keys)
{
Assert.DoesNotContain(ShipModCatalog.HullKey(package).Value, keys);
Assert.DoesNotContain(ShipModCatalog.AuthoringKey(package).Value, keys);
}
// Preload's own two steps over file facts, run headlessly: neither may refuse a hull the catalog holds.
foreach (var hull in hulls)
{
var package = ShipModCatalog.PackageOf(cache, hull, Mods);
ShipModPlan.Build(package.Hull, package.Visual);
}
}
// The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots. // The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
[Fact] [Fact]
public void TheBootCatalogSurvivesBadPackages() public void TheBootCatalogSurvivesBadPackages()
@ -554,8 +602,9 @@ public sealed class ShipModCatalogTests : IDisposable
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne()
{ {
var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}")); {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}},{""mesh"":7,""extras"":{""aetheria.id"":""c""}}],
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path)); ""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]}"));
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true), ["c"] = (5, false, -1, true) }, ShipModCatalog.ReadNodeIds(path));
} }
[Fact] [Fact]

View file

@ -209,7 +209,7 @@ public sealed class ShipRigTests : IDisposable
private static string GlbJson(Node[] nodes) private static string GlbJson(Node[] nodes)
{ {
var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[]}],""nodes"":["); var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}],""nodes"":[");
for (var index = 0; index < nodes.Length; index++) for (var index = 0; index < nodes.Length; index++)
{ {
var node = nodes[index]; var node = nodes[index];