245 lines
10 KiB
Python
245 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate FG-3 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/fg3/media"
|
|
MANIFEST_PATH = ROOT / "runtime/assets/fg3/asset-manifest.json"
|
|
EXPECTED_SIZES = {(380, 180), (422, 195), (320, 180)}
|
|
EXPECTED_BOSSES = {
|
|
"BO-R2-01": {"frames": 4, "cell": (128, 96)},
|
|
"BO-R2-02": {"frames": 4, "cell": (96, 96)},
|
|
}
|
|
EXPECTED_ACTORS = {
|
|
"PF-B09", "PF-B07",
|
|
"EN-R2-01", "EN-R2-02", "EN-R2-03", "EN-R2-04", "EN-R2-05", "EN-R2-06",
|
|
"EN-R2-07", "EN-R2-08", "EN-R2-09", "EN-R2-10", "EN-R2-11", "EN-R2-12",
|
|
}
|
|
EXPECTED_TASKS = {
|
|
"task_01M056E56CNT64A0M3VB617Z9R",
|
|
"task_01M056H19DE2EQDR3DS0YJQ7MA",
|
|
"task_01M056P0CCPYVFS2GXD91GK7KZ",
|
|
"task_01M058059WFB3F4G96SC9SDRWD",
|
|
}
|
|
|
|
|
|
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_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-02", "environment region changed")
|
|
require(environment["source_band_cuts"] == [0, 682, 1365, 2048], "source thirds changed")
|
|
require(environment["palette_size"] == 32, "environment palette contract changed")
|
|
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}")
|
|
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), "Boss identity inventory mismatch")
|
|
checked = 0
|
|
for identity_id, expected in EXPECTED_BOSSES.items():
|
|
boss = manifest["bosses"][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}")
|
|
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}")
|
|
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}")
|
|
if identity_id == "BO-R2-01" and index == 2:
|
|
require(
|
|
significant_component_count(alpha) <= 5,
|
|
"Sluiceback action frame retains detached streak/debris",
|
|
)
|
|
checked += 1
|
|
return checked
|
|
|
|
|
|
def verify_actors(manifest: dict) -> int:
|
|
require(set(manifest["actors"]) == EXPECTED_ACTORS, "FG-3 actor identity inventory mismatch")
|
|
checked = 0
|
|
for identity_id, actor in manifest["actors"].items():
|
|
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}")
|
|
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}")
|
|
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 main() -> int:
|
|
verify_provider_reports()
|
|
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
|
require(manifest.get("kind") == "abg-fg3-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)
|
|
print(
|
|
"ABG_FG3_MEDIA_OK "
|
|
f"tasks=4 cost_usd=0.056 environment={environment_count} actors={actor_count} boss_frames={boss_count} "
|
|
"palette_max=32 cluster_pitch=2 edge=0 chroma=0 alpha=binary"
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|