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":
|
||||
var emitter = DirectChild(node, "Emitter")?.GetComponent<MeshRenderer>();
|
||||
if (emitter == null) throw new InvalidOperationException($"{name} needs an Emitter mesh child");
|
||||
ShipModShips.Hide(emitter, invisible);
|
||||
AddOrGet<ThrusterHardpoint>(node.gameObject).Emitter = emitter;
|
||||
thrusters.Add(node.GetComponent<ThrusterHardpoint>());
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public static class ShipPrefabAuthoringSmoke
|
|||
ShipPrefabAuthoring.Configure(root);
|
||||
var ship = root.GetComponent<ShipInstance>();
|
||||
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[0].FiringPoint[0].name == "Muzzle.0", "numeric muzzle order");
|
||||
Require(ship.RadiatorHardpoints.Length == 1 && ship.RadiatorHardpoints[0].Mesh != null, "radiator mesh");
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ using GameCult.Caching;
|
|||
using UnityEngine;
|
||||
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
|
||||
// hull's record key, into an inactive assembled copy that Instantiate clones synchronously, so LoadEntity never loads async.
|
||||
// A composed hull Preload cannot build is an import fault: Loading faults with ImportFault, naming every such hull's package.
|
||||
// Mod ships in the running game. Compose (Bind) is the one verdict on whether a mod hull exists, from the package's files;
|
||||
// Preload only builds a prototype for each, keyed by the hull's record key, into an inactive assembled copy that Instantiate
|
||||
// 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.
|
||||
public static class ShipModShips
|
||||
{
|
||||
|
|
@ -101,6 +102,7 @@ public static class ShipModShips
|
|||
node.name = id;
|
||||
return node;
|
||||
}
|
||||
// An importer report, not a verdict: Bind has already judged the file.
|
||||
MeshRenderer Renderer(string id)
|
||||
{
|
||||
var found = Node(id).GetComponentInChildren<MeshRenderer>(true);
|
||||
|
|
@ -130,9 +132,6 @@ public static class ShipModShips
|
|||
var surface = AddOrGet<HullCollider>(hullNode.gameObject);
|
||||
|
||||
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);
|
||||
Reset(shield.transform);
|
||||
AddOrGet<ShieldEnvelope>(shield.gameObject);
|
||||
|
|
|
|||
|
|
@ -251,14 +251,15 @@ 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}.");
|
||||
// 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.
|
||||
// Assemble reads a renderer or collider mesh from these anchors' nodes, and the shield node's scale as three positive
|
||||
// radii (ruling thrusters-radiators-are-meshes). These refusals cover everything the package's files say about whether
|
||||
// the importer can build a node (skin, dangling or empty mesh, not only "names a mesh") and are Assemble's only judge,
|
||||
// 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" || 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.");
|
||||
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)
|
||||
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.");
|
||||
|
|
@ -309,7 +310,23 @@ public static class ShipModCatalog
|
|||
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
|
||||
// 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.
|
||||
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"];
|
||||
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}.");
|
||||
index++;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -328,6 +328,54 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
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.
|
||||
[Fact]
|
||||
public void TheBootCatalogSurvivesBadPackages()
|
||||
|
|
@ -554,8 +602,9 @@ public sealed class ShipModCatalogTests : IDisposable
|
|||
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"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}}]}"));
|
||||
Assert.Equal(new Dictionary<string, (uint, bool, int, bool)> { ["a"] = (1, false, -1, true), ["b"] = (4, true, -1, true) }, ShipModCatalog.ReadNodeIds(path));
|
||||
{""name"":""plain""},{""extras"":{""aetheria.id"":""a""}},{""extras"":{}},{""extras"":{""aetheria.id"":""""}},{""mesh"":0,""extras"":{""aetheria.id"":""b""}},{""mesh"":7,""extras"":{""aetheria.id"":""c""}}],
|
||||
""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]
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ public sealed class ShipRigTests : IDisposable
|
|||
|
||||
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++)
|
||||
{
|
||||
var node = nodes[index];
|
||||
|
|
|
|||
Loading…
Reference in a new issue