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 <noreply@anthropic.com>
This commit is contained in:
parent
da6fe0334e
commit
3f29d803eb
2 changed files with 52 additions and 11 deletions
|
|
@ -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<string, (uint Index, bool HasMesh, int Parent)> nodes)
|
||||
private static void BindRig(ShipAuthoring ship, Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> nodes)
|
||||
{
|
||||
var joints = new HashSet<string>((ship.Joints ?? new List<ShipJoint>()).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<string, (uint Index, bool HasMesh, int Parent)> 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<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> 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<string, (uint Index, bool HasMesh, int Parent)>(StringComparer.Ordinal);
|
||||
var ids = new Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)>(StringComparer.Ordinal);
|
||||
var nodes = (json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).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++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<InvalidOperationException>(() =>
|
||||
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<string, (uint, bool, int)> { ["a"] = (1, false, -1), ["b"] = (4, true, -1) }, ShipModCatalog.ReadNodeIds(path));
|
||||
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["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<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, int Parent)> nodes) =>
|
||||
private static Dictionary<string, uint> Indices(Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> nodes) =>
|
||||
nodes.ToDictionary(node => node.Key, node => node.Value.Index);
|
||||
|
||||
private static int Occurrences(byte[] haystack, byte[] needle)
|
||||
|
|
|
|||
Loading…
Reference in a new issue