Files
aetherbound-guild/tools/verify_fg4_media.py
T

381 lines
18 KiB
Python

#!/usr/bin/env python3
"""Validate FG-4 media provenance and deterministic runtime outputs."""
from __future__ import annotations
from collections import deque
import hashlib
import json
from pathlib import Path
from PIL import Image
ROOT = Path(__file__).resolve().parents[1]
MEDIA_ROOT = ROOT / "docs/runtime/fg4/media"
MANIFEST_PATH = ROOT / "runtime/assets/fg4/asset-manifest.json"
EXPECTED_SIZES = {(380, 180), (422, 195), (320, 180)}
EXPECTED_BOSSES = {
"BO-R3-01": {"frames": 4, "cell": (128, 96)},
"BO-R3-02": {"frames": 4, "cell": (96, 112)},
}
EXPECTED_ACTORS = {
"EN-R3-01", "EN-R3-02", "EN-R3-03", "EN-R3-04", "EN-R3-05", "EN-R3-06",
"EN-R3-07", "EN-R3-08", "EN-R3-09", "EN-R3-10", "EN-R3-11", "EN-R3-12",
}
EXPECTED_R4_BOSSES = {
"BO-R4-01": {"frames": 4, "cell": (128, 96)},
"BO-R4-02": {"frames": 4, "cell": (128, 96)},
}
EXPECTED_R4_ACTORS = {
"EN-R4-01", "EN-R4-02", "EN-R4-03", "EN-R4-04", "EN-R4-05", "EN-R4-06",
"EN-R4-07", "EN-R4-08", "EN-R4-09", "EN-R4-10", "EN-R4-11", "EN-R4-12",
}
EXPECTED_TASKS = {
"task_01M05MHAPX586RQDE8FC5XZYP8",
"task_01M05MKQJ9YG7YZ1BZ5WMQFZYK",
"task_01M05MV77JF5X2VT93EYREED3E",
"task_01M05N7A7HQ13BVFNBFR0GXDQ1",
}
def sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def require(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def runtime_path(relative: str) -> Path:
return ROOT / "runtime" / relative
def verify_source(record: dict) -> None:
source = ROOT / record["source"]
require(source.is_file(), f"missing source: {source}")
require(sha256(source) == record["source_sha256"], f"source hash mismatch: {source}")
def verify_import(path: Path) -> None:
import_path = Path(str(path) + ".import")
require(import_path.is_file(), f"missing Godot import record: {import_path}")
payload = import_path.read_text(encoding="utf-8")
expected = f'source_file="res://{path.relative_to(ROOT / "runtime").as_posix()}"'
require(expected in payload, f"Godot import source mismatch: {import_path}")
require("mipmaps/generate=false" in payload, f"mipmap import must remain disabled: {import_path}")
def verify_provider_reports() -> None:
tasks = set()
total_cost = 0.0
for report_path in sorted((MEDIA_ROOT / "reports").glob("*.json")):
report = json.loads(report_path.read_text(encoding="utf-8"))
require(report.get("status") == "completed", f"incomplete report: {report_path}")
tasks.add(report["task_id"])
total_cost += float(report["cost"])
output = ROOT / report["output"]
require(output.is_file(), f"missing provider output: {output}")
require(output.stat().st_size == int(report["bytes"]), f"provider byte mismatch: {output}")
require(float(report["credits_cost"]) == 0.14, f"credit mismatch: {report_path}")
require(tasks == EXPECTED_TASKS, "provider task inventory mismatch")
require(abs(total_cost - 0.056) < 0.000001, "provider cost must be exactly USD 0.056")
def verify_cluster_pitch(image: Image.Image) -> None:
pixels = image.load()
for top in range(0, image.height - 1, 2):
for left in range(0, image.width - 1, 2):
block = {
pixels[left, top],
pixels[left + 1, top],
pixels[left, top + 1],
pixels[left + 1, top + 1],
}
require(len(block) == 1, f"non-integer environment cluster at {left},{top}")
def verify_environment(manifest: dict) -> int:
environment = manifest["environment"]
require(environment["region_id"] == "RG-03", "environment region changed")
require(environment["source_band_cuts"] == [0, 682, 1365, 2048], "source thirds changed")
require(environment["palette_size"] == 32, "environment palette contract changed")
require(environment["provider_task_id"] == "task_01M05MHAPX586RQDE8FC5XZYP8", "environment task changed")
verify_source(environment)
seen_sizes = set()
checked = 0
for layer_name in ("background", "midground", "foreground"):
records = environment["layers"][layer_name]
for size_key, record in records.items():
width, height = (int(value) for value in size_key.split("x"))
seen_sizes.add((width, height))
path = runtime_path(record["path"])
require(path.is_file(), f"missing environment layer: {path}")
require(sha256(path) == record["sha256"], f"environment hash mismatch: {path}")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == (width, height), f"environment size mismatch: {path}")
require(record["cluster_pitch"] == 2, f"cluster pitch mismatch: {path}")
require(int(record["colors"]) <= 32, f"palette exceeds 32 colors: {path}")
alphas = {pixel[3] for pixel in rgba.getdata()}
require(alphas <= {0, 255}, f"soft alpha found: {path}")
if layer_name == "background":
require(alphas == {255}, f"background must be opaque: {path}")
else:
require(alphas == {0, 255}, f"overlay alpha missing: {path}")
key_pixels = sum(
1
for red, green, blue, alpha in rgba.getdata()
if alpha and red >= 240 and blue >= 240 and green <= 24
)
require(key_pixels == 0, f"magenta residue found: {path}")
verify_cluster_pitch(rgba)
checked += 1
require(seen_sizes == EXPECTED_SIZES, "environment target-size inventory mismatch")
return checked
def component_areas(alpha: Image.Image) -> list[int]:
width, height = alpha.size
pixels = list(alpha.getdata())
visited = bytearray(width * height)
areas: list[int] = []
for start, value in enumerate(pixels):
if value == 0 or visited[start]:
continue
visited[start] = 1
queue: deque[int] = deque([start])
area = 0
while queue:
current = queue.popleft()
area += 1
x, y = current % width, current // width
for next_y in range(max(0, y - 1), min(height, y + 2)):
row_start = next_y * width
for next_x in range(max(0, x - 1), min(width, x + 2)):
neighbor = row_start + next_x
if pixels[neighbor] and not visited[neighbor]:
visited[neighbor] = 1
queue.append(neighbor)
areas.append(area)
return areas
def significant_component_count(alpha: Image.Image) -> int:
return sum(1 for area in component_areas(alpha) if area >= 10)
def verify_bosses(manifest: dict) -> int:
require(set(manifest["bosses"]) == set(EXPECTED_BOSSES) | set(EXPECTED_R4_BOSSES), "Boss identity inventory mismatch")
checked = 0
for identity_id, expected in EXPECTED_BOSSES.items():
boss = manifest["bosses"][identity_id]
verify_source(boss)
require(boss["provider_task_id"] == "task_01M05N7A7HQ13BVFNBFR0GXDQ1", f"Boss task mismatch: {identity_id}")
require(boss["state"] == "boss_action", f"Boss state mismatch: {identity_id}")
require(boss["fps"] == 8 and boss["loop"] is False, f"Boss timing mismatch: {identity_id}")
require(len(boss["frames"]) == expected["frames"], f"Boss frame count mismatch: {identity_id}")
atlas = runtime_path(boss["atlas"])
require(atlas.is_file(), f"Boss atlas missing: {identity_id}")
require(sha256(atlas) == boss["atlas_sha256"], f"Boss atlas mismatch: {identity_id}")
verify_import(atlas)
for index, frame in enumerate(boss["frames"]):
path = runtime_path(frame["path"])
require(path.is_file(), f"Boss frame missing: {path}")
require(sha256(path) == frame["sha256"], f"Boss frame hash mismatch: {path}")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == expected["cell"], f"Boss cell mismatch: {path}")
alpha = rgba.getchannel("A")
bounds = alpha.getbbox()
require(bounds is not None, f"empty Boss frame: {path}")
left, top, right, bottom = bounds
require(
left > 0
and top > 0
and right < expected["cell"][0]
and bottom < expected["cell"][1],
f"Boss touches edge: {path}",
)
require(set(alpha.getdata()) <= {0, 255}, f"soft Boss alpha found: {path}")
key_pixels = sum(
1
for red, green, blue, value in rgba.getdata()
if value and red >= 240 and blue >= 240 and green <= 24
)
require(key_pixels == 0, f"magenta residue found: {path}")
require(significant_component_count(alpha) <= 8, f"Boss frame retains detached debris: {path}")
checked += 1
return checked
def verify_actors(manifest: dict) -> int:
require(set(manifest["actors"]) == EXPECTED_ACTORS | EXPECTED_R4_ACTORS, "FG-4 actor identity inventory mismatch")
checked = 0
for identity_id in sorted(EXPECTED_ACTORS):
actor = manifest["actors"][identity_id]
verify_source(actor)
require(actor["provider_task_id"] == "task_01M05N7A7HQ13BVFNBFR0GXDQ1", f"actor task mismatch: {identity_id}")
require(actor["logical_grid"] == [36, 36], f"actor logical grid mismatch: {identity_id}")
require(actor["palette_size"] == 16, f"actor palette contract mismatch: {identity_id}")
require(int(actor["colors"]) <= 16, f"actor palette exceeds 16 colors: {identity_id}")
require(len(actor["frames"]) == 1, f"actor anchor count mismatch: {identity_id}")
atlas = runtime_path(actor["atlas"])
require(atlas.is_file(), f"actor atlas missing: {identity_id}")
require(sha256(atlas) == actor["atlas_sha256"], f"actor atlas mismatch: {identity_id}")
verify_import(atlas)
frame = actor["frames"][0]
path = runtime_path(frame["path"])
require(path.is_file(), f"actor frame missing: {path}")
require(sha256(path) == frame["sha256"], f"actor frame mismatch: {path}")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == (72, 72), f"actor cell mismatch: {path}")
alpha = rgba.getchannel("A")
bounds = alpha.getbbox()
require(bounds is not None, f"empty actor frame: {path}")
left, top, right, bottom = bounds
require(left > 0 and top > 0 and right < 72 and bottom < 72, f"actor touches edge: {path}")
require(set(alpha.getdata()) <= {0, 255}, f"soft actor alpha found: {path}")
require(min(component_areas(alpha)) >= 16, f"tiny detached actor debris found: {path}")
key_pixels = sum(
1
for red, green, blue, value in rgba.getdata()
if value and red >= 240 and blue >= 240 and green <= 24
)
require(key_pixels == 0, f"magenta residue found: {path}")
verify_cluster_pitch(rgba)
checked += 1
return checked
def verify_rg04_environment(manifest: dict) -> int:
environments = manifest.get("environments", {})
require(set(environments) == {"RG-03", "RG-04"}, "FG-4 environment map mismatch")
require(environments["RG-03"] == manifest["environment"], "legacy RG-03 environment changed")
environment = environments["RG-04"]
require(environment.get("region_id") == "RG-04", "Quillroot region mismatch")
require(environment.get("provider_task_id") is None, "Quillroot must remain local original")
require(int(environment.get("palette_size", 0)) <= 18, "Quillroot palette exceeds 18 colors")
verify_source(environment)
seen_sizes = set()
checked = 0
for layer_name in ("background", "midground", "foreground"):
records = environment["layers"][layer_name]
for size_key, record in records.items():
width, height = (int(value) for value in size_key.split("x"))
seen_sizes.add((width, height))
path = runtime_path(record["path"])
require(path.is_file() and sha256(path) == record["sha256"], f"Quillroot layer mismatch: {path}")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == (width, height), f"Quillroot layer size mismatch: {path}")
require(int(record["colors"]) <= 18, f"Quillroot layer palette mismatch: {path}")
alphas = set(rgba.getchannel("A").getdata())
require(alphas <= {0, 255}, f"Quillroot soft alpha: {path}")
require(alphas == ({255} if layer_name == "background" else {0, 255}), f"Quillroot alpha contract: {path}")
verify_cluster_pitch(rgba)
checked += 1
require(seen_sizes == EXPECTED_SIZES, "Quillroot target-size inventory mismatch")
return checked
def verify_rg04_actors(manifest: dict) -> int:
checked = 0
for identity_id in sorted(EXPECTED_R4_ACTORS):
actor = manifest["actors"][identity_id]
verify_source(actor)
require(actor.get("provider_task_id") is None, f"{identity_id} must remain local original")
require(actor.get("logical_grid") == [36, 36], f"{identity_id} logical grid mismatch")
require(int(actor.get("colors", 0)) <= 18, f"{identity_id} palette exceeds 18")
require(len(actor.get("frames", [])) == 1, f"{identity_id} frame inventory mismatch")
for value in (actor["atlas"], actor["frames"][0]["path"]):
path = runtime_path(value)
require(path.is_file(), f"missing Quillroot actor media: {path}")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == (72, 72), f"Quillroot actor size mismatch: {path}")
require(set(rgba.getchannel("A").getdata()) <= {0, 255}, f"Quillroot actor soft alpha: {path}")
verify_cluster_pitch(rgba)
checked += 1
return checked
def verify_rg04_bosses(manifest: dict) -> int:
checked = 0
for identity_id, expected in EXPECTED_R4_BOSSES.items():
boss = manifest["bosses"][identity_id]
verify_source(boss)
require(boss.get("provider_task_id") is None, f"{identity_id} must remain local original")
require(boss.get("fps") == 8 and boss.get("loop") is False, f"{identity_id} timing mismatch")
require(len(boss.get("frames", [])) == expected["frames"], f"{identity_id} frame count mismatch")
atlas = runtime_path(boss["atlas"])
require(atlas.is_file() and sha256(atlas) == boss["atlas_sha256"], f"{identity_id} atlas mismatch")
verify_import(atlas)
for frame in boss["frames"]:
path = runtime_path(frame["path"])
require(path.is_file() and sha256(path) == frame["sha256"], f"{identity_id} frame mismatch")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == expected["cell"], f"{identity_id} cell mismatch")
require(rgba.getchannel("A").getbbox() is not None, f"{identity_id} empty frame")
require(set(rgba.getchannel("A").getdata()) <= {0, 255}, f"{identity_id} soft alpha")
checked += 1
return checked
def verify_spark(manifest: dict) -> int:
require(set(manifest.get("props", {})) == {"spark"}, "FG-4 prop inventory mismatch")
spark = manifest["props"]["spark"]
verify_source(spark)
require(spark["provider_task_id"] == "task_01M05MV77JF5X2VT93EYREED3E", "spark task mismatch")
require(spark["logical_grid"] == [18, 18], "spark logical grid mismatch")
require(spark["palette_size"] == 12 and int(spark["colors"]) <= 12, "spark palette mismatch")
require(len(spark["frames"]) == 1, "one source-bound spark is required")
frame = spark["frames"][0]
path = runtime_path(frame["path"])
require(path.is_file() and sha256(path) == frame["sha256"], "spark frame mismatch")
verify_import(path)
with Image.open(path) as opened:
rgba = opened.convert("RGBA")
require(rgba.size == (36, 36), "spark frame size mismatch")
alpha = rgba.getchannel("A")
require(alpha.getbbox() is not None, "spark frame is empty")
require(set(alpha.getdata()) <= {0, 255}, "soft spark alpha found")
require(min(component_areas(alpha)) >= 8, "tiny detached spark debris found")
verify_cluster_pitch(rgba)
return 1
def main() -> int:
verify_provider_reports()
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
require(manifest.get("kind") == "abg-fg4-runtime-media", "runtime manifest kind mismatch")
require(manifest.get("filter") == "nearest", "runtime filter must be nearest")
require(manifest.get("integer_placement") is True, "integer placement must be true")
environment_count = verify_environment(manifest)
actor_count = verify_actors(manifest)
boss_count = verify_bosses(manifest)
prop_count = verify_spark(manifest)
rg04_environment_count = verify_rg04_environment(manifest)
rg04_actor_count = verify_rg04_actors(manifest)
rg04_boss_count = verify_rg04_bosses(manifest)
print(
"ABG_FG4_MEDIA_OK "
f"tasks=4 cost_usd=0.056 environment={environment_count} actors={actor_count} boss_frames={boss_count} props={prop_count} "
f"rg04_environment={rg04_environment_count} rg04_actors={rg04_actor_count} rg04_boss_frames={rg04_boss_count} rg04_paid_calls=0 "
"spark_slots_runtime=8 palette_max=32 cluster_pitch=2 edge=0 chroma=0 alpha=binary imports=nearest_runtime"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())