diff --git a/.gitignore b/.gitignore index 2cbd223f9..811814afa 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ GameData/*.lock /Aetheria.Shared/obj/ /tools/**/bin/ /tools/**/obj/ +/ModTools/ /tests/**/bin/ /tests/**/obj/ diff --git a/Assets/Scripts/ServerShared/ShipAuthoring.cs b/Assets/Scripts/ServerShared/ShipAuthoring.cs index ec5f2a3c2..c3cb1032e 100644 --- a/Assets/Scripts/ServerShared/ShipAuthoring.cs +++ b/Assets/Scripts/ServerShared/ShipAuthoring.cs @@ -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().Any(occupied => occupied)) diff --git a/docs/moddable-ship-authoring.md b/docs/moddable-ship-authoring.md index 650286d75..02c4b64a8 100644 --- a/docs/moddable-ship-authoring.md +++ b/docs/moddable-ship-authoring.md @@ -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 /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 ` 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 `/GameData/Mods/`. + +From a repository checkout, publish AetherDb into the ignored `/ModTools` and +author from the repo as the game folder: + + dotnet publish tools/AetherDb -c Release -r --self-contained -p:PublishSingleFile=true -o ModTools + +with `` 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 diff --git a/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs new file mode 100644 index 000000000..f417d4e10 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/AetherDbRootTests.cs @@ -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()); + 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(AetherDb.FindRoot); + Assert.Contains("GameData holds Aetheria.cc", error.Message); + } +} diff --git a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs index a2426906b..83f9b9ad1 100644 --- a/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipModCatalogTests.cs @@ -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()); diff --git a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs index 804ff723f..8c195e895 100644 --- a/tests/Aetheria.Shared.Tests/ShipValidationTests.cs +++ b/tests/Aetheria.Shared.Tests/ShipValidationTests.cs @@ -59,7 +59,25 @@ public sealed class ShipValidationTests { var ship = ShipAuthoringTests.Fixture(); ship.WithId(id); - Assert.Contains("ship ID must use", Assert.Throws(ship.Validate).Message); + Assert.Contains("Ship ID must use", Assert.Throws(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(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"); diff --git a/tools/AetherDb/AetherDb.cs b/tools/AetherDb/AetherDb.cs index 4a17e6b14..d5b4ffdb3 100644 --- a/tools/AetherDb/AetherDb.cs +++ b/tools/AetherDb/AetherDb.cs @@ -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."); } } diff --git a/tools/AetherDb/ShipAuthoringCommands.cs b/tools/AetherDb/ShipAuthoringCommands.cs index dc5dcfb8b..097fe1add 100644 --- a/tools/AetherDb/ShipAuthoringCommands.cs +++ b/tools/AetherDb/ShipAuthoringCommands.cs @@ -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)); diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 17b81a3a0..df02126a3 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -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) diff --git a/tools/blender/tests/smoke_frame.py b/tools/blender/tests/smoke_frame.py index b745124e6..739001bdc 100644 --- a/tools/blender/tests/smoke_frame.py +++ b/tools/blender/tests/smoke_frame.py @@ -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 -/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 /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 diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index de5958ad1..7fa3f90dc 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -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=. 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")