From 7d5e14a65b1148f13b5e571744690247449378a8 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 06:08:02 +0000 Subject: [PATCH 1/4] Red test: a boot catalog must hold no hull the importer cannot build ABootCatalogHoldsNoHullTheImporterCannotBuild fails at f7fd76857: dangling, POSITION-less, skinned and primitive-less mesh nodes are all composed and included (Excluded is empty). Co-Authored-By: Claude Sonnet 5.5 --- .../ShipModCatalogTests.cs | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index f439eecc9..476fde846 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -328,6 +328,49 @@ public sealed class ShipModCatalogTests : IDisposable 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 index, no POSITION, 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 + { + ["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"":[]") + }; + 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().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. [Fact] public void TheBootCatalogSurvivesBadPackages() From b66d4f89abb8effc34c23815a170e6f43c063501 Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 06:08:44 +0000 Subject: [PATCH 2/4] Bind decides every GLB-structure fact of a mod ship once ReadNodeIds' HasMesh is now BuildsMesh: no skin, mesh index inside meshes, at least one primitive, POSITION and triangle mode on each. Assemble's shield-radii throw is deleted (Bind's PositiveScale is the judge). The FBX thruster emitter takes Hide, with a smoke Require. Unity-only edits are not compiled in this Body. Co-Authored-By: Claude Sonnet 5.5 --- Assets/Scripts/Editor/ShipPrefabAuthoring.cs | 1 + .../Editor/ShipPrefabAuthoringSmoke.cs | 2 ++ Assets/Scripts/Gameplay/ShipModShips.cs | 11 +++---- Assets/Scripts/ServerShared/ShipModCatalog.cs | 31 ++++++++++++++----- .../ShipModCatalogTests.cs | 5 +-- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs index d2937b79d..45bbc8609 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoring.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoring.cs @@ -112,6 +112,7 @@ public static class ShipPrefabAuthoring case "Thruster": var emitter = DirectChild(node, "Emitter")?.GetComponent(); if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child"); + ShipModShips.Hide(emitter, invisible); AddOrGet(node.gameObject).Emitter = emitter; thrusters.Add(node.GetComponent()); break; diff --git a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs index 9f740f557..5342eb352 100644 --- a/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs +++ b/Assets/Scripts/Editor/ShipPrefabAuthoringSmoke.cs @@ -28,6 +28,8 @@ public static class ShipPrefabAuthoringSmoke ShipPrefabAuthoring.Configure(root); var ship = root.GetComponent(); 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[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order"); Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh"); diff --git a/Assets/Scripts/Gameplay/ShipModShips.cs b/Assets/Scripts/Gameplay/ShipModShips.cs index 89106a6b7..33f09f590 100644 --- a/Assets/Scripts/Gameplay/ShipModShips.cs +++ b/Assets/Scripts/Gameplay/ShipModShips.cs @@ -6,9 +6,10 @@ using GameCult.Caching; using UnityEngine; 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 -// hull's record key, into an inactive assembled copy that Instantiate clones synchronously, so LoadEntity never loads async. -// A composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package. +// Mod ships in the running game. Compose (Bind) is the one verdict on whether a mod hull exists, from the package's files; +// Preload only builds a prototype for each, keyed by the hull's record key, into an inactive assembled copy that Instantiate +// 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. public static class ShipModShips { @@ -101,6 +102,7 @@ public static class ShipModShips node.name = id; return node; } + // An importer report, not a verdict: Bind has already judged the file. MeshRenderer Renderer(string id) { var found = Node(id).GetComponentInChildren(true); @@ -130,9 +132,6 @@ public static class ShipModShips var surface = AddOrGet(hullNode.gameObject); 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(), shieldMarker, false); Reset(shield.transform); AddOrGet(shield.gameObject); diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index e9ed613a2..88dc06b85 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -251,14 +251,15 @@ 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}."); - // Assemble reads a renderer or collider mesh from these anchors' nodes and refuses without one, and reads the shield - // node's scale as three positive radii (ruling thrusters-radiators-are-meshes). These refusals stand in for Assemble's - // throws, 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. + // Assemble reads a renderer or collider mesh from these anchors' nodes, and the shield node's scale as three positive + // radii (ruling thrusters-radiators-are-meshes). These refusals cover everything the package's files say about whether + // the importer can build a node (skin, dangling or empty mesh, not only "names a mesh") and are Assemble's only judge, + // 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) if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") && !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) 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."); @@ -309,7 +310,23 @@ public static class ShipModCatalog private static bool PositiveScale(JObject node) => 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. public static Dictionary ReadNodeIds(string path) { @@ -333,7 +350,7 @@ public static class ShipModCatalog { var id = (string)node["extras"]?["aetheria.id"]; 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}."); index++; } diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 476fde846..877430027 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -597,8 +597,9 @@ public sealed class ShipModCatalogTests : IDisposable public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne() { 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""}}]}")); - Assert.Equal(new Dictionary { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path)); + {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}},{""mesh"":7,""extras"":{""aetheria.id"":""c""}}], + ""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]}")); + Assert.Equal(new Dictionary { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true), ["c"] = (5, false, -1, true) }, ShipModCatalog.ReadNodeIds(path)); } [Fact] From 339c617a74a7d6ec88c8242f8dc48d9c9966d09c Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 06:22:33 +0000 Subject: [PATCH 3/4] ShipRigTests fixture: the shared mesh has a POSITION primitive The fixture's empty primitives array is a mesh the importer cannot build, which Bind now refuses. Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipRigTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Aetheria.Shared.Tests/ShipRigTests.cs b/tests/Aetheria.Shared.Tests/ShipRigTests.cs index 34581f34a..fbad3ec05 100644 --- a/tests/Aetheria.Shared.Tests/ShipRigTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipRigTests.cs @@ -209,7 +209,7 @@ public sealed class ShipRigTests : IDisposable 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++) { var node = nodes[index]; From ad295acaf3bfc66be1a77d52abf65675167e397b Mon Sep 17 00:00:00 2001 From: Nibu Date: Fri, 13 Nov 2026 06:45:43 +0000 Subject: [PATCH 4/4] Pin BuildsMesh clauses: points mode, one bad primitive, negative and fractional mesh index, missing meshes array Co-Authored-By: Claude Sonnet 5.5 --- tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 877430027..175a6bd9f 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -329,7 +329,7 @@ public sealed class ShipModCatalogTests : IDisposable } // Bind is the one verdict on whether a mod hull exists: a package whose GLB names a node mesh the importer cannot build - // (dangling index, no POSITION, skinned node, no primitives) never reaches the boot catalog, so Preload has nothing left to refuse on file facts. + // (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() { @@ -339,7 +339,12 @@ public sealed class ShipModCatalogTests : IDisposable ["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.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) {