diff --git a/docs/prototype/generation/phase6_battle_scale_04/BATCH_CONTRACT.md b/docs/prototype/generation/phase6_battle_scale_04/BATCH_CONTRACT.md index 770b843f..34f9264e 100644 --- a/docs/prototype/generation/phase6_battle_scale_04/BATCH_CONTRACT.md +++ b/docs/prototype/generation/phase6_battle_scale_04/BATCH_CONTRACT.md @@ -1,7 +1,7 @@ # Phase 6 Battle-Scale Motion Proof 04 Date: 2026-08-12 -Stage: `BATTLE_SCALE_CANDIDATE_READY` +Stage: `MOTION_CONTINUITY_REPAIR_OPEN` Motion authority: `../phase6_motion_proof_03/` Style authority: `../phase6_ui_direction_01/outputs/05_battle_ui_japanese_scale.png` @@ -61,3 +61,18 @@ canvas boundaries and output hashes. This proof can reach `processed` and `agent_visual_reviewed` only. Human visual, engine runtime, touch, audio, device and release gates remain open. + +## Independent Review R2 Failure + +Fixed review of `1304d52bef0b0cb0a43635590ad8c18e888ffa04` passed +counts, scale, bounds, salience, inventory and background gates, then failed at +the earliest motion-continuity boundary. The compositor used modulo indexing +for the enemy Riftblade's `loop:false` telegraph and reset frame 5 to frame 0 +at output transitions 005->006 and 011->012 instead of entering attack. + +Forward repair must read every cycle manifest's `loop` flag. Looping cycles may +retain phase-offset modulo selection. Non-looping allied actions use their +declared phase as a start delay and clamp at the terminal frame. The one +high-salience enemy must play telegraph 0-5 once, attack 0-5 once, then hold its +terminal attack frame. Accepted sprite sources, counts, positions, scale, +background, HUD and salience limits remain unchanged. diff --git a/docs/prototype/generation/phase6_battle_scale_04/INDEPENDENT_REVIEW_R2.md b/docs/prototype/generation/phase6_battle_scale_04/INDEPENDENT_REVIEW_R2.md new file mode 100644 index 00000000..1241057a --- /dev/null +++ b/docs/prototype/generation/phase6_battle_scale_04/INDEPENDENT_REVIEW_R2.md @@ -0,0 +1,27 @@ +# Phase 6 Battle-Scale Motion Proof 04 Review R2 + +Date: 2026-08-12 +Fixed revision: `1304d52bef0b0cb0a43635590ad8c18e888ffa04` +Verdict: `FAIL` + +## Earliest Failure + +The Riftblade telegraph manifest declares `loop: false` and ends in an +attack-ready crouch. The compositor nevertheless selected every state with +modulo indexing while leaving the high-salience enemy in telegraph for all 16 +frames. Output transitions 005->006 and 011->012 reset telegraph frame 5 to +frame 0 instead of transitioning into attack, creating a visible state-reset +seam introduced by composition. + +## Gates Passed Before Failure + +- immutable fixed archive and source inventories; +- exactly 10 allies, 12 ordinary enemies and one boss in every frame; +- contracted actor/boss heights and battle-safe bounds; +- exactly two allied and two enemy-side high-salience assignments per frame; +- H.264 2048x1152, 8 FPS, 16 frames and 2.0 seconds; +- background emptiness, faction separation and boss distinction. + +This is a product-proof failure, not a sprite-source failure or review harness +invalidity. The original candidate remains append-only. Repair is limited to +manifest-aware non-loop scheduling and fresh derived evidence. diff --git a/tools/build_battle_scale_motion_proof.py b/tools/build_battle_scale_motion_proof.py index 95b0f5e3..4c2044dc 100644 --- a/tools/build_battle_scale_motion_proof.py +++ b/tools/build_battle_scale_motion_proof.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +from functools import lru_cache import hashlib import json from dataclasses import dataclass @@ -50,6 +51,28 @@ def frame_files(state_dir: Path) -> list[Path]: return files +@lru_cache(maxsize=None) +def animation_contract(state_dir: Path) -> tuple[str, bool]: + manifest_path = state_dir.parent / "cycle-manifest.json" + if not manifest_path.is_file(): + raise SystemExit(f"missing cycle manifest: {manifest_path}") + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + frames = frame_files(state_dir) + if len(manifest.get("frames", [])) != len(frames): + raise SystemExit(f"cycle manifest frame mismatch: {manifest_path}") + return str(manifest["state"]), bool(manifest["loop"]) + + +def selected_frame(actor: Actor, tick: int) -> tuple[Path, str, bool, int]: + frames = frame_files(actor.state_dir) + state, loop = animation_contract(actor.state_dir) + if loop: + frame_index = (tick + actor.phase) % len(frames) + else: + frame_index = min(max(tick - actor.phase, 0), len(frames) - 1) + return frames[frame_index], state, loop, frame_index + + def fitted_sprite(path: Path, target_height: int) -> tuple[Image.Image, tuple[int, int, int, int]]: source = Image.open(path).convert("RGBA") alpha = source.getchannel("A") @@ -64,8 +87,7 @@ def fitted_sprite(path: Path, target_height: int) -> tuple[Image.Image, tuple[in def paste_actor(canvas: Image.Image, actor: Actor, tick: int) -> dict[str, object]: - frames = frame_files(actor.state_dir) - source = frames[(tick + actor.phase) % len(frames)] + source, state, loop, frame_index = selected_frame(actor, tick) sprite, source_bbox = fitted_sprite(source, actor.height) left = round(actor.x - sprite.width / 2) top = actor.bottom - sprite.height @@ -76,6 +98,10 @@ def paste_actor(canvas: Image.Image, actor: Actor, tick: int) -> dict[str, objec canvas.alpha_composite(sprite, (left, top)) return { "side": actor.side, + "state": state, + "loop": loop, + "animation_tick": tick, + "frame_index": frame_index, "source": str(source), "source_sha256": sha256(source), "source_alpha_bbox": list(source_bbox), @@ -200,7 +226,24 @@ def main() -> None: canvas = background.convert("RGBA") actor_reports: list[dict[str, object]] = [] for actor in sorted(enemies, key=lambda item: item.bottom): - actor_reports.append(paste_actor(canvas, actor, tick)) + if actor.high_salience: + state = "telegraph" if tick < 6 else "attack" + state_dir = repo / ( + "docs/prototype/generation/phase6_motion_proof_03/runs/" + f"enemy-riftblade/accepted/{state}/frames" + ) + actor = Actor( + actor.side, + state_dir, + actor.x, + actor.bottom, + actor.height, + 0, + actor.high_salience, + ) + actor_reports.append(paste_actor(canvas, actor, tick if tick < 6 else tick - 6)) + else: + actor_reports.append(paste_actor(canvas, actor, tick)) boss_state = "telegraph" if tick < 8 else "attack" boss_dir = repo / f"docs/prototype/generation/phase6_motion_proof_03/runs/boss-tempest-seraph/accepted/{boss_state}/frames" boss_actor = Actor("boss", boss_dir, boss_template.x, boss_template.bottom, boss_template.height, 0, True)