#!/usr/bin/env python3
"""Summarize original runtime input/update/tile projection evidence."""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
from pathlib import Path

from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
WEB_GAME = ROOT / "web" / "game.html"
RUNTIME_MATERIALIZERS_JSON = OUT / "save_selector_opcode20_runtime_materializers.json"

UPDATE_FUNCTION = 0x00411476
INPUT_STEP_CALL = 0x004114E2
OBJECT_CALLBACK_STEP_CALL = 0x004114E7
SCRIPT_TIMER_STEP_CALL = 0x004114EC
DRAW_PREP_CALL = 0x0041150D
INPUT_STEP_FUNCTION = 0x00422D74
OBJECT_CALLBACK_STEP_FUNCTION = 0x00435C04
SCRIPT_TIMER_STEP_FUNCTION = 0x00432FF0
SCRIPT_RUNNER_FUNCTION = 0x00402321
SCRIPT_HANDLER_TABLE = 0x00440538
SCRIPT_STOP_FLAG = 0x0055A1B8
DRAW_PREP_FUNCTION = 0x00424A2A
DRAW_RECT_HELPER_CALL = 0x00424B45
DRAW_RECT_HELPER_FUNCTION = 0x00424CD6
DRAW_RECT_BOUNDS_CALL = 0x00424D06
DRAW_RECT_BOUNDS_FUNCTION = 0x00416CE2
SPRITE_FRAME_SCRIPT_FUNCTION = 0x004044BD
SPRITE_FRAME_SCRIPT_OPCODE = 0x21
SPRITE_FRAME_POINTER_SCRIPT_FUNCTION = 0x0040448A
SPRITE_FRAME_POINTER_SCRIPT_OPCODE = 0x20
SPRITE_FRAME_STATE_TABLE_SCRIPT_FUNCTION = 0x0040408F
SPRITE_FRAME_STATE_TABLE_SCRIPT_OPCODE = 0x18
SPRITE_SURFACE_TABLE = 0x0055ABD8
ACTION_PACKER_CALL = 0x00422D91
ACTION_PACKER_FUNCTION = 0x0042F6D2
KEYBOARD_STATE = 0x0055B868
CURRENT_INPUT_MASK = 0x0059E310
PRESSED_EDGE_MASK = 0x0059E312
PREVIOUS_INPUT_MASK = 0x0055B2AC
PRIMARY_KEY_TABLE = 0x00526D58
CAMERA_TILE_X = 0x004576DC
CAMERA_TILE_Y = 0x004576DE
ACTIVE_DRAW_LIST = 0x0055A1C0
WEB_TILE_SIZE = 16
ACTOR_CONTROLLER_FUNCTION = 0x0043022D
ACTOR_COLLISION_HELPER_CALL = 0x0043047B
ACTOR_COLLISION_HELPER_FUNCTION = 0x004319F8
ACTIVE_ACTOR_COUNT = 0x004576E8
ACTIVE_ACTOR_SLOT_TABLE = 0x00574538
ACTIVE_ACTOR_POINTER_TABLE = 0x0059DD70
ACTOR_HISTORY_INDEX_TABLE = 0x00574540
ACTOR_SEED_X_TABLE = 0x00574550
ACTOR_SEED_Y_TABLE = 0x00574552
ACTOR_HISTORY_DIRECTION_TABLE = 0x00574554
ACTOR_HISTORY_SLOT_COUNT = 7
ACTOR_HISTORY_ACTOR_SLOTS = 3
ACTOR_HISTORY_INITIAL_CURSORS = [0, 4, 1]
ACTOR_HISTORY_STRIDE_WORDS = 3
ACTOR_DIRECTION_LATCH = 0x00574533
MAP_WIDTH_TILES = 0x00595ADA
MAP_HEIGHT_TILES = 0x00595ADC
MAP_TILE_WORD_GRID = 0x00595AF0
MAP_COLLISION_FLAGS = 0x0058D7D0
MAP_COLLISION_EDGE_FLAGS = 0x0058D7CE
MAP_LOADER_FUNCTION = 0x0042449C
MAP_PAYLOAD_LOAD_CALL = 0x004244CF
MAP_LAYER0_COPY_CALL = 0x00424547
MAP_LAYER1_COLLISION_COPY_CALL = 0x004245A4
MAP_LAYER_COPY_FUNCTION = 0x00436E60
SCRIPT_TILE_STEP_HANDLER = 0x00409658
COLLISION_OBJECT_LIST_HEAD = 0x00574100
COLLISION_OBJECT_LIST_SENTINEL = 0x005741EC
PARTY_CHARACTER_DESCRIPTOR_TABLE = 0x00442D95
PARTY_CHARACTER_DESCRIPTOR_TABLE_ROW_COUNT = 12

PARTY_CHARACTER_FRAME_DESCRIPTORS = [
    {
        "name": "Ataho",
        "resource": "cara_at1.cns",
        "descriptorVa": 0x004F867C,
        "surfaceIndex": 0x11,
        "frameScriptVa": 0x004F8EF8,
        "stateTableVa": 0x004F8E68,
    },
    {
        "name": "Rinshan",
        "resource": "cara_rs1.cns",
        "descriptorVa": 0x004E75A0,
        "surfaceIndex": 0x14,
        "frameScriptVa": 0x004E7978,
        "stateTableVa": 0x004E78EC,
    },
    {
        "name": "Smashu",
        "resource": "cara_sm1.cns",
        "descriptorVa": 0x00546004,
        "surfaceIndex": 0x17,
        "frameScriptVa": 0x00546418,
        "stateTableVa": 0x0054638C,
    },
]

ACTOR_ANIMATION_STATE_LABELS = {
    0: "state0/bootstrap duplicate",
    1: "idle down",
    2: "idle up",
    3: "idle left",
    4: "idle right",
    5: "moving down",
    6: "moving up",
    7: "moving left",
    8: "moving right",
}


COLLISION_DIRECTION_CASES = [
    {
        "direction": "down",
        "inputLatch": 0x05,
        "caseVa": 0x00431A20,
        "edgeBlockedLatch": 0x01,
        "edgeCheckVa": 0x00431A48,
        "flagTestVa": 0x00431ABA,
        "flagBit": 0x02,
        "flagGrid": MAP_COLLISION_FLAGS,
        "scanShape": "horizontal footprint width at bottom edge row",
        "boundaryRule": "mapHeight - footprintHeight == tileY",
    },
    {
        "direction": "up",
        "inputLatch": 0x06,
        "caseVa": 0x00431AD3,
        "edgeBlockedLatch": 0x02,
        "edgeCheckVa": 0x00431AE6,
        "flagTestVa": 0x00431B58,
        "flagBit": 0x01,
        "flagGrid": MAP_COLLISION_FLAGS,
        "scanShape": "horizontal footprint width at top/block row",
        "boundaryRule": "tileY == 0",
    },
    {
        "direction": "left",
        "inputLatch": 0x07,
        "caseVa": 0x00431B71,
        "edgeBlockedLatch": 0x03,
        "edgeCheckVa": 0x00431B84,
        "flagTestVa": 0x00431BD0,
        "flagBit": 0x04,
        "flagGrid": MAP_COLLISION_FLAGS,
        "scanShape": "left footprint edge sample at bottom row",
        "boundaryRule": "tileX == 0",
    },
    {
        "direction": "right",
        "inputLatch": 0x08,
        "caseVa": 0x00431BE4,
        "edgeBlockedLatch": 0x04,
        "edgeCheckVa": 0x00431C0C,
        "flagTestVa": 0x00431C65,
        "flagBit": 0x08,
        "flagGrid": MAP_COLLISION_EDGE_FLAGS,
        "scanShape": "right footprint edge sample through edge-shifted collision table",
        "boundaryRule": "mapWidth - footprintWidth == tileX",
    },
]


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value:08x}"


def load_json(path: Path, fallback):
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


def read_at(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hex32(va)} is outside raw sections")
    return exe[offset: offset + size]


def read_u8(exe: bytes, sections: list[dict], va: int) -> int:
    return read_at(exe, sections, va, 1)[0]


def read_u16(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<H", read_at(exe, sections, va, 2))[0]


def read_u32(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<I", read_at(exe, sections, va, 4))[0]


def rel32_target(exe: bytes, sections: list[dict], va: int) -> int:
    opcode = read_u8(exe, sections, va)
    if opcode != 0xE8:
        raise ValueError(f"expected near call at {hex32(va)}")
    rel = struct.unpack("<i", read_at(exe, sections, va + 1, 4))[0]
    return va + 5 + rel


def direct_call(exe: bytes, sections: list[dict], va: int, label: str) -> dict:
    target = rel32_target(exe, sections, va)
    return {
        "label": label,
        "callVaHex": hex32(va),
        "targetVaHex": hex32(target),
    }


def bytes_hex(exe: bytes, sections: list[dict], va: int, size: int) -> str:
    return read_at(exe, sections, va, size).hex(" ")


def count_bytes(exe: bytes, sections: list[dict], start_va: int, end_va: int, needle: bytes) -> int:
    start = va_to_offset(sections, start_va)
    end = va_to_offset(sections, end_va)
    if start is None or end is None:
        raise ValueError(f"VA range {hex32(start_va)}..{hex32(end_va)} is outside raw sections")
    return exe[start:end].count(needle)


def count_any_bytes(exe: bytes, sections: list[dict], start_va: int, end_va: int, needles: list[bytes]) -> int:
    return sum(count_bytes(exe, sections, start_va, end_va, needle) for needle in needles)


def snippet_matches(exe: bytes, sections: list[dict], va: int, expected_hex: str) -> bool:
    expected = bytes.fromhex(expected_hex)
    return read_at(exe, sections, va, len(expected)) == expected


def js_const_int(source: str, name: str) -> int | None:
    match = re.search(rf"\bconst\s+{re.escape(name)}\s*=\s*(\d+)\s*;", source)
    return int(match.group(1)) if match else None


def js_const_int_array(source: str, name: str) -> list[int] | None:
    match = re.search(rf"\bconst\s+{re.escape(name)}\s*=\s*\[([^\]]*)\]\s*;", source)
    if not match:
        return None
    values = []
    for item in match.group(1).split(","):
        item = item.strip()
        if not item:
            continue
        if not re.fullmatch(r"\d+", item):
            return None
        values.append(int(item))
    return values


def web_alignment(web_path: Path = WEB_GAME) -> dict:
    source = web_path.read_text(encoding="utf-8")
    original_frame_ms = js_const_int(source, "ORIGINAL_FRAME_MS")
    walk_frames = js_const_int(source, "PLAYER_WALK_FRAMES")
    idle_frame = js_const_int(source, "PLAYER_IDLE_FRAME")
    first_walk_frame = js_const_int(source, "PLAYER_FIRST_WALK_FRAME")
    walk_cadence_commands = js_const_int(source, "PLAYER_ORIGINAL_WALK_CADENCE_COMMANDS")
    frame_width = js_const_int(source, "PLAYER_FRAME_WIDTH")
    frame_height = js_const_int(source, "PLAYER_FRAME_HEIGHT")
    party_trail_ring_slots = js_const_int(source, "PARTY_TRAIL_RING_SLOTS")
    party_trail_actor_slots = js_const_int(source, "PARTY_TRAIL_ACTOR_SLOTS")
    party_trail_initial_cursors = js_const_int_array(source, "PARTY_TRAIL_INITIAL_CURSORS")
    tile_step_expr = "ORIGINAL_FRAME_MS"
    default_step = original_frame_ms if original_frame_ms is not None and "DEFAULT_TILE_STEP_MS = ORIGINAL_FRAME_MS" in source else None
    clamp_match = re.search(r"clamp\(requestedMoveMs,\s*(\d+),\s*(\d+)\)", source)
    if clamp_match:
        clamp_min = int(clamp_match.group(1))
        clamp_max = int(clamp_match.group(2))
    else:
        symbolic_clamp_match = re.search(r"clamp\(requestedMoveMs,\s*ORIGINAL_FRAME_MS,\s*(\d+)\)", source)
        clamp_min = original_frame_ms if symbolic_clamp_match else None
        clamp_max = int(symbolic_clamp_match.group(1)) if symbolic_clamp_match else None
    move_ms_clamp = {
        "min": clamp_min,
        "max": clamp_max,
    }
    default_collision_match = re.search(r'\bconst\s+DEFAULT_COLLISION_MODE\s*=\s*"([^"]+)"\s*;', source)
    if not default_collision_match:
        default_collision_match = re.search(r':\s*"([^"]+)";\s*const requestedVisualMode', source)
    frame_quantized = all(snippet in source for snippet in [
        "const frameStep = Math.min(PLAYER_WALK_FRAMES - 1, Math.floor(t * PLAYER_WALK_FRAMES));",
        "const tileT = frameStep / (PLAYER_WALK_FRAMES - 1);",
        "player.frame = walkingFrame(frameStep);",
    ])
    source_rect_selector_mapping = all(snippet in source for snippet in [
        "const PLAYER_ORIGINAL_WALK_CADENCE_COMMANDS = 8;",
        "const PLAYER_ORIGINAL_TILE_STEP_COMMANDS = DEFAULT_TILE_STEP_MS / ORIGINAL_FRAME_MS;",
        "const PLAYER_ORIGINAL_IDLE_SELECTORS = {",
        "const PLAYER_ORIGINAL_WALK_SCRIPT_SELECTORS = {",
        "0: [4, 5, 4, 0, 6, 7, 6, 0],",
        "1: [12, 13, 12, 2, 14, 15, 14, 2],",
        "2: [16, 17, 16, 3, 18, 19, 18, 3],",
        "3: [8, 9, 8, 1, 10, 11, 10, 1],",
        "const PLAYER_ORIGINAL_SOURCE_RECTS = {",
        "function originalPartyFrameSelector(dir, frame = 0, moving = false, scriptPhase = null)",
        "return sequence[phase] ?? idleSelector;",
        "return PLAYER_ORIGINAL_SOURCE_RECTS[selector] || PLAYER_ORIGINAL_SOURCE_RECTS[0];",
    ])
    source_rect_selector_cadence = all(snippet in source for snippet in [
        "walkScriptPhase: 0,",
        "function wrapWalkScriptPhase(value)",
        "const scriptPhaseStep = Math.min(",
        "Math.floor(step.elapsed / ORIGINAL_FRAME_SECONDS),",
        "player.walkScriptPhase = wrapWalkScriptPhase(step.walkScriptStartPhase + scriptPhaseStep);",
        "step.walkScriptStartPhase + Math.round(step.duration / ORIGINAL_FRAME_SECONDS),",
        "walkScriptStartPhase: player.walkScriptPhase,",
    ])
    tile_chaining = all(snippet in source for snippet in [
        "if (player.step) {",
        "const [dx, dy] = activeMovementVector();",
        "player.step = {",
        "duration: TILE_STEP_SECONDS",
    ])
    wall_slide = all(snippet in source for snippet in [
        "const wallSlidePreference = { x: 1, y: 1 };",
        "function chooseMovementStep(tile, dx, dy) {",
        "wallSlidePreference.x = -preferred;",
        "wallSlidePreference.y = -preferred;",
    ])
    wall_slide_actual_direction = all(snippet in source for snippet in [
        "function directionForVector(dx, dy, fallback = player.dir)",
        "const inputDir = directionForVector(dx, dy);",
        "const movementDir = directionForVector(movement.dx, movement.dy, inputDir);",
        "player.dir = movementDir;",
        "movementAction: movementActionForVector(movement.dx, movement.dy) ?? lastMovementAction,",
    ])
    wall_slide_diagonal_followup = all(snippet in source for snippet in [
        "let wallSlideFollowupAction = null;",
        "let activeMovementFromWallSlideFollowup = false;",
        "function chooseMovementStepForActiveInput(tile, dx, dy)",
        "function updateWallSlideFollowupAction(inputAction, actualAction)",
        "const alternateActions = heldKeyboardMovementActions(heldMask)",
    ])
    camera_deadzone = all(snippet in source for snippet in [
        "const CAMERA_DEADZONE_TILES_X = 6;",
        "const CAMERA_DEADZONE_TILES_Y = 5;",
        "function updateCamera({ force = false } = {})",
        "const deadzoneWidth = Math.min(canvas.width, CAMERA_DEADZONE_TILES_X * map.tileSize);",
        "const deadzoneHeight = Math.min(PLAYFIELD_HEIGHT, CAMERA_DEADZONE_TILES_Y * map.tileSize);",
    ])
    deferred_map_query_sync = all(snippet in source for snippet in [
        "const MAP_QUERY_SYNC_THROTTLE_MS = 240;",
        "let pendingMapQuerySync = false;",
        "function requestMapQuerySync()",
        "function flushPendingMapQuerySync({ force = false } = {})",
        "requestMapQuerySync();",
        "flushPendingMapQuerySync({ force: true });",
    ])
    original_layer1_flag_collision = all(snippet in source for snippet in [
        '"originalLayer1Flags"',
        "const ORIGINAL_LAYER1_DIRECTION_BLOCK_BITS = {",
        "function originalLayer1FlagPasses(tileX, tileY, dx = 0, dy = 0)",
        "function canMoveToTarget(x, y, dx, dy)",
        'collisionMode === "originalLayer1Flags"',
    ])
    actor_overlap_collision = all(snippet in source for snippet in [
        'const actorCollisionEnabled = query.get("actorCollision") === "1";',
        'query.has("actorCollision")',
        "function actorFootprintKeys(x, y)",
        "function partyCollisionActors()",
        "function activeActorCollisionBlocks(x, y)",
        "return tileFlagsPass && !activeActorCollisionBlocks(x, y) && !activeEventObjectCollisionBlocks(x, y);",
        "actorCollision=${actorCollisionEnabled}",
    ])
    party_trail_history = all(snippet in source for snippet in [
        "const PARTY_TRAIL_RING_SLOTS = 7;",
        "let partyTrail = Array(PARTY_TRAIL_RING_SLOTS).fill(null);",
        "let partyFollowerSteps = [];",
        "function recordPartyTrailPosition(target = player, detail = {})",
        "partyTrail[partyTrailCursors[0]] = partyTrailEntryForMovement(target, detail);",
        "recordPartyTrailPosition(movement.target, {",
        "function partyTrailEntryForActor(index)",
        "return partyTrail[cursor] || partyFallbackPosition(index);",
        "function interpolatedPartyActor(member, index)",
    ])
    party_trail_cursor_model = all(snippet in source for snippet in [
        "const PARTY_TRAIL_ACTOR_SLOTS = 3;",
        "const PARTY_TRAIL_INITIAL_CURSORS = [0, 4, 1];",
        "let partyTrailCursors = [...PARTY_TRAIL_INITIAL_CURSORS];",
        "function resetPartyTrail()",
        "function normalizePartyTrailRing()",
        "function advancePartyTrailCursors()",
        "partyTrailCursors = partyTrailCursors.map((cursor) => {",
        "return next >= PARTY_TRAIL_RING_SLOTS ? 0 : next;",
        "const cursor = partyTrailCursors[index + 1] ?? 0;",
    ])
    browser_interpolation_overlay = all(snippet in source for snippet in [
        "const interpolationOffset = {",
        "const interpolatedProjectedScreen = {",
        "x: adjustedProjectedScreen.x + interpolationOffset.x,",
        "y: adjustedProjectedScreen.y + interpolationOffset.y,",
        "fixedPoint: {",
        "x: projectedScreen.x * ORIGINAL_DRAW_PROJECTION_FIXED_SCALE,",
        "y: projectedScreen.y * ORIGINAL_DRAW_PROJECTION_FIXED_SCALE,",
    ])
    transition_checks = all(snippet in source for snippet in [
        "checkMapExitTrialTransition(dx, dy)",
        "checkConfirmedTransitionAttempt(dx, dy)",
        "const movement = chooseMovementStep(tile, dx, dy);",
    ])
    default_collision_mode = default_collision_match.group(1) if default_collision_match else None
    return {
        "source": str(web_path),
        "originalFrameMs": original_frame_ms,
        "defaultTileStepMsExpression": tile_step_expr if "DEFAULT_TILE_STEP_MS = ORIGINAL_FRAME_MS" in source else None,
        "defaultTileStepMs": default_step,
        "moveMsClamp": move_ms_clamp,
        "walkFramesPerDirection": walk_frames,
        "idleFrame": idle_frame,
        "firstWalkFrame": first_walk_frame,
        "walkCadenceCommandCount": walk_cadence_commands,
        "frameWidth": frame_width,
        "frameHeight": frame_height,
        "frameQuantizedInterpolation": frame_quantized,
        "sourceRectSelectorMapping": source_rect_selector_mapping,
        "sourceRectSelectorCadence": source_rect_selector_cadence,
        "sourceRectSelectorMappingModel": (
            "web character source rects use original party actor +0x28 frame selector indices for idle states and the full 8-command timer=1 walking selector cadence"
            if source_rect_selector_mapping and source_rect_selector_cadence else
            "web character source rects use original party actor +0x28 frame selector indices for idle states and walking script phases"
            if source_rect_selector_mapping else None
        ),
        "heldInputTileChaining": tile_chaining,
        "wallSlideFallbackPresent": wall_slide,
        "wallSlideActualDirection": wall_slide_actual_direction,
        "wallSlideDiagonalFollowup": wall_slide_diagonal_followup,
        "cameraDeadzonePresent": camera_deadzone,
        "cameraDeadzoneTiles": {"x": 6, "y": 5} if camera_deadzone else None,
        "deferredMapQuerySync": deferred_map_query_sync,
        "originalLayer1FlagCollisionModePresent": original_layer1_flag_collision,
        "actorOverlapCollisionModePresent": actor_overlap_collision,
        "actorOverlapCollisionDefaultEnabled": False if actor_overlap_collision else None,
        "actorOverlapCollisionModeQuery": "actorCollision=1 enables review-only party blocking" if actor_overlap_collision else None,
        "partyTrailHistoryPresent": party_trail_history,
        "partyTrailHistoryModel": "7-slot move-start tile/direction cursor ring plus per-follower segment interpolation"
        if party_trail_history else None,
        "partyTrailRingSlotCount": party_trail_ring_slots,
        "partyTrailActorSlotCount": party_trail_actor_slots,
        "partyTrailInitialCursors": party_trail_initial_cursors,
        "partyTrailCursorModelPresent": party_trail_cursor_model,
        "partyTrailInitialCursorsMatchOriginal": party_trail_initial_cursors == ACTOR_HISTORY_INITIAL_CURSORS,
        "partyTrailRecordsAcceptedMoveTarget": "recordPartyTrailPosition(movement.target, {" in source,
        "partyTrailRingBounded": (
            party_trail_history is True and party_trail_ring_slots == ACTOR_HISTORY_SLOT_COUNT
        ),
        "browserInterpolationOverlayDetected": browser_interpolation_overlay,
        "browserInterpolationFixedPointPreserved": browser_interpolation_overlay,
        "transitionChecksBeforeMovement": transition_checks,
        "defaultCollisionMode": default_collision_mode,
        "defaultCollisionModeSource": "DEFAULT_COLLISION_MODE" if "const DEFAULT_COLLISION_MODE" in source else "inline fallback",
        "collisionFallbackClass": "local tile class + layer0 fallback"
        if "collision: local tile class + layer0 fallback" in source else None,
    }


def actor_motion_summary(exe: bytes, sections: list[dict]) -> dict:
    controller_range = (ACTOR_CONTROLLER_FUNCTION, 0x004319F3)
    helper_range = (ACTOR_COLLISION_HELPER_FUNCTION, 0x00431D00)
    script_range = (SCRIPT_TILE_STEP_HANDLER, 0x004098B5)
    controller_counts = {
        "currentInputMaskRefCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("10 e3 59 00")),
        "objectTileXFieldRefCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("e8 00 00 00")),
        "objectTileYFieldRefCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("ea 00 00 00")),
        "objectTileXSeedWriteCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 89 81 e8 00 00 00")),
        "objectTileYSeedWriteCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 89 81 ea 00 00 00")),
        "objectTileXIncrementCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 ff 80 e8 00 00 00")),
        "objectTileXDecrementCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 ff 88 e8 00 00 00")),
        "objectTileYIncrementCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 ff 80 ea 00 00 00")),
        "objectTileYDecrementCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 ff 88 ea 00 00 00")),
        "historyIndexIncrementCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("fe 80 40 45 57 00")),
        "historyIndexResetCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("c6 80 40 45 57 00 00")),
        "historyTileXStoreCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 89 04 4d 50 45 57 00")),
        "historyTileYStoreCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 89 04 4d 52 45 57 00")),
        "historyDirectionStoreCount": count_bytes(exe, sections, *controller_range, bytes.fromhex("66 89 04 4d 54 45 57 00")),
    }
    helper_counts = {
        "collisionFlagTableReadCount": (
            count_bytes(exe, sections, *helper_range, bytes.fromhex("66 8b 04 4d d0 d7 58 00"))
            + count_bytes(exe, sections, *helper_range, bytes.fromhex("66 8b 04 4d ce d7 58 00"))
        ),
        "mapWidthRefCount": count_bytes(exe, sections, *helper_range, bytes.fromhex("da 5a 59 00")),
        "mapHeightRefCount": count_bytes(exe, sections, *helper_range, bytes.fromhex("dc 5a 59 00")),
        "objectTileXFieldRefCount": count_bytes(exe, sections, *helper_range, bytes.fromhex("e8 00 00 00")),
        "objectTileYFieldRefCount": count_bytes(exe, sections, *helper_range, bytes.fromhex("ea 00 00 00")),
    }
    script_counts = {
        "objectTileXIncrementCount": count_bytes(exe, sections, *script_range, bytes.fromhex("66 ff 80 e8 00 00 00")),
        "objectTileXDecrementCount": count_bytes(exe, sections, *script_range, bytes.fromhex("66 ff 88 e8 00 00 00")),
        "objectTileYIncrementCount": count_bytes(exe, sections, *script_range, bytes.fromhex("66 ff 80 ea 00 00 00")),
        "objectTileYDecrementCount": count_bytes(exe, sections, *script_range, bytes.fromhex("66 ff 88 ea 00 00 00")),
        "objectTileXFieldRefCount": count_bytes(exe, sections, *script_range, bytes.fromhex("e8 00 00 00")),
        "objectTileYFieldRefCount": count_bytes(exe, sections, *script_range, bytes.fromhex("ea 00 00 00")),
    }
    verification = {
        "actorControllerPrologueMatchesExpected": snippet_matches(exe, sections, ACTOR_CONTROLLER_FUNCTION, "55 8b ec 83 ec 34"),
        "activeActorGateReadMatchesExpected": snippet_matches(exe, sections, 0x00430238, "a0 e8 76 45 00"),
        "activeActorPointerLoadMatchesExpected": snippet_matches(exe, sections, 0x00430284, "8b 04 8d 70 dd 59 00"),
        "actorTileSeedWritesMatchExpected": (
            snippet_matches(exe, sections, 0x00430367, "66 89 81 e8 00 00 00")
            and snippet_matches(exe, sections, 0x00430383, "66 89 81 ea 00 00 00")
        ),
        "inputMaskConflictFilterMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004303B5, "66 a3 10 e3 59 00"),
                (0x004303C6, "66 a3 10 e3 59 00"),
                (0x004303F7, "66 a3 10 e3 59 00"),
                (0x00430408, "66 a3 10 e3 59 00"),
            ]
        ),
        "directionLatchStoresMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x0043041E, "c6 05 33 45 57 00 05"),
                (0x00430435, "c6 05 33 45 57 00 06"),
                (0x0043044C, "c6 05 33 45 57 00 07"),
                (0x00430463, "c6 05 33 45 57 00 08"),
            ]
        ),
        "collisionHelperCallMatchesExpected": rel32_target(exe, sections, ACTOR_COLLISION_HELPER_CALL) == ACTOR_COLLISION_HELPER_FUNCTION,
        "collisionHelperPrologueMatchesExpected": snippet_matches(exe, sections, ACTOR_COLLISION_HELPER_FUNCTION, "55 8b ec 83 ec 14"),
        "collisionFlagTestsMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00431ABA, "a8 02"),
                (0x00431B58, "a8 01"),
                (0x00431BD0, "a8 04"),
                (0x00431C65, "a8 08"),
            ]
        ),
        "collisionDirectionCasesMatchExpected": all(
            snippet_matches(exe, sections, row["caseVa"], f"c6 05 33 45 57 00 {row['inputLatch']:02x}")
            and snippet_matches(exe, sections, row["flagTestVa"], f"a8 {row['flagBit']:02x}")
            for row in COLLISION_DIRECTION_CASES
        ),
        "collisionObjectListWalkMatchesExpected": (
            snippet_matches(exe, sections, 0x00431CA6, "a1 00 41 57 00")
            and snippet_matches(exe, sections, 0x00431CBC, "b8 40 3b 57 00")
            and snippet_matches(exe, sections, 0x00431CC1, "05 ac 06 00 00")
            and snippet_matches(exe, sections, 0x00431CCF, "8b 45 fc")
            and snippet_matches(exe, sections, 0x00431DAE, "c6 05 33 45 57 00 00")
            and snippet_matches(exe, sections, 0x00431E65, "c6 05 33 45 57 00 00")
            and snippet_matches(exe, sections, 0x00431EEC, "c6 05 33 45 57 00 00")
            and snippet_matches(exe, sections, 0x00431F73, "c6 05 33 45 57 00 00")
        ),
        "scriptTileStepMutationsPresent": all(value == 1 for value in [
            script_counts["objectTileXIncrementCount"],
            script_counts["objectTileXDecrementCount"],
            script_counts["objectTileYIncrementCount"],
            script_counts["objectTileYDecrementCount"],
        ]),
        "partyTrailHistoryRingMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00431042, "8b 45 f0 fe 80 40 45 57 00"),
                (0x0043105F, "8b 45 f0 c6 80 40 45 57 00 00"),
                (0x00431083, "66 89 04 4d 50 45 57 00"),
                (0x004310A0, "66 89 04 4d 52 45 57 00"),
                (0x004310BB, "66 89 04 4d 54 45 57 00"),
                (0x004311BE, "8b 45 f0 33 c9 8a 88 40 45 57 00"),
                (0x004311CE, "66 8b 0c 45 50 45 57 00"),
                (0x004311FF, "66 8b 0c 45 52 45 57 00"),
                (0x0043124D, "66 8b 04 45 50 45 57 00"),
                (0x00431258, "66 89 81 e8 00 00 00"),
                (0x0043126D, "66 8b 04 45 52 45 57 00"),
                (0x00431278, "66 89 81 ea 00 00 00"),
                (0x0043128F, "66 8b 0c 45 54 45 57 00"),
            ]
        ),
        "partyTrailCallTimingMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00430B5B, "8b 45 fc 66 ff 80 ea 00 00 00"),
                (0x00430B74, "8b 45 fc 66 ff 88 ea 00 00 00"),
                (0x00430B8D, "8b 45 fc 66 ff 88 e8 00 00 00"),
                (0x00430BA6, "8b 45 fc 66 ff 80 e8 00 00 00"),
                (0x00431042, "8b 45 f0 fe 80 40 45 57 00"),
                (0x00431083, "66 89 04 4d 50 45 57 00"),
                (0x004311BE, "8b 45 f0 33 c9 8a 88 40 45 57 00"),
            ]
        ),
        "partyTrailInitialCursorsMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x0043019F, "c6 05 40 45 57 00 00"),
                (0x004301B5, "83 7d fc 03"),
                (0x004301BF, "b8 07 00 00 00"),
                (0x004301C7, "8d 0c 49"),
                (0x004301CA, "2b c1"),
                (0x004301CF, "88 81 40 45 57 00"),
            ]
        ),
        "partyTrailHistoryTablesZeroed": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004301E9, "83 7d fc 07"),
                (0x004301F9, "66 c7 04 45 50 45 57 00 00 00"),
                (0x00430209, "66 c7 04 45 52 45 57 00 00 00"),
                (0x00430219, "66 c7 04 45 54 45 57 00 00 00"),
            ]
        ),
        "partyActorProjectionMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00431100, "66 8b 88 e8 00 00 00"),
                (0x0043111D, "89 48 1c"),
                (0x00431125, "66 8b 88 ea 00 00 00"),
                (0x00431142, "89 48 20"),
                (0x004311BE, "8b 45 f0 33 c9 8a 88 40 45 57 00"),
                (0x004311CE, "66 8b 0c 45 50 45 57 00"),
                (0x004311EC, "89 48 1c"),
                (0x004311FF, "66 8b 0c 45 52 45 57 00"),
                (0x0043121D, "89 48 20"),
                (0x00431258, "66 89 81 e8 00 00 00"),
                (0x00431278, "66 89 81 ea 00 00 00"),
            ]
        ),
        "partyActorAnimationStateStoresMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004312E2, "c7 40 68 05 00 00 00"),
                (0x004312F1, "c7 40 68 01 00 00 00"),
                (0x00431340, "c7 40 68 06 00 00 00"),
                (0x0043134F, "c7 40 68 02 00 00 00"),
                (0x0043139E, "c7 40 68 07 00 00 00"),
                (0x004313AD, "c7 40 68 03 00 00 00"),
                (0x004313FC, "c7 40 68 08 00 00 00"),
                (0x0043140B, "c7 40 68 04 00 00 00"),
            ]
        ),
    }
    return {
        "actorController": {
            "functionVaHex": hex32(ACTOR_CONTROLLER_FUNCTION),
            "activeActorCountGlobalHex": hex32(ACTIVE_ACTOR_COUNT),
            "activeActorSlotTableHex": hex32(ACTIVE_ACTOR_SLOT_TABLE),
            "activeActorPointerTableHex": hex32(ACTIVE_ACTOR_POINTER_TABLE),
            "historyIndexTableHex": hex32(ACTOR_HISTORY_INDEX_TABLE),
            "seedTileXTableHex": hex32(ACTOR_SEED_X_TABLE),
            "seedTileYTableHex": hex32(ACTOR_SEED_Y_TABLE),
            "historyDirectionTableHex": hex32(ACTOR_HISTORY_DIRECTION_TABLE),
            "partyTrailHistory": {
                "indexTableHex": hex32(ACTOR_HISTORY_INDEX_TABLE),
                "slotCount": ACTOR_HISTORY_SLOT_COUNT,
                "actorSlotCount": ACTOR_HISTORY_ACTOR_SLOTS,
                "initialCursors": ACTOR_HISTORY_INITIAL_CURSORS,
                "slotStrideWords": ACTOR_HISTORY_STRIDE_WORDS,
                "tileXTableHex": hex32(ACTOR_SEED_X_TABLE),
                "tileYTableHex": hex32(ACTOR_SEED_Y_TABLE),
                "directionTableHex": hex32(ACTOR_HISTORY_DIRECTION_TABLE),
                "initializationVas": {
                    "leaderCursorZero": "0x0043019f",
                    "cursorFormula": "0x004301bf..0x004301cf",
                    "historyTableZeroLoop": "0x004301da..0x00430223",
                },
                "callTiming": {
                    "classification": "leader tile mutation precedes cursor advance/history write; follower reads happen after the leader write in the same actor controller pass",
                    "tileMutationVas": {
                        "downYIncrement": "0x00430b5b",
                        "upYDecrement": "0x00430b74",
                        "leftXDecrement": "0x00430b8d",
                        "rightXIncrement": "0x00430ba6",
                    },
                    "leaderWriteAfterMutationVas": {
                        "cursorAdvance": "0x00431042",
                        "tileXStore": "0x00431083",
                        "tileYStore": "0x004310a0",
                        "directionStore": "0x004310bb",
                    },
                    "followerReadAfterLeaderWriteVas": {
                        "loopStart": "0x00431173",
                        "indexRead": "0x004311be",
                        "tileXApply": "0x00431258",
                        "tileYApply": "0x00431278",
                    },
                },
                "leaderHistoryWriteVas": {
                    "indexIncrement": "0x00431042",
                    "indexReset": "0x0043105f",
                    "tileXStore": "0x00431083",
                    "tileYStore": "0x004310a0",
                    "directionStore": "0x004310bb",
                },
                "followerHistoryReadVas": {
                    "indexRead": "0x004311be",
                    "tileXRead": "0x004311ce",
                    "tileYRead": "0x004311ff",
                    "tileXApply": "0x00431258",
                    "tileYApply": "0x00431278",
                    "directionRead": "0x0043128f",
                },
                "classification": "7-slot tile/direction trail ring for companion actor following",
            },
            "directionLatchGlobalHex": hex32(ACTOR_DIRECTION_LATCH),
            "directionLatchValues": {
                "down": "0x05",
                "up": "0x06",
                "left": "0x07",
                "right": "0x08",
            },
            "partyActorScreenProjection": {
                "leaderTileProjectionVas": {
                    "tileXRead": "0x00431100",
                    "drawXStore": "0x0043111d",
                    "tileYRead": "0x00431125",
                    "drawYStore": "0x00431142",
                },
                "followerTrailProjectionVas": {
                    "cursorRead": "0x004311be",
                    "trailXRead": "0x004311ce",
                    "drawXStore": "0x004311ec",
                    "trailYRead": "0x004311ff",
                    "drawYStore": "0x0043121d",
                    "tileXApply": "0x00431258",
                    "tileYApply": "0x00431278",
                },
                "animationStateFieldHex": "+0x68",
                "movingStateValues": {
                    "down": "0x05",
                    "up": "0x06",
                    "left": "0x07",
                    "right": "0x08",
                },
                "idleStateValues": {
                    "down": "0x01",
                    "up": "0x02",
                    "left": "0x03",
                    "right": "0x04",
                },
                "classification": (
                    "party actor draw coordinates are recomputed from tile/trail entries; "
                    "+0x68 stores direction/animation state; party frame scripts provide the selector phase outside the actor controller"
                ),
            },
            "collisionHelperCall": direct_call(exe, sections, ACTOR_COLLISION_HELPER_CALL, "input-driven direction/collision helper"),
            "counts": controller_counts,
        },
        "collisionHelper": {
            "functionVaHex": hex32(ACTOR_COLLISION_HELPER_FUNCTION),
            "mapWidthGlobalHex": hex32(MAP_WIDTH_TILES),
            "mapHeightGlobalHex": hex32(MAP_HEIGHT_TILES),
            "collisionFlagTableHex": hex32(MAP_COLLISION_FLAGS),
            "collisionEdgeFlagTableHex": hex32(MAP_COLLISION_EDGE_FLAGS),
            "objectFootprintFieldsHex": ["+0xe6", "+0xe7"],
            "directionBlockFlagBits": {
                "down": "0x02",
                "up": "0x01",
                "left": "0x04",
                "right": "0x08",
            },
            "blockedDirectionFallbackValues": {
                "down": "0x01",
                "up": "0x02",
                "left": "0x03",
                "right": "0x04",
            },
            "directionCases": [
                {
                    "direction": row["direction"],
                    "inputLatchHex": f"0x{row['inputLatch']:02x}",
                    "caseVaHex": hex32(row["caseVa"]),
                    "blockedFallbackLatchHex": f"0x{row['edgeBlockedLatch']:02x}",
                    "edgeCheckVaHex": hex32(row["edgeCheckVa"]),
                    "flagTestVaHex": hex32(row["flagTestVa"]),
                    "flagBitHex": f"0x{row['flagBit']:02x}",
                    "flagGridHex": hex32(row["flagGrid"]),
                    "scanShape": row["scanShape"],
                    "boundaryRule": row["boundaryRule"],
                }
                for row in COLLISION_DIRECTION_CASES
            ],
            "objectOverlapResponse": {
                "listHeadGlobalHex": hex32(COLLISION_OBJECT_LIST_HEAD),
                "sentinelHex": hex32(COLLISION_OBJECT_LIST_SENTINEL),
                "loopStartVaHex": "0x00431ca6",
                "activeMaskTestHex": "0x0101",
                "collisionPayloadFieldHex": "+0xec",
                "responseFieldHex": "+0x68",
                "clearsDirectionLatchOnOverlap": True,
                "classification": "actor-overlap collision response after tile-flag checks",
            },
            "counts": helper_counts,
        },
        "scriptTileStepHandler": {
            "functionVaHex": hex32(SCRIPT_TILE_STEP_HANDLER),
            "classification": "script-command tile-field mutator",
            "counts": script_counts,
        },
        "verification": verification,
        "promotionStatus": "actor-tile-mutation-and-collision-flags-grounded",
}


def draw_projection_summary(exe: bytes, sections: list[dict]) -> dict:
    helper_call = direct_call(exe, sections, DRAW_RECT_HELPER_CALL, "projected object dirty-rect helper")
    bounds_call = direct_call(exe, sections, DRAW_RECT_BOUNDS_CALL, "projected object bounds helper")
    verification = {
        "drawPrepPrologueMatchesExpected": snippet_matches(exe, sections, DRAW_PREP_FUNCTION, "55 8b ec 83 ec 0c"),
        "drawPrepDirectTileProjectionMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00424A88, "66 8b 88 e8 00 00 00"),
                (0x00424A91, "66 a1 dc 76 45 00"),
                (0x00424A99, "c1 e1 04"),
                (0x00424A9C, "83 c1 18"),
                (0x00424A9F, "c1 e1 10"),
                (0x00424AA5, "89 48 1c"),
                (0x00424AAD, "66 8b 88 ea 00 00 00"),
                (0x00424AB6, "66 a1 de 76 45 00"),
                (0x00424ABE, "c1 e1 04"),
                (0x00424AC1, "83 c1 08"),
                (0x00424AC4, "c1 e1 10"),
                (0x00424ACA, "89 48 20"),
            ]
        ),
        "drawPrepModeDispatchMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00424B1C, "83 7d f4 01"),
                (0x00424B26, "83 7d f4 02"),
                (0x00424B30, "83 7d f4 04"),
                (0x00424B3F, "6a 02"),
            ]
        ),
        "drawRectHelperCallMatchesExpected": helper_call["targetVaHex"] == hex32(DRAW_RECT_HELPER_FUNCTION),
        "drawRectBoundsCallMatchesExpected": bounds_call["targetVaHex"] == hex32(DRAW_RECT_BOUNDS_FUNCTION),
        "drawRectBoundsReadsProjectedCoordsMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00416D14, "8b 40 1c"),
                (0x00416D20, "c1 f8 10"),
                (0x00416D2B, "8b 40 20"),
                (0x00416D37, "c1 f8 10"),
            ]
        ),
    }
    return {
        "drawRectHelperCall": helper_call,
        "drawRectBoundsCall": bounds_call,
        "drawPrepModes": {
            "directTileProjectionMode": "low bits == 1",
            "existingProjectedCoordModes": ["low bits == 2", "low bits == 4"],
        },
        "projectedCoordFieldsHex": {
            "drawX": "+0x1c",
            "drawY": "+0x20",
            "drawOrder": "+0x24",
            "frameSelector": "+0x28",
        },
        "classification": (
            "draw prep either writes fixed-point draw coordinates directly from tile/camera fields "
            "or reuses existing projected coordinates for alternate object modes; the dirty-rect "
            "helper consumes +0x1c/+0x20 as projected coordinates"
        ),
        "verification": verification,
    }


def find_frame_script_pointer_initializer(
    exe: bytes,
    sections: list[dict],
    script_va: int,
    expected_frame_script_va: int,
    scan_size: int = 0x70,
) -> dict | None:
    for offset in range(0, scan_size - 8 + 1, 4):
        command_va = script_va + offset
        if (
            read_u32(exe, sections, command_va) == SPRITE_FRAME_POINTER_SCRIPT_OPCODE
            and read_u32(exe, sections, command_va + 4) == expected_frame_script_va
        ):
            return {
                "commandVaHex": hex32(command_va),
                "bytes": bytes_hex(exe, sections, command_va, 8),
                "frameScriptPointerVaHex": hex32(expected_frame_script_va),
            }
    return None


def find_animation_state_table_command(
    exe: bytes,
    sections: list[dict],
    script_va: int,
    expected_state_table_va: int,
    scan_size: int = 0x70,
) -> dict | None:
    for offset in range(0, scan_size - 8 + 1):
        command_va = script_va + offset
        if (
            read_at(exe, sections, command_va, 4) == bytes([SPRITE_FRAME_STATE_TABLE_SCRIPT_OPCODE, 0xA0, 0x64, 0x68])
            and read_u32(exe, sections, command_va + 4) == expected_state_table_va
        ):
            return {
                "commandVaHex": hex32(command_va),
                "bytes": bytes_hex(exe, sections, command_va, 8),
                "stateTableVaHex": hex32(expected_state_table_va),
                "semantics": "object+0x64 = dword[stateTable + byte(object+0x68) * 4]",
            }
    return None


def parse_frame_selector_sequence(exe: bytes, sections: list[dict], script_va: int) -> dict:
    commands = []
    cursor = script_va
    for order in range(16):
        opcode = read_u8(exe, sections, cursor)
        if opcode != SPRITE_FRAME_SCRIPT_OPCODE:
            break
        mode = read_u8(exe, sections, cursor + 1)
        timer = read_u16(exe, sections, cursor + 2)
        selector = read_u32(exe, sections, cursor + 4)
        commands.append({
            "order": order,
            "commandVaHex": hex32(cursor),
            "opcodeHex": f"0x{opcode:02x}",
            "mode": mode,
            "timer": timer,
            "selectorHex": hex32(selector),
            "surfaceIndexHex": f"0x{(selector >> 16) & 0xff:02x}",
            "frameIndex": selector & 0xffff,
        })
        cursor += 8
    tail_opcode = read_u8(exe, sections, cursor)
    tail = {
        "commandVaHex": hex32(cursor),
        "opcodeHex": f"0x{tail_opcode:02x}",
    }
    if tail_opcode == 0x03:
        target = read_u32(exe, sections, cursor + 4)
        tail.update({
            "targetVaHex": hex32(target),
            "loopsToSequenceStart": target == script_va,
        })
    return {
        "scriptVaHex": hex32(script_va),
        "commandCount": len(commands),
        "selectorSequenceHex": [command["selectorHex"] for command in commands],
        "frameIndices": [command["frameIndex"] for command in commands],
        "timerSequence": [command["timer"] for command in commands],
        "surfaceIndicesHex": sorted({command["surfaceIndexHex"] for command in commands}),
        "commands": commands,
        "tail": tail,
    }


def linked_cns_names_in_script(
    exe: bytes,
    sections: list[dict],
    strings: dict[int, str],
    script_va: int,
    scan_dwords: int = 96,
) -> list[str]:
    names = []
    for index in range(scan_dwords):
        try:
            value = read_u32(exe, sections, script_va + index * 4)
        except (IndexError, ValueError):
            break
        name = strings.get(value)
        if name and name.endswith(".cns") and name not in names:
            names.append(name)
    return names


def valid_frame_pointer_initializer_sites(
    exe: bytes,
    sections: list[dict],
    script_va: int,
    scan_size: int = 0x90,
) -> list[dict]:
    sites = []
    seen = set()
    for offset in range(0, scan_size - 8 + 1, 4):
        command_va = script_va + offset
        try:
            opcode = read_u32(exe, sections, command_va)
            target = read_u32(exe, sections, command_va + 4)
        except (IndexError, ValueError):
            break
        if opcode != SPRITE_FRAME_POINTER_SCRIPT_OPCODE or va_to_offset(sections, target) is None:
            continue
        key = (command_va, target)
        if key in seen:
            continue
        seen.add(key)
        sites.append({
            "commandVaHex": hex32(command_va),
            "bytes": bytes_hex(exe, sections, command_va, 8),
            "frameScriptPointerVaHex": hex32(target),
        })
    return sites


def animation_state_table_command_sites(
    exe: bytes,
    sections: list[dict],
    script_va: int,
    scan_size: int = 0x90,
) -> list[dict]:
    sites = []
    seen = set()
    for offset in range(0, scan_size - 8 + 1):
        command_va = script_va + offset
        try:
            command = read_at(exe, sections, command_va, 8)
        except (IndexError, ValueError):
            break
        target = struct.unpack("<I", command[4:8])[0]
        if (
            command[:4] != bytes([SPRITE_FRAME_STATE_TABLE_SCRIPT_OPCODE, 0xA0, 0x64, 0x68])
            or va_to_offset(sections, target) is None
        ):
            continue
        key = (command_va, target)
        if key in seen:
            continue
        seen.add(key)
        sites.append({
            "commandVaHex": hex32(command_va),
            "bytes": bytes_hex(exe, sections, command_va, 8),
            "stateTableVaHex": hex32(target),
            "semantics": "object+0x64 = dword[stateTable + byte(object+0x68) * 4]",
        })
    return sites


def descriptor_resource_class(index: int, resources: list[str], has_state_table: bool) -> str:
    if index in {0, 1, 2} and has_state_table:
        return "party-walking-state-table"
    if any(name.startswith("btl_") for name in resources):
        return "battle/effect-resource"
    if any(name.startswith(("zsa_", "zsl_", "cara_")) for name in resources):
        return "object-resource"
    return "resource-or-script-descriptor"


def object_descriptor_table_summary(exe: bytes, sections: list[dict]) -> dict:
    strings = find_cns_strings(exe, sections)
    rows = []
    for index in range(PARTY_CHARACTER_DESCRIPTOR_TABLE_ROW_COUNT):
        entry_va = PARTY_CHARACTER_DESCRIPTOR_TABLE + index * 4
        descriptor_va = read_u32(exe, sections, entry_va)
        script_vas = [read_u32(exe, sections, descriptor_va + offset) for offset in (0, 4, 8)]
        resources = []
        frame_initializer_sites = []
        state_table_sites = []
        for script_index, script_va in enumerate(script_vas):
            for name in linked_cns_names_in_script(exe, sections, strings, script_va):
                if name not in resources:
                    resources.append(name)
            for site in valid_frame_pointer_initializer_sites(exe, sections, script_va):
                site = dict(site)
                site["descriptorScriptIndex"] = script_index
                site["descriptorScriptVaHex"] = hex32(script_va)
                if not any(
                    row["commandVaHex"] == site["commandVaHex"]
                    and row["frameScriptPointerVaHex"] == site["frameScriptPointerVaHex"]
                    for row in frame_initializer_sites
                ):
                    frame_initializer_sites.append(site)
            for site in animation_state_table_command_sites(exe, sections, script_va):
                site = dict(site)
                site["descriptorScriptIndex"] = script_index
                site["descriptorScriptVaHex"] = hex32(script_va)
                if not any(
                    row["commandVaHex"] == site["commandVaHex"]
                    and row["stateTableVaHex"] == site["stateTableVaHex"]
                    for row in state_table_sites
                ):
                    state_table_sites.append(site)

        state_table_targets = []
        for site in state_table_sites:
            target = site["stateTableVaHex"]
            if target not in state_table_targets:
                state_table_targets.append(target)
        state_entries = []
        if state_table_targets:
            state_table_va = int(state_table_targets[0], 16)
            for state in range(9):
                entry_script_va = read_u32(exe, sections, state_table_va + state * 4)
                parsed = parse_frame_selector_sequence(exe, sections, entry_script_va)
                state_entries.append({
                    "state": state,
                    "scriptVaHex": parsed["scriptVaHex"],
                    "commandCount": parsed["commandCount"],
                    "surfaceIndicesHex": parsed["surfaceIndicesHex"],
                    "frameIndices": parsed["frameIndices"],
                    "loopsToSelf": (parsed.get("tail") or {}).get("loopsToSequenceStart") is True,
                })
        has_state_table = bool(state_table_targets)
        rows.append({
            "index": index,
            "entryVaHex": hex32(entry_va),
            "descriptorVaHex": hex32(descriptor_va),
            "descriptorScriptsHex": [hex32(value) for value in script_vas],
            "linkedCns": resources,
            "resourceClass": descriptor_resource_class(index, resources, has_state_table),
            "validFramePointerInitializerCount": len(frame_initializer_sites),
            "validFramePointerInitializerSites": frame_initializer_sites,
            "animationStateTableCommandCount": len(state_table_sites),
            "animationStateTableTargetsHex": state_table_targets,
            "usesActorAnimationStateTable": has_state_table,
            "stateEntries": state_entries,
            "fieldMapCnsRefCount": sum(
                1 for name in resources if name.startswith("map") and not name.startswith("map_")
            ),
        })

    state_table_rows = [row["index"] for row in rows if row["usesActorAnimationStateTable"]]
    non_party_rows = [row for row in rows if row["index"] not in {0, 1, 2}]
    class_counts: dict[str, int] = {}
    for row in rows:
        class_counts[row["resourceClass"]] = class_counts.get(row["resourceClass"], 0) + 1
    verification = {
        "descriptorTableRowCountMatchesExpected": len(rows) == PARTY_CHARACTER_DESCRIPTOR_TABLE_ROW_COUNT,
        "stateTableRowsMatchPartyDescriptors": state_table_rows == [0, 1, 2],
        "nonPartyDescriptorRowsHaveNoAnimationStateTables": all(
            row["usesActorAnimationStateTable"] is False for row in non_party_rows
        ),
        "nonPartyDescriptorRowsHaveNoFieldMapCnsRefs": all(
            row["fieldMapCnsRefCount"] == 0 for row in non_party_rows
        ),
        "partyStateTablesHaveLoopingFrameSelectors": all(
            row["stateEntries"]
            and all(entry["commandCount"] > 0 and entry["loopsToSelf"] for entry in row["stateEntries"])
            for row in rows[:3]
        ),
    }
    return {
        "descriptorTableHex": hex32(PARTY_CHARACTER_DESCRIPTOR_TABLE),
        "rowCount": len(rows),
        "partyStateTableRowIndices": state_table_rows,
        "nonPartyDescriptorRowCount": len(non_party_rows),
        "nonPartyAnimationStateTableRowCount": sum(
            1 for row in non_party_rows if row["usesActorAnimationStateTable"]
        ),
        "nonPartyFieldMapCnsRefCount": sum(row["fieldMapCnsRefCount"] for row in non_party_rows),
        "resourceClassCounts": class_counts,
        "rows": rows,
        "verification": verification,
        "classification": (
            "the 12-row descriptor table has +0x68 animation state-table selectors only in rows 0..2 "
            "for the party walking descriptors; rows 3..11 are battle/effect/object resource descriptors "
            "with no +0x68 state-table selector and no field-map CNS refs"
        ),
    }


def descriptor_activation_order_summary(runtime_materializers: dict | None) -> dict:
    runtime_materializers = runtime_materializers or {}
    load = runtime_materializers.get("loadRebuildEvidence") or {}
    sample = runtime_materializers.get("sampleOrderSummary") or {}
    handler_tables = runtime_materializers.get("handlerTables") or {}
    general_handlers = handler_tables.get("generalMutationHandlers") or []
    save_selector_handlers = handler_tables.get("saveSelectorSameLowByteHandlers") or []
    materializers = runtime_materializers.get("materializers") or []
    source_path = str(RUNTIME_MATERIALIZERS_JSON)
    materializer_names = [row.get("name") for row in materializers if row.get("name")]
    classification = (
        "save load calls 0x00432323 after count/order and slot blocks are read and before selector pointer "
        "selection; later add/remove active descriptor mutations are general-table opcode 0x62/0x63 paths, "
        "while the current selector 2:0 active order remains unproven because public samples do not cover it"
    )
    verification = {
        "runtimeMaterializerReportLoaded": bool(runtime_materializers),
        "loadRebuildAfterSaveReadBlocks": load.get("rebuildAfterSaveReadBlocks") is True,
        "loadRebuildBeforeSelectorPointerSelection": (
            load.get("rebuildBeforeSelectorPointerSelection") is True
        ),
        "generalMutationHandlersIdentified": (
            {row.get("opcodeHex"): row.get("handlerVaHex") for row in general_handlers}
            == {"0x62": "0x00407cc1", "0x63": "0x00407ce5"}
        ),
        "saveSelectorSameLowBytesSeparated": (
            {row.get("opcodeHex"): row.get("handlerVaHex") for row in save_selector_handlers}
            == {"0x62": "0x0040239f", "0x63": "0x0041004f"}
        ),
        "opcode20SelfMutationPathEliminated": (
            runtime_materializers.get("opcode20SelfMutationPathEliminated") is True
            and runtime_materializers.get("descriptorScriptMutationRowCount") == 0
        ),
        "currentFrontierActiveOrderStillUnproven": (
            runtime_materializers.get("currentFrontierActiveOrderProven") is False
        ),
    }
    return {
        "source": source_path,
        "materializerReportPresent": bool(runtime_materializers),
        "countRuntimeVaHex": runtime_materializers.get("countRuntimeVaHex"),
        "orderBytesVaHex": runtime_materializers.get("orderBytesVaHex"),
        "slotBaseHex": runtime_materializers.get("slotBaseHex"),
        "loadRebuildCallVaHex": load.get("rebuildCallVaHex"),
        "loadRebuildFunctionVaHex": load.get("rebuildFunctionVaHex"),
        "rebuildAfterSaveReadBlocks": load.get("rebuildAfterSaveReadBlocks"),
        "rebuildBeforeSelectorPointerSelection": load.get("rebuildBeforeSelectorPointerSelection"),
        "materializerNames": materializer_names,
        "generalMutationHandlers": general_handlers,
        "saveSelectorSameLowByteHandlers": save_selector_handlers,
        "currentRouteSameLowByteRowCount": runtime_materializers.get("currentRouteSameLowByteRowCount"),
        "currentRouteGeneralMutationEvidenceCount": runtime_materializers.get("currentRouteGeneralMutationEvidenceCount"),
        "descriptorScriptMutationRowCount": runtime_materializers.get("descriptorScriptMutationRowCount"),
        "opcode20SelfMutationPathEliminated": runtime_materializers.get("opcode20SelfMutationPathEliminated"),
        "sampleActiveDescriptorSets": sample.get("sampleActiveDescriptorSets"),
        "currentFrontierSelector": runtime_materializers.get("currentFrontierSelector"),
        "currentFrontierActiveOrderProven": runtime_materializers.get("currentFrontierActiveOrderProven"),
        "currentFrontierSampleCovered": sample.get("currentFrontierSampleCovered"),
        "verification": verification,
        "classification": classification,
    }


def party_actor_frame_script_summary(exe: bytes, sections: list[dict]) -> dict:
    frame_pointer_handler_entry = SCRIPT_HANDLER_TABLE + SPRITE_FRAME_POINTER_SCRIPT_OPCODE * 4
    frame_pointer_handler = read_u32(exe, sections, frame_pointer_handler_entry)
    state_table_handler_entry = SCRIPT_HANDLER_TABLE + SPRITE_FRAME_STATE_TABLE_SCRIPT_OPCODE * 4
    state_table_handler = read_u32(exe, sections, state_table_handler_entry)
    descriptor_table_entries = [
        read_u32(exe, sections, PARTY_CHARACTER_DESCRIPTOR_TABLE + index * 4)
        for index in range(len(PARTY_CHARACTER_FRAME_DESCRIPTORS))
    ]
    descriptor_table_summary = object_descriptor_table_summary(exe, sections)
    characters = []
    for index, expected in enumerate(PARTY_CHARACTER_FRAME_DESCRIPTORS):
        descriptor_va = descriptor_table_entries[index]
        script_vas = [read_u32(exe, sections, descriptor_va + offset) for offset in (0, 4, 8)]
        initializer_sites = []
        state_table_sites = []
        for script_index in (1, 2):
            script_va = script_vas[script_index]
            initializer = find_frame_script_pointer_initializer(
                exe,
                sections,
                script_va,
                expected["frameScriptVa"],
            )
            if initializer:
                initializer["descriptorScriptIndex"] = script_index
                initializer["descriptorScriptVaHex"] = hex32(script_va)
                initializer_sites.append(initializer)
            state_table = find_animation_state_table_command(
                exe,
                sections,
                script_va,
                expected["stateTableVa"],
            )
            if state_table:
                state_table["descriptorScriptIndex"] = script_index
                state_table["descriptorScriptVaHex"] = hex32(script_va)
                state_table_sites.append(state_table)
        state_entries = []
        for state in range(9):
            entry_script_va = read_u32(exe, sections, expected["stateTableVa"] + state * 4)
            parsed = parse_frame_selector_sequence(exe, sections, entry_script_va)
            parsed.update({
                "state": state,
                "actorControllerMeaning": ACTOR_ANIMATION_STATE_LABELS.get(state, f"state {state}"),
            })
            state_entries.append(parsed)
        characters.append({
            "index": index,
            "name": expected["name"],
            "resource": expected["resource"],
            "descriptorTableEntryHex": hex32(PARTY_CHARACTER_DESCRIPTOR_TABLE + index * 4),
            "descriptorVaHex": hex32(descriptor_va),
            "expectedDescriptorVaHex": hex32(expected["descriptorVa"]),
            "descriptorScriptsHex": [hex32(value) for value in script_vas],
            "baseSurfaceIndexHex": f"0x{expected['surfaceIndex']:02x}",
            "baseFrameScriptVaHex": hex32(expected["frameScriptVa"]),
            "animationStatePointerTableHex": hex32(expected["stateTableVa"]),
            "frameScriptPointerInitializerSites": initializer_sites,
            "animationStatePointerTableCommandSites": state_table_sites,
            "stateEntries": state_entries,
        })
    verification = {
        "framePointerOpcodeTableEntryMatchesExpected": (
            frame_pointer_handler == SPRITE_FRAME_POINTER_SCRIPT_FUNCTION
        ),
        "stateTableOpcodeTableEntryMatchesExpected": (
            state_table_handler == SPRITE_FRAME_STATE_TABLE_SCRIPT_FUNCTION
        ),
        "framePointerInitializerHandlerMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00404490, "8b 45 08 8b 40 40"),
                (0x00404496, "8b 40 04"),
                (0x0040449C, "89 41 64"),
                (0x004044A2, "81 08 00 00 00 04"),
                (0x004044AB, "66 c7 40 62 01 00"),
                (0x004044B4, "83 40 40 08"),
            ]
        ),
        "stateTableCommandHandlerReadsObjectStateAndTableMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004040DC, "8b 45 08 8b 40 40 33 c9 8a 48 03"),
                (0x004040E7, "8b 45 08 33 d2 8a 14 01"),
                (0x004040EF, "8b 45 08 8b 40 40 8b 40 04 8b 04 90"),
                (0x0040422D, "8b 45 fc 8b 4d 08 8b 49 40 33 d2 8a 51 02"),
                (0x0040423B, "8b 4d 08 89 04 0a"),
            ]
        ),
        "partyDescriptorTableEntriesMatchExpected": descriptor_table_entries == [
            row["descriptorVa"] for row in PARTY_CHARACTER_FRAME_DESCRIPTORS
        ],
        "descriptorScriptsContainFramePointerInitializers": all(
            len(character["frameScriptPointerInitializerSites"]) == 2
            for character in characters
        ),
        "descriptorScriptsContainAnimationStatePointerTables": all(
            len(character["animationStatePointerTableCommandSites"]) == 2
            for character in characters
        ),
        "statePointerTablesCoverActorStates1To8": all(
            len(character["stateEntries"]) >= 9
            and all(
                character["stateEntries"][state]["commandCount"] > 0
                for state in range(1, 9)
            )
            for character in characters
        ),
        "statePointerTableSelectorsMatchCharacterSurfaces": all(
            all(
                entry["surfaceIndicesHex"] == [character["baseSurfaceIndexHex"]]
                for entry in character["stateEntries"][:9]
            )
            for character in characters
        ),
        "statePointerTableBranchesLoopToSelf": all(
            all(
                (entry.get("tail") or {}).get("opcodeHex") == "0x03"
                and (entry.get("tail") or {}).get("loopsToSequenceStart") is True
                for entry in character["stateEntries"][:9]
            )
            for character in characters
        ),
        **descriptor_table_summary["verification"],
    }
    return {
        "descriptorTableHex": hex32(PARTY_CHARACTER_DESCRIPTOR_TABLE),
        "descriptorTableRowCount": descriptor_table_summary["rowCount"],
        "actorAnimationStateFieldHex": "+0x68",
        "frameScriptPointerFieldHex": "+0x64",
        "frameSelectorFieldHex": "+0x28",
        "framePointerInitializer": {
            "opcodeHex": f"0x{SPRITE_FRAME_POINTER_SCRIPT_OPCODE:02x}",
            "handlerEntryHex": hex32(frame_pointer_handler_entry),
            "handlerVaHex": hex32(frame_pointer_handler),
            "functionVaHex": hex32(SPRITE_FRAME_POINTER_SCRIPT_FUNCTION),
            "classification": "opcode 0x20 stores dword [stream+4] into object+0x64, enables the script timer flag, reloads object+0x62 to 1, and advances object+0x40 by 8",
        },
        "animationStatePointerTableCommand": {
            "opcodeHex": f"0x{SPRITE_FRAME_STATE_TABLE_SCRIPT_OPCODE:02x}",
            "handlerEntryHex": hex32(state_table_handler_entry),
            "handlerVaHex": hex32(state_table_handler),
            "functionVaHex": hex32(SPRITE_FRAME_STATE_TABLE_SCRIPT_FUNCTION),
            "commandBytes": "18 a0 64 68 <state-table-va>",
            "classification": "opcode 0x18 command bytes 18 a0 64 68 assign object+0x64 from a dword pointer table indexed by byte object+0x68",
        },
        "characters": characters,
        "objectDescriptorTable": descriptor_table_summary,
        "verification": verification,
        "classification": (
            "party actor descriptors initialize +0x64 with opcode 0x20, then use opcode 0x18 command "
            "bytes 18 a0 64 68 to select +0x64 = stateTable[object+0x68]; states 1..8 decode to "
            "opcode 0x21 +0x28 selector sequences that loop through opcode 0x03; the full 12-row descriptor "
            "table is classified so rows 3..11 stay separate as battle/effect/object resource descriptors"
        ),
    }


def sprite_frame_selector_summary(exe: bytes, sections: list[dict]) -> dict:
    controller_range = (ACTOR_CONTROLLER_FUNCTION, 0x004319F3)
    bounds_range = (DRAW_RECT_BOUNDS_FUNCTION, 0x004173D6)
    frame_selector_handler_entry = SCRIPT_HANDLER_TABLE + SPRITE_FRAME_SCRIPT_OPCODE * 4
    frame_selector_handler = struct.unpack("<I", read_at(exe, sections, frame_selector_handler_entry, 4))[0]
    party_actor_frame_scripts = party_actor_frame_script_summary(exe, sections)
    frame_selector_write_needles = [
        bytes.fromhex("89 40 28"),
        bytes.fromhex("89 41 28"),
        bytes.fromhex("89 48 28"),
        bytes.fromhex("c7 40 28"),
        bytes.fromhex("81 60 28"),
        bytes.fromhex("09 48 28"),
    ]
    animation_state_read_needles = [
        bytes.fromhex("8b 40 68"),
        bytes.fromhex("8a 40 68"),
        bytes.fromhex("8a 48 68"),
        bytes.fromhex("83 78 68"),
        bytes.fromhex("3b 48 68"),
    ]
    actor_controller_frame_selector_write_count = count_any_bytes(
        exe, sections, *controller_range, frame_selector_write_needles
    )
    bounds_helper_animation_state_read_count = count_any_bytes(
        exe, sections, *bounds_range, animation_state_read_needles
    )
    frame_timer_verification = {
        "scriptTimerLoopMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00432FF0, "55 8b ec 83 ec 0c"),
                (0x00433008, "83 7d f4 05"),
                (0x00433020, "8b 04 c5 48 3b 57 00"),
                (0x00433046, "8d 04 c5 40 3b 57 00"),
                (0x0043305E, "f6 40 03 20"),
                (0x00433070, "f6 40 03 04"),
                (0x0043307A, "8b 45 f8 66 ff 48 62"),
                (0x00433081, "8b 45 f8 0f bf 40 62"),
            ]
        ),
        "scriptTimerPointerSwapMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00433090, "8b 45 f8 8b 40 40"),
                (0x00433099, "8b 45 f8 8b 40 64 8b 4d f8 89 41 40"),
                (0x004330A9, "e8 73 f2 fc ff"),
                (0x004330B1, "8b 45 f8 8b 40 40 8b 4d f8 89 41 64"),
                (0x004330BD, "8b 45 fc 8b 4d f8 89 41 40"),
            ]
        ) and rel32_target(exe, sections, 0x004330A9) == SCRIPT_RUNNER_FUNCTION,
        "generalScriptRunnerDispatchMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00402327, "c7 05 b8 a1 55 00 00 00 00 00"),
                (0x00402331, "83 3d b8 a1 55 00 00"),
                (0x00402345, "8b 40 40"),
                (0x0040234C, "ff 14 8d 38 05 44 00"),
            ]
        ),
        "frameSelectorOpcodeTableEntryMatchesExpected": frame_selector_handler == SPRITE_FRAME_SCRIPT_FUNCTION,
        "frameSelectorModeDispatchMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004044CE, "8a 48 01"),
                (0x00404578, "83 7d fc 00"),
                (0x00404582, "83 7d fc 01"),
                (0x0040458C, "83 7d fc 02"),
            ]
        ),
    }
    verification = {
        "boundsHelperFrameSelectorReadsMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00416CEE, "8b 40 28"),
                (0x00416CF1, "25 ff ff 00 00"),
                (0x00416CFC, "8b 40 28"),
                (0x00416CFF, "c1 e8 10"),
                (0x00416D02, "25 ff 00 00 00"),
                (0x00416D07, "8b 04 85 d8 ab 55 00"),
            ]
        ),
        "boundsHelperGridFrameMathMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00416D98, "8b 45 ec"),
                (0x00416D9D, "8a 48 19"),
                (0x00416DAB, "0f bf 48 32"),
                (0x00416DB4, "f7 f1"),
                (0x00416DBB, "0f bf 49 2e"),
                (0x00416DC8, "0f bf 48 32"),
                (0x00416DD6, "0f bf 49 30"),
            ]
        ),
        "boundsHelperObjectOffsetAdjustmentMatchesExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x00416E07, "8b 45 08"),
                (0x00416E0A, "8b 40 30"),
                (0x00416E13, "8b 40 34"),
                (0x00416E28, "0f af 45 f8"),
                (0x00416E50, "29 08"),
                (0x00416E89, "29 48 04"),
            ]
        ),
        "scriptFrameSelectorWritesMatchExpected": all(
            snippet_matches(exe, sections, va, expected)
            for va, expected in [
                (0x004044F6, "89 41 28"),
                (0x00404523, "81 60 28 00 00 ff ff"),
                (0x00404539, "09 48 28"),
                (0x0040455E, "c1 e1 10"),
                (0x00404564, "89 48 28"),
            ]
        ),
        "actorControllerFrameSelectorWritesAbsent": actor_controller_frame_selector_write_count == 0,
        "boundsHelperAnimationStateReadsAbsent": bounds_helper_animation_state_read_count == 0,
        **frame_timer_verification,
        **party_actor_frame_scripts["verification"],
    }
    return {
        "boundsFunctionHex": hex32(DRAW_RECT_BOUNDS_FUNCTION),
        "scriptFrameSelectorFunctionHex": hex32(SPRITE_FRAME_SCRIPT_FUNCTION),
        "selectorFieldHex": "+0x28",
        "animationStateFieldHex": "+0x68",
        "surfaceTableHex": hex32(SPRITE_SURFACE_TABLE),
        "selectorPacking": {
            "frameIndex": "low 16 bits",
            "surfaceTableIndex": "bits 16..23, indexed through 0x0055abd8",
        },
        "surfaceMetadataFieldsHex": {
            "layoutMode": "+0x19",
            "frameWidth": "+0x2e",
            "frameHeight": "+0x30",
            "framesPerRow": "+0x32",
            "rectOrOffsetTable": "+0x34",
        },
        "objectOffsetFieldsHex": ["+0x30", "+0x34"],
        "scriptFrameSelectorWriter": {
            "functionVaHex": hex32(SPRITE_FRAME_SCRIPT_FUNCTION),
            "fullSelectorStoreVaHex": "0x004044f6",
            "lowFrameIndexStoreVas": ["0x00404523", "0x00404539"],
            "surfaceIndexStoreVaHex": "0x00404564",
            "classification": "object script commands write +0x28 either as a full selector, low frame index, or high surface-table index",
        },
        "scriptFrameTimer": {
            "functionVaHex": hex32(SCRIPT_TIMER_STEP_FUNCTION),
            "bucketCount": 5,
            "activeObjectListTableHex": "0x00573b40/0x00573b48",
            "inactiveSkipFlag": "+0x03 bit 0x20",
            "timerEnabledFlag": "+0x03 bit 0x04",
            "frameTimerFieldHex": "+0x62",
            "activeScriptPointerFieldHex": "+0x40",
            "frameScriptPointerFieldHex": "+0x64",
            "generalScriptRunnerHex": hex32(SCRIPT_RUNNER_FUNCTION),
            "handlerTableHex": hex32(SCRIPT_HANDLER_TABLE),
            "stopFlagHex": hex32(SCRIPT_STOP_FLAG),
            "frameSelectorOpcodeHex": f"0x{SPRITE_FRAME_SCRIPT_OPCODE:02x}",
            "frameSelectorHandlerEntryHex": hex32(frame_selector_handler_entry),
            "frameSelectorHandlerVaHex": hex32(frame_selector_handler),
            "modeCases": [
                {
                    "mode": 0,
                    "timerSource": "word [stream+0x02]",
                    "selectorWrite": "dword [stream+0x04] -> object+0x28",
                    "advanceBytes": 8,
                    "storeVaHex": "0x004044f6",
                },
                {
                    "mode": 1,
                    "timerSource": "word [stream+0x02]",
                    "selectorWrite": "word [stream+0x06] -> low object+0x28, preserving surface index",
                    "advanceBytes": 8,
                    "storeVaHex": "0x00404523/0x00404539",
                },
                {
                    "mode": 2,
                    "timerSource": None,
                    "selectorWrite": "word [stream+0x02] << 16 -> object+0x28 surface index",
                    "advanceBytes": 4,
                    "storeVaHex": "0x00404564",
                },
            ],
            "classification": (
                "object script/timer update decrements +0x62 and, when it reaches zero, runs the +0x64 "
                "frame script through the generic 0x00440538 handler table; opcode 0x21 writes +0x28 and "
                "reloads/advances the frame script"
            ),
        },
        "partyActorFrameScripts": party_actor_frame_scripts,
        "counts": {
            "actorControllerFrameSelectorWriteCount": actor_controller_frame_selector_write_count,
            "boundsHelperAnimationStateReadCount": bounds_helper_animation_state_read_count,
        },
        "classification": (
            "+0x28 is the draw frame selector consumed by the bounds/source-rect helper; "
            "its low 16 bits select the frame and bits 16..23 select the 0x0055abd8 surface table. "
            "The helper also applies per-object +0x30/+0x34 sprite offsets when the object flags request it. "
            "The object script/timer path drives +0x28 through opcode 0x21 frame-script commands, while the "
            "actor controller writes +0x68 direction/animation state but does not write +0x28 in the identified "
            "controller range. Party actor descriptors ground the +0x68-to-+0x64 frame-script table and "
            "opcode 0x21 +0x28 selector sequences for Ataho/Rinshan/Smashu; the remaining descriptor rows are "
            "classified as battle/effect/object resources without +0x68 state-table selectors."
        ),
        "verification": verification,
    }


def map_collision_loader_summary(exe: bytes, sections: list[dict]) -> dict:
    verification = {
        "mapLoaderPrologueMatchesExpected": snippet_matches(exe, sections, MAP_LOADER_FUNCTION, "55 8b ec 83 ec 0c"),
        "mapPayloadLoadCallMatchesExpected": rel32_target(exe, sections, MAP_PAYLOAD_LOAD_CALL) == 0x00422F7C,
        "mapWidthHeightWritesMatchExpected": (
            snippet_matches(exe, sections, 0x004244DD, "66 a3 da 5a 59 00")
            and snippet_matches(exe, sections, 0x004244ED, "66 a3 dc 5a 59 00")
        ),
        "mapLayer0CopyDestinationMatchesExpected": snippet_matches(exe, sections, 0x0042453F, "8d 04 45 f0 5a 59 00"),
        "mapLayer1CollisionCopyDestinationMatchesExpected": snippet_matches(exe, sections, 0x0042459C, "8d 04 45 d0 d7 58 00"),
        "mapLayerCopyCallsMatchExpected": (
            rel32_target(exe, sections, MAP_LAYER0_COPY_CALL) == MAP_LAYER_COPY_FUNCTION
            and rel32_target(exe, sections, MAP_LAYER1_COLLISION_COPY_CALL) == MAP_LAYER_COPY_FUNCTION
        ),
    }
    return {
        "functionVaHex": hex32(MAP_LOADER_FUNCTION),
        "payloadLoadCall": direct_call(exe, sections, MAP_PAYLOAD_LOAD_CALL, "map CNS payload loader"),
        "widthGlobalHex": hex32(MAP_WIDTH_TILES),
        "heightGlobalHex": hex32(MAP_HEIGHT_TILES),
        "tileWordGridHex": hex32(MAP_TILE_WORD_GRID),
        "collisionFlagGridHex": hex32(MAP_COLLISION_FLAGS),
        "layerWordSizeBytes": 2,
        "layer0CopyCall": direct_call(exe, sections, MAP_LAYER0_COPY_CALL, "CNS layer0 word grid copy"),
        "layer1CollisionCopyCall": direct_call(exe, sections, MAP_LAYER1_COLLISION_COPY_CALL, "CNS layer1 collision flag grid copy"),
        "copyFunctionHex": hex32(MAP_LAYER_COPY_FUNCTION),
        "copyOrder": [
            "width/height words",
            "CNS layer0 word grid -> 0x00595af0",
            "CNS layer1 word grid -> 0x0058d7d0",
        ],
        "collisionDirectionBits": {
            "down": "0x02",
            "up": "0x01",
            "left": "0x04",
            "right": "0x08",
        },
        "verification": verification,
        "promotionStatus": "collision-flag-grid-mapped-to-cns-layer1",
    }


def build_summary(exe_path: Path = EXE, web_path: Path = WEB_GAME) -> dict:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    runtime_materializers = load_json(RUNTIME_MATERIALIZERS_JSON, {})
    web = web_alignment(web_path)
    actor_motion = actor_motion_summary(exe, sections)
    map_loader = map_collision_loader_summary(exe, sections)
    draw_projection = draw_projection_summary(exe, sections)
    sprite_frame_selector = sprite_frame_selector_summary(exe, sections)
    party_actor_frame_scripts = sprite_frame_selector["partyActorFrameScripts"]
    object_descriptor_table = party_actor_frame_scripts["objectDescriptorTable"]
    descriptor_activation_order = descriptor_activation_order_summary(runtime_materializers)
    per_frame_calls = [
        direct_call(exe, sections, INPUT_STEP_CALL, "input/action mask update"),
        direct_call(exe, sections, OBJECT_CALLBACK_STEP_CALL, "active object callback update"),
        direct_call(exe, sections, SCRIPT_TIMER_STEP_CALL, "object script/timer update"),
    ]
    draw_call = direct_call(exe, sections, DRAW_PREP_CALL, "active draw-list tile projection")
    action_packer = direct_call(exe, sections, ACTION_PACKER_CALL, "primary input table action packer")
    x_tile_shift = read_u8(exe, sections, 0x00424A9B)
    y_tile_shift = read_u8(exe, sections, 0x00424AC0)
    x_screen_bias = read_u8(exe, sections, 0x00424A9E)
    y_screen_bias = read_u8(exe, sections, 0x00424AC3)
    x_fixed_shift = read_u8(exe, sections, 0x00424AA1)
    y_fixed_shift = read_u8(exe, sections, 0x00424AC6)
    tile_size = 1 << x_tile_shift if x_tile_shift == y_tile_shift else None
    per_frame_targets = [row["targetVaHex"] for row in per_frame_calls]
    verification = {
        "updateFunctionMatchesExpected": hex32(UPDATE_FUNCTION) == "0x00411476",
        "perFrameCallsMatchExpected": per_frame_targets == [
            hex32(INPUT_STEP_FUNCTION),
            hex32(OBJECT_CALLBACK_STEP_FUNCTION),
            hex32(SCRIPT_TIMER_STEP_FUNCTION),
        ],
        "drawPrepCallMatchesExpected": draw_call["targetVaHex"] == hex32(DRAW_PREP_FUNCTION),
        "actionPackerCallMatchesExpected": action_packer["targetVaHex"] == hex32(ACTION_PACKER_FUNCTION),
        "inputMaskStoresMatchExpected": (
            bytes_hex(exe, sections, 0x00422D99, 6) == "66 a3 10 e3 59 00"
            and bytes_hex(exe, sections, 0x00422DBE, 6) == "66 a3 12 e3 59 00"
        ),
        "cameraGlobalsMatchExpected": (
            bytes_hex(exe, sections, 0x00424A91, 6) == "66 a1 dc 76 45 00"
            and bytes_hex(exe, sections, 0x00424AB6, 6) == "66 a1 de 76 45 00"
        ),
        "tileProjectionShiftMatchesExpected": x_tile_shift == 4 and y_tile_shift == 4,
        "tileProjectionBiasMatchesExpected": x_screen_bias == 0x18 and y_screen_bias == 0x08,
        "fixedPointShiftMatchesExpected": x_fixed_shift == 0x10 and y_fixed_shift == 0x10,
        "webTileSizeMatchesRuntimeProjection": tile_size == WEB_TILE_SIZE,
        "webOriginalFrameMsMatchesRuntimeTiming": web.get("originalFrameMs") == 48,
        "webDefaultTileStepMatchesOriginalFrame": web.get("defaultTileStepMs") == 48,
        "webWalkFrameConstantsMatchSpriteSheet": (
            web.get("walkFramesPerDirection") == 5
            and web.get("idleFrame") == 0
            and web.get("firstWalkFrame") == 1
            and web.get("frameWidth") == 48
            and web.get("frameHeight") == 64
        ),
        "webFrameQuantizedInterpolationPresent": web.get("frameQuantizedInterpolation") is True,
        "webSourceRectSelectorMappingPresent": web.get("sourceRectSelectorMapping") is True,
        "webSourceRectSelectorCadencePresent": web.get("sourceRectSelectorCadence") is True,
        "webHeldInputTileChainingPresent": web.get("heldInputTileChaining") is True,
        "webWallSlideFallbackPresent": web.get("wallSlideFallbackPresent") is True,
        "webDefaultCollisionModeIsOriginalLayer1Flags": web.get("defaultCollisionMode") == "originalLayer1Flags",
        "webOriginalLayer1FlagCollisionModePresent": web.get("originalLayer1FlagCollisionModePresent") is True,
        "webActorOverlapCollisionModePresent": web.get("actorOverlapCollisionModePresent") is True,
        "webActorOverlapCollisionDefaultDisabled": web.get("actorOverlapCollisionDefaultEnabled") is False,
        "webPartyTrailHistoryPresent": web.get("partyTrailHistoryPresent") is True,
        "webPartyTrailRingMatchesOriginalSlotCount": web.get("partyTrailRingBounded") is True,
        "webPartyTrailCursorModelPresent": web.get("partyTrailCursorModelPresent") is True,
        "webPartyTrailInitialCursorsMatchOriginal": web.get("partyTrailInitialCursorsMatchOriginal") is True,
        "webPartyTrailRecordsAcceptedMoveTarget": web.get("partyTrailRecordsAcceptedMoveTarget") is True,
        "webPartyTrailCallTimingMatchesOriginalTileMutation": (
            web.get("partyTrailRecordsAcceptedMoveTarget") is True
            and actor_motion["verification"]["partyTrailCallTimingMatchesExpected"] is True
        ),
        "actorControllerMatchesExpected": all(actor_motion["verification"].values()),
        "drawProjectionDetailsMatchExpected": all(draw_projection["verification"].values()),
        "spriteFrameSelectorDetailsMatchExpected": all(sprite_frame_selector["verification"].values()),
        "descriptorActivationOrderPathMatchesExpected": all(
            descriptor_activation_order["verification"].values()
        ),
        "inputDrivenTileMutationGrounded": actor_motion["actorController"]["counts"]["currentInputMaskRefCount"] >= 20,
        "originalPartyTrailRingGrounded": actor_motion["verification"]["partyTrailHistoryRingMatchesExpected"] is True,
        "originalCollisionFlagTestsGrounded": actor_motion["collisionHelper"]["counts"]["collisionFlagTableReadCount"] == 4,
        "collisionFlagGridMappedToCnsLayer1": all(map_loader["verification"].values()),
    }
    summary = {
        "source": str(exe_path),
        "mainUpdate": {
            "functionVaHex": hex32(UPDATE_FUNCTION),
            "frameCountArgumentHex": "arg+0x08",
            "frameCountClamp": 3,
            "pausedFallbackFrameCount": 1,
            "perFrameCalls": per_frame_calls,
            "drawPrepCall": draw_call,
            "activeDrawListHex": hex32(ACTIVE_DRAW_LIST),
        },
        "inputPolling": {
            "functionVaHex": hex32(INPUT_STEP_FUNCTION),
            "actionPackerCall": action_packer,
            "keyboardStateVaHex": hex32(KEYBOARD_STATE),
            "primaryKeyTableVaHex": hex32(PRIMARY_KEY_TABLE),
            "currentInputMaskVaHex": hex32(CURRENT_INPUT_MASK),
            "pressedEdgeMaskVaHex": hex32(PRESSED_EDGE_MASK),
            "previousInputMaskVaHex": hex32(PREVIOUS_INPUT_MASK),
            "directionBits": {
                "left": "0x0001",
                "right": "0x0002",
                "up": "0x0004",
                "down": "0x0008",
            },
            "continuousMaskEvidence": "current mask is stored at 0x0059e310 every update; pressed-edge mask is computed separately at 0x0059e312",
        },
        "tileProjection": {
            "functionVaHex": hex32(DRAW_PREP_FUNCTION),
            "objectTileXFieldHex": "+0xe8",
            "objectTileYFieldHex": "+0xea",
            "cameraTileXGlobalHex": hex32(CAMERA_TILE_X),
            "cameraTileYGlobalHex": hex32(CAMERA_TILE_Y),
            "tileShiftBits": x_tile_shift,
            "tileSizePixels": tile_size,
            "screenBiasX": x_screen_bias,
            "screenBiasY": y_screen_bias,
            "fixedPointShiftBits": x_fixed_shift,
            "drawXFieldHex": "+0x1c",
            "drawYFieldHex": "+0x20",
            "projectionFormula": "drawX = ((object[+0xe8] - word[0x004576dc]) << 4) + 0x18; drawY = ((object[+0xea] - word[0x004576de]) << 4) + 0x08; both are then shifted left by 16 before storing",
            "drawProjectionDetails": draw_projection,
        },
        "webMapping": {
            "webTileSizePixels": WEB_TILE_SIZE,
            "tileSizeMatchesRuntimeProjection": tile_size == WEB_TILE_SIZE,
            "movementStepClass": "tile-grid movement with per-frame object/update callbacks and 16px tile projection",
            "tileGridAndCameraConsumerGrounded": True,
            "originalDrawPrepProjectionGrounded": all(draw_projection["verification"].values()),
            "partyActorDrawProjectionGrounded": (
                actor_motion["verification"]["partyActorProjectionMatchesExpected"] is True
            ),
            "partyActorAnimationStateValuesGrounded": (
                actor_motion["verification"]["partyActorAnimationStateStoresMatchExpected"] is True
            ),
            "originalSpriteFrameSelectorGrounded": all(sprite_frame_selector["verification"].values()),
            "originalFrameSelectorToSourceRectGrounded": all(
                sprite_frame_selector["verification"].get(key) is True
                for key in [
                    "boundsHelperFrameSelectorReadsMatchExpected",
                    "boundsHelperGridFrameMathMatchesExpected",
                    "boundsHelperObjectOffsetAdjustmentMatchesExpected",
                ]
            ),
            "originalSpriteFrameScriptTimerGrounded": all(
                sprite_frame_selector["verification"].get(key) is True
                for key in [
                    "scriptTimerLoopMatchesExpected",
                    "scriptTimerPointerSwapMatchesExpected",
                    "generalScriptRunnerDispatchMatchesExpected",
                    "frameSelectorOpcodeTableEntryMatchesExpected",
                    "frameSelectorModeDispatchMatchesExpected",
                ]
            ),
            "actorAnimationStateDirectFrameMappingEliminated": (
                sprite_frame_selector["verification"].get("actorControllerFrameSelectorWritesAbsent") is True
                and sprite_frame_selector["verification"].get("boundsHelperAnimationStateReadsAbsent") is True
            ),
            "actorAnimationStateToFrameSelectorPartyDescriptorsGrounded": (
                all(party_actor_frame_scripts["verification"].values())
            ),
            "objectDescriptorTableClassified": (
                all(object_descriptor_table["verification"].values())
            ),
            "objectDescriptorTableRowCount": object_descriptor_table["rowCount"],
            "objectDescriptorPartyStateTableRows": object_descriptor_table["partyStateTableRowIndices"],
            "nonPartyDescriptorAnimationStateTablesAbsent": (
                object_descriptor_table["nonPartyAnimationStateTableRowCount"] == 0
            ),
            "nonPartyDescriptorFieldMapRefsAbsent": (
                object_descriptor_table["nonPartyFieldMapCnsRefCount"] == 0
            ),
            "descriptorActivationOrderPathGrounded": (
                all(descriptor_activation_order["verification"].values())
            ),
            "descriptorActivationOrderCountRuntimeVa": descriptor_activation_order["countRuntimeVaHex"],
            "descriptorActivationOrderBytesVa": descriptor_activation_order["orderBytesVaHex"],
            "descriptorActivationSlotBaseVa": descriptor_activation_order["slotBaseHex"],
            "descriptorActivationLoadRebuildGrounded": (
                descriptor_activation_order["rebuildAfterSaveReadBlocks"] is True
                and descriptor_activation_order["rebuildBeforeSelectorPointerSelection"] is True
            ),
            "descriptorActivationOpcode20SelfMutationEliminated": (
                descriptor_activation_order["opcode20SelfMutationPathEliminated"] is True
            ),
            "descriptorActivationCurrentSelectorActiveOrderStillUnproven": (
                descriptor_activation_order["currentFrontierActiveOrderProven"] is False
            ),
            "actorAnimationStateToFrameSelectorStillUnresolved": True,
            "actorAnimationStateToFrameSelectorRemainingGap": (
                "the 12-row descriptor table is classified: rows 0..2 ground +0x68 -> +0x64 state-table "
                "frame scripts for Ataho/Rinshan/Smashu, while rows 3..11 are battle/effect/object resource "
                "descriptors without +0x68 state-table selectors or field-map refs; save-load active-order "
                "materialization is grounded, but current selector 2:0 active order and scripted behavior for "
                "non-player descriptors remain inferred"
            ),
            "webDefaultTileStepMs": web.get("defaultTileStepMs"),
            "webFrameQuantizedTileStepGrounded": (
                web.get("defaultTileStepMs") == 48
                and web.get("walkFramesPerDirection") == 5
                and web.get("firstWalkFrame") == 1
                and web.get("frameQuantizedInterpolation") is True
            ),
            "webSourceRectSelectorMappingGrounded": web.get("sourceRectSelectorMapping") is True,
            "webSourceRectSelectorCadenceGrounded": web.get("sourceRectSelectorCadence") is True,
            "webSourceRectPhaseStillInferred": not (
                web.get("sourceRectSelectorMapping") is True
                and web.get("sourceRectSelectorCadence") is True
            ),
            "webCollisionFallbackStillInferred": True,
            "exactActorWalkInterpolationStillInferred": True,
            "webPositionInterpolationStillVisualApproximation": web.get("frameQuantizedInterpolation") is True,
            "originalActorPositionModel": "tile-field-snap-plus-fixed-point-draw-projection",
            "originalActorSubTileInterpolationEvidenceFound": False,
            "originalActorSubTileInterpolationEvidenceScope": (
                "identified actor controller tile mutations +0xe8/+0xea and draw projection writes +0x1c/+0x20"
            ),
            "originalActorTileMutationVas": [
                "0x00430b5b",
                "0x00430b74",
                "0x00430b8d",
                "0x00430ba6",
            ],
            "originalActorDrawProjectionVas": [
                "0x0043111d",
                "0x00431142",
                "0x004311ec",
                "0x0043121d",
            ],
            "browserInterpolationClassification": "browser-visual-overlay-not-original-proof",
            "browserInterpolationOffsetField": "interpolationOffset",
            "browserInterpolationOverlayDetected": web.get("browserInterpolationOverlayDetected"),
            "browserInterpolationFixedPointPreserved": web.get("browserInterpolationFixedPointPreserved"),
            "browserInterpolationOverlayNotPromotingOriginal": True,
            "exactActorSpriteFramePhaseStillInferred": web.get("sourceRectSelectorCadence") is not True,
            "inputDrivenTileMutationGrounded": True,
            "originalPartyTrailRingGrounded": True,
            "originalCollisionFlagTestsGrounded": True,
            "originalCollisionFlagTableNotMappedToCnsTiles": False,
            "originalCollisionFlagGridMappedToCnsLayer1": True,
            "webOriginalLayer1FlagCollisionModePresent": web.get("originalLayer1FlagCollisionModePresent"),
            "webDefaultCollisionModeUsesOriginalLayer1Flags": web.get("defaultCollisionMode") == "originalLayer1Flags",
            "webActorOverlapCollisionModePresent": web.get("actorOverlapCollisionModePresent"),
            "webActorOverlapCollisionDefaultEnabled": web.get("actorOverlapCollisionDefaultEnabled"),
            "webActorOverlapCollisionModeQuery": web.get("actorOverlapCollisionModeQuery"),
            "webActorOverlapCollisionStillOptIn": web.get("actorOverlapCollisionDefaultEnabled") is False,
            "webPartyTrailHistoryPresent": web.get("partyTrailHistoryPresent"),
            "webPartyTrailRingSlotCount": web.get("partyTrailRingSlotCount"),
            "webPartyTrailRingMatchesOriginalSlotCount": web.get("partyTrailRingBounded"),
            "webPartyTrailActorSlotCount": web.get("partyTrailActorSlotCount"),
            "webPartyTrailInitialCursors": web.get("partyTrailInitialCursors"),
            "webPartyTrailCursorModelPresent": web.get("partyTrailCursorModelPresent"),
            "webPartyTrailInitialCursorsMatchOriginal": web.get("partyTrailInitialCursorsMatchOriginal"),
            "webPartyTrailRecordsAcceptedMoveTarget": web.get("partyTrailRecordsAcceptedMoveTarget"),
            "webPartyTrailCallTimingMatchesOriginalTileMutation": (
                web.get("partyTrailRecordsAcceptedMoveTarget") is True
                and actor_motion["verification"]["partyTrailCallTimingMatchesExpected"] is True
            ),
            "webPartyTrailStillApproximate": False,
            "webDefaultCollisionModeStillFallback": web.get("defaultCollisionMode") == "tileClassLayer0",
        },
        "actorMotion": actor_motion,
        "spriteFrameSelector": sprite_frame_selector,
        "descriptorActivationOrder": descriptor_activation_order,
        "mapCollisionLoader": map_loader,
        "webImplementation": web,
        "verification": verification,
        "promotionStatus": "movement-consumer-partially-grounded",
        "conclusion": (
            "The original per-frame update at 0x00411476 calls the input/action-mask step, active-object callback "
            "step, and object script/timer step once for each capped frame. The input step stores a continuous "
            "action mask separately from the new-press edge mask, which matches the browser's held-key walking "
            "behavior. The draw preparation path maps object tile fields +0xe8/+0xea against save/camera tile "
            "globals 0x004576dc/0x004576de, multiplies by 16 pixels, applies small screen biases, and stores "
            "fixed-point draw coordinates; the draw dirty-rect helper then consumes those projected +0x1c/+0x20 "
            "coordinates. The web runtime uses that actor-controller tile mutation evidence as a 48ms tile step, keeps the original "
            "party actor +0x28 idle source rects and full 8-command walking selector cadence for each facing "
            "direction, and keeps frame-quantized tile interpolation as a browser visual overlay rather than "
            "original sub-tile interpolation proof. "
            "The input-driven actor controller at 0x0043022d reads the continuous input mask, writes object tile "
            "fields +0xe8/+0xea, and calls the collision helper at 0x004319f8, which checks map collision flag "
            "bits from 0x0058d7d0/0x0058d7ce against object footprint fields +0xe6/+0xe7. The map loader at "
            "0x0042449c copies the CNS layer0 word grid to 0x00595af0 and the CNS layer1 word grid directly to "
            "0x0058d7d0, so the original collision flag grid is now mapped back to CNS layer1. The actor controller "
            "also maintains a 7-slot tile/direction history ring at 0x00574540/0x00574550/0x00574552/0x00574554 "
            "for companion actor following, seeded with per-actor cursors 0,4,1; in the same actor-controller pass "
            "it writes the mutated leader tile before follower reads. The browser has a partyTrail cursor-ring model "
            "for the same surface and records the accepted movement target into the ring. "
            "The actor controller also recomputes party actor draw coordinates from the leader tile or follower "
            "trail entry and uses object +0x68 for direction/animation state values 1-8. The object script/timer "
            "update at 0x00432ff0 drives +0x28 through +0x62/+0x64 and opcode 0x21 handler 0x004044bd. "
            "Party actor descriptors at 0x00442d95 initialize +0x64 through opcode 0x20 handler 0x0040448a, "
            "then use opcode 0x18 command bytes 18 a0 64 68 to select state-table frame scripts from object "
            "+0x68 for cara_at1/cara_rs1/cara_sm1. Those selected scripts contain opcode 0x21 selector "
            "sequences that loop via opcode 0x03. The same 12-row descriptor table is now classified: rows "
            "0..2 are party walking descriptors, while rows 3..11 are battle/effect/object resource descriptors "
            "with no +0x68 state-table selector and no field-map CNS refs. Save-load active descriptor "
            "materialization is also separated: 0x00432323 rebuilds slot descriptor pointers from count/order "
            "after save blocks are read and before selector pointer selection, and later add/remove paths are "
            "general-table opcode 0x62/0x63 mutations. The draw helper's "
            "+0x28 frame selector, 0x0055abd8 surface-table lookup, source-rect math, and optional +0x30/+0x34 "
            "sprite offset path are identified, and the identified actor-controller range does not directly write +0x28, "
            "and no original sub-tile interpolation writer has been identified in the actor tile/projection path; "
            "current selector 2:0 active order and scripted behavior for non-player descriptors remain unresolved. "
            "The browser now keeps the same "
            "8-command timer=1 selector cadence for party actor source rects while a held move chains "
            "from one original-frame tile step into the next. "
            "The browser uses originalLayer1Flags as the default collision mode for that grid, lets party actors "
            "overlap/pass through each other by default, and keeps actorCollision=1 only as a review switch for "
            "the old overlap-blocking behavior; tileClassLayer0 remains available as a local-review fallback. Full non-player "
            "descriptor behavior still requires more reconstruction."
        ),
    }
    return summary


def markdown(summary: dict) -> str:
    update = summary["mainUpdate"]
    input_polling = summary["inputPolling"]
    projection = summary["tileProjection"]
    mapping = summary["webMapping"]
    web = summary["webImplementation"]
    loader = summary["mapCollisionLoader"]
    sprite_frame = summary["spriteFrameSelector"]
    frame_timer = sprite_frame["scriptFrameTimer"]
    party_frame_scripts = sprite_frame["partyActorFrameScripts"]
    object_descriptor_table = party_frame_scripts["objectDescriptorTable"]
    descriptor_activation = summary["descriptorActivationOrder"]
    collision = summary["actorMotion"]["collisionHelper"]
    party_projection = summary["actorMotion"]["actorController"]["partyActorScreenProjection"]
    trail = summary["actorMotion"]["actorController"]["partyTrailHistory"]
    overlap = collision["objectOverlapResponse"]
    per_frame_calls = ", ".join(
        f"`{row['callVaHex']} -> {row['targetVaHex']}` {row['label']}"
        for row in update["perFrameCalls"]
    )
    lines = [
        "# Runtime Movement",
        "",
        f"- update function: `{update['functionVaHex']}`",
        f"- per-frame calls: {per_frame_calls}",
        f"- draw prep call: `{update['drawPrepCall']['callVaHex']} -> {update['drawPrepCall']['targetVaHex']}`",
        f"- input action packer: `{input_polling['actionPackerCall']['callVaHex']} -> {input_polling['actionPackerCall']['targetVaHex']}`",
        f"- continuous input mask: `{input_polling['currentInputMaskVaHex']}`; pressed-edge mask: `{input_polling['pressedEdgeMaskVaHex']}`",
        f"- direction bits: left {input_polling['directionBits']['left']}, right {input_polling['directionBits']['right']}, up {input_polling['directionBits']['up']}, down {input_polling['directionBits']['down']}",
        f"- tile projection: object `{projection['objectTileXFieldHex']}`/`{projection['objectTileYFieldHex']}` minus camera `{projection['cameraTileXGlobalHex']}`/`{projection['cameraTileYGlobalHex']}`",
        f"- tile size: `{projection['tileSizePixels']}` px; web tile size matches: {mapping['tileSizeMatchesRuntimeProjection']}",
        f"- draw projection grounded: prep {mapping['originalDrawPrepProjectionGrounded']}; party actors {mapping['partyActorDrawProjectionGrounded']}; +0x68 animation state values grounded: {mapping['partyActorAnimationStateValuesGrounded']}",
        f"- sprite frame selector grounded: {mapping['originalSpriteFrameSelectorGrounded']}; selector `{sprite_frame['selectorFieldHex']}` low 16 frame index, bits 16..23 surface table `{sprite_frame['surfaceTableHex']}`; source-rect grounded: {mapping['originalFrameSelectorToSourceRectGrounded']}; script timer `{frame_timer['functionVaHex']} -> {frame_timer['generalScriptRunnerHex']}`, opcode {frame_timer['frameSelectorOpcodeHex']} handler `{frame_timer['frameSelectorHandlerVaHex']}`; script timer grounded: {mapping['originalSpriteFrameScriptTimerGrounded']}; direct +0x68 mapping eliminated: {mapping['actorAnimationStateDirectFrameMappingEliminated']}; party +0x68-to-frame-script grounded: {mapping['actorAnimationStateToFrameSelectorPartyDescriptorsGrounded']}; remaining +0x68-to-+0x28 gap: {mapping['actorAnimationStateToFrameSelectorRemainingGap']}",
        f"- party actor frame scripts: descriptor table `{party_frame_scripts['descriptorTableHex']}`, frame pointer opcode {party_frame_scripts['framePointerInitializer']['opcodeHex']} handler `{party_frame_scripts['framePointerInitializer']['handlerVaHex']}`, state table opcode {party_frame_scripts['animationStatePointerTableCommand']['opcodeHex']} handler `{party_frame_scripts['animationStatePointerTableCommand']['handlerVaHex']}`, command `{party_frame_scripts['animationStatePointerTableCommand']['commandBytes']}`; {party_frame_scripts['classification']}",
        f"- object descriptor table classified: {mapping['objectDescriptorTableClassified']}; rows {mapping['objectDescriptorTableRowCount']}; party state-table rows {mapping['objectDescriptorPartyStateTableRows']}; non-party +0x68 state tables absent: {mapping['nonPartyDescriptorAnimationStateTablesAbsent']}; non-party field-map CNS refs absent: {mapping['nonPartyDescriptorFieldMapRefsAbsent']}; {object_descriptor_table['classification']}",
        f"- descriptor activation/order path grounded: {mapping['descriptorActivationOrderPathGrounded']}; count `{descriptor_activation['countRuntimeVaHex']}` order `{descriptor_activation['orderBytesVaHex']}` slot base `{descriptor_activation['slotBaseHex']}`; load rebuild `{descriptor_activation['loadRebuildCallVaHex']} -> {descriptor_activation['loadRebuildFunctionVaHex']}` after save reads: {descriptor_activation['rebuildAfterSaveReadBlocks']} before selector selection: {descriptor_activation['rebuildBeforeSelectorPointerSelection']}; opcode 0x20 self-mutation eliminated: {descriptor_activation['opcode20SelfMutationPathEliminated']}; current selector active order proven: {descriptor_activation['currentFrontierActiveOrderProven']}",
        f"- web tile step: `{web['defaultTileStepMs']}` ms from `{web['defaultTileStepMsExpression']}`; clamp {web['moveMsClamp']['min']}..{web['moveMsClamp']['max']} ms",
        f"- web walk frames: {web['walkFramesPerDirection']} per direction, idle {web['idleFrame']}, moving starts at {web['firstWalkFrame']}, frame {web['frameWidth']}x{web['frameHeight']}",
        f"- web source rect selector mapping grounded: {mapping['webSourceRectSelectorMappingGrounded']}; 8-command cadence grounded: {mapping['webSourceRectSelectorCadenceGrounded']}; cadence commands: {web['walkCadenceCommandCount']}; model: {web['sourceRectSelectorMappingModel']}",
        f"- frame-quantized interpolation present: {web['frameQuantizedInterpolation']}; held input tile chaining: {web['heldInputTileChaining']}",
        f"- wall-slide fallback present: {web['wallSlideFallbackPresent']}; actual movement direction drives facing: {web['wallSlideActualDirection']}; diagonal follow-up: {web['wallSlideDiagonalFollowup']}; default collision mode: `{web['defaultCollisionMode']}` ({web['collisionFallbackClass']})",
        f"- camera deadzone present: {web['cameraDeadzonePresent']}; deadzone tiles: {web['cameraDeadzoneTiles']}",
        f"- deferred map query sync: {web['deferredMapQuerySync']}",
        f"- actor-overlap collision mode present: {web['actorOverlapCollisionModePresent']}; default enabled: {web['actorOverlapCollisionDefaultEnabled']}; query: `{web['actorOverlapCollisionModeQuery']}`",
        f"- actor controller: `{summary['actorMotion']['actorController']['functionVaHex']}`; collision helper: `{summary['actorMotion']['collisionHelper']['functionVaHex']}`",
        f"- original party trail ring: {trail['slotCount']} slots at `{trail['indexTableHex']}`/`{trail['tileXTableHex']}`/`{trail['tileYTableHex']}`/`{trail['directionTableHex']}`; initial cursors {trail['initialCursors']}; timing: {trail['callTiming']['classification']}; web partyTrail slots: {web['partyTrailRingSlotCount']}; cursor model: {web['partyTrailCursorModelPresent']}; records accepted target: {web['partyTrailRecordsAcceptedMoveTarget']}; bounded: {web['partyTrailRingBounded']}",
        f"- original collision flags: `{summary['actorMotion']['collisionHelper']['collisionFlagTableHex']}`/`{summary['actorMotion']['collisionHelper']['collisionEdgeFlagTableHex']}`; direction bits {summary['actorMotion']['collisionHelper']['directionBlockFlagBits']}",
        f"- directional collision cases: {len(collision['directionCases'])}; object overlap response: {overlap['classification']}; clears latch: {overlap['clearsDirectionLatchOnOverlap']}",
        f"- map collision loader: `{loader['functionVaHex']}` copies CNS layer0 to `{loader['tileWordGridHex']}` and CNS layer1 flags to `{loader['collisionFlagGridHex']}`",
        f"- web original layer1 flag collision mode present: {mapping['webOriginalLayer1FlagCollisionModePresent']}; default uses original layer1 flags: {mapping['webDefaultCollisionModeUsesOriginalLayer1Flags']}; tileClass fallback default: {mapping['webDefaultCollisionModeStillFallback']}",
        f"- web actor-overlap collision mode present: {mapping['webActorOverlapCollisionModePresent']}; default enabled: {mapping['webActorOverlapCollisionDefaultEnabled']}; opt-in review switch: {mapping['webActorOverlapCollisionStillOptIn']}",
        f"- web party-trail history present: {mapping['webPartyTrailHistoryPresent']}; ring slots: {mapping['webPartyTrailRingSlotCount']}; actor slots: {mapping['webPartyTrailActorSlotCount']}; initial cursors: {mapping['webPartyTrailInitialCursors']}; cursor model: {mapping['webPartyTrailCursorModelPresent']}; matches original cursors: {mapping['webPartyTrailInitialCursorsMatchOriginal']}; records accepted target: {mapping['webPartyTrailRecordsAcceptedMoveTarget']}; call timing match: {mapping['webPartyTrailCallTimingMatchesOriginalTileMutation']}; still approximate: {mapping['webPartyTrailStillApproximate']}",
        f"- input-driven tile mutation grounded: {mapping['inputDrivenTileMutationGrounded']}; original collision flag tests grounded: {mapping['originalCollisionFlagTestsGrounded']}",
        f"- original actor position model: {mapping['originalActorPositionModel']}; sub-tile interpolation evidence found: {mapping['originalActorSubTileInterpolationEvidenceFound']}; scope: {mapping['originalActorSubTileInterpolationEvidenceScope']}",
        f"- browser interpolation classification: {mapping['browserInterpolationClassification']}; overlay detected: {mapping['browserInterpolationOverlayDetected']}; fixed-point preserved: {mapping['browserInterpolationFixedPointPreserved']}; offset field: `{mapping['browserInterpolationOffsetField']}`",
        f"- web position interpolation still visual approximation: {mapping['webPositionInterpolationStillVisualApproximation']}; web source-rect phase still inferred: {mapping['webSourceRectPhaseStillInferred']}; exact actor sprite-frame phase still inferred: {mapping['exactActorSpriteFramePhaseStillInferred']}",
        f"- status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Projection Formula",
        "",
        f"`{projection['projectionFormula']}`",
        "",
        "## Verification",
        "",
        "| check | value |",
        "| --- | --- |",
    ]
    for key, value in summary["verification"].items():
        lines.append(f"| {key} | {value} |")
    lines.extend([
        "",
        "## Actor Motion",
        "",
        "| area | value |",
        "| --- | --- |",
        f"| actor controller | `{summary['actorMotion']['actorController']['functionVaHex']}` |",
        f"| current input mask refs | {summary['actorMotion']['actorController']['counts']['currentInputMaskRefCount']} |",
        f"| party trail history | {trail['classification']} at `{trail['indexTableHex']}`/`{trail['tileXTableHex']}`/`{trail['tileYTableHex']}`/`{trail['directionTableHex']}` |",
        f"| party actor screen projection | {party_projection['classification']} |",
        f"| collision helper call | `{summary['actorMotion']['actorController']['collisionHelperCall']['callVaHex']} -> {summary['actorMotion']['actorController']['collisionHelperCall']['targetVaHex']}` |",
        f"| collision helper | `{summary['actorMotion']['collisionHelper']['functionVaHex']}` |",
        f"| collision flag table reads | {summary['actorMotion']['collisionHelper']['counts']['collisionFlagTableReadCount']} |",
        f"| object overlap response | {overlap['classification']} |",
        f"| script tile step handler | `{summary['actorMotion']['scriptTileStepHandler']['functionVaHex']}` |",
        f"| sprite frame selector | {sprite_frame['classification']} |",
        f"| sprite frame selector writer | {sprite_frame['scriptFrameSelectorWriter']['classification']} at `{sprite_frame['scriptFrameSelectorWriter']['functionVaHex']}` |",
        f"| sprite frame timer | {frame_timer['classification']} at `{frame_timer['functionVaHex']}` via `{frame_timer['generalScriptRunnerHex']}` opcode {frame_timer['frameSelectorOpcodeHex']} handler `{frame_timer['frameSelectorHandlerVaHex']}` |",
        f"| party actor frame script table | {party_frame_scripts['classification']} at `{party_frame_scripts['descriptorTableHex']}` |",
        f"| object descriptor table | {object_descriptor_table['classification']} at `{object_descriptor_table['descriptorTableHex']}` |",
        f"| descriptor activation/order | {descriptor_activation['classification']} from `{descriptor_activation['source']}` |",
        f"| map collision loader | `{loader['functionVaHex']}` |",
        f"| CNS layer0 destination | `{loader['tileWordGridHex']}` |",
        f"| CNS layer1 collision destination | `{loader['collisionFlagGridHex']}` |",
        "",
        "## Party Actor Frame Scripts",
        "",
        "| character | descriptor | surface | state table | idle state 1 selector | moving state 5 loop |",
        "| --- | --- | --- | --- | --- | --- |",
    ])
    for character in party_frame_scripts["characters"]:
        state1 = character["stateEntries"][1]
        state5 = character["stateEntries"][5]
        lines.append(
            f"| {character['resource']} | `{character['descriptorVaHex']}` | "
            f"`{character['baseSurfaceIndexHex']}` | `{character['animationStatePointerTableHex']}` | "
            f"{', '.join(f'`{value}`' for value in state1['selectorSequenceHex'])} | "
            f"{', '.join(f'`{value}`' for value in state5['selectorSequenceHex'])} |"
        )
    lines.extend([
        "",
        "## Object Descriptor Table",
        "",
        "| index | descriptor | class | linked CNS | +0x68 state table | valid frame initializers |",
        "| ---: | --- | --- | --- | --- | ---: |",
    ])
    for row in object_descriptor_table["rows"]:
        lines.append(
            f"| {row['index']} | `{row['descriptorVaHex']}` | {row['resourceClass']} | "
            f"{', '.join(f'`{name}`' for name in row['linkedCns']) or '-'} | "
            f"{', '.join(f'`{value}`' for value in row['animationStateTableTargetsHex']) or '-'} | "
            f"{row['validFramePointerInitializerCount']} |"
        )
    lines.extend([
        "",
        "## Directional Collision Cases",
        "",
        "| direction | input latch | blocked fallback | flag bit | flag grid | scan shape | boundary |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in collision["directionCases"]:
        lines.append(
            f"| {row['direction']} | `{row['inputLatchHex']}` | `{row['blockedFallbackLatchHex']}` | "
            f"`{row['flagBitHex']}` | `{row['flagGridHex']}` | {row['scanShape']} | {row['boundaryRule']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    update = summary["mainUpdate"]
    input_polling = summary["inputPolling"]
    projection = summary["tileProjection"]
    mapping = summary["webMapping"]
    web = summary["webImplementation"]
    calls = "".join(
        f"<li><code>{html.escape(row['callVaHex'])} -&gt; {html.escape(row['targetVaHex'])}</code> {html.escape(row['label'])}</li>"
        for row in update["perFrameCalls"]
    )
    checks = "\n".join(
        f"<tr><td>{html.escape(key)}</td><td>{html.escape(str(value))}</td></tr>"
        for key, value in summary["verification"].items()
    )
    actor = summary["actorMotion"]
    sprite_frame = summary["spriteFrameSelector"]
    frame_timer = sprite_frame["scriptFrameTimer"]
    party_frame_scripts = sprite_frame["partyActorFrameScripts"]
    object_descriptor_table = party_frame_scripts["objectDescriptorTable"]
    descriptor_activation = summary["descriptorActivationOrder"]
    collision = actor["collisionHelper"]
    trail = actor["actorController"]["partyTrailHistory"]
    party_projection = actor["actorController"]["partyActorScreenProjection"]
    overlap = collision["objectOverlapResponse"]
    loader = summary["mapCollisionLoader"]
    direction_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(row['direction'])}</td>"
        f"<td><code>{html.escape(row['inputLatchHex'])}</code></td>"
        f"<td><code>{html.escape(row['blockedFallbackLatchHex'])}</code></td>"
        f"<td><code>{html.escape(row['flagBitHex'])}</code></td>"
        f"<td><code>{html.escape(row['flagGridHex'])}</code></td>"
        f"<td>{html.escape(row['scanShape'])}</td>"
        f"<td>{html.escape(row['boundaryRule'])}</td>"
        "</tr>"
        for row in collision["directionCases"]
    )
    party_frame_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(character['resource'])}</td>"
        f"<td><code>{html.escape(character['descriptorVaHex'])}</code></td>"
        f"<td><code>{html.escape(character['baseSurfaceIndexHex'])}</code></td>"
        f"<td><code>{html.escape(character['animationStatePointerTableHex'])}</code></td>"
        f"<td>{html.escape(', '.join((character['stateEntries'][1])['selectorSequenceHex']))}</td>"
        f"<td>{html.escape(', '.join((character['stateEntries'][5])['selectorSequenceHex']))}</td>"
        "</tr>"
        for character in party_frame_scripts["characters"]
    )
    object_descriptor_rows = "\n".join(
        "<tr>"
        f"<td>{row['index']}</td>"
        f"<td><code>{html.escape(row['descriptorVaHex'])}</code></td>"
        f"<td>{html.escape(row['resourceClass'])}</td>"
        f"<td>{html.escape(', '.join(row['linkedCns']) or '-')}</td>"
        f"<td>{html.escape(', '.join(row['animationStateTableTargetsHex']) or '-')}</td>"
        f"<td>{row['validFramePointerInitializerCount']}</td>"
        "</tr>"
        for row in object_descriptor_table["rows"]
    )
    return "\n".join([
        "<!doctype html><meta charset='utf-8'>",
        "<title>Runtime Movement</title>",
        "<style>body{font-family:system-ui,sans-serif;margin:24px;line-height:1.45;max-width:1040px}table{border-collapse:collapse}th,td{border:1px solid #ddd;padding:6px 8px;text-align:left}th{background:#f4f4f4}code{white-space:nowrap}</style>",
        "<h1>Runtime Movement</h1>",
        f"<p><b>Update function:</b> <code>{update['functionVaHex']}</code>; <b>status:</b> <code>{summary['promotionStatus']}</code></p>",
        f"<ul>{calls}</ul>",
        f"<p><b>Draw prep:</b> <code>{update['drawPrepCall']['callVaHex']} -&gt; {update['drawPrepCall']['targetVaHex']}</code></p>",
        f"<p><b>Input packer:</b> <code>{input_polling['actionPackerCall']['callVaHex']} -&gt; {input_polling['actionPackerCall']['targetVaHex']}</code>; continuous mask <code>{input_polling['currentInputMaskVaHex']}</code>; edge mask <code>{input_polling['pressedEdgeMaskVaHex']}</code>.</p>",
        f"<p><b>Tile projection:</b> {html.escape(projection['projectionFormula'])}</p>",
        f"<p><b>Tile size:</b> {projection['tileSizePixels']} px; web match: {mapping['tileSizeMatchesRuntimeProjection']}; draw projection grounded: prep {mapping['originalDrawPrepProjectionGrounded']}; party actors {mapping['partyActorDrawProjectionGrounded']}; web source rect selector mapping grounded: {mapping['webSourceRectSelectorMappingGrounded']}; 8-command cadence grounded: {mapping['webSourceRectSelectorCadenceGrounded']}; web position interpolation still visual approximation: {mapping['webPositionInterpolationStillVisualApproximation']}; exact actor sprite-frame phase still inferred: {mapping['exactActorSpriteFramePhaseStillInferred']}.</p>",
        f"<p><b>original actor position model:</b> {html.escape(mapping['originalActorPositionModel'])}; sub-tile interpolation evidence found: {mapping['originalActorSubTileInterpolationEvidenceFound']}; scope {html.escape(mapping['originalActorSubTileInterpolationEvidenceScope'])}. browser interpolation classification: {html.escape(mapping['browserInterpolationClassification'])}; overlay detected: {mapping['browserInterpolationOverlayDetected']}; fixed-point preserved: {mapping['browserInterpolationFixedPointPreserved']}; offset field <code>{html.escape(mapping['browserInterpolationOffsetField'])}</code>.</p>",
        f"<p><b>Actor motion:</b> controller <code>{actor['actorController']['functionVaHex']}</code>, collision helper <code>{collision['functionVaHex']}</code>, collision flags <code>{collision['collisionFlagTableHex']}</code>/<code>{collision['collisionEdgeFlagTableHex']}</code>; input-driven tile mutation grounded {mapping['inputDrivenTileMutationGrounded']}, original collision flag tests grounded {mapping['originalCollisionFlagTestsGrounded']}. Directional cases {len(collision['directionCases'])}; object overlap response {html.escape(overlap['classification'])}; clears latch {overlap['clearsDirectionLatchOnOverlap']}. party trail ring {trail['slotCount']} slots at <code>{trail['indexTableHex']}</code>/<code>{trail['tileXTableHex']}</code>/<code>{trail['tileYTableHex']}</code>/<code>{trail['directionTableHex']}</code>; initial cursors {html.escape(str(trail['initialCursors']))}; timing {html.escape(trail['callTiming']['classification'])}. party actor screen projection: {html.escape(party_projection['classification'])}.</p>",
        f"<p><b>Sprite frame selector:</b> sprite frame selector grounded {mapping['originalSpriteFrameSelectorGrounded']}; <code>{sprite_frame['selectorFieldHex']}</code> uses {html.escape(sprite_frame['selectorPacking']['frameIndex'])} plus {html.escape(sprite_frame['selectorPacking']['surfaceTableIndex'])}; source-rect path grounded {mapping['originalFrameSelectorToSourceRectGrounded']}; script/timer <code>{frame_timer['functionVaHex']} -&gt; {frame_timer['generalScriptRunnerHex']}</code>, table <code>{frame_timer['handlerTableHex']}</code>, opcode {frame_timer['frameSelectorOpcodeHex']} handler <code>{frame_timer['frameSelectorHandlerVaHex']}</code>, grounded {mapping['originalSpriteFrameScriptTimerGrounded']}; direct +0x68 mapping eliminated {mapping['actorAnimationStateDirectFrameMappingEliminated']}; party +0x68-to-frame-script grounded {mapping['actorAnimationStateToFrameSelectorPartyDescriptorsGrounded']}; remaining +0x68-to-+0x28 gap {html.escape(mapping['actorAnimationStateToFrameSelectorRemainingGap'])}. {html.escape(sprite_frame['classification'])}</p>",
        f"<p><b>Party actor frame scripts:</b> descriptor table <code>{party_frame_scripts['descriptorTableHex']}</code>; opcode {party_frame_scripts['framePointerInitializer']['opcodeHex']} handler <code>{party_frame_scripts['framePointerInitializer']['handlerVaHex']}</code>; opcode {party_frame_scripts['animationStatePointerTableCommand']['opcodeHex']} handler <code>{party_frame_scripts['animationStatePointerTableCommand']['handlerVaHex']}</code>; command <code>{html.escape(party_frame_scripts['animationStatePointerTableCommand']['commandBytes'])}</code>. {html.escape(party_frame_scripts['classification'])}</p>",
        f"<p><b>Object descriptor table:</b> classified {mapping['objectDescriptorTableClassified']}; rows {mapping['objectDescriptorTableRowCount']}; party state-table rows {html.escape(str(mapping['objectDescriptorPartyStateTableRows']))}; non-party +0x68 state tables absent {mapping['nonPartyDescriptorAnimationStateTablesAbsent']}; non-party field-map CNS refs absent {mapping['nonPartyDescriptorFieldMapRefsAbsent']}. {html.escape(object_descriptor_table['classification'])}</p>",
        f"<p><b>Descriptor activation/order:</b> grounded {mapping['descriptorActivationOrderPathGrounded']}; count <code>{html.escape(str(descriptor_activation['countRuntimeVaHex']))}</code>, order <code>{html.escape(str(descriptor_activation['orderBytesVaHex']))}</code>, slot base <code>{html.escape(str(descriptor_activation['slotBaseHex']))}</code>; load rebuild <code>{html.escape(str(descriptor_activation['loadRebuildCallVaHex']))} -&gt; {html.escape(str(descriptor_activation['loadRebuildFunctionVaHex']))}</code>, after save reads {descriptor_activation['rebuildAfterSaveReadBlocks']}, before selector selection {descriptor_activation['rebuildBeforeSelectorPointerSelection']}; opcode 0x20 self-mutation eliminated {descriptor_activation['opcode20SelfMutationPathEliminated']}; current selector active order proven {descriptor_activation['currentFrontierActiveOrderProven']}. {html.escape(descriptor_activation['classification'])}</p>",
        f"<p><b>Map collision loader:</b> <code>{loader['functionVaHex']}</code> copies CNS layer0 to <code>{loader['tileWordGridHex']}</code> and CNS layer1 collision flags to <code>{loader['collisionFlagGridHex']}</code>.</p>",
        f"<p><b>Web movement alignment:</b> {web['defaultTileStepMs']} ms step from {html.escape(str(web['defaultTileStepMsExpression']))}; walk frames {web['walkFramesPerDirection']} ({web['frameWidth']}x{web['frameHeight']}), idle {web['idleFrame']}, first moving {web['firstWalkFrame']}; source rect selectors {html.escape(str(web['sourceRectSelectorMappingModel']))}; cadence commands {web['walkCadenceCommandCount']}; frame-quantized interpolation {web['frameQuantizedInterpolation']}; wall-slide fallback {web['wallSlideFallbackPresent']}; actual movement direction drives facing {web['wallSlideActualDirection']}; diagonal follow-up {web['wallSlideDiagonalFollowup']}; camera deadzone {web['cameraDeadzonePresent']} {html.escape(str(web['cameraDeadzoneTiles']))}; deferred map query sync {web['deferredMapQuerySync']}; original layer1 flag collision mode {web['originalLayer1FlagCollisionModePresent']}; party overlap pass-through default {not web['actorOverlapCollisionDefaultEnabled']} via <code>{html.escape(str(web['actorOverlapCollisionModeQuery']))}</code>; partyTrail history {web['partyTrailHistoryPresent']} with {web['partyTrailRingSlotCount']} slots and cursor model {web['partyTrailCursorModelPresent']} seeded {html.escape(str(web['partyTrailInitialCursors']))}, accepted target write {web['partyTrailRecordsAcceptedMoveTarget']}; default collision <code>{html.escape(str(web['defaultCollisionMode']))}</code>.</p>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Verification</h2>",
        "<table><thead><tr><th>check</th><th>value</th></tr></thead><tbody>",
        checks,
        "</tbody></table>",
        "<h2>Party Actor Frame Scripts</h2>",
        "<table><thead><tr><th>character</th><th>descriptor</th><th>surface</th><th>state table</th><th>idle state 1 selector</th><th>moving state 5 loop</th></tr></thead><tbody>",
        party_frame_rows,
        "</tbody></table>",
        "<h2>Object Descriptor Table</h2>",
        "<table><thead><tr><th>index</th><th>descriptor</th><th>class</th><th>linked CNS</th><th>+0x68 state table</th><th>valid frame initializers</th></tr></thead><tbody>",
        object_descriptor_rows,
        "</tbody></table>",
        "<h2>Directional Collision Cases</h2>",
        "<table><thead><tr><th>direction</th><th>input latch</th><th>blocked fallback</th><th>flag bit</th><th>flag grid</th><th>scan shape</th><th>boundary</th></tr></thead><tbody>",
        direction_rows,
        "</tbody></table>",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "runtime_movement.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "runtime_movement.md").write_text(markdown(summary), encoding="utf-8")
    (out_dir / "runtime_movement.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--web-index", type=Path, default=WEB_GAME)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.exe, args.web_index)
    write_outputs(summary, args.out_dir)
    print(f"wrote runtime movement -> {args.out_dir / 'runtime_movement.md'}")


if __name__ == "__main__":
    main()
