From 4f9ada5a6f37b91d272d8156a487d38543009c8c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:31:46 +0200 Subject: [PATCH 1/6] ship_cc: refuse a cultcache-py that is not the tested version _libraries reads [project].version from the pyproject.toml beside the cultcache-py source before importing it and raises ValueError naming the version found and the packages path when it differs from TESTED_CULTCACHE_PY (0.3.0) or the file is missing. Builds: yes. test_refuses_a_file_with_no_record fails as before (cultcache-py unpack error on a 0-byte file). Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/ship_cc.py | 19 ++++++++++++ tools/blender/tests/test_ship_cc.py | 40 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index df76cdb6f..5ef61445d 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -9,11 +9,16 @@ from __future__ import annotations import sys import hashlib +import tomllib from dataclasses import replace from datetime import datetime, timezone from pathlib import Path from typing import Any, NamedTuple +# The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded +# from source, so its version is [project].version in the pyproject.toml beside that source. +TESTED_CULTCACHE_PY = "0.3.0" + SCHEMA = "aetheria.ship_authoring" HULL_SCHEMA = "aetheria.hulldata" @@ -62,7 +67,21 @@ def encode_hardpoint(**fields: Any) -> list[Any]: return [fields[name] for name in HARDPOINT_MEMBERS] +def _cultcache_py_version(cultlib_packages: str) -> str: + pyproject = Path(cultlib_packages) / "cultcache-py" / "pyproject.toml" + try: + with pyproject.open("rb") as handle: + return str(tomllib.load(handle)["project"]["version"]) + except (OSError, tomllib.TOMLDecodeError, KeyError): + raise ValueError(f"cultcache-py's pyproject.toml is missing or unreadable under {cultlib_packages}; this add-on " + f"is tested against cultcache-py {TESTED_CULTCACHE_PY}") from None + + def _libraries(cultlib_packages: str): + found = _cultcache_py_version(cultlib_packages) + if found != TESTED_CULTCACHE_PY: + raise ValueError(f"cultcache-py {found} under {cultlib_packages} is not the tested {TESTED_CULTCACHE_PY}; the " + f"add-on writes raw envelopes and refuses a CultLib whose write path may have moved") source = Path(cultlib_packages) / "cultcache-py" / "src" if source.exists() and str(source) not in sys.path: sys.path.insert(0, str(source)) diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index fb9b9994e..5762df25c 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -568,6 +568,46 @@ class RefusalsNameNoInputTests(ShipFileCase): self.assertNotIn(forbidden, str(caught.exception)) +class UntestedCultCacheTests(ShipFileCase): + """_libraries refuses a CultLib whose cultcache-py is not the version the raw envelope writes were tested against.""" + + def packages(self, version=None): + root = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, root, True) + (root / "cultcache-py").mkdir() + if version is not None: + (root / "cultcache-py" / "pyproject.toml").write_text( + f'[project]\nname = "cultcache-py"\nversion = "{version}"\n', encoding="utf-8") + return str(root) + + def test_a_different_version_is_refused_naming_the_version_and_the_packages_path(self): + stub = self.packages("9.9.9") + before = self.bytes() + for call in (lambda: ship_cc.read(self.path, stub), lambda: ship_cc.read_layout(self.path, stub)): + with self.assertRaises(ValueError) as caught: + call() + self.assertIn("9.9.9", str(caught.exception)) + self.assertIn(stub, str(caught.exception)) + self.assertEqual(before, self.bytes()) + + def test_a_save_through_an_untested_cultcache_writes_nothing(self): + _, _, revision = ship_cc.read_layout(self.path, PACKAGES) + before = self.bytes() + with self.assertRaises(ValueError): + ship_cc.replace_layout(self.path, self.packages("9.9.9"), "mod.skiff", revision, [2, 2, [True] * 4], []) + self.assertEqual(before, self.bytes()) + + def test_a_missing_pyproject_is_refused(self): + stub = self.packages() + with self.assertRaises(ValueError) as caught: + ship_cc.read(self.path, stub) + self.assertIn(stub, str(caught.exception)) + + def test_the_tested_version_is_the_one_the_real_packages_directory_carries(self): + self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(PACKAGES)) + self.assertEqual("mod.skiff", ship_cc.read(self.path, PACKAGES).ship.body[0]) + + class ShipIdTests(unittest.TestCase): def test_admits_the_ids_the_c_sharp_rule_admits(self): for ship_id in ("probe.a", "a", "0x", "a_b-c", "com10", "com0", "lpt0", "console", "aux2", "a.nul", "x.com1"): From 5545c850a27a42eb35f97643747dc7c7f2deb7f6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:37:50 +0200 Subject: [PATCH 2/6] Add-on: Max texture preference; ship.glb exports larger material images scaled _export_glb points each Image Texture node of the exported objects' materials whose image is longer than the Max texture preference (default 2048) at a scaled copy (longest side at the cap, aspect kept) and restores the originals and removes the copies in its finally block, so the .blend's images are never modified. smoke_package.py reads the GLB's PNG headers: a 4096x1024 map exports at 2048x512 and at 512x128 under a 512 cap, a 256x256 map exports as it is, the .blend images keep their size and no copy is left in bpy.data.images. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 65 ++++++++++++++++++++++-- tools/blender/tests/smoke_package.py | 64 +++++++++++++++++++++++ 2 files changed, 126 insertions(+), 3 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 75e70b1f6..3ee83df5d 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -612,8 +612,14 @@ class AETHERIA_AP_preferences(bpy.types.AddonPreferences): 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") + max_texture: bpy.props.IntProperty( + name="Max texture", default=2048, min=64, soft_max=8192, + description="The longest side, in pixels, a material image keeps in ship.glb; larger images are exported " + "scaled to it and the .blend's own images are left as they are") + def draw(self, context): self.layout.prop(self, "game_folder") + self.layout.prop(self, "max_texture") GAME_FOLDER_REFUSAL = ("Set the add-on's Game folder preference to the folder whose GameData holds Aetheria.cc " @@ -684,6 +690,55 @@ def _anchors(collection): return rows +DEFAULT_MAX_TEXTURE = 2048 + + +def _max_texture(context): + addon = context.preferences.addons.get(__package__) + return addon.preferences.max_texture if addon and addon.preferences else DEFAULT_MAX_TEXTURE + + +def _image_nodes(tree, seen=None): + """Every Image Texture node of a node tree, node groups included.""" + seen = set() if seen is None else seen + if tree is None or tree.name_full in seen: + return + seen.add(tree.name_full) + for node in tree.nodes: + if node.type == "TEX_IMAGE" and node.image is not None: + yield node + elif node.type == "GROUP": + yield from _image_nodes(node.node_tree, seen) + + +def _swap_in_scaled_images(objects, limit, swaps): + """Points each Image Texture node of the objects' materials that holds an image longer than limit at a scaled copy, + and appends each (node, original, copy) to swaps for _restore_images, so a failure part way still restores. The + originals are never modified.""" + copies = {} + for obj in objects: + for slot in obj.material_slots: + for node in _image_nodes(slot.material.node_tree if slot.material else None): + original = node.image + longest = max(original.size) + if longest <= limit: + continue + if original not in copies: + copy = original.copy() + copies[original] = copy + copy.scale(max(1, round(original.size[0] * limit / longest)), + max(1, round(original.size[1] * limit / longest))) + node.image = copies[original] + swaps.append((node, original, copies[original])) + + +def _restore_images(swaps): + for node, original, _ in swaps: + node.image = original + for copy in {id(copy): copy for _, _, copy in swaps}.values(): + bpy.data.images.remove(copy) + + def _export_glb(context, collection, filepath): """Exports the collection's objects with custom properties as node extras: Generated's anchors are exported; Grease Pencil, Source and the Grid are not.""" @@ -694,14 +749,17 @@ def _export_glb(context, collection, filepath): previous_layer = view_layer.active_layer_collection previous_selection = [obj for obj in view_layer.objects if obj.select_get()] previous_active = view_layer.objects.active + swaps = [] try: view_layer.active_layer_collection = layer for obj in previous_selection: obj.select_set(False) derived = _derived_names(collection) - for obj in collection.all_objects: - if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects: - obj.select_set(True) + exported = [obj for obj in collection.all_objects + if obj.type != "GREASEPENCIL" and obj.name not in derived and obj.name in view_layer.objects] + for obj in exported: + obj.select_set(True) + _swap_in_scaled_images(exported, _max_texture(context), swaps) bpy.ops.export_scene.gltf( filepath=filepath, export_format="GLB", use_active_collection=True, use_selection=True, export_extras=True, export_yup=True, export_apply=True) @@ -709,6 +767,7 @@ def _export_glb(context, collection, filepath): traceback.print_exc() raise RuntimeError("The GLB export failed; its message is in the system console") from None finally: + _restore_images(swaps) for obj in view_layer.objects: obj.select_set(obj in previous_selection) view_layer.objects.active = previous_active diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index 8ebafc843..e42615111 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -22,7 +22,9 @@ the .blend is saved there before the final bind, so the bind crosses drives and import contextlib import io +import json import os +import struct import sys import tempfile import traceback @@ -61,6 +63,58 @@ def tagged(collection, name, data, role=None, anchor_id=None, location=(0, 0, 0) return obj +def textured(obj, name, width, height): + """Gives the object a material whose base colour is a generated width x height image; returns the image.""" + image = bpy.data.images.new(name, width, height) + image.pixels[0] = 0.5 # a dirty generated image is what the exporter encodes + obj.data.uv_layers.new(name="UVMap") + material = bpy.data.materials.new(name) + material.use_nodes = True + tree = material.node_tree + texture = tree.nodes.new("ShaderNodeTexImage") + texture.image = image + tree.links.new(texture.outputs["Color"], tree.nodes["Principled BSDF"].inputs["Base Color"]) + obj.data.materials.append(material) + return image + + +def glb_image_sizes(glb): + """The (width, height) of each PNG the GLB embeds, read from the PNG headers in its binary chunk.""" + data = Path(glb).read_bytes() + json_length, _ = struct.unpack_from("II", png, 16)) + return sorted(sizes) + + +def texture_cap_pass(addon, hull_object, albedo, detail, limit, expected, glb): + """Package at the Max texture preference limit; the GLB holds exactly the expected image sizes, and the .blend's own + images are untouched and no scaled copy is left in bpy.data.images.""" + bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = limit + names = sorted(image.name for image in bpy.data.images) + result, report = package(addon) + if result != {"FINISHED"}: + raise SystemExit(f"Texture cap {limit}: Package was refused") + if glb_image_sizes(glb) != sorted(expected): + raise SystemExit(f"Texture cap {limit}: the GLB images are {glb_image_sizes(glb)}, not {sorted(expected)}") + if tuple(albedo.size) != (4096, 1024) or tuple(detail.size) != (256, 256): + raise SystemExit(f"Texture cap {limit}: Package changed a .blend image to {tuple(albedo.size)}, {tuple(detail.size)}") + nodes = [node.image for slot in hull_object.material_slots for node in slot.material.node_tree.nodes + if node.type == "TEX_IMAGE"] + if nodes != [albedo, detail]: + raise SystemExit(f"Texture cap {limit}: a material node does not hold its original image") + if sorted(image.name for image in bpy.data.images) != names: + raise SystemExit(f"Texture cap {limit}: a scaled copy is left in bpy.data.images") + + BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc") @@ -196,6 +250,9 @@ def main(): ship = bpy.data.collections.new("Smoke Gale") scene.collection.children.link(ship) hull = tagged(ship, "Hull", tetrahedron("Hull")) + albedo = textured(hull, "Hull Albedo", 4096, 1024) + detail = textured(hull, "Hull Detail", 256, 256) + hull.data.polygons[0].material_index = 1 # the exporter writes only materials some face uses tagged(ship, "Map Icon", quad("Map Icon"), "map-icon", "map", (0, 0, -1)) tagged(ship, "Hull Collider", tetrahedron("Hull Collider"), "hull-collider", "collider") tagged(ship, "Shield", None, "shield", "shield") @@ -224,6 +281,13 @@ def main(): result, report = package(aetheria_ships) if result != {"FINISHED"}: raise SystemExit("Pass 1: the validator refused the primitive ship") + # The texture cap: the 4096 x 1024 map exports at 2048 x 512 (the longest side at the cap, the aspect kept), the + # 256 x 256 one is under the cap and exports as it is, and the preference decides the cap. + glb = Path(path).with_name("ship.glb") + texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 512), (256, 256)], glb) + texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 128), (256, 256)], glb) + bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048 + print("TEXTURE_CAP_OK") # A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing. if sorted(obj.name for obj in bpy.data.objects) != objects_before or any( child.get("aetheria.frame") for child in ship.children): From 4acf05da46a1d94f7558fb296c0d32735879fa49 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:38:42 +0200 Subject: [PATCH 3/6] smoke_mounts: the mirror matrix on wide grids, 14 and 15 cells across Passes 7 and 8 run mirror_matrix on ships whose grids are 14 (even) and 15 (odd) wide; the expected twin cells are grid_w - a - w computed in the matrix itself. Pass 6 (5 wide) now runs through the same matrix_pass function. This is the commit that adds the cases for follow-up mirror-wide-grid-test. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_mounts.py | 53 ++++++++++++++++++----------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/tools/blender/tests/smoke_mounts.py b/tools/blender/tests/smoke_mounts.py index 1c0944f6e..fd8bb0022 100644 --- a/tools/blender/tests/smoke_mounts.py +++ b/tools/blender/tests/smoke_mounts.py @@ -35,7 +35,9 @@ 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.mounts" ODD_ID = "smoke.mounts.odd" -ODD_LENGTH = 7 +# (ship id, length in cells, the source sphere's Y scale, wide, grid width parity) of passes 6 to 8; the id's mod directory +# is removed at the end. +WIDTH_PASSES = ((ODD_ID, 7, 0.6, False, 1), (ODD_ID + ".wide-even", 14, 1.0, True, 0), (ODD_ID + ".wide-odd", 15, 1.0, True, 1)) 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" COLLECTION = "Smoke Mounts" @@ -412,30 +414,40 @@ def main(): twin.parent = root print("PASS 3 package derives ok") - # Pass 6: the same mirror matrix on a ship whose grid is an odd number of cells wide. - for obj in list(collection.all_objects): # the first ship's objects hold the names the matrix looks mounts up by + # Passes 6 to 8: the same mirror matrix on ships whose grids are odd, wide and even, and wide and odd (10 or more + # cells across, where real hulls live). Each pass clears the previous ship's objects, which hold the names the matrix + # looks mounts up by. + previous = collection + for number, (ship_id, length, y_scale, wide, parity) in enumerate(WIDTH_PASSES, start=6): + previous = matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity) + print(f"PASS {number} mirror matrix on a {scene.aetheria_layout.width}-wide grid ok") + + +def matrix_pass(scene, previous, ship_id, length, y_scale, wide, parity): + for obj in list(previous.all_objects): bpy.data.objects.remove(obj) - odd = bpy.data.collections.new(COLLECTION + " Odd") - scene.collection.children.link(odd) - odd_mesh = bpy.data.meshes.new("Odd Sphere") + ship = bpy.data.collections.new(f"{COLLECTION} {ship_id}") + scene.collection.children.link(ship) + mesh = bpy.data.meshes.new(f"{ship_id} sphere") bm = bmesh.new() bmesh.ops.create_uvsphere(bm, u_segments=48, v_segments=24, radius=0.5) - bm.to_mesh(odd_mesh) + bm.to_mesh(mesh) bm.free() - odd_sphere = new_object(odd, "Odd Source Sphere", odd_mesh) - odd_sphere.scale = (1, 0.6, 0.4) - select(odd_sphere) - scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ODD_ID, "Smoke Mounts Odd" - scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", ODD_LENGTH + sphere = new_object(ship, f"{ship_id} Source Sphere", mesh) + sphere.scale = (1, y_scale, 0.4) + select(sphere) + scene.aetheria_new_ship_id, scene.aetheria_new_ship_name = ship_id, ship_id + scene.aetheria_new_ship_like, scene.aetheria_new_ship_length = "Djinni", length result, message = run(bpy.ops.aetheria.new_ship) - require(result == {"FINISHED"}, f"New Ship (odd) was refused: {message}") + require(result == {"FINISHED"}, f"New Ship ({ship_id}) was refused: {message}") result, message = run(bpy.ops.aetheria.save_ship_layout) - require(result == {"FINISHED"}, f"Save (odd) was refused: {message}") - odd_root = aetheria_ships._ship_root(odd) - odd_origin = odd["aetheria.grid_origin"] - require(scene.aetheria_layout.width % 2 == 1, f"The odd ship's grid is {scene.aetheria_layout.width} wide") - mirror_matrix(scene, odd_root, (odd_origin[0], odd_origin[1]), odd_sphere) - print("PASS 6 mirror matrix on an odd grid ok") + require(result == {"FINISHED"}, f"Save ({ship_id}) was refused: {message}") + width = scene.aetheria_layout.width + require(width % 2 == parity and (width >= 10 or not wide), f"The {ship_id} grid is {width} wide") + origin = ship["aetheria.grid_origin"] + mirror_matrix(scene, sys.modules["aetheria_ships"]._ship_root(ship), (origin[0], origin[1]), sphere) + return ship + created_mods = not MODS.exists() @@ -456,7 +468,8 @@ finally: if not existed: shutil.rmtree(MODS / SHIP_ID, ignore_errors=True) if not existed: - shutil.rmtree(MODS / ODD_ID, ignore_errors=True) + for pass_id, *_ in WIDTH_PASSES: + shutil.rmtree(MODS / pass_id, ignore_errors=True) if created_mods and not existed: shutil.rmtree(MODS, ignore_errors=True) sys.stdout.flush() From 873ee6eae4d4ad273711be7ad02efd79c8e07cc1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 05:41:08 +0200 Subject: [PATCH 4/6] Guards tests: version drift of any size is refused; the texture fixture's aspect is not a power-of-two ratio The untested-cultcache test now refuses 0.4.0, 0.3.1 and 0.3 as well as 9.9.9 (a major-only comparison survived). smoke_package's hull map is 4096x1000, so the scaled size (2048x500, 512x125) is not what a clamp of both sides or of the other axis would give. Builds: yes. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_package.py | 10 +++++----- tools/blender/tests/test_ship_cc.py | 13 +++++++------ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index e42615111..c4568199b 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -105,7 +105,7 @@ def texture_cap_pass(addon, hull_object, albedo, detail, limit, expected, glb): raise SystemExit(f"Texture cap {limit}: Package was refused") if glb_image_sizes(glb) != sorted(expected): raise SystemExit(f"Texture cap {limit}: the GLB images are {glb_image_sizes(glb)}, not {sorted(expected)}") - if tuple(albedo.size) != (4096, 1024) or tuple(detail.size) != (256, 256): + if tuple(albedo.size) != (4096, 1000) or tuple(detail.size) != (256, 256): raise SystemExit(f"Texture cap {limit}: Package changed a .blend image to {tuple(albedo.size)}, {tuple(detail.size)}") nodes = [node.image for slot in hull_object.material_slots for node in slot.material.node_tree.nodes if node.type == "TEX_IMAGE"] @@ -250,7 +250,7 @@ def main(): ship = bpy.data.collections.new("Smoke Gale") scene.collection.children.link(ship) hull = tagged(ship, "Hull", tetrahedron("Hull")) - albedo = textured(hull, "Hull Albedo", 4096, 1024) + albedo = textured(hull, "Hull Albedo", 4096, 1000) detail = textured(hull, "Hull Detail", 256, 256) hull.data.polygons[0].material_index = 1 # the exporter writes only materials some face uses tagged(ship, "Map Icon", quad("Map Icon"), "map-icon", "map", (0, 0, -1)) @@ -281,11 +281,11 @@ def main(): result, report = package(aetheria_ships) if result != {"FINISHED"}: raise SystemExit("Pass 1: the validator refused the primitive ship") - # The texture cap: the 4096 x 1024 map exports at 2048 x 512 (the longest side at the cap, the aspect kept), the + # The texture cap: the 4096 x 1000 map exports at 2048 x 500 (the longest side at the cap, the aspect kept), the # 256 x 256 one is under the cap and exports as it is, and the preference decides the cap. glb = Path(path).with_name("ship.glb") - texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 512), (256, 256)], glb) - texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 128), (256, 256)], glb) + texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 500), (256, 256)], glb) + texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 125), (256, 256)], glb) bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048 print("TEXTURE_CAP_OK") # A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing. diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 5762df25c..176182e6e 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -581,13 +581,14 @@ class UntestedCultCacheTests(ShipFileCase): return str(root) def test_a_different_version_is_refused_naming_the_version_and_the_packages_path(self): - stub = self.packages("9.9.9") before = self.bytes() - for call in (lambda: ship_cc.read(self.path, stub), lambda: ship_cc.read_layout(self.path, stub)): - with self.assertRaises(ValueError) as caught: - call() - self.assertIn("9.9.9", str(caught.exception)) - self.assertIn(stub, str(caught.exception)) + for version in ("9.9.9", "0.4.0", "0.3.1", "0.3"): # any drift, not only a different major + stub = self.packages(version) + for call in (lambda: ship_cc.read(self.path, stub), lambda: ship_cc.read_layout(self.path, stub)): + with self.subTest(version=version), self.assertRaises(ValueError) as caught: + call() + self.assertIn(version, str(caught.exception)) + self.assertIn(stub, str(caught.exception)) self.assertEqual(before, self.bytes()) def test_a_save_through_an_untested_cultcache_writes_nothing(self): From aa2bf0d41fb7378ab25cd23f6395ab2b07618195 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 06:59:24 +0200 Subject: [PATCH 5/6] Guards fix: cultcache-py version is read from the imported module; texture cap boundary, rounding, orientation, group and failure cases pinned Findings of verdict cut-ships-addon-guards.s1: guard-not-import (the version now comes from the checkout the imported cultcache_py lives in, one owner, no pyproject-beside-a-path), prefix-compare-survives (0.3.0.1, 0.3.0rc1, 0.3.00 refused), cap-boundary-unpinned and group-restore-unpinned (smoke_package texture_boundary_cases: at the cap, one over, height-long, non-square rounding, file/packed/node-group images, failed export restores). A copy whose scale raises is removed. All commits build. Co-Authored-By: Claude Opus 5.5 --- tools/blender/aetheria_ships/__init__.py | 8 ++- tools/blender/aetheria_ships/ship_cc.py | 23 +++--- tools/blender/tests/smoke_package.py | 89 ++++++++++++++++++++++++ tools/blender/tests/test_ship_cc.py | 55 ++++++++++++--- 4 files changed, 153 insertions(+), 22 deletions(-) diff --git a/tools/blender/aetheria_ships/__init__.py b/tools/blender/aetheria_ships/__init__.py index 3ee83df5d..7df050681 100644 --- a/tools/blender/aetheria_ships/__init__.py +++ b/tools/blender/aetheria_ships/__init__.py @@ -725,9 +725,13 @@ def _swap_in_scaled_images(objects, limit, swaps): continue if original not in copies: copy = original.copy() + try: + copy.scale(max(1, round(original.size[0] * limit / longest)), + max(1, round(original.size[1] * limit / longest))) + except BaseException: + bpy.data.images.remove(copy) # not yet in swaps, so _restore_images cannot find it + raise copies[original] = copy - copy.scale(max(1, round(original.size[0] * limit / longest)), - max(1, round(original.size[1] * limit / longest))) node.image = copies[original] swaps.append((node, original, copies[original])) diff --git a/tools/blender/aetheria_ships/ship_cc.py b/tools/blender/aetheria_ships/ship_cc.py index 5ef61445d..cb76e744c 100644 --- a/tools/blender/aetheria_ships/ship_cc.py +++ b/tools/blender/aetheria_ships/ship_cc.py @@ -16,7 +16,7 @@ from pathlib import Path from typing import Any, NamedTuple # The cultcache-py this module's raw envelope writes were tested against. cultcache_py has no __version__ and is loaded -# from source, so its version is [project].version in the pyproject.toml beside that source. +# from source, so the guard reads [project].version of the checkout the imported module itself came from. TESTED_CULTCACHE_PY = "0.3.0" SCHEMA = "aetheria.ship_authoring" @@ -67,26 +67,27 @@ def encode_hardpoint(**fields: Any) -> list[Any]: return [fields[name] for name in HARDPOINT_MEMBERS] -def _cultcache_py_version(cultlib_packages: str) -> str: - pyproject = Path(cultlib_packages) / "cultcache-py" / "pyproject.toml" +def _cultcache_py_version(module: Any) -> str: + """[project].version of the cultcache-py checkout the imported module lives in (src/cultcache_py/__init__.py sits + two levels under it). A module with no file or no readable pyproject has no version: "".""" try: - with pyproject.open("rb") as handle: + with (Path(module.__file__).parents[2] / "pyproject.toml").open("rb") as handle: return str(tomllib.load(handle)["project"]["version"]) - except (OSError, tomllib.TOMLDecodeError, KeyError): - raise ValueError(f"cultcache-py's pyproject.toml is missing or unreadable under {cultlib_packages}; this add-on " - f"is tested against cultcache-py {TESTED_CULTCACHE_PY}") from None + except (AttributeError, TypeError, IndexError, OSError, tomllib.TOMLDecodeError, KeyError): + return "" def _libraries(cultlib_packages: str): - found = _cultcache_py_version(cultlib_packages) - if found != TESTED_CULTCACHE_PY: - raise ValueError(f"cultcache-py {found} under {cultlib_packages} is not the tested {TESTED_CULTCACHE_PY}; the " - f"add-on writes raw envelopes and refuses a CultLib whose write path may have moved") source = Path(cultlib_packages) / "cultcache-py" / "src" if source.exists() and str(source) not in sys.path: sys.path.insert(0, str(source)) import cultcache_py # type: ignore import msgpack # type: ignore + found = _cultcache_py_version(cultcache_py) + if found != TESTED_CULTCACHE_PY: + raise ValueError(f"the imported cultcache-py (version {found or 'unreadable'}; CultLib packages {cultlib_packages}) " + f"is not the tested {TESTED_CULTCACHE_PY}; the add-on writes raw envelopes and refuses a " + f"CultLib whose write path may have moved") return cultcache_py, msgpack diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index c4568199b..e70d21710 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -20,6 +20,7 @@ unsaved .blend stores the absolute path, which still resolves after Save As to t the .blend is saved there before the final bind, so the bind crosses drives and the package passes prove it resolves. """ +import array import contextlib import io import json @@ -115,6 +116,93 @@ def texture_cap_pass(addon, hull_object, albedo, detail, limit, expected, glb): raise SystemExit(f"Texture cap {limit}: a scaled copy is left in bpy.data.images") +def expected_cap_size(width, height, limit): + """The longest side at the cap, the other side scaled by the same ratio and rounded half up; untouched at or under the + cap. Integer arithmetic, so it shares nothing with the add-on's float rounding.""" + longest = max(width, height) + if longest <= limit: + return (width, height) + return (max(1, (2 * width * limit + longest) // (2 * longest)), max(1, (2 * height * limit + longest) // (2 * longest))) + + +def texture_boundary_cases(addon, hull_object, glb): + """Each case gives the hull one material whose image is width x height (generated, file-backed, packed, or inside a + node group) and packages at cap 256: the GLB holds exactly the expected size, the image is as it was (size, pixels, + node identity), and no copy is left. Cases sit exactly at the cap, one over, on the long side being the height, on + sizes where rounding and flooring differ, and on a tall image whose width rounds up.""" + bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = limit = 256 + original_materials = list(hull_object.data.materials) + folder = Path(tempfile.mkdtemp(prefix="aetheria-smoke-cap-")) + cases = [("generated", 256, 128), ("generated", 257, 125), ("generated", 257, 128), ("generated", 256, 256), + ("generated", 255, 255), ("generated", 375, 376), ("generated", 125, 513), ("generated", 1000, 4097), + ("file", 513, 400), ("file", 256, 257), ("packed", 513, 400), ("packed", 258, 70), ("group", 513, 400), + ("group", 125, 513)] + try: + for kind, width, height in cases: + label = f"{kind} {width}x{height}" + while hull_object.data.materials: + hull_object.data.materials.pop(index=0) + image = bpy.data.images.new("CapCase", width, height) + image.pixels.foreach_set([((k * 7) % 251) / 251.0 for k in range(width * height * 4)]) + if kind in ("file", "packed"): + image.filepath_raw = str(folder / f"{kind}_{width}_{height}.png") + image.file_format = "PNG" + image.save() + bpy.data.images.remove(image) + image = bpy.data.images.load(str(folder / f"{kind}_{width}_{height}.png")) + image.name = "CapCase" + if kind == "packed": + image.pack() + material = bpy.data.materials.new("CapCase") + material.use_nodes = True + tree = material.node_tree + if kind == "group": + group = bpy.data.node_groups.new("CapGroup", "ShaderNodeTree") + group.interface.new_socket("Color", in_out="OUTPUT", socket_type="NodeSocketColor") + texture = group.nodes.new("ShaderNodeTexImage") + texture.image = image + group.links.new(texture.outputs["Color"], group.nodes.new("NodeGroupOutput").inputs[0]) + holder = tree.nodes.new("ShaderNodeGroup") + holder.node_tree = group + tree.links.new(holder.outputs[0], tree.nodes["Principled BSDF"].inputs["Base Color"]) + else: + texture = tree.nodes.new("ShaderNodeTexImage") + texture.image = image + tree.links.new(texture.outputs["Color"], tree.nodes["Principled BSDF"].inputs["Base Color"]) + hull_object.data.materials.append(material) + pixels = bytes(array.array("f", image.pixels[:]).tobytes()) + names = sorted(i.name for i in bpy.data.images) + result, report = package(addon) + if result != {"FINISHED"}: + raise SystemExit(f"Texture case {label}: Package was refused") + if glb_image_sizes(glb) != [expected_cap_size(width, height, limit)]: + raise SystemExit(f"Texture case {label}: the GLB image is {glb_image_sizes(glb)}, " + f"not {expected_cap_size(width, height, limit)}") + if (tuple(image.size) != (width, height) or bytes(array.array("f", image.pixels[:]).tobytes()) != pixels + or texture.image != image or sorted(i.name for i in bpy.data.images) != names): + raise SystemExit(f"Texture case {label}: the .blend's image changed or a copy is left behind") + if kind == "file" and Path(image.filepath_raw).exists() is False: + raise SystemExit(f"Texture case {label}: the source file is gone") + # An exporter that fails (the GLB's path is a directory) still restores the originals and deletes the copies. + names = sorted(i.name for i in bpy.data.images) + glb.unlink() + glb.mkdir() + try: + result, report = package(addon) + finally: + glb.rmdir() + if result != {"CANCELLED"}: + raise SystemExit("Texture failure case: the export into a directory did not fail") + if tuple(image.size) != (width, height) or texture.image != image or sorted(i.name for i in bpy.data.images) != names: + raise SystemExit("Texture failure case: a failed export left a scaled copy or a swapped node") + finally: + while hull_object.data.materials: + hull_object.data.materials.pop(index=0) + for material in original_materials: + hull_object.data.materials.append(material) + bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048 + + BIND_KEYS = ("aetheria.asset_kind", "aetheria.id", "aetheria.ship_cc") @@ -287,6 +375,7 @@ def main(): texture_cap_pass(aetheria_ships, hull, albedo, detail, 2048, [(2048, 500), (256, 256)], glb) texture_cap_pass(aetheria_ships, hull, albedo, detail, 512, [(512, 125), (256, 256)], glb) bpy.context.preferences.addons["aetheria_ships"].preferences.max_texture = 2048 + texture_boundary_cases(aetheria_ships, hull, glb) print("TEXTURE_CAP_OK") # A collection with no Ship Root is packaged exactly as before: the frame step makes and moves nothing. if sorted(obj.name for obj in bpy.data.objects) != objects_before or any( diff --git a/tools/blender/tests/test_ship_cc.py b/tools/blender/tests/test_ship_cc.py index 176182e6e..f4973f9b7 100644 --- a/tools/blender/tests/test_ship_cc.py +++ b/tools/blender/tests/test_ship_cc.py @@ -569,31 +569,55 @@ class RefusalsNameNoInputTests(ShipFileCase): class UntestedCultCacheTests(ShipFileCase): - """_libraries refuses a CultLib whose cultcache-py is not the version the raw envelope writes were tested against.""" + """_libraries refuses a cultcache-py that is not the version the raw envelope writes were tested against. The version + is read from the checkout of the module that actually imports, so each case imports a stub cultcache_py.""" - def packages(self, version=None): + def setUp(self): + super().setUp() + saved_modules = {name: module for name, module in sys.modules.items() if name.split(".")[0] == "cultcache_py"} + saved_path = list(sys.path) + for name in saved_modules: + del sys.modules[name] + self.addCleanup(self.restore, saved_modules, saved_path) + + @staticmethod + def restore(saved_modules, saved_path): + for name in [name for name in sys.modules if name.split(".")[0] == "cultcache_py"]: + del sys.modules[name] + sys.modules.update(saved_modules) + sys.path[:] = saved_path + + def packages(self, version=None, with_source=True): root = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, root, True) - (root / "cultcache-py").mkdir() + checkout = root / "cultcache-py" + checkout.mkdir() if version is not None: - (root / "cultcache-py" / "pyproject.toml").write_text( + (checkout / "pyproject.toml").write_text( f'[project]\nname = "cultcache-py"\nversion = "{version}"\n', encoding="utf-8") + if with_source: + (checkout / "src" / "cultcache_py").mkdir(parents=True) + (checkout / "src" / "cultcache_py" / "__init__.py").write_text("", encoding="utf-8") return str(root) def test_a_different_version_is_refused_naming_the_version_and_the_packages_path(self): before = self.bytes() - for version in ("9.9.9", "0.4.0", "0.3.1", "0.3"): # any drift, not only a different major - stub = self.packages(version) - for call in (lambda: ship_cc.read(self.path, stub), lambda: ship_cc.read_layout(self.path, stub)): + # any drift, not only a different major; 0.3.0.1 and 0.3.0rc1 start with the tested version and are not it + for version in ("9.9.9", "0.4.0", "0.3.1", "0.3", "0.3.0.1", "0.3.0rc1", "0.3.00"): + for call in (lambda stub: ship_cc.read(self.path, stub), lambda stub: ship_cc.read_layout(self.path, stub)): + stub = self.packages(version) with self.subTest(version=version), self.assertRaises(ValueError) as caught: - call() + call(stub) + self.fail("accepted") self.assertIn(version, str(caught.exception)) self.assertIn(stub, str(caught.exception)) + self.restore({}, list(sys.path)) # the next import is fresh self.assertEqual(before, self.bytes()) def test_a_save_through_an_untested_cultcache_writes_nothing(self): _, _, revision = ship_cc.read_layout(self.path, PACKAGES) before = self.bytes() + self.restore({}, list(sys.path)) with self.assertRaises(ValueError): ship_cc.replace_layout(self.path, self.packages("9.9.9"), "mod.skiff", revision, [2, 2, [True] * 4], []) self.assertEqual(before, self.bytes()) @@ -604,9 +628,22 @@ class UntestedCultCacheTests(ShipFileCase): ship_cc.read(self.path, stub) self.assertIn(stub, str(caught.exception)) + def test_the_guard_certifies_the_library_that_is_imported_not_the_path_it_was_given(self): + # A pyproject that says 0.3.0 beside a path whose source is not what imports does not vouch for the import. + real = self.packages("0.4.0") # imports first, and is the library in use + with self.assertRaises(ValueError): + ship_cc.read(self.path, real) + liar = self.packages("0.3.0", with_source=False) # claims the tested version, supplies no source + sys.path.insert(0, str(Path(real) / "cultcache-py" / "src")) + with self.assertRaises(ValueError) as caught: + ship_cc.read(self.path, liar) + self.assertIn("0.4.0", str(caught.exception)) + def test_the_tested_version_is_the_one_the_real_packages_directory_carries(self): - self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(PACKAGES)) + self.restore({}, list(sys.path)) self.assertEqual("mod.skiff", ship_cc.read(self.path, PACKAGES).ship.body[0]) + import cultcache_py + self.assertEqual(ship_cc.TESTED_CULTCACHE_PY, ship_cc._cultcache_py_version(cultcache_py)) class ShipIdTests(unittest.TestCase): From 042a13f9794b3ddf415ece327de625f9b00eebbd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 12 Oct 2026 07:03:13 +0200 Subject: [PATCH 6/6] smoke_package: a tall image whose width rounds up pins rounding of the first axis (200x513 -> 100x256, not 99) Builds. Co-Authored-By: Claude Opus 5.5 --- tools/blender/tests/smoke_package.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/blender/tests/smoke_package.py b/tools/blender/tests/smoke_package.py index e70d21710..886c349e2 100644 --- a/tools/blender/tests/smoke_package.py +++ b/tools/blender/tests/smoke_package.py @@ -134,7 +134,7 @@ def texture_boundary_cases(addon, hull_object, glb): original_materials = list(hull_object.data.materials) folder = Path(tempfile.mkdtemp(prefix="aetheria-smoke-cap-")) cases = [("generated", 256, 128), ("generated", 257, 125), ("generated", 257, 128), ("generated", 256, 256), - ("generated", 255, 255), ("generated", 375, 376), ("generated", 125, 513), ("generated", 1000, 4097), + ("generated", 255, 255), ("generated", 375, 376), ("generated", 125, 513), ("generated", 200, 513), ("generated", 1000, 4097), ("file", 513, 400), ("file", 256, 257), ("packed", 513, 400), ("packed", 258, 70), ("group", 513, 400), ("group", 125, 513)] try: