From 3f29d803eb30a9a4c3311a3bc2db39de2d026cde Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 16 Oct 2026 21:37:51 +0200 Subject: [PATCH] Bind refuses a mod Assemble would throw on: meshless map-icon and hull-collider, non-positive shield scale Compose is the one owner of whether a mod hull is available. Bind now reads the node scale and matrix in ReadNodeIds and refuses what Assemble refuses. Not yet built or run; the verify job runs at the end of the cut. Co-Authored-By: Claude Opus 5.5 --- Assets/Scripts/ServerShared/ShipModCatalog.cs | 26 +++++++++---- .../ShipModCatalogTests.cs | 37 +++++++++++++++++-- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index 52d383ce8..653becda2 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -201,11 +201,17 @@ 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}."); - // A thruster's mesh is its exhaust emission surface and a radiator's mesh is what glows (ruling - // thrusters-radiators-are-meshes), so either on an empty node is refused here, before any catalog sees the ship. + // 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. foreach (var anchor in ship.Anchors) - if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh) + 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."); + 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."); BindRig(ship, modelNodes); CultRecordRefs.Validate(hull); return new Package @@ -218,7 +224,7 @@ public static class ShipModCatalog // The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's // Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a radiator // mesh sits under no joint. - private static void BindRig(ShipAuthoring ship, Dictionary nodes) + private static void BindRig(ShipAuthoring ship, Dictionary nodes) { var joints = new HashSet((ship.Joints ?? new List()).Select(joint => joint.Id), StringComparer.Ordinal); var idAt = nodes.ToDictionary(node => (int)node.Value.Index, node => node.Key); @@ -249,9 +255,13 @@ public static class ShipModCatalog } } + // No matrix, and every scale component (default 1) above zero: what Assemble's shield radii need. + 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 - // (-1 for none) are read here; geometry belongs to the runtime importer. - public static Dictionary ReadNodeIds(string path) + // (-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) { using var stream = File.OpenRead(path); using var reader = new BinaryReader(stream); @@ -262,7 +272,7 @@ public static class ShipModCatalog 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 Dictionary(StringComparer.Ordinal); + var ids = new Dictionary(StringComparer.Ordinal); var nodes = (json["nodes"]?.OfType() ?? Enumerable.Empty()).ToArray(); var parents = Enumerable.Repeat(-1, nodes.Length).ToArray(); for (var parent = 0; parent < nodes.Length; parent++) @@ -273,7 +283,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)))) + if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, 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 721285b7b..188a82474 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -383,7 +383,8 @@ public sealed class ShipModCatalogTests : IDisposable } // Ruling thrusters-radiators-are-meshes: a thruster's mesh is its emission surface and a radiator's is what glows, so - // either on an empty node is refused before compose; a weapon mount (and shield, tractor) may be an empty. + // either on an empty node is refused before compose, as are the map icon and hull collider Assemble reads a mesh from, + // and a shield whose node does not carry three positive radii; a weapon mount (and shield, tractor) may be an empty. [Fact] public void AMeshRoleAnchorOnAnEmptyNodeIsRefused() { @@ -402,6 +403,25 @@ public sealed class ShipModCatalogTests : IDisposable Assert.Contains("radiator-mesh anchor thruster.port needs a mesh", Assert.Throws(() => ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + foreach (var (dropped, message) in new[] { ("map", "map-icon anchor map needs a mesh"), ("collider", "hull-collider anchor collider needs a mesh") }) + { + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + WritePackage("mod.skiff", meshNodes: ShipFixture.MeshNodes.Where(node => node != dropped).ToArray()); + Assert.Contains(message, Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + } + foreach (var shieldNode in new[] { @"{""name"":""shield"",""scale"":[1,0,1]", @"{""name"":""shield"",""scale"":[1,1,-1]", @"{""name"":""shield"",""matrix"":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]" }) + { + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + WritePackage("mod.skiff"); + var glb = Path.Combine(Mods, "mod.skiff", "skiff.glb"); + var json = ShipFixture.GlbJson(FixtureNodes).Replace(@"{""name"":""shield""", shieldNode); + File.WriteAllBytes(glb, ShipFixture.Glb(json, ShipFixture.MeshBytes())); + Assert.Contains("shield anchor shield needs three positive scale radii", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + } + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + // The same radiator on a mesh node, and the weapon mount, shield and tractor on empty nodes, are accepted. Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); WritePackage("mod.skiff", tweak: AsRadiator); @@ -488,7 +508,18 @@ public sealed class ShipModCatalogTests : IDisposable { 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), ["b"] = (4, true, -1) }, ShipModCatalog.ReadNodeIds(path)); + Assert.Equal(new Dictionary { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path)); + } + + [Fact] + public void PositiveScaleReadsAnAbsentScaleAsPositiveAndANegativeComponentOrAMatrixAsNot() + { + var path = Write("scales.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[ + {""extras"":{""aetheria.id"":""none""}},{""scale"":[2,3,4],""extras"":{""aetheria.id"":""pos""}},{""scale"":[1,-1,1],""extras"":{""aetheria.id"":""neg""}}, + {""scale"":[1,1,0],""extras"":{""aetheria.id"":""zero""}},{""matrix"":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1],""extras"":{""aetheria.id"":""matrix""}}]}")); + var nodes = ShipModCatalog.ReadNodeIds(path); + Assert.Equal(new[] { "none", "pos" }, nodes.Where(node => node.Value.PositiveScale).Select(node => node.Key).OrderByDescending(key => key)); + Assert.Equal(new[] { "matrix", "neg", "zero" }, nodes.Where(node => !node.Value.PositiveScale).Select(node => node.Key).OrderBy(key => key)); } [Fact] @@ -652,7 +683,7 @@ public sealed class ShipModCatalogTests : IDisposable private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", Action tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes); - private static Dictionary Indices(Dictionary nodes) => + private static Dictionary Indices(Dictionary nodes) => nodes.ToDictionary(node => node.Key, node => node.Value.Index); private static int Occurrences(byte[] haystack, byte[] needle)