AetherDb for ship authors: rooted at a game folder, found in ModTools; ship-ID rule in create

FindRoot keys on GameData/Aetheria.cc. ShipAuthoringStore.RequireShipId is the one ID rule
(Validate and create), refusing Windows device names and a trailing dot, naming no value.
The add-on's one preference is Game folder; _aetheria_repo, the repo preference and the
dotnet-run invocation are deleted. Smokes take SMOKE_GAME_FOLDER. Not yet built or tested
at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-10 06:53:03 +02:00
commit 63d16e81f3
11 changed files with 183 additions and 52 deletions

1
.gitignore vendored
View file

@ -66,6 +66,7 @@ GameData/*.lock
/Aetheria.Shared/obj/
/tools/**/bin/
/tools/**/obj/
/ModTools/
/tests/**/bin/
/tests/**/obj/

View file

@ -182,14 +182,26 @@ public static class ShipAuthoringStore
throw new InvalidOperationException($"{label}: a hull names one body, but this one names both a Unity prefab and a visual record.");
}
// The one ship-ID rule, shared by Validate and ship-authoring create. The ID becomes a folder name on every platform, so
// Windows device names (alone or before a dot) and a trailing dot are refused too. The messages name the rule, never the value.
public static void RequireShipId(string id)
{
if (string.IsNullOrWhiteSpace(id)) throw new InvalidOperationException("Ship ID is required.");
if (!(id[0] >= 'a' && id[0] <= 'z' || id[0] >= '0' && id[0] <= '9') || id.Any(c =>
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
throw new InvalidOperationException("Ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
if (id[id.Length - 1] == '.') throw new InvalidOperationException("Ship ID must not end with a dot.");
var stem = id.Split('.')[0];
if (stem is "con" or "prn" or "aux" or "nul"
|| stem.Length == 4 && (stem.StartsWith("com") || stem.StartsWith("lpt")) && stem[3] >= '1' && stem[3] <= '9')
throw new InvalidOperationException("Ship ID must not be a Windows device name.");
}
// The one semantic check every path shares: the hull and its visual, judged together.
public static void Validate(HullData hull, ShipAuthoring ship)
{
if (ship == null) throw new InvalidOperationException("Ship authoring record is null.");
if (string.IsNullOrWhiteSpace(ship.Id)) throw new InvalidOperationException("Ship ID is required.");
if (!(ship.Id[0] >= 'a' && ship.Id[0] <= 'z' || ship.Id[0] >= '0' && ship.Id[0] <= '9') || ship.Id.Any(c =>
!(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-')))
throw new InvalidOperationException($"{ship.Id}: ship ID must use lower-case ASCII letters, digits, dots, underscores, or hyphens.");
RequireShipId(ship.Id);
if (hull == null) throw new InvalidOperationException($"{ship.Id}: hull data is required.");
if (string.IsNullOrWhiteSpace(hull.Name)) throw new InvalidOperationException($"{ship.Id}: hull name is required.");
if (hull.Shape?.Cells == null || !hull.Shape.Cells.Cast<bool>().Any(occupied => occupied))

View file

@ -61,12 +61,30 @@ weapon mount object. Thrusters and radiators are mesh objects; the others may
be empties. Package exports the collection, Grease Pencil excluded, to
`ship.glb` beside the `.cc`, writes the anchors, captures the lines when the
collection holds exactly one Grease Pencil object, and shows AetherDb's
verdict in the panel. It runs `dotnet run --project <repo>/tools/AetherDb`;
the repo is the add-on preference **Aetheria repo**, or by default the nearest
folder above the `.cc` holding `tools/AetherDb`.
verdict in the panel. It runs the AetherDb in the game folder's `ModTools`
(see Tools below).
`ship-authoring inspect <ship.cc>` reads drafts; `validate` requires complete
hull, model, and anchor authoring, and judges `ship.glb` once it exists.
## Tools
Install Blender and the `aetheria_ships` add-on. In the add-on's preferences set
**Game folder** to your Aetheria install, the folder holding `GameData` (its
`GameData/Aetheria.cc` and `GameData/Mods`). Left empty, the add-on uses the
nearest folder above the bound `.cc` whose `GameData` holds `Aetheria.cc`.
AetherDb ships with the game in `ModTools`; the add-on runs it with the game
folder as its working directory, and needs neither Unity, the Aetheria repository
nor dotnet. New Ship creates the package under `<game folder>/GameData/Mods/<id>`.
From a repository checkout, publish AetherDb into the ignored `<repo>/ModTools` and
author from the repo as the game folder:
dotnet publish tools/AetherDb -c Release -r <rid> --self-contained -p:PublishSingleFile=true -o ModTools
with `<rid>` one of `win-x64`, `linux-x64`, `osx-arm64`. Linux needs `libssl3`
and `libicu`; on macOS clear the quarantine flag (`xattr -d com.apple.quarantine
ModTools/AetherDb`).
## Objective
A mod author edits one ship `.cc` file, including the hull schematic and every

View file

@ -0,0 +1,41 @@
using System;
using System.IO;
using Xunit;
[CollectionDefinition("process working directory", DisableParallelization = true)]
public sealed class ProcessWorkingDirectoryCollection { }
// FindRoot reads the process working directory, so these run alone.
[Collection("process working directory")]
public sealed class AetherDbRootTests : IDisposable
{
private readonly TempDirectory _game = new TempDirectory();
private readonly string _previous = Environment.CurrentDirectory;
public void Dispose()
{
Environment.CurrentDirectory = _previous;
_game.Dispose();
}
[Fact]
public void TheRootIsTheNearestFolderWhoseGameDataHoldsAetheriaCc()
{
Directory.CreateDirectory(Path.Combine(_game.Path, "GameData", "Mods", "probe.a"));
File.WriteAllBytes(Path.Combine(_game.Path, "GameData", "Aetheria.cc"), Array.Empty<byte>());
Environment.CurrentDirectory = _game.Path;
var game = Environment.CurrentDirectory;
Environment.CurrentDirectory = Path.Combine(game, "GameData", "Mods", "probe.a");
Assert.Equal(game, AetherDb.FindRoot());
}
[Fact]
public void AFolderHoldingOnlyTheRepositoryMarkerIsNotARoot()
{
Directory.CreateDirectory(Path.Combine(_game.Path, "Aetheria.Shared"));
File.WriteAllText(Path.Combine(_game.Path, "Aetheria.Shared", "Aetheria.Shared.csproj"), "");
Environment.CurrentDirectory = _game.Path;
var error = Assert.Throws<DirectoryNotFoundException>(AetherDb.FindRoot);
Assert.Contains("GameData holds Aetheria.cc", error.Message);
}
}

View file

@ -419,6 +419,17 @@ public sealed class ShipModCatalogTests : IDisposable
Assert.Contains("records and model", output);
}
[Theory]
[InlineData("con"), InlineData("nul.x"), InlineData("abc.")]
public void CreateRefusesABadIdBeforeTouchingTheDisk(string id)
{
var target = Path.Combine(_directory.Path, "GameData", "Mods", id, "ship.cc");
var (code, output, error) = Run("create", target, id, "Probe", "--like", "Djinni");
Assert.Equal(1, code);
Assert.Contains("Ship ID", error);
Assert.False(Directory.Exists(Path.Combine(_directory.Path, "GameData")));
}
private static (int Code, string Output, string Error) Run(params string[] args)
{
var (output, error) = (new StringWriter(), new StringWriter());

View file

@ -59,7 +59,25 @@ public sealed class ShipValidationTests
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
Assert.Contains("ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
Assert.Contains("Ship ID must use", Assert.Throws<InvalidOperationException>(ship.Validate).Message);
}
[Theory]
[InlineData("com0"), InlineData("console"), InlineData("lpt"), InlineData("auxiliary"), InlineData("a.con"), InlineData("com10")]
public void IdsThatOnlyResembleDeviceNamesAreAdmitted(string id)
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
ship.Validate();
}
[Theory]
[InlineData("con"), InlineData("abc.")]
public void IdRefusalsNameNoValue(string id)
{
var ship = ShipAuthoringTests.Fixture();
ship.WithId(id);
Assert.DoesNotContain(id, Assert.Throws<InvalidOperationException>(ship.Validate).Message);
}
[Theory]
@ -157,6 +175,11 @@ public sealed class ShipValidationTests
Add("id-null", s => s.Visual.Id = null, "Ship ID is required");
Add("id-blank", s => s.Visual.Id = " ", "Ship ID is required");
Add("id-device-con", s => s.Visual.Id = "con", "Windows device name");
Add("id-device-with-extension", s => s.Visual.Id = "nul.x", "Windows device name");
Add("id-device-com1", s => s.Visual.Id = "com1", "Windows device name");
Add("id-device-lpt9", s => s.Visual.Id = "lpt9", "Windows device name");
Add("id-trailing-dot", s => s.Visual.Id = "abc.", "must not end with a dot");
Add("hull-null", s => s.Hull = null, "hull data is required");
Add("hull-name-null", s => s.Hull.Name = null, "hull name is required");
Add("hull-name-blank", s => s.Hull.Name = " ", "hull name is required");

View file

@ -19,15 +19,15 @@ public sealed class AetherDb
catalogWritable: catalogWritable);
}
// With no root given, walks up from the working directory to the repository
// With no root given, walks up from the working directory to the game folder: the first folder whose GameData holds Aetheria.cc
public static AetherDb Open(bool catalogWritable = false, bool withRun = false, string root = null) =>
new AetherDb(root ?? FindRoot(), catalogWritable, withRun);
public static string FindRoot()
{
for (var dir = new DirectoryInfo(Environment.CurrentDirectory); dir != null; dir = dir.Parent)
if (File.Exists(Path.Combine(dir.FullName, "Aetheria.Shared", "Aetheria.Shared.csproj")))
if (File.Exists(Path.Combine(dir.FullName, "GameData", "Aetheria.cc")))
return dir.FullName;
throw new DirectoryNotFoundException("Run from inside the Aetheria repository.");
throw new DirectoryNotFoundException("Run from a folder whose GameData holds Aetheria.cc.");
}
}

View file

@ -46,6 +46,7 @@ public static class ShipAuthoringCommands
}
if (args[0] == "create" && (args.Length == 4 || args.Length == 6 && args[4] == "--like"))
{
ShipAuthoringStore.RequireShipId(args[2]);
var path = Path.GetFullPath(args[1]);
if (File.Exists(path)) throw new InvalidOperationException($"Refusing to replace {path}");
Directory.CreateDirectory(Path.GetDirectoryName(path));

View file

@ -12,6 +12,7 @@ bl_info = {
import math
import subprocess
import sys
from pathlib import Path
import bpy
@ -26,7 +27,7 @@ MODEL_ASSET = "ship.glb"
SOURCE = "Source" # child collection: the Tripo mesh, input to Rasterise, never exported
GENERATED = "Generated" # child collection: derived objects (the Grid), never exported
SHIP_ROOT = "Ship Root"
AETHERDB_TIMEOUT = 600 # seconds; the first run builds AetherDb
AETHERDB_TIMEOUT = 120 # seconds
HARDPOINT_TYPES = tuple((str(i), name, name) for i, name in enumerate(HARDPOINT_TYPE_NAMES))
@ -302,38 +303,49 @@ class AETHERIA_OT_capture_ship_lines(bpy.types.Operator):
class AETHERIA_AP_preferences(bpy.types.AddonPreferences):
bl_idname = __package__
aetheria_repo: bpy.props.StringProperty(
name="Aetheria repo", subtype="DIR_PATH",
description="Aetheria checkout whose tools/AetherDb validates packages. "
"Empty: the nearest folder above the bound .cc that holds tools/AetherDb")
game_folder: bpy.props.StringProperty(
name="Game folder", subtype="DIR_PATH",
description="The folder whose GameData holds Aetheria.cc and Mods (the install folder); its ModTools holds "
"AetherDb. Empty: the nearest folder above the bound .cc whose GameData holds Aetheria.cc")
def draw(self, context):
self.layout.prop(self, "aetheria_repo")
self.layout.prop(self, "game_folder")
def _aetheria_repo(context, ship_cc):
GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc "
"and whose ModTools holds AetherDb")
def _game_folder(context, ship_cc=None):
"""The folder AetherDb runs against: the Game folder preference, else the nearest folder above the bound .cc whose
GameData holds Aetheria.cc. Refusals name the preference and never a path."""
addon = context.preferences.addons.get(__package__)
configured = addon.preferences.aetheria_repo if addon and addon.preferences else ""
configured = addon.preferences.game_folder if addon and addon.preferences else ""
if configured:
repo = Path(bpy.path.abspath(configured))
if not (repo / "tools" / "AetherDb").is_dir():
raise ValueError(f"The Aetheria repo preference {repo} has no tools/AetherDb")
return repo
for folder in Path(ship_cc).resolve().parents:
if (folder / "tools" / "AetherDb").is_dir():
return folder
raise ValueError("No Aetheria repo above the ship .cc; set it in the add-on's preferences")
folder = Path(bpy.path.abspath(configured))
if not (folder / "GameData" / "Aetheria.cc").is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
return folder
if ship_cc:
for folder in Path(ship_cc).resolve().parents:
if (folder / "GameData" / "Aetheria.cc").is_file():
return folder
raise ValueError(GAME_FOLDER_REFUSAL)
def _aetherdb(context, ship_cc, *args):
"""Runs one AetherDb ship-authoring command; returns its exit code and its output, stdout then stderr. The compiler
warnings 'dotnet run' prints when it rebuilds AetherDb are left out; errors are kept."""
repo = _aetheria_repo(context, ship_cc)
result = subprocess.run(
["dotnet", "run", "--project", str(repo / "tools" / "AetherDb"), "--", "ship-authoring", *args],
cwd=repo, capture_output=True, text=True, timeout=AETHERDB_TIMEOUT)
lines = (result.stdout + "\n" + result.stderr).splitlines()
return result.returncode, "\n".join(line for line in lines if line.strip() and ": warning " not in line)
def _aetherdb(context, *args, ship_cc=None):
"""Runs one AetherDb ship-authoring command from the game folder's ModTools; returns its exit code and its output,
stdout then stderr. ship_cc is the bound .cc the game folder is found from when the preference is empty."""
folder = _game_folder(context, ship_cc)
tool = folder / "ModTools" / ("AetherDb.exe" if sys.platform == "win32" else "AetherDb")
if not tool.is_file():
raise ValueError(GAME_FOLDER_REFUSAL)
result = subprocess.run([str(tool), "ship-authoring", *args], cwd=folder, capture_output=True, text=True,
timeout=AETHERDB_TIMEOUT)
lines = (result.stdout + "
" + result.stderr).splitlines()
return result.returncode, "
".join(line for line in lines if line.strip())
def _layer_collection(layer, collection):
@ -573,9 +585,8 @@ def _new_ship(context, ship_id, name, reference, length):
longest = max(extent_x, extent_y)
if longest <= 0:
raise ValueError("The hull meshes have no horizontal extent")
repo = _aetheria_repo(context, bpy.data.filepath or ".")
path = str(repo / "GameData" / "Mods" / ship_id / "ship.cc")
code, message = _aetherdb(context, path, "create", path, ship_id, name, "--like", reference)
path = str(_game_folder(context, bpy.data.filepath or None) / "GameData" / "Mods" / ship_id / "ship.cc")
code, message = _aetherdb(context, "create", path, ship_id, name, "--like", reference, ship_cc=path)
if code != 0:
raise RuntimeError(f"create failed: {message}")
collection = _bind_collection(context, path)
@ -647,7 +658,7 @@ class AETHERIA_OT_package_ship(bpy.types.Operator):
replace_lines(path, cultlib, capture_grease_pencil(
pencils[0], context.evaluated_depsgraph_get(), scene.frame_current,
evaluated=scene.aetheria_capture_evaluated_lines))
code, message = _aetherdb(context, path, "validate", path)
code, message = _aetherdb(context, "validate", path, ship_cc=path)
except (OSError, ValueError, RuntimeError, ImportError, KeyError, subprocess.SubprocessError) as exc:
scene.aetheria_package_report = f"Package failed: {exc}"
self.report({"ERROR"}, scene.aetheria_package_report)

View file

@ -2,9 +2,9 @@
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_frame.py
Environment as smoke_package.py's header says: CULTLIB_PACKAGES, plus CULTLIB_ROOT and CULTMATH_ROOT at the pinned
revisions for the 'dotnet run --project tools/AetherDb' calls, and msgpack importable. New Ship writes the ship under
<repo>/GameData/Mods/smoke.frame, which this smoke removes when it ends.
Environment as smoke_package.py's header says: CULTLIB_PACKAGES and SMOKE_GAME_FOLDER (the published AetherDb under its
ModTools, no dotnet), and msgpack importable. New Ship writes the ship under <game folder>/GameData/Mods/smoke.frame,
which this smoke removes when it ends.
Pass 1 sends a UV sphere (1 x 0.6 x 0.4, long axis X, off the origin) through New Ship, length 10, like Djinni.
Pass 2 adds a render mesh (a copy of the sphere) and a nub at the +Y end, rasterises, flips the nose, and requires the
@ -27,7 +27,8 @@ import bpy
REPO = Path(__file__).resolve().parents[3]
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
SHIP_ID = "smoke.frame"
MODS = REPO / "GameData" / "Mods"
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
MODS = GAME / "GameData" / "Mods"
def new_object(collection, name, mesh, location=(0, 0, 0), parent=None):
@ -95,7 +96,7 @@ def main():
addon_utils.enable("aetheria_ships", default_set=True, handle_error=None)
import aetheria_ships
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
require(not (MODS / SHIP_ID).exists(), f"{MODS / SHIP_ID} exists; remove it first")
scene = bpy.context.scene

View file

@ -2,14 +2,16 @@
blender --background --factory-startup --python-use-system-env --python tools/blender/tests/smoke_package.py
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py), and whatever AetherDb's build needs
(CULTLIB_ROOT and CULTMATH_ROOT at the pinned revisions), since the add-on runs 'dotnet run --project tools/AetherDb'.
Environment: CULTLIB_PACKAGES (CultLib's packages directory, as test_ship_cc.py) and SMOKE_GAME_FOLDER, a folder holding
GameData/Aetheria.cc and the published AetherDb under ModTools (AetherDb.exe on Windows), which the smoke sets as the
add-on's Game folder. No dotnet is needed: the add-on runs the published tool and nothing else.
SMOKE_MODS names the mods directory to write into (default: a new temp directory); the package is left there so the
Unity play smoke can load it, and its path is printed as SMOKE_PACKAGE=<ship.cc>.
Pass 1 packages a primitive ship built from role-tagged objects and requires the C# validator to accept it. Pass 2 swaps
the thruster disc for an empty of the same id and requires the validator to refuse it, naming the anchor. Pass 3 puts
the disc back and packages again, so the package left behind is the good one.
the disc back and packages again, so the package left behind is the good one. Pass 4 points Game folder at a folder
that holds Aetheria.cc but no ModTools/AetherDb and requires Package to report the fixed preference text and no path.
Before packaging, the bind cases: a bind that fails while computing the stored path leaves no aetheria.* property; an
unsaved .blend stores the absolute path, which still resolves after Save As to two other folders; a saved .blend on the
@ -27,6 +29,7 @@ import addon_utils
import bpy
REPO = Path(__file__).resolve().parents[3]
GAME = Path(os.environ.get("SMOKE_GAME_FOLDER") or sys.exit("Set SMOKE_GAME_FOLDER to a folder holding GameData/Aetheria.cc and ModTools/AetherDb"))
PACKAGES = os.environ.get("CULTLIB_PACKAGES") or sys.exit("Set CULTLIB_PACKAGES to CultLib's packages directory")
SHIP_ID = "smoke.gale"
@ -130,13 +133,13 @@ def main():
import aetheria_ships
# Brokkr is the Blender host's CultLib path provider and is not installed in a factory-startup Blender.
aetheria_ships._brokkr_cultlib = lambda context: PACKAGES
bpy.context.preferences.addons["aetheria_ships"].preferences.aetheria_repo = str(REPO)
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
mods = Path(os.environ.get("SMOKE_MODS") or tempfile.mkdtemp(prefix="aetheria-smoke-mods-"))
path = str(mods / SHIP_ID / "ship.cc")
if Path(path).exists():
raise SystemExit(f"{path} already exists; point SMOKE_MODS at an empty directory")
code, message = aetheria_ships._aetherdb(bpy.context, path, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
code, message = aetheria_ships._aetherdb(bpy.context, "create", path, SHIP_ID, "Smoke Gale", "--like", "Djinni")
print(f"CREATE {code}: {message}")
if code != 0:
raise SystemExit("create --like Djinni failed")
@ -184,6 +187,15 @@ def main():
result, report = package(aetheria_ships)
if result != {"FINISHED"}:
raise SystemExit("Pass 3: the restored ship was refused")
bare = Path(tempfile.mkdtemp(prefix="aetheria-smoke-bare-"))
(bare / "GameData").mkdir()
(bare / "GameData" / "Aetheria.cc").write_bytes(b"")
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(bare)
result, report = package(aetheria_ships)
if result != {"CANCELLED"} or report != "Package failed: " + aetheria_ships.GAME_FOLDER_REFUSAL or "Game folder" not in report or str(bare) in report or str(GAME) in report:
raise SystemExit("Pass 4: a game folder without ModTools/AetherDb was not refused with the fixed text")
bpy.context.preferences.addons["aetheria_ships"].preferences.game_folder = str(GAME)
print(f"SMOKE_PACKAGE={path}")
print("SMOKE_PACKAGE_OK")