381 lines
18 KiB
Python
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())
|