Aetheria/tests/Aetheria.Shared.Tests/ShipFixture.cs
Meta Vens f5743cbad2 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>
2026-10-03 17:07:08 +02:00

88 lines
4.7 KiB
C#

using System;
using System.IO;
using System.Linq;
using System.Text;
using CultMath;
// Writes the mod package the tests, the Python round-trip check and the Unity smokes (ShipModPreview.Smoke and
// ShipModPlaySmoke) share. It is generated from ShipAuthoringTests.Fixture() so no opaque bytes are committed.
internal static class ShipFixture
{
public static readonly string[] Nodes = { "map", "collider", "shield", "tractor", "thruster-port", "gun", "gun-muzzle" };
// The nodes that carry the fixture mesh: the anchors the assembler reads a renderer or a collider mesh from.
public static readonly string[] MeshNodes = { "map", "collider", "thruster-port" };
// One tetrahedron, non-indexed (12 vertices), so a convex MeshCollider can cook it. Written by the generator.
private static readonly float[] Tetrahedron =
{
0, 0, 0, 0, 1, 0, 1, 0, 0,
0, 0, 0, 1, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 1, 0, 1, 0,
1, 0, 0, 0, 1, 0, 0, 0, 1
};
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 (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)
? @",""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}""}}}}")) + "]}";
}
public static byte[] Glb(string json, byte[] bin = null)
{
var body = Encoding.UTF8.GetBytes(json);
var padded = new byte[(body.Length + 3) / 4 * 4];
Array.Fill(padded, (byte)' ');
body.CopyTo(padded, 0);
bin ??= Array.Empty<byte>();
var binPadded = new byte[(bin.Length + 3) / 4 * 4];
bin.CopyTo(binPadded, 0);
var bytes = new byte[28 + padded.Length + binPadded.Length];
BitConverter.GetBytes(0x46546C67u).CopyTo(bytes, 0);
BitConverter.GetBytes(2u).CopyTo(bytes, 4);
BitConverter.GetBytes((uint)bytes.Length).CopyTo(bytes, 8);
BitConverter.GetBytes((uint)padded.Length).CopyTo(bytes, 12);
BitConverter.GetBytes(0x4E4F534Au).CopyTo(bytes, 16);
padded.CopyTo(bytes, 20);
BitConverter.GetBytes((uint)binPadded.Length).CopyTo(bytes, 20 + padded.Length);
BitConverter.GetBytes(0x004E4942u).CopyTo(bytes, 24 + padded.Length);
binPadded.CopyTo(bytes, 28 + padded.Length);
return bytes;
}
// <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[] 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.
ship.Hull.Hardpoints.Add(new HardpointData { Type = HardpointType.Energy, Position = new int2(0, 0), Shape = new Shape(), Transform = "gun" });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun", Role = "weapon-mount", ModelNodeId = "gun" });
ship.Visual.Anchors.Add(new ShipAnchor { Id = "gun.muzzle", Role = "weapon-muzzle", ModelNodeId = "gun-muzzle", ParentId = "gun" });
tweak?.Invoke(ship);
ship.WithId(id);
ship.Hull.Name = hullName;
ship.Visual.ModelAsset = modelAsset;
var directory = Path.Combine(modsRoot, id);
Directory.CreateDirectory(directory);
using (var cache = ShipAuthoringStore.Open(Path.Combine(directory, "ship.cc"), writable: true))
{
ShipAuthoringStore.Write(cache, ship.Hull, ship.Visual);
cache.FlushAsync().Wait();
}
File.WriteAllBytes(Path.Combine(directory, modelAsset), Glb(GlbJson(nodes ?? Nodes, meshNodes), MeshBytes()));
return directory;
}
}