diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 446bb6d89..6a9e5c5c0 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -63,6 +63,11 @@ be empties. Package exports the collection, Grease Pencil excluded, to collection holds exactly one Grease Pencil object, and shows AetherDb's verdict in the panel. It runs the AetherDb in the game folder's `ModTools` (see Tools below). +A package made with `create` and no `--like` has no top speed, which `validate` refuses; `ship-authoring stats --top-speed ` +adds the one `VelocityLimit` it needs (and edits it on a later run). +Without Blender, `ship-authoring starter --like ` writes a complete flyable package: the +shipped hull's stats, a 4×6 grid carrying two thrusters, a radiator, two energy guns and the internal modules, and a boxed +`ship.glb` with every anchor. The model is a stand-in to replace through Blender and **Package**. `ship-authoring inspect ` reads drafts; `validate` requires complete hull, model, and anchor authoring, and judges `ship.glb` once it exists. diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index 717108d8d..3def3e6dd 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -501,6 +501,28 @@ public sealed class ShipModCatalogTests : IDisposable Assert.True(File.Exists(target), error); } + // starter: a complete package from nothing but a hull to take stats from. The package must pass the reader the game boots + // with (GLB node ids and mesh roles included) and plan a rig, and a second run must not touch the first package. + [Fact] + public void StarterWritesAPackageTheGameLoadsAndPlansARigFrom() + { + var like = new HullData { Name = "Like", HullType = HullType.Ship, Mass = 1234, Behaviors = { new VelocityLimitData() } }; + var mods = Path.Combine(_directory.Path, "GameData", "Mods"); + var path = ShipStarterCommand.Write(mods, "probe.starter", "Probe Starter", like); + + var package = ShipModCatalog.ReadPackage(path); + Assert.Equal(("Probe Starter", 1234f), (package.Hull.Name, package.Hull.Mass)); + var plan = ShipModPlan.Build(package.Hull, package.Visual); + Assert.Equal(2, plan.Thrusters.Length); + Assert.Single(plan.Radiators); + Assert.Equal(new[] { 1, 1 }, plan.Weapons.Select(weapon => weapon.Muzzles.Length)); + Assert.Equal(package.Hull.Shape.Width * package.Hull.Shape.Height, package.Hull.Shape.Cells.Cast().Count(cell => cell)); + + var before = File.ReadAllBytes(path); + Assert.Throws(() => ShipStarterCommand.Write(mods, "probe.starter", "Other", like)); + Assert.Equal(before, File.ReadAllBytes(path)); + } + [Fact] public void CentreCommandPrintsShapeCenterOfMass() { diff --git a/tools/AetherDb/ShipStarterCommand.cs b/tools/AetherDb/ShipStarterCommand.cs new file mode 100644 index 000000000..2a502a1f5 --- /dev/null +++ b/tools/AetherDb/ShipStarterCommand.cs @@ -0,0 +1,213 @@ +using GameCult.Caching; +using CultMath; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text; + +// `ship-authoring starter`: a complete, flyable mod package for authors with no Blender at hand. It writes the package +// (//ship.cc and ship.glb) with the stats of a shipped hull, a small hull grid, a working rig (two thrusters, +// a radiator, two energy guns, the internal modules) and a boxed model that carries every anchor the rig needs. The +// model is a stand-in an author replaces through Blender and Package; the records are judged by the same validator and +// reader the game boots with, so a starter that is written is a starter the game loads. +public static class ShipStarterCommand +{ + // Hull grid, x across, y from the stern (0) to the bow. The mount rows below say what sits in each cell. + private const int Width = 4, Height = 6; + + private sealed class Part + { + public string Id, Role, Parent; + public HardpointType? Mount; + public int2 Cell, Size = new int2(1, 1); + public float3 Position; + // Box half-extents; null for an empty node. Scale is the node scale, used by the shield envelope. + public float3? Half; + public float3? Scale; + } + + public static string Write(string modsRoot, string id, string name, HullData like) + { + ShipAuthoringStore.RequireShipId(id); + var directory = Path.Combine(Path.GetFullPath(modsRoot), id); + var path = Path.Combine(directory, "ship.cc"); + if (File.Exists(path) || File.Exists(Path.Combine(directory, "ship.glb"))) + throw new InvalidOperationException($"Refusing to replace the package in {directory}"); + + // Metres, nose toward -Y, up +Z, as the Blender add-on's kit frame has it; the GLB writer converts to glTF's Y-up. + var parts = new[] + { + new Part { Id = "map", Role = "map-icon", Half = new float3(3f, 5f, .05f) }, + new Part { Id = "collider", Role = "hull-collider", Half = new float3(3f, 5f, 1f) }, + new Part { Id = "shield", Role = "shield", Scale = new float3(4f, 6f, 2.5f) }, + new Part { Id = "tractor", Role = "tractor", Position = new float3(0f, -5f, 0f) }, + new Part { Id = "thruster.port", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(0, 0), Size = new int2(2, 1), Position = new float3(2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) }, + new Part { Id = "thruster.starboard", Role = "thruster-emitter", Mount = HardpointType.Thruster, Cell = new int2(2, 0), Size = new int2(2, 1), Position = new float3(-2.2f, 5.4f, 0f), Half = new float3(.4f, .6f, .4f) }, + new Part { Id = "radiator", Role = "radiator-mesh", Mount = HardpointType.Radiator, Cell = new int2(1, 1), Size = new int2(2, 1), Position = new float3(0f, 2f, 1.1f), Half = new float3(1.5f, .8f, .08f) }, + new Part { Id = "gun.port", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(0, 5), Position = new float3(1.8f, -3f, 1.1f) }, + new Part { Id = "gun.port.muzzle", Role = "weapon-muzzle", Parent = "gun.port", Position = new float3(1.8f, -4f, 1.1f) }, + new Part { Id = "gun.starboard", Role = "weapon-mount", Mount = HardpointType.Energy, Cell = new int2(3, 5), Position = new float3(-1.8f, -3f, 1.1f) }, + new Part { Id = "gun.starboard.muzzle", Role = "weapon-muzzle", Parent = "gun.starboard", Position = new float3(-1.8f, -4f, 1.1f) }, + }; + // Internal modules show on no model, so they have a cell and no anchor. + var internals = new[] + { + ("reactor", HardpointType.Reactor, new int2(1, 2), new int2(2, 2)), ("shield", HardpointType.Shield, new int2(3, 4), new int2(1, 1)), + ("control", HardpointType.ControlModule, new int2(1, 4), new int2(2, 2)), ("sensors", HardpointType.Sensors, new int2(0, 4), new int2(1, 1)), + }; + + var ship = new ShipAuthoring + { + Id = id, + ModelAsset = "ship.glb", + Anchors = parts.Select(part => new ShipAnchor { Id = part.Id, Role = part.Role, ModelNodeId = part.Id, ParentId = part.Parent }).ToList() + }; + var hull = ShipAuthoringStore.HullLike(like, name, id); + hull.Shape = new Shape(Width, Height); + for (var x = 0; x < Width; x++) + for (var y = 0; y < Height; y++) + hull.Shape.Cells[x, y] = true; + hull.Hardpoints = parts.Where(part => part.Mount != null) + .Select(part => (part.Id, part.Mount.Value, part.Cell, part.Size)) + .Concat(internals.Select(row => (Id: "internal." + row.Item1, Mount: row.Item2, Cell: row.Item3, Size: row.Item4))) + .Select(row => new HardpointData + { + Type = row.Item2, Position = row.Item3, Shape = Block(row.Item4), Transform = row.Item1, + // The grid's stern is y = 0 and its bow the far row: a drive there pushes the ship toward the bow, which the + // authoring grid spells Reversed (as Djinni's stern drives are). + Rotation = row.Item2 == HardpointType.Thruster ? ItemRotation.Reversed : ItemRotation.None + }).ToList(); + ShipAuthoringStore.Validate(hull, ship); + + Directory.CreateDirectory(directory); + File.WriteAllBytes(Path.Combine(directory, ship.ModelAsset), Glb(parts)); + using (var cache = ShipAuthoringStore.Open(path, writable: true)) + { + ShipAuthoringStore.Write(cache, hull, ship); + cache.FlushAsync().Wait(); + } + // The reader the game boots with, over the written package: the GLB's node ids and mesh roles are judged too. + ShipModCatalog.ReadPackage(path); + return path; + } + + private static Shape Block(int2 size) + { + var shape = new Shape(size.x, size.y); + foreach (var cell in shape.AllCoordinates) shape[cell] = true; + return shape; + } + + // One GLB holding every part as a root-level node, each box with its own vertices (a convex collider cooks from it + // as it is), and each muzzle a child of its mount. Node ids travel in extras.aetheria.id, as Package writes them. + private static byte[] Glb(Part[] parts) + { + var bin = new List(); + var views = new List(); + var accessors = new List(); + var meshes = new List(); + var meshOf = new Dictionary(); + + int View(byte[] bytes, int target) + { + while (bin.Count % 4 != 0) bin.Add(0); + views.Add($@"{{""buffer"":0,""byteOffset"":{bin.Count},""byteLength"":{bytes.Length},""target"":{target}}}"); + bin.AddRange(bytes); + return views.Count - 1; + } + string F(float value) => value.ToString("R", CultureInfo.InvariantCulture); + + foreach (var part in parts.Where(part => part.Half != null)) + { + var (hx, hy, hz) = (part.Half.Value.x, part.Half.Value.y, part.Half.Value.z); + var positions = new List(); + var normals = new List(); + var indices = new List(); + // Six faces, outward normals, in the Blender frame; Gltf() turns each into glTF's. + foreach (var (axis, sign) in new[] { (0, 1), (0, -1), (1, 1), (1, -1), (2, 1), (2, -1) }) + { + var u = (axis + 1) % 3; + var v = (axis + 2) % 3; + var half = new[] { hx, hy, hz }; + var first = (ushort)(positions.Count / 3); + foreach (var (a, b) in new[] { (-1, -1), (1, -1), (1, 1), (-1, 1) }) + { + var corner = new float[3]; + corner[axis] = sign * half[axis]; + corner[u] = a * half[u]; + corner[v] = b * half[v]; + positions.AddRange(Gltf(corner[0], corner[1], corner[2])); + var normal = new float[3]; + normal[axis] = sign; + normals.AddRange(Gltf(normal[0], normal[1], normal[2])); + } + // The (axis, u, v) frame is right-handed, so the corner order above winds counter-clockwise seen from +axis. + var order = sign > 0 ? new[] { 0, 1, 2, 0, 2, 3 } : new[] { 0, 2, 1, 0, 3, 2 }; + indices.AddRange(order.Select(i => (ushort)(first + i))); + } + var positionView = View(positions.SelectMany(BitConverter.GetBytes).ToArray(), 34962); + var normalView = View(normals.SelectMany(BitConverter.GetBytes).ToArray(), 34962); + var indexView = View(indices.SelectMany(BitConverter.GetBytes).ToArray(), 34963); + var low = Gltf(-hx, -hy, -hz); + var high = Gltf(hx, hy, hz); + var box = new[] { Math.Min(low[0], high[0]), Math.Min(low[1], high[1]), Math.Min(low[2], high[2]), Math.Max(low[0], high[0]), Math.Max(low[1], high[1]), Math.Max(low[2], high[2]) }; + accessors.Add($@"{{""bufferView"":{positionView},""componentType"":5126,""count"":{positions.Count / 3},""type"":""VEC3"",""min"":[{F(box[0])},{F(box[1])},{F(box[2])}],""max"":[{F(box[3])},{F(box[4])},{F(box[5])}]}}"); + accessors.Add($@"{{""bufferView"":{normalView},""componentType"":5126,""count"":{normals.Count / 3},""type"":""VEC3""}}"); + accessors.Add($@"{{""bufferView"":{indexView},""componentType"":5123,""count"":{indices.Count},""type"":""SCALAR""}}"); + meshOf[part] = meshes.Count; + meshes.Add($@"{{""primitives"":[{{""attributes"":{{""POSITION"":{accessors.Count - 3},""NORMAL"":{accessors.Count - 2}}},""indices"":{accessors.Count - 1}}}]}}"); + } + + // A muzzle is a child of its mount, so its translation is relative to the mount's. + var byId = parts.ToDictionary(part => part.Id, StringComparer.Ordinal); + var nodeIndex = parts.Select((part, index) => (part.Id, index)).ToDictionary(pair => pair.Id, pair => pair.index, StringComparer.Ordinal); + string Node(Part part) + { + var translation = part.Parent == null ? part.Position : part.Position - byId[part.Parent].Position; + var g = Gltf(translation.x, translation.y, translation.z); + var json = new StringBuilder($@"{{""name"":""{part.Id}"""); + if (meshOf.TryGetValue(part, out var mesh)) json.Append($@",""mesh"":{mesh}"); + if (part.Scale != null) { var s = Gltf(part.Scale.Value.x, part.Scale.Value.y, part.Scale.Value.z); json.Append($@",""scale"":[{F(Math.Abs(s[0]))},{F(Math.Abs(s[1]))},{F(Math.Abs(s[2]))}]"); } + json.Append($@",""translation"":[{F(g[0])},{F(g[1])},{F(g[2])}]"); + var children = parts.Where(child => child.Parent == part.Id).Select(child => nodeIndex[child.Id]).ToArray(); + if (children.Length > 0) json.Append($@",""children"":[{string.Join(",", children)}]"); + json.Append($@",""extras"":{{""aetheria.id"":""{part.Id}""}}}}"); + return json.ToString(); + } + var roots = parts.Where(part => part.Parent == null).Select(part => nodeIndex[part.Id]); + var document = $@"{{""asset"":{{""version"":""2.0"",""generator"":""AetherDb ship-authoring starter""}},""scene"":0,""scenes"":[{{""nodes"":[{string.Join(",", roots)}]}}]," + + $@"""nodes"":[{string.Join(",", parts.Select(Node))}],""meshes"":[{string.Join(",", meshes)}],""accessors"":[{string.Join(",", accessors)}]," + + $@"""bufferViews"":[{string.Join(",", views)}],""buffers"":[{{""byteLength"":{bin.Count + (4 - bin.Count % 4) % 4}}}]}}"; + return Container(document, bin.ToArray()); + } + + // Blender (x right, y back, z up) to glTF (x right, y up, z toward the viewer): the exporter's +Y-up conversion. + private static float[] Gltf(float x, float y, float z) => new[] { x, z, -y }; + + private static byte[] Container(string json, byte[] bin) + { + byte[] Pad(byte[] bytes, byte fill) + { + var padded = new byte[(bytes.Length + 3) / 4 * 4]; + Array.Fill(padded, fill); + bytes.CopyTo(padded, 0); + return padded; + } + var body = Pad(Encoding.UTF8.GetBytes(json), (byte)' '); + var data = Pad(bin, 0); + using var stream = new MemoryStream(); + using var writer = new BinaryWriter(stream); + writer.Write(0x46546C67u); + writer.Write(2u); + writer.Write((uint)(28 + body.Length + data.Length)); + writer.Write((uint)body.Length); + writer.Write(0x4E4F534Au); + writer.Write(body); + writer.Write((uint)data.Length); + writer.Write(0x004E4942u); + writer.Write(data); + return stream.ToArray(); + } +}