Merge pull request 'Mod ships: the catalog gate decides every file-decidable GLB fact once' (#22) from eureka/aetheria-release-mod-ship-glb-gate into master
This commit is contained in:
commit
b2322c4ce5
6 changed files with 84 additions and 16 deletions
|
|
@ -112,6 +112,7 @@ public static class ShipPrefabAuthoring
|
||||||
case "Thruster":
|
case "Thruster":
|
||||||
var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>();
|
var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>();
|
||||||
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
|
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
|
||||||
|
ShipModShips.Hide(emitter, invisible);
|
||||||
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
|
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
|
||||||
thrusters.Add(node.GetComponent<ThrusterHardpoint>());
|
thrusters.Add(node.GetComponent<ThrusterHardpoint>());
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ public static class ShipPrefabAuthoringSmoke
|
||||||
ShipPrefabAuthoring.Configure(root);
|
ShipPrefabAuthoring.Configure(root);
|
||||||
var ship = root.GetComponent<ShipInstance>();
|
var ship = root.GetComponent<ShipInstance>();
|
||||||
Require(ship != null && ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter");
|
Require(ship != null && ship.ThrusterHardpoints.Length == 1 && ship.ThrusterHardpoints[0].Emitter != null, "thruster emitter");
|
||||||
|
Require(Array.TrueForAll(ship.ThrusterHardpoints[0].Emitter.sharedMaterials, material => material == ship.InvisibleMaterial),
|
||||||
|
"thruster emitter drawn with the invisible material only");
|
||||||
Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles");
|
Require(ship.WeaponHardpoints.Length == 1 && ship.WeaponHardpoints[0].FiringPoint.Length == 2, "weapon muzzles");
|
||||||
Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order");
|
Require(ship.WeaponHardpoints[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order");
|
||||||
Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh");
|
Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh");
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,10 @@ using GameCult.Caching;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
using Object = UnityEngine.Object;
|
using Object = UnityEngine.Object;
|
||||||
|
|
||||||
// Mod ships in the running game. Compose decides which mod hulls exist; Preload builds a prototype for each, keyed by the
|
// Mod ships in the running game. Compose (Bind) is the one verdict on whether a mod hull exists, from the package's files;
|
||||||
// hull's record key, into an inactive assembled copy that Instantiate clones synchronously, so LoadEntity never loads async.
|
// Preload only builds a prototype for each, keyed by the hull's record key, into an inactive assembled copy that Instantiate
|
||||||
// A composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package.
|
// clones synchronously, so LoadEntity never loads async. ImportFault reports what the Unity importer made of a composed hull:
|
||||||
|
// a composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package.
|
||||||
// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template.
|
// The prototypes are cache-only: rebuilt every boot from the catalog, the mod GLBs and the template.
|
||||||
public static class ShipModShips
|
public static class ShipModShips
|
||||||
{
|
{
|
||||||
|
|
@ -101,6 +102,7 @@ public static class ShipModShips
|
||||||
node.name = id;
|
node.name = id;
|
||||||
return node;
|
return node;
|
||||||
}
|
}
|
||||||
|
// An importer report, not a verdict: Bind has already judged the file.
|
||||||
MeshRenderer Renderer(string id)
|
MeshRenderer Renderer(string id)
|
||||||
{
|
{
|
||||||
var found = Node(id).GetComponentInChildren<MeshRenderer>(true);
|
var found = Node(id).GetComponentInChildren<MeshRenderer>(true);
|
||||||
|
|
@ -130,9 +132,6 @@ public static class ShipModShips
|
||||||
var surface = AddOrGet<HullCollider>(hullNode.gameObject);
|
var surface = AddOrGet<HullCollider>(hullNode.gameObject);
|
||||||
|
|
||||||
var shieldMarker = Node(plan.Shield);
|
var shieldMarker = Node(plan.Shield);
|
||||||
var scale = shieldMarker.localScale;
|
|
||||||
if (!(scale.x > 0 && scale.y > 0 && scale.z > 0))
|
|
||||||
throw new InvalidOperationException($"{ship.Id}: the shield anchor scale must have three positive radii.");
|
|
||||||
var shield = Object.Instantiate(template.Shield.GetComponent<ShieldManager>(), shieldMarker, false);
|
var shield = Object.Instantiate(template.Shield.GetComponent<ShieldManager>(), shieldMarker, false);
|
||||||
Reset(shield.transform);
|
Reset(shield.transform);
|
||||||
AddOrGet<ShieldEnvelope>(shield.gameObject);
|
AddOrGet<ShieldEnvelope>(shield.gameObject);
|
||||||
|
|
|
||||||
|
|
@ -251,14 +251,15 @@ public static class ShipModCatalog
|
||||||
foreach (var anchor in ship.Anchors)
|
foreach (var anchor in ship.Anchors)
|
||||||
if (!modelNodes.ContainsKey(anchor.ModelNodeId))
|
if (!modelNodes.ContainsKey(anchor.ModelNodeId))
|
||||||
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
|
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={anchor.ModelNodeId} for anchor {anchor.Id}.");
|
||||||
// Assemble reads a renderer or collider mesh from these anchors' nodes and refuses without one, and reads the shield
|
// Assemble reads a renderer or collider mesh from these anchors' nodes, and the shield node's scale as three positive
|
||||||
// node's scale as three positive radii (ruling thrusters-radiators-are-meshes). These refusals stand in for Assemble's
|
// radii (ruling thrusters-radiators-are-meshes). These refusals cover everything the package's files say about whether
|
||||||
// throws, so a package Compose includes is one the importer's assembler can build; the exhaust emission surface and
|
// the importer can build a node (skin, dangling or empty mesh, not only "names a mesh") and are Assemble's only judge,
|
||||||
// the glowing radiator are meshes by that same ruling. A weapon mount and a tractor may be empties.
|
// 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)
|
foreach (var anchor in ship.Anchors)
|
||||||
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") &&
|
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh" || anchor.Role == "map-icon" || anchor.Role == "hull-collider") &&
|
||||||
!modelNodes[anchor.ModelNodeId].HasMesh)
|
!modelNodes[anchor.ModelNodeId].HasMesh)
|
||||||
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none.");
|
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none the importer can build.");
|
||||||
foreach (var anchor in ship.Anchors)
|
foreach (var anchor in ship.Anchors)
|
||||||
if (anchor.Role == "shield" && !modelNodes[anchor.ModelNodeId].PositiveScale)
|
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.");
|
throw new InvalidOperationException($"{ship.Id}: shield anchor {anchor.Id} needs three positive scale radii on model node {anchor.ModelNodeId}, with no matrix.");
|
||||||
|
|
@ -309,7 +310,23 @@ public static class ShipModCatalog
|
||||||
private static bool PositiveScale(JObject node) =>
|
private static bool PositiveScale(JObject node) =>
|
||||||
node["matrix"] == null && (node["scale"] == null || node["scale"].Select(component => (double)component).All(component => component > 0));
|
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
|
// A mesh the importer builds as a plain mesh node: no skin (glTFast makes a skinned node a SkinnedMeshRenderer, which
|
||||||
|
// Assemble's MeshRenderer/MeshFilter reads cannot see), a mesh index inside meshes, at least one primitive, and every
|
||||||
|
// primitive with POSITION and a mode absent or 4 (triangles). Geometry accessors and buffers are not read.
|
||||||
|
private static bool BuildsMesh(JObject json, JObject node)
|
||||||
|
{
|
||||||
|
if (node["skin"] != null && node["skin"].Type != JTokenType.Null) return false;
|
||||||
|
var meshes = json["meshes"] as JArray;
|
||||||
|
if (node["mesh"] == null || node["mesh"].Type != JTokenType.Integer || meshes == null) return false;
|
||||||
|
var index = (long)node["mesh"];
|
||||||
|
if (index < 0 || index >= meshes.Count) return false;
|
||||||
|
var primitives = (meshes[(int)index] as JObject)?["primitives"] as JArray;
|
||||||
|
return primitives != null && primitives.Count > 0 && primitives.All(primitive =>
|
||||||
|
primitive is JObject p && p["attributes"]?["POSITION"] != null &&
|
||||||
|
(p["mode"] == null || (p["mode"].Type == JTokenType.Integer && (int)p["mode"] == 4)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GLB is the xenos asset boundary. Only its node extras, whether each node carries a mesh the importer builds as a plain mesh node, and its nearest identified ancestor's node index
|
||||||
// (-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.
|
// (-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)
|
public static Dictionary<string, (uint Index, bool HasMesh, int Parent, bool PositiveScale)> ReadNodeIds(string path)
|
||||||
{
|
{
|
||||||
|
|
@ -333,7 +350,7 @@ public static class ShipModCatalog
|
||||||
{
|
{
|
||||||
var id = (string)node["extras"]?["aetheria.id"];
|
var id = (string)node["extras"]?["aetheria.id"];
|
||||||
if (string.IsNullOrEmpty(id)) { index++; continue; }
|
if (string.IsNullOrEmpty(id)) { index++; continue; }
|
||||||
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index), PositiveScale(node))))
|
if (!ids.TryAdd(id, (index, BuildsMesh(json, node), IdentifiedAncestor(parents, nodes, (int)index), PositiveScale(node))))
|
||||||
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
|
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
|
||||||
index++;
|
index++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,54 @@ public sealed class ShipModCatalogTests : IDisposable
|
||||||
Assert.Contains("GLB", excluded.Reason);
|
Assert.Contains("GLB", excluded.Reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bind is the one verdict on whether a mod hull exists: a package whose GLB names a node mesh the importer cannot build
|
||||||
|
// (dangling or negative index, no meshes array, non-integer index, no POSITION on any one primitive, non-triangle mode, skinned node, no primitives) never reaches the boot catalog, so Preload has nothing left to refuse on file facts.
|
||||||
|
[Fact]
|
||||||
|
public void ABootCatalogHoldsNoHullTheImporterCannotBuild()
|
||||||
|
{
|
||||||
|
WritePackage("mod.good", hullName: "Good");
|
||||||
|
var broken = new Dictionary<string, (string From, string To)>
|
||||||
|
{
|
||||||
|
["mod.dangling"] = (@"{""name"":""collider"",""mesh"":0", @"{""name"":""collider"",""mesh"":7"),
|
||||||
|
["mod.nopos"] = (@"""attributes"":{""POSITION"":0}", @"""attributes"":{}"),
|
||||||
|
["mod.skinned"] = (@"{""name"":""map"",""mesh"":0", @"{""name"":""map"",""mesh"":0,""skin"":0"),
|
||||||
|
["mod.noprims"] = (@"""primitives"":[{""attributes"":{""POSITION"":0}}]", @"""primitives"":[]"),
|
||||||
|
["mod.onebad"] = (@"""primitives"":[{""attributes"":{""POSITION"":0}}]", @"""primitives"":[{""attributes"":{""POSITION"":0}},{""attributes"":{}}]"),
|
||||||
|
["mod.points"] = (@"""attributes"":{""POSITION"":0}}", @"""attributes"":{""POSITION"":0},""mode"":0}"),
|
||||||
|
["mod.negative"] = (@"""mesh"":0", @"""mesh"":-1"),
|
||||||
|
["mod.nomeshes"] = (@"""meshes"":", @"""other"":"),
|
||||||
|
["mod.fractional"] = (@"""mesh"":0", @"""mesh"":0.0")
|
||||||
|
};
|
||||||
|
foreach (var (package, (from, to)) in broken)
|
||||||
|
{
|
||||||
|
WritePackage(package, hullName: package.Substring(4));
|
||||||
|
var json = ShipFixture.GlbJson(FixtureNodes);
|
||||||
|
Assert.Contains(from, json);
|
||||||
|
File.WriteAllBytes(Path.Combine(Mods, package, "skiff.glb"), ShipFixture.Glb(json.Replace(from, to), ShipFixture.MeshBytes()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var (catalog, excluded) = ShipModCatalog.ResolveCatalog(Shipped, Derived, Mods);
|
||||||
|
|
||||||
|
Assert.Equal(Derived, catalog);
|
||||||
|
Assert.Equal(broken.Keys.OrderBy(name => name, StringComparer.Ordinal), excluded.Select(e => e.Package).OrderBy(name => name, StringComparer.Ordinal));
|
||||||
|
Assert.All(excluded, e => Assert.Contains("needs a mesh", e.Reason));
|
||||||
|
using var cache = AetheriaStores.Open(catalog);
|
||||||
|
var hulls = cache.GetAll<HullData>().Where(hull => hull.Visual.IsSet()).ToArray();
|
||||||
|
Assert.Equal(new[] { "Good" }, hulls.Select(hull => hull.Name));
|
||||||
|
var keys = cache.AllStoredDocuments.Select(record => record.Key.Value).ToHashSet();
|
||||||
|
foreach (var package in broken.Keys)
|
||||||
|
{
|
||||||
|
Assert.DoesNotContain(ShipModCatalog.HullKey(package).Value, keys);
|
||||||
|
Assert.DoesNotContain(ShipModCatalog.AuthoringKey(package).Value, keys);
|
||||||
|
}
|
||||||
|
// Preload's own two steps over file facts, run headlessly: neither may refuse a hull the catalog holds.
|
||||||
|
foreach (var hull in hulls)
|
||||||
|
{
|
||||||
|
var package = ShipModCatalog.PackageOf(cache, hull, Mods);
|
||||||
|
ShipModPlan.Build(package.Hull, package.Visual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
|
// The game's boot: a bad package is named and the rest boot; with none left, the shipped catalog boots.
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TheBootCatalogSurvivesBadPackages()
|
public void TheBootCatalogSurvivesBadPackages()
|
||||||
|
|
@ -554,8 +602,9 @@ public sealed class ShipModCatalogTests : IDisposable
|
||||||
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne()
|
public void NodeIdsMapToTheirGlbNodeIndexSkippingNodesWithoutOne()
|
||||||
{
|
{
|
||||||
var path = Write("nodes.glb", ShipFixture.Glb(@"{""asset"":{""version"":""2.0""},""nodes"":[
|
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""}}]}"));
|
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}},{""mesh"":7,""extras"":{""aetheria.id"":""c""}}],
|
||||||
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path));
|
""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}]}"));
|
||||||
|
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true), ["c"] = (5, false, -1, true) }, ShipModCatalog.ReadNodeIds(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ public sealed class ShipRigTests : IDisposable
|
||||||
|
|
||||||
private static string GlbJson(Node[] nodes)
|
private static string GlbJson(Node[] nodes)
|
||||||
{
|
{
|
||||||
var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[]}],""nodes"":[");
|
var text = new StringBuilder(@"{""asset"":{""version"":""2.0""},""meshes"":[{""primitives"":[{""attributes"":{""POSITION"":0}}]}],""nodes"":[");
|
||||||
for (var index = 0; index < nodes.Length; index++)
|
for (var index = 0; index < nodes.Length; index++)
|
||||||
{
|
{
|
||||||
var node = nodes[index];
|
var node = nodes[index];
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue