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:
parent
ed15d34b35
commit
f5743cbad2
3 changed files with 88 additions and 15 deletions
|
|
@ -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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue