Merge cut ships-addon-guards (Soul verdict cut-ships-addon-guards.s2)

This commit is contained in:
Meta Vens 2026-10-12 19:44:33 +02:00
commit b53d071ef6
5 changed files with 350 additions and 23 deletions

View file

@ -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,59 @@ 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()
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
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 +753,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 +771,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

View file

@ -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 the guard reads [project].version of the checkout the imported module itself came from.
TESTED_CULTCACHE_PY = "0.3.0"
SCHEMA = "aetheria.ship_authoring"
HULL_SCHEMA = "aetheria.hulldata"
@ -62,12 +67,27 @@ def encode_hardpoint(**fields: Any) -> list[Any]:
return [fields[name] for name in HARDPOINT_MEMBERS]
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 (Path(module.__file__).parents[2] / "pyproject.toml").open("rb") as handle:
return str(tomllib.load(handle)["project"]["version"])
except (AttributeError, TypeError, IndexError, OSError, tomllib.TOMLDecodeError, KeyError):
return ""
def _libraries(cultlib_packages: str):
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

View file

@ -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()

View file

@ -20,9 +20,12 @@ 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
import os
import struct
import sys
import tempfile
import traceback
@ -61,6 +64,145 @@ 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", data, 12)
document = json.loads(data[20:20 + json_length])
bin_start = 20 + json_length + 8
sizes = []
for image in document.get("images", []):
view = document["bufferViews"][image["bufferView"]]
start = bin_start + view.get("byteOffset", 0)
png = data[start:start + view["byteLength"]]
if image.get("mimeType") != "image/png" or png[:8] != b"\x89PNG\r\n\x1a\n":
raise SystemExit("A GLB image is not a PNG")
sizes.append(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, 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"]
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")
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", 200, 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")
@ -196,6 +338,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, 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))
tagged(ship, "Hull Collider", tetrahedron("Hull Collider"), "hull-collider", "collider")
tagged(ship, "Shield", None, "shield", "shield")
@ -224,6 +369,14 @@ 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 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, 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(
child.get("aetheria.frame") for child in ship.children):

View file

@ -568,6 +568,84 @@ class RefusalsNameNoInputTests(ShipFileCase):
self.assertNotIn(forbidden, str(caught.exception))
class UntestedCultCacheTests(ShipFileCase):
"""_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 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)
checkout = root / "cultcache-py"
checkout.mkdir()
if version is not None:
(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()
# 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(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())
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_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.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):
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"):