diff --git a/Assets/Scripts/ServerShared/ShipModCatalog.cs b/Assets/Scripts/ServerShared/ShipModCatalog.cs index ee80add89..38216f481 100644 --- a/Assets/Scripts/ServerShared/ShipModCatalog.cs +++ b/Assets/Scripts/ServerShared/ShipModCatalog.cs @@ -177,12 +177,22 @@ 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. + foreach (var anchor in ship.Anchors) + if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh) + throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none."); CultRecordRefs.Validate(hull); - return new Package { Hull = hull, Visual = ship, ModelPath = modelPath, NodeIndices = modelNodes }; + return new Package + { + Hull = hull, Visual = ship, ModelPath = modelPath, + NodeIndices = modelNodes.ToDictionary(node => node.Key, node => node.Value.Index, StringComparer.Ordinal) + }; } - // GLB is the xenos asset boundary. Only its node extras are read here; geometry belongs to the runtime importer. - public static Dictionary ReadNodeIds(string path) + // GLB is the xenos asset boundary. Only its node extras, and whether each node names a mesh, are read here; geometry + // belongs to the runtime importer. + public static Dictionary ReadNodeIds(string path) { using var stream = File.OpenRead(path); using var reader = new BinaryReader(stream); @@ -193,14 +203,14 @@ 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(); uint index = 0; foreach (var node in nodes) { var id = (string)node["extras"]?["aetheria.id"]; if (string.IsNullOrEmpty(id)) { index++; continue; } - if (!ids.TryAdd(id, index)) + if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null))) throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}."); index++; } diff --git a/tests/Aetheria.Shared.Tests/ShipFixture.cs b/tests/Aetheria.Shared.Tests/ShipFixture.cs index 2e8d4994e..9f2e1c75f 100644 --- a/tests/Aetheria.Shared.Tests/ShipFixture.cs +++ b/tests/Aetheria.Shared.Tests/ShipFixture.cs @@ -25,18 +25,19 @@ internal static class ShipFixture public static byte[] MeshBytes() => Tetrahedron.SelectMany(BitConverter.GetBytes).ToArray(); // The GLB carries a glTF scene naming every node: glTFast instantiates a scene, and a scene-less file has none. - // Nodes in MeshNodes get the shared tetrahedron mesh, whose vertices live in the BIN chunk (MeshBytes). - public static string GlbJson(string[] nodes) + // Nodes in meshNodes (default MeshNodes) get the shared tetrahedron mesh, whose vertices live in the BIN chunk (MeshBytes). + public static string GlbJson(string[] nodes, string[] meshNodes = null) { + meshNodes ??= MeshNodes; var length = MeshBytes().Length; - var mesh = nodes.Any(MeshNodes.Contains) + var mesh = nodes.Any(meshNodes.Contains) ? @",""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]," + @"""accessors"":[{""bufferView"":0,""componentType"":5126,""count"":12,""type"":""VEC3"",""min"":[0,0,0],""max"":[1,1,1]}]," + $@"""bufferViews"":[{{""buffer"":0,""byteLength"":{length}}}],""buffers"":[{{""byteLength"":{length}}}]" : ""; return @"{""asset"":{""version"":""2.0""},""scene"":0,""scenes"":[{""nodes"":[" + string.Join(",", nodes.Select((_, index) => index)) + @"]}]" + mesh + @",""nodes"":[" + string.Join(",", nodes.Select(node => - $@"{{""name"":""{node}""{(MeshNodes.Contains(node) ? @",""mesh"":0" : "")},""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; + $@"{{""name"":""{node}""{(meshNodes.Contains(node) ? @",""mesh"":0" : "")},""extras"":{{""aetheria.id"":""{node}""}}}}")) + "]}"; } public static byte[] Glb(string json, byte[] bin = null) @@ -63,7 +64,7 @@ internal static class ShipFixture // //ship.cc plus the GLB at the record's ModelAsset path. public static string WritePackage(string modsRoot, string id, string hullName = "Skiff", string[] nodes = null, - string modelAsset = "skiff.glb", Action tweak = null) + string modelAsset = "skiff.glb", Action tweak = null, string[] meshNodes = null) { var ship = ShipAuthoringTests.Fixture(); // A playable rig: the fixture thruster plus one energy weapon mount on the hull's other cell, with its muzzle. @@ -81,7 +82,7 @@ internal static class ShipFixture ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual); cache.FlushAsync().Wait(); } - File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes), MeshBytes())); + File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes, meshNodes), MeshBytes())); return directory; } } diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 82cca60f5..a2426906b 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -370,12 +370,71 @@ public sealed class ShipModCatalogTests : IDisposable ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); } + // 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. + [Fact] + public void AMeshRoleAnchorOnAnEmptyNodeIsRefused() + { + var withoutThrusterMesh = ShipFixture.MeshNodes.Where(node => node != "thruster-port").ToArray(); + WritePackage("mod.skiff", meshNodes: withoutThrusterMesh); + Assert.Contains("thruster-emitter anchor thruster.port needs a mesh, but model node thruster-port has none", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + + Directory.Delete(Path.Combine(Mods, "mod.skiff"), true); + void AsRadiator(ShipParts ship) + { + ship.Hull.Hardpoints.Single(hardpoint => hardpoint.Transform == "thruster.port").Type = HardpointType.Radiator; + ship.Visual.Anchors.Single(anchor => anchor.Id == "thruster.port").Role = "radiator-mesh"; + } + WritePackage("mod.skiff", tweak: AsRadiator, meshNodes: withoutThrusterMesh); + Assert.Contains("radiator-mesh anchor thruster.port needs a mesh", Assert.Throws(() => + ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc"))).Message); + + // 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); + var nodes = ShipModCatalog.ReadNodeIds(Path.Combine(Mods, "mod.skiff", "skiff.glb")); + Assert.False(nodes["gun"].HasMesh || nodes["shield"].HasMesh || nodes["tractor"].HasMesh); + Assert.Equal("mod.skiff", ShipModCatalog.ReadPackage(Path.Combine(Mods, "mod.skiff", "ship.cc")).Visual.Id); + } + + // AetherDb's validate judges the GLB once it exists, so Blender's Package sees what Compose would refuse. + [Fact] + public void ValidateCommandReadsTheGlbWhenPresent() + { + WritePackage("mod.skiff", nodes: FixtureNodes.Where(node => node != "thruster-port").ToArray()); + var path = Path.Combine(Mods, "mod.skiff", "ship.cc"); + var (code, output, error) = Run("validate", path); + Assert.Equal(1, code); + Assert.Contains("model has no node with aetheria.id=thruster-port for anchor thruster.port", error); + + File.Delete(Path.Combine(Mods, "mod.skiff", "skiff.glb")); + (code, output, error) = Run("validate", path); + Assert.Equal(0, code); + Assert.Contains("records only", output); + + WritePackage("mod.good"); + (code, output, _) = Run("validate", Path.Combine(Mods, "mod.good", "ship.cc")); + Assert.Equal(0, code); + Assert.Contains("records and model", output); + } + + private static (int Code, string Output, string Error) Run(params string[] args) + { + var (output, error) = (new StringWriter(), new StringWriter()); + var (previousOut, previousError) = (Console.Out, Console.Error); + Console.SetOut(output); + Console.SetError(error); + try { return (ShipAuthoringCommands.Run(args), output.ToString(), error.ToString()); } + finally { Console.SetOut(previousOut); Console.SetError(previousError); } + } + [Fact] 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"":""""}},{""extras"":{""aetheria.id"":""b""}}]}")); - Assert.Equal(new Dictionary { ["a"] = 1, ["b"] = 4 }, ShipModCatalog.ReadNodeIds(path)); + {""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}")); + Assert.Equal(new Dictionary { ["a"] = (1, false), ["b"] = (4, true) }, ShipModCatalog.ReadNodeIds(path)); } [Fact] @@ -414,7 +473,7 @@ public sealed class ShipModCatalogTests : IDisposable BitConverter.GetBytes((uint)json.Length).CopyTo(bytes, 12); BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16); json.CopyTo(bytes, 20); - Assert.Equal(new Dictionary { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes))); + Assert.Equal(new Dictionary { ["a"] = 0 }, Indices(ShipModCatalog.ReadNodeIds(Write("full.glb", bytes)))); } // A run store references a mod hull by its key, so the same packages must derive the same records on every launch, @@ -537,7 +596,10 @@ public sealed class ShipModCatalogTests : IDisposable } private void WritePackage(string id, string hullName = "Skiff", string[] nodes = null, string modelAsset = "skiff.glb", - Action tweak = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak); + Action tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes); + + private static Dictionary Indices(Dictionary nodes) => + nodes.ToDictionary(node => node.Key, node => node.Value.Index); private static int Occurrences(byte[] haystack, byte[] needle) {