Ship packages: a thruster or radiator anchor on a mesh-less GLB node is refused at bind

ReadNodeIds is the one GLB parse and now records, per aetheria.id node, whether it carries a mesh. Bind refuses a
thruster-emitter or radiator-mesh anchor whose node has none, naming the ship and anchor (ruling
thrusters-radiators-are-meshes), so Compose and validate refuse such a package before the catalog sees it. AetherDb
'ship-authoring validate' judges the GLB when it exists (next commit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 17:07:08 +02:00
commit f5743cbad2
3 changed files with 88 additions and 15 deletions

View file

@ -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<string, uint> 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<string, (uint Index, bool HasMesh)> 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<string, uint>(StringComparer.Ordinal);
var ids = new Dictionary<string, (uint Index, bool HasMesh)>(StringComparer.Ordinal);
var nodes = json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>();
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++;
}

View file

@ -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
// <modsRoot>/<id>/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<ShipParts> tweak = null)
string modelAsset = "skiff.glb", Action<ShipParts> 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;
}
}

View file

@ -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<InvalidOperationException>(() =>
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<InvalidOperationException>(() =>
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<string, uint> { ["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<string, (uint, bool)> { ["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<string, uint> { ["a"] = 0 }, ShipModCatalog.ReadNodeIds(Write("full.glb", bytes)));
Assert.Equal(new Dictionary<string, uint> { ["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<ShipParts> tweak = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak);
Action<ShipParts> tweak = null, string[] meshNodes = null) => ShipFixture.WritePackage(Mods, id, hullName, nodes, modelAsset, tweak, meshNodes);
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh)> nodes) =>
nodes.ToDictionary(node => node.Key, node => node.Value.Index);
private static int Occurrences(byte[] haystack, byte[] needle)
{