#!/usr/bin/env python3
"""Probe monster battle display frame tables.

The player action VM has fixed per-player display tables.  Monster actors use a
different layer: shared action records choose damage/effect semantics, while the
visible frames appear to come from CNS-local display tables adjacent to each
monster asset descriptor.
"""
from __future__ import annotations

import html
import json
import re
import struct
import subprocess
from pathlib import Path
from typing import Any

from build_battle_display_vm_static_decode import EXE, OUT, hex32, read_sections, walk_script
from probe_exe_scene_tables import va_to_offset


ACTION_MAPPING = OUT / "battle_action_mapping.json"
MONSTER_RECTS = OUT / "monster_frame_rect_exe_pattern_scan.json"
MONSTER_ACTION_SELECTION = OUT / "battle_monster_action_selection_review.json"
ENEMY_STAT_TABLE = OUT / "enemy_stat_table.json"
PLAYER_DISPLAY_RANGES = [
    (0x004D5D00, 0x004D7600),
    (0x004DAD00, 0x004DC700),
    (0x00524400, 0x00525900),
]

MONSTER_TABLE_SEARCH_BEFORE = 0x20
MONSTER_TABLE_SEARCH_AFTER = 0x700
MAX_TABLE_ENTRIES = 96
MIN_TABLE_ENTRIES = 3
TEXT_VA_START = 0x00401000
TEXT_VA_END = 0x0043A000
ACTOR_FIELD_SCAN_START = 0x58
ACTOR_FIELD_SCAN_END = 0x67
ACTION_VM_RUNNER = 0x00402321
ACTION_VM_HANDLER_TABLE = 0x00440538
ENEMY_STAT_BASE = 0x00457C60
ENEMY_STAT_STRIDE = 0x38
ACTIVE_DESCRIPTOR_BASE = 0x00457750
ACTIVE_DESCRIPTOR_STRIDE = 0xD8


def parse_hex(value: Any) -> int | None:
    if isinstance(value, int):
        return value
    if isinstance(value, str) and value:
        return int(value, 16)
    return None


def signed_delta(value: int | None) -> str:
    if value is None:
        return ""
    return f"{value:+d}"


def in_player_display_range(va: int) -> bool:
    return any(start <= va < end for start, end in PLAYER_DISPLAY_RANGES)


def is_readable_static_pointer(sections: list[dict[str, Any]], va: int) -> bool:
    return 0x00400000 <= va < 0x00580000 and va_to_offset(sections, va) is not None


def read_u32_va(raw: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(raw):
        return None
    return struct.unpack_from("<I", raw, offset)[0]


def read_u16_at(raw: bytes, offset: int) -> int:
    return struct.unpack_from("<H", raw, offset)[0]


def read_action_vm_handler(raw: bytes, sections: list[dict[str, Any]], opcode: int) -> int | None:
    return read_u32_va(raw, sections, ACTION_VM_HANDLER_TABLE + opcode * 4)


def walk_cached(
    raw: bytes,
    sections: list[dict[str, Any]],
    cache: dict[int, dict[str, Any]],
    pointer: int,
    index: int,
) -> dict[str, Any]:
    if pointer not in cache:
        cache[pointer] = walk_script(raw, sections, pointer, index, max_steps=120)
    return cache[pointer]


def walk_is_vm_like(walk: dict[str, Any]) -> bool:
    if (walk.get("instructionCount") or 0) <= 0:
        return False
    if str(walk.get("stopReason") or "").startswith("unknown"):
        return False
    categories = {row.get("category") for row in walk.get("rows") or []}
    return bool(categories & {"frame", "sound", "effect-sound", "movement", "spawn-child-vm"})


def table_run(
    raw: bytes,
    sections: list[dict[str, Any]],
    cache: dict[int, dict[str, Any]],
    table_va: int,
    max_entries: int = MAX_TABLE_ENTRIES,
) -> dict[str, Any] | None:
    entries: list[dict[str, Any]] = []
    unique_pointers: set[int] = set()
    for index in range(max_entries):
        pointer = read_u32_va(raw, sections, table_va + index * 4)
        if pointer is None:
            break
        if not is_readable_static_pointer(sections, pointer) or in_player_display_range(pointer):
            break
        walk = walk_cached(raw, sections, cache, pointer, index)
        if not walk_is_vm_like(walk):
            break
        unique_pointers.add(pointer)
        entries.append(entry_summary(index, pointer, walk))
    if len(entries) < MIN_TABLE_ENTRIES:
        return None
    frame_entry_count = sum(1 for entry in entries if entry["frames"])
    action_slot_count = sum(1 for entry in entries if 10 <= entry["index"] <= 14 and entry["frames"])
    score = len(entries) * 2 + len(unique_pointers) + frame_entry_count * 3 + action_slot_count * 6
    return {
        "tableVa": table_va,
        "tableVaHex": hex32(table_va),
        "entryCount": len(entries),
        "uniquePointerCount": len(unique_pointers),
        "frameEntryCount": frame_entry_count,
        "actionSlotFrameCount": action_slot_count,
        "score": score,
        "entries": entries,
        "selectorInterpretation": selector_interpretation(entries),
    }


def entry_summary(index: int, pointer: int, walk: dict[str, Any]) -> dict[str, Any]:
    frames = []
    for frame in walk.get("frames") or []:
        sprite_hex = frame.get("spriteHex")
        sprite_value = int(sprite_hex, 16) if isinstance(sprite_hex, str) and sprite_hex else None
        frames.append({
            "vaHex": frame.get("vaHex") or "",
            "selectorHighWord": sprite_value,
            "selectorHighWordHex": sprite_hex or "",
            "selectorLowWord": frame.get("frame"),
            "gate": frame.get("gate"),
        })

    def compact_sound(sound: dict[str, Any]) -> dict[str, Any] | None:
        wlk_no = sound.get("wlkNo")
        if wlk_no is None:
            wlk_no = sound.get("normalWlkNo")
        if wlk_no is None:
            return None
        wlk_file_index = sound.get("wlkFileIndex")
        if wlk_file_index is None and sound.get("effectArgsHex"):
            parts = str(sound.get("effectArgsHex") or "").split()
            if len(parts) >= 3:
                try:
                    wlk_file_index = int(parts[2], 16)
                except ValueError:
                    wlk_file_index = None
        compact = {
            "vaHex": sound.get("vaHex") or "",
            "wlkNo": wlk_no,
            "wlkFileIndex": wlk_file_index if wlk_file_index is not None else (wlk_no if isinstance(wlk_no, int) else None),
            "normalWlkNo": sound.get("normalWlkNo"),
            "altWlkNo": sound.get("altWlkNo"),
            "mode": sound.get("mode"),
            "summary": sound.get("summary") or "",
        }
        if sound.get("effectArgsHex"):
            compact["effectArgsHex"] = sound.get("effectArgsHex")
        return compact

    result_sounds = [item for sound in walk.get("sounds") or [] if (item := compact_sound(sound))]
    effect_sounds = [item for sound in walk.get("effectSounds") or [] if (item := compact_sound(sound))]
    sounds = result_sounds + effect_sounds
    movements = []
    position_writes = []
    wait_barriers = []
    helper_calls = []
    actor_flags = []
    for row in walk.get("rows") or []:
        if row.get("category") == "movement":
            movements.append({
                "vaHex": row.get("vaHex") or "",
                "summary": row.get("summary") or "",
                "movementMode": row.get("movementMode"),
                "motionMode": row.get("motionMode"),
                "motionKind": row.get("motionKind"),
                "selector": row.get("selector"),
                "selectorHex": row.get("selectorHex"),
                "divisor": row.get("divisor"),
                "stepDivisor": row.get("stepDivisor"),
                "targetRangePolicy": row.get("targetRangePolicy"),
                "selectorMeaning": row.get("selectorMeaning"),
                "kind1Formula": row.get("kind1Formula"),
                "kind2Formula": row.get("kind2Formula"),
                "handlerEvidence": row.get("handlerEvidence"),
            })
        elif row.get("category") == "write" and row.get("destHex") in {"0x1c", "0x20"}:
            position_writes.append({
                "vaHex": row.get("vaHex") or "",
                "summary": row.get("summary") or "",
                "destHex": row.get("destHex") or "",
                "immFixed": row.get("immFixed"),
                "opName": row.get("opName") or "",
            })
        elif row.get("category") == "wait" or row.get("opcode") in {"0xbf", "0xc1"}:
            wait_barriers.append({
                "vaHex": row.get("vaHex") or "",
                "opcode": row.get("opcode") or "",
                "mode": row.get("mode"),
                "maskHex": row.get("maskHex") or "",
                "bytes": row.get("bytes") or "",
                "summary": row.get("summary") or "",
            })
        elif row.get("category") == "cleanup/helper":
            helper_calls.append({
                "vaHex": row.get("vaHex") or "",
                "helperId": row.get("helperId"),
                "helperIdHex": row.get("helperIdHex") or "",
                "bytes": row.get("bytes") or "",
                "summary": row.get("summary") or "",
            })
        elif row.get("category") == "actor-flags":
            actor_flags.append({
                "vaHex": row.get("vaHex") or "",
                "mode": row.get("mode"),
                "maskHex": row.get("maskHex") or "",
                "bytes": row.get("bytes") or "",
                "summary": row.get("summary") or "",
            })
    categories = [row.get("category") for row in walk.get("rows") or []]
    return {
        "index": index,
        "phaseIfBaseIndexed": index,
        "monsterActionSlotIfPhaseMinus0x0a": index - 0x0A if index >= 0x0A else None,
        "pointerVaHex": hex32(pointer),
        "stopReason": walk.get("stopReason") or "",
        "instructionCount": walk.get("instructionCount"),
        "categories": sorted({category for category in categories if category}),
        "frames": frames,
        "frameSelectorSequence": [
            frame["selectorHighWord"] if frame["selectorLowWord"] == 0 else frame["selectorLowWord"]
            for frame in frames
        ],
        "sounds": sounds,
        "resultSounds": result_sounds,
        "effectSounds": effect_sounds,
        "waitBarriers": wait_barriers,
        "helperCalls": helper_calls,
        "repeatLoops": walk.get("repeatLoops") or [],
        "actorFlags": actor_flags,
        "movements": movements,
        "positionWrites": position_writes,
    }


def monster_movement_summary(asset_rows: list[dict[str, Any]]) -> dict[str, Any]:
    action_entries = []
    movement_entries = []
    selector_entries = []
    position_write_entries = []
    signature_counts: dict[str, int] = {}
    for row in asset_rows:
        for entry in row.get("entries") or []:
            slot = entry.get("monsterActionSlotIfPhaseMinus0x0a")
            if slot is None:
                continue
            action_entries.append((row, entry))
            movements = entry.get("movements") or []
            position_writes = entry.get("positionWrites") or []
            if movements:
                movement_entries.append((row, entry))
            if position_writes:
                position_write_entries.append((row, entry))
            for movement in movements:
                key = (
                    f"{movement.get('motionKind') or 'movement'},"
                    f"mode={movement.get('motionMode', movement.get('movementMode'))},"
                    f"selector={movement.get('selector')},"
                    f"divisor={movement.get('stepDivisor', movement.get('divisor'))}"
                )
                signature_counts[key] = signature_counts.get(key, 0) + 1
                if movement.get("selector") not in (None, 0):
                    selector_entries.append((row, entry))
    notable = []
    for row, entry in selector_entries[:40]:
        notable.append({
            "asset": row.get("asset"),
            "slot": entry.get("monsterActionSlotIfPhaseMinus0x0a"),
            "tableIndex": entry.get("index"),
            "frames": entry.get("frameSelectorSequence") or [],
            "movements": [movement.get("summary") for movement in entry.get("movements") or []],
            "positionWrites": [write.get("summary") for write in entry.get("positionWrites") or []],
        })
    return {
        "actionEntryCount": len(action_entries),
        "entriesWithMovementOpcode": len(movement_entries),
        "entriesWithNonzeroPlacementSelector": len(selector_entries),
        "entriesWithPositionWrite": len(position_write_entries),
        "movementSignatureCounts": dict(sorted(signature_counts.items(), key=lambda item: (-item[1], item[0]))),
        "interpretation": [
            "Monster action slots are CNS-local frame scripts, not the same player-position rule table used by the three playable characters.",
            "Many slots only reset to selector 0 or swap local frames/sounds in place.",
            "Some slots do use nonzero placement/motion selectors such as selector 3 and coordinate writes. Treat these as per-action lunge/offset/return scripts rather than a global player-style 'walk to target, attack, return' rule.",
            "The local scripts prove that monster assets can perform in-place frame changes and short local displacement, but they do not by themselves prove which shared attack selects which slot.",
        ],
        "notableNonzeroSelectorSamples": notable,
    }


def actor_field_semantics() -> list[dict[str, str]]:
    return [
        {
            "field": "actor+0x58",
            "label": "action table family/category",
            "status": "confirmed",
            "evidence": "0x0043329f writes category 0/1/2 before actor+0x59. Accessors branch on this field to choose player-owned table, shared table, or special handling.",
        },
        {
            "field": "actor+0x59",
            "label": "shared/player action id",
            "status": "confirmed",
            "evidence": "0x0041505d and 0x00433649 use it to select the action/name/payload record. Opening/player paths also use it directly as display phase base.",
        },
        {
            "field": "actor+0x5a",
            "label": "type-2 local display slot selector",
            "status": "confirmed-reader-and-vm-script-produced",
            "evidence": "0x0040cfb9 reads actor+0x5a, adds 0x0a, stores actor+0x60, then calls 0x411754(actor+0x88, 0x7d). Direct x86 writes still only show the player-path zero reset, but out/battle_monster_action_selection_review.json now confirms descriptor/vtable script3 target blocks write actor/object+0x5a through VM opcode 0x10 (`10 c0 5a yy`).",
        },
        {
            "field": "actor+0x5b",
            "label": "display/action refresh flag",
            "status": "observed",
            "evidence": "Several battle action paths set actor+0x5b=1 when a display phase/status transition is queued.",
        },
        {
            "field": "actor+0x5c",
            "label": "battle actor flags / wait bits",
            "status": "observed",
            "evidence": "Action and result paths OR/AND/test this dword around phase transitions; not needed for skillId->display-slot promotion yet.",
        },
        {
            "field": "actor+0x60",
            "label": "current display phase",
            "status": "confirmed",
            "evidence": "0x0040cf60/0x0040cfb9 set it immediately before 0x411754 display VM dispatch.",
        },
        {
            "field": "actor+0x61",
            "label": "target/linked actor index",
            "status": "observed",
            "evidence": "Selection and post-action paths copy actor indexes here; it is consumed to find linked actors during battle sequencing.",
        },
        {
            "field": "actor+0x67",
            "label": "payload-derived result/target selector",
            "status": "confirmed",
            "evidence": "0x00433545 and 0x004335c6 call 0x00433649 to read payload byte at actionRecord+0x1a+unitIndex*8 and store it in actor+0x67.",
        },
    ]


def display_slot_producer_search() -> dict[str, Any]:
    return {
        "status": "direct-x86-producer-not-found; superseded-by-vm-script-producer",
        "supersededBy": "out/battle_monster_action_selection_review.json",
        "staticConclusion": (
            "This older probe still correctly shows that no direct x86 field write "
            "produces a nonzero actor+0x5a. That negative direct-write scan is no "
            "longer the final producer conclusion: the current static model finds the "
            "producer in descriptor/vtable VM bytecode. Script3 target blocks contain "
            "`10 c0 59 xx` (shared action id) followed by `10 c0 5a yy` (visible "
            "local slot), and the existing reader bridge remains actor+0x5a + 0x0a."
        ),
        "directActor5aReferences": [
            {
                "vaHex": "0x0040cf4c",
                "access": "write-zero",
                "summary": "player/owned actor display path clears actor+0x5a before using actor+0x59+0x0a as the display phase.",
            },
            {
                "vaHex": "0x0040cfbe",
                "access": "read",
                "summary": "enemy/shared actor display path reads actor+0x5a, adds 0x0a, stores actor+0x60, then dispatches the display VM.",
            },
        ],
        "confirmedNonProducers": [
            {
                "vaHex": "0x00433f0e",
                "summary": "result/special dispatcher. It routes damage/status/recovery handling through 0x00546970 or 0x00546a38; it does not write actor+0x5a.",
            },
            {
                "vaHex": "0x00546970",
                "summary": "result-family function table used by hit payload byte +0x1b. This is damage/status/recovery result handling, not visible monster action slot binding.",
            },
            {
                "vaHex": "0x00546a38",
                "summary": "special/action-side effect table used when actor+0x58 != 0. Valid early entries are code, then the area falls into data; it is not a 0x5a slot table.",
            },
            {
                "vaHex": "0x0040be38",
                "summary": "main actor initialization zeroes enemy/static actor memory and copies only descriptor/stat/source ranges that do not populate actor+0x5a.",
            },
            {
                "vaHex": "0x0040cd00..0x0040cefe",
                "summary": "player-owned skill growth/update path. It increments actor+0x59/display skill levels for type-1 actors and does not produce enemy actor+0x5a.",
            },
            {
                "vaHex": "0x0040d080",
                "summary": "damage/hit application loop for opcode 0xa6. It iterates targets and routes result logic; it does not bind shared enemy skillId to a local display slot.",
            },
            {
                "vaHex": "0x0040d250..0x0040ddfd",
                "summary": "reaction/status/display phase setters. These can queue hit/death/status phases such as actor+0x60=3/4/5/6, but they are not normal attack slot producers.",
            },
            {
                "vaHex": "0x0040c57c..0x0040c61c",
                "summary": "packed dword writes to +0x58 set the VM/script context state at [ebp+8]+0x58. The battle actor is held separately in [ebp-0x4], so these writes are not actor+0x5a producers.",
            },
            {
                "vaHex": "0x0040c0c5",
                "summary": "secondary actor creation path also zeroes the actor and fills stats/coords without assigning actor+0x5a.",
            },
            {
                "vaHex": "0x0040f5a9",
                "summary": "clone/action helper clears actor+0x48..0x9f, then writes actor+0x59 only; it does not inherit or produce actor+0x5a.",
            },
            {
                "vaHex": "0x0041505d",
                "summary": "action label/name display helper reads actor+0x58/+0x59 to choose text/payload records; no actor+0x5a write is present.",
            },
            {
                "vaHex": "0x00414f54",
                "summary": "the 0x7c companion helper has the same structure as 0x7d but uses a player-owned action/name table; it reads actor+0x59, not actor+0x5a.",
            },
            {
                "vaHex": "0x00411754",
                "summary": "display VM dispatcher only calls handler table 0x454c10[opcode]. It does not compute or write actor+0x5a.",
            },
            {
                "vaHex": "0x00433aca",
                "summary": "target selection helper chooses a living target actor index using actor+0x61/result state; it does not choose visible monster action frames.",
            },
            {
                "vaHex": "0x0043329f",
                "summary": "selection helper writes actor+0x58 category and actor+0x59 action id. This is the skill/action id producer, not the local display slot producer.",
            },
            {
                "vaHex": "0x00422b95",
                "summary": "display slot derived-field updater. It reads display/script fields and writes derived screen values, not battle actor+0x5a.",
            },
            {
                "vaHex": "0x0043215c",
                "summary": "display slot attach/script context bridge. It connects display records and script contexts to battle actor slots but does not assign actor+0x5a.",
            },
            {
                "vaHex": "0x00402549 / 0x00404a2f / 0x00404f75",
                "summary": "generic action/script VM context handlers. Their argument object carries a +0x40 bytecode pointer, and +0x58 is used as a temporary/result or child-context pointer; these writes are not battle actor fields.",
            },
            {
                "vaHex": "0x0040b55f / 0x0040b696 / 0x0040b84a",
                "summary": "generic opcodes 0x8c/0x8d/0x8f can write to [context+0xa8 + script operand], but .data contains no occurrence whose destination operand is 0x5a.",
            },
            {
                "vaHex": "0x0040b752",
                "summary": "generic opcode 0x8e loads/saves active descriptor record+0x40/+0x41 through globals 0x59e340/0x59e34a. That explains action id selection, not local display slot binding.",
            },
            {
                "vaHex": "0x0041e72d / 0x0041fc8a",
                "summary": "vtable-like handlers referenced from table 0x0047f250. They write +0x58 on their own display/script object and advance object+0xb0 script data; these writes are not battle actor+0x5a producers.",
            },
        ],
        "contextCaveats": [
            "Several nearby writes to offset +0x58 are VM context writes, not battle actor writes. In display opcode handlers, [ebp+8]+0x58 often belongs to the script context whose +0x40 is the bytecode stream pointer.",
            "The dynamic bytecode-destination opcodes 0x8c/0x8d/0x8f were explicitly checked for destination operand 0x5a; none were found in .data.",
            "Direct static writes, packed +0x58 writes, dynamic bytecode destinations, and direct +0x60 phase bypasses were checked; none produce a nonzero actor+0x5a. This is a direct-write limitation, not proof that descriptor VM bytecode cannot produce the field.",
            "Real-turn runtime observation is intentionally excluded here; monster attack conclusions are static-analysis only.",
        ],
        "currentConclusion": "actor+0x59 selects the shared action/payload/name record; actor+0x5a selects the monster-local visible slot. Both are data-driven by descriptor script3 target blocks, not by a direct x86 assignment.",
    }


def display_dispatch_review() -> list[dict[str, Any]]:
    return [
        {
            "vaHex": "0x00411754",
            "role": "display VM dispatcher",
            "evidence": "calls dword[0x454c10 + opcode*4] with the display object argument.",
            "interpretation": "The caller supplies opcode 0x7c/0x7d/etc.; this function does not choose a monster frame slot.",
        },
        {
            "vaHex": "0x00414f54",
            "role": "handler table entry 0x7c",
            "evidence": "0x454c10[0x7c] = 0x00414f54; reads actor+0x58 and actor+0x59, then chooses a player-owned action/name record.",
            "interpretation": "Companion/player action label/helper path.",
        },
        {
            "vaHex": "0x0041505d",
            "role": "handler table entry 0x7d",
            "evidence": "0x454c10[0x7d] = 0x0041505d; reads actor+0x58 and actor+0x59, then chooses shared action/name/payload records.",
            "interpretation": "Shared/enemy action label/helper path. This confirms actor+0x59 as shared action id, not a local frame index.",
        },
        {
            "vaHex": "0x0040cf0a",
            "role": "normal action display phase setup",
            "evidence": "type 1: actor+0x60 = actor+0x59 + 0x0a; type 2: actor+0x60 = actor+0x5a + 0x0a; then dispatches 0x7c/0x7d.",
            "interpretation": "This is the only confirmed bridge from actor state into display phase. The type-2 bridge reads +0x5a, and the current static scans find no nonzero producer.",
        },
    ]


def action_vm_handler_review(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    key_opcodes = [
        {
            "opcode": 0x98,
            "role": "secondary/enemy actor allocation",
            "summary": "Creates battle actor records from static descriptors and zeroes the actor body. This initializes actor+0x5a to 0.",
            "slotRelevance": "initializer; no nonzero slot value",
        },
        {
            "opcode": 0x9E,
            "role": "action selection / target latch",
            "summary": "Calls 0x0043329f to write actor+0x58 category and actor+0x59 action id, then uses actor+0x67 to choose actor+0x61 target behavior. The dword writes to [ctx+0x58] here are script-context state, not battle actor+0x58.",
            "slotRelevance": "action id producer, but not actor+0x5a producer",
        },
        {
            "opcode": 0xA3,
            "role": "player-owned skill growth/update",
            "summary": "Only runs when the current actor category is player-owned/normal. It can increment actor+0x59 and skill growth tables, then queues phase 8. It skips category != 0 and never writes actor+0x5a.",
            "slotRelevance": "player skill progression; not enemy local display slot binding",
        },
        {
            "opcode": 0xA4,
            "role": "normal action display phase bridge",
            "summary": "For type 1 actors, clears actor+0x5a and sets actor+0x60 = actor+0x59 + 0x0a. For type 2 actors, reads actor+0x5a, sets actor+0x60 = actor+0x5a + 0x0a, then dispatches display helper 0x7d.",
            "slotRelevance": "confirmed reader of actor+0x5a; not a producer",
        },
        {
            "opcode": 0xA5,
            "role": "payload-derived selector refresh",
            "summary": "Calls 0x004335c6, which writes actor+0x67 from the selected payload unit for normal category, or constants for category 1/2.",
            "slotRelevance": "result/target selector; not visible local action slot",
        },
        {
            "opcode": 0xA6,
            "role": "hit/damage/status application loop",
            "summary": "Iterates target actors, calls 0x00433f0e(attacker,target), and queues hit/result fields. It consumes payload units but does not choose monster CNS action slot.",
            "slotRelevance": "damage/result logic; not actor+0x5a producer",
        },
        {
            "opcode": 0xAD,
            "role": "battle actor flag opcode",
            "summary": "Sets/clears actor+0x5c flags and can force actor+0x60 reaction phase 3. It changes timing/wait state, not attack-slot selection.",
            "slotRelevance": "flag/reaction; not actor+0x5a producer",
        },
        {
            "opcode": 0xBF,
            "role": "busy/action wait gate",
            "summary": "Waits while actor busy flags remain. It controls synchronization between display and result phases.",
            "slotRelevance": "timing gate only",
        },
        {
            "opcode": 0xC1,
            "role": "masked actor-flag wait gate",
            "summary": "Waits for selected actor flag bits to clear.",
            "slotRelevance": "timing gate only",
        },
        {
            "opcode": 0xC2,
            "role": "WLK effect sound",
            "summary": "Chooses WLK effect sound by opcode operands and actor side flags.",
            "slotRelevance": "sound only",
        },
    ]
    rows: list[dict[str, Any]] = []
    for row in key_opcodes:
        handler = read_action_vm_handler(raw, sections, row["opcode"])
        rows.append({
            **row,
            "opcodeHex": f"0x{row['opcode']:02x}",
            "tableEntryVaHex": hex32(ACTION_VM_HANDLER_TABLE + row["opcode"] * 4),
            "handlerVaHex": hex32(handler),
        })
    neighborhood: list[dict[str, Any]] = []
    for opcode in range(0x98, 0xA7):
        handler = read_action_vm_handler(raw, sections, opcode)
        neighborhood.append({
            "opcode": opcode,
            "opcodeHex": f"0x{opcode:02x}",
            "handlerVaHex": hex32(handler),
        })
    return {
        "status": "action-vm-opcode-flow-separates-runtime-phases-from-descriptor-producer",
        "runnerVaHex": hex32(ACTION_VM_RUNNER),
        "handlerTableVaHex": hex32(ACTION_VM_HANDLER_TABLE),
        "dispatchRule": "0x00402321 reads opcode byte at [object+0x40] and calls dword[0x00440538 + opcode*4].",
        "keyHandlers": rows,
        "nearbyOpcodeTable": neighborhood,
        "conclusion": [
            "The static VM flow separates three concerns: opcode 0x9e selects actor+0x58/+0x59 action id, opcode 0xa4 bridges actor state to display phase, and opcode 0xa6 applies hit/damage/status payloads.",
            "The confirmed type-2 visible-slot reader remains opcode 0xa4 reading actor+0x5a. The producer is not one of these runtime action VM handlers; it is descriptor script3 opcode 0x10 bytecode.",
            "The dword writes to [context+0x58] in opcode 0x9e are not battle actor writes; they update the VM context state and only incidentally touch a byte position named +0x5a.",
            "Therefore this action-VM flow remains a consumer/phase bridge review, while out/battle_monster_action_selection_review.json owns the shared action id -> local monster CNS display slot binding.",
        ],
    }


def monster_display_vm_write_search(
    raw: bytes,
    sections: list[dict[str, Any]],
    cache: dict[int, dict[str, Any]],
    asset_rows: list[dict[str, Any]],
) -> dict[str, Any]:
    write_dest_counts: dict[str, int] = {}
    interesting_writes: list[dict[str, Any]] = []
    table_count = 0
    entry_count = 0
    write_row_count = 0
    watched_dests = {
        "0x58",
        "0x59",
        "0x5a",
        "0x5b",
        "0x5c",
        "0x5d",
        "0x5e",
        "0x5f",
        "0x60",
        "0x61",
        "0x62",
        "0x63",
        "0x67",
    }
    for asset in asset_rows:
        if asset.get("displayActionTableStatus") == "not-found":
            continue
        table_va = parse_hex(asset.get("displayActionTableVaHex"))
        entry_total = int(asset.get("entryCount") or 0)
        if table_va is None or entry_total <= 0:
            continue
        table_count += 1
        for index in range(entry_total):
            pointer = read_u32_va(raw, sections, table_va + index * 4)
            if pointer is None:
                continue
            walk = walk_cached(raw, sections, cache, pointer, index)
            entry_count += 1
            for row in walk.get("rows") or []:
                dest = row.get("destHex")
                if row.get("category") not in {"write", "child-write"} and not dest:
                    continue
                write_row_count += 1
                dest_key = str(dest or "")
                write_dest_counts[dest_key] = write_dest_counts.get(dest_key, 0) + 1
                if dest_key in watched_dests:
                    interesting_writes.append({
                        "asset": asset.get("asset") or "",
                        "tableVaHex": hex32(table_va),
                        "entryIndex": index,
                        "scriptVaHex": hex32(pointer),
                        "rowVaHex": row.get("vaHex") or "",
                        "category": row.get("category") or "",
                        "destHex": dest_key,
                        "summary": row.get("summary") or "",
                    })
    return {
        "status": "no-monster-display-vm-write-to-actor-action-fields",
        "tableCount": table_count,
        "entryCount": entry_count,
        "writeRowCount": write_row_count,
        "writeDestCounts": dict(sorted(write_dest_counts.items())),
        "watchedDests": sorted(watched_dests),
        "interestingWrites": interesting_writes,
        "interpretation": [
            "A full static walk of the detected monster descriptor-local display VM tables found no write/child-write to +0x58..+0x63 or +0x67.",
            "Observed write destinations are display/script fields such as +0x1c and +0x20, not battle actor action fields.",
            "This weakens the indirect-VM-write hypothesis for actor+0x5a. A remaining producer, if it exists, is likely outside these descriptor-local display scripts.",
        ],
    }


def opening_runtime_observation() -> dict[str, Any]:
    return {
        "status": "excluded-static-analysis-only",
        "source": None,
        "runtimeCrashed": None,
        "sampleCount": 0,
        "battleSampleCount": 0,
        "stateRows": [],
        "transitions": [],
        "observations": [
            "Opening/runtime probes are intentionally excluded from this monster attack report.",
            "The intro contains scripted player attacks and no monster attack turn, so it cannot prove enemy action-slot binding.",
            "Current conclusions below are based on EXE static analysis only.",
        ],
        "caveats": [
            "Do not use opening captures to promote monster action bindings.",
            "If runtime work is needed later, it should target an actual enemy turn, not the opening cinematic.",
        ],
    }


def objdump_text_instructions() -> list[dict[str, Any]]:
    proc = subprocess.run(
        ["objdump", "-d", "-Mintel", str(EXE)],
        check=True,
        capture_output=True,
        text=True,
    )
    rows: list[dict[str, Any]] = []
    line_re = re.compile(r"^\s*([0-9a-fA-F]+):\s+([0-9a-fA-F ]+)\s+\t(.+)$")
    for raw_line in proc.stdout.splitlines():
        match = line_re.match(raw_line)
        if not match:
            continue
        va = int(match.group(1), 16)
        if not (TEXT_VA_START <= va < TEXT_VA_END):
            continue
        rows.append({
            "va": va,
            "vaHex": hex32(va),
            "bytes": " ".join(match.group(2).split()),
            "asm": match.group(3).strip(),
            "raw": raw_line.strip(),
        })
    return rows


def operand_size(asm: str) -> int | None:
    if "BYTE PTR" in asm:
        return 1
    if "DWORD PTR" in asm:
        return 4
    if "WORD PTR" in asm:
        return 2
    return None


def first_operand(asm: str) -> str:
    parts = asm.split(None, 1)
    if len(parts) < 2:
        return ""
    return parts[1].split(",", 1)[0].strip()


def mnemonic(asm: str) -> str:
    return asm.split(None, 1)[0] if asm else ""


def memory_fields(asm: str) -> list[int]:
    fields: list[int] = []
    for match in re.finditer(r"\[[^\]]*\+0x([0-9a-fA-F]+)\]", asm):
        field = int(match.group(1), 16)
        if ACTOR_FIELD_SCAN_START <= field <= ACTOR_FIELD_SCAN_END:
            fields.append(field)
    return fields


def field_access_kind(asm: str, field: int) -> str:
    op = mnemonic(asm)
    first = first_operand(asm)
    field_token = f"+0x{field:x}]"
    is_dest = "[" in first and field_token in first.lower()
    if op == "mov":
        return "write" if is_dest else "read"
    if op in {"inc", "dec"} and is_dest:
        return "readwrite"
    if op in {"or", "and", "add", "sub", "xor", "shl", "shr", "sar"} and is_dest:
        return "readwrite"
    if op in {"call", "jmp", "push", "cmp", "test", "movsx", "movzx", "lea"}:
        return "read"
    return "write" if is_dest else "read"


def immediate_after_comma(asm: str) -> int | None:
    if "," not in asm:
        return None
    tail = asm.rsplit(",", 1)[1].strip()
    if tail.startswith("0x"):
        try:
            return int(tail, 16)
        except ValueError:
            return None
    if re.fullmatch(r"-?\d+", tail):
        return int(tail, 10)
    return None


def write_touches_actor5a(field: int, size: int | None) -> bool:
    if size is None:
        return field == 0x5A
    return field <= 0x5A < field + size


def byte_written_to_5a(field: int, size: int | None, imm: int | None) -> int | None:
    if imm is None or size is None or not write_touches_actor5a(field, size):
        return None
    shift = (0x5A - field) * 8
    return (imm >> shift) & 0xFF


def nearby_rows(rows: list[dict[str, Any]], index: int, radius: int = 8) -> list[dict[str, Any]]:
    return rows[max(0, index - radius): min(len(rows), index + radius + 1)]


def known_touching_5a_classification(va: int) -> str:
    if va == 0x0040CF4C:
        return "battle actor explicit +0x5a zero reset on player/owned action path"
    if va == 0x0040CF32:
        return "display VM call context +0x58 clear ([ebp+8]), not battle actor"
    if 0x0040C57C <= va <= 0x0040C61C:
        return "opcode/script context +0x58 state write, not battle actor"
    if 0x0040EF62 <= va <= 0x0040F3D3:
        return "opcode/script context +0x58 state write, not battle actor"
    if 0x0040FF6B <= va <= 0x0040FFC3:
        return "opcode/script context +0x58 state write, not battle actor"
    if va == 0x00402581:
        return "action/script VM context +0x58 child-context pointer write; object uses +0x40 bytecode pointer, not battle actor"
    if 0x0040466E <= va <= 0x00405CB4:
        return "action/script VM context +0x58 temporary/result register write; object uses +0x40 bytecode pointer, not battle actor"
    if 0x004332BA <= va <= 0x004333B8:
        return "battle actor action category/id producer; byte writes only, no +0x5a byte touched"
    if 0x0041E77F <= va <= 0x0041E829:
        return "non-actor vtable/display object +0x58 state write; handler lives in vtable-like table 0x0047f250 and advances object+0xb0 script data"
    if 0x0041FBE3 <= va <= 0x0041FDBA:
        return "non-actor vtable/display object +0x58 state write; handler lives in vtable-like table 0x0047f250 and advances object+0xb0 script data"
    return "unclassified direct memory write; no static proof that this is a battle actor +0x5a producer"


def actor_action_field_static_audit() -> dict[str, Any]:
    rows = objdump_text_instructions()
    actor_refs = [row for row in rows if "0x59db30" in row["asm"]]
    field_access_counts: dict[str, int] = {}
    field_write_counts: dict[str, int] = {}
    direct_5a_refs: list[dict[str, Any]] = []
    writes_touching_5a: list[dict[str, Any]] = []
    unaligned_pre58_writes_touching_5a: list[dict[str, Any]] = []
    actor_array_nearby_writes: list[dict[str, Any]] = []

    for index, row in enumerate(rows):
        asm = row["asm"]
        for match in re.finditer(r"\[[^\]]*\+0x([0-9a-fA-F]+)\]", asm):
            field = int(match.group(1), 16)
            size = operand_size(asm)
            access = field_access_kind(asm, field)
            if (
                access in {"write", "readwrite"}
                and size is not None
                and field < ACTOR_FIELD_SCAN_START
                and field <= 0x5A < field + size
            ):
                imm = immediate_after_comma(asm)
                unaligned_pre58_writes_touching_5a.append({
                    "vaHex": row["vaHex"],
                    "fieldHex": f"+0x{field:02x}",
                    "access": access,
                    "size": size,
                    "asm": asm,
                    "imm": imm,
                    "byteWrittenTo5a": byte_written_to_5a(field, size, imm),
                    "classification": known_touching_5a_classification(row["va"]),
                })
        fields = memory_fields(asm)
        if not fields:
            continue
        for field in fields:
            access = field_access_kind(asm, field)
            size = operand_size(asm)
            field_key = f"+0x{field:02x}"
            count_key = f"{field_key}:{access}"
            field_access_counts[count_key] = field_access_counts.get(count_key, 0) + 1
            if access in {"write", "readwrite"}:
                field_write_counts[field_key] = field_write_counts.get(field_key, 0) + 1
            if field == 0x5A:
                direct_5a_refs.append({
                    "vaHex": row["vaHex"],
                    "access": access,
                    "size": size,
                    "asm": asm,
                })
            if access in {"write", "readwrite"} and write_touches_actor5a(field, size):
                imm = immediate_after_comma(asm)
                byte5a = byte_written_to_5a(field, size, imm)
                local = nearby_rows(rows, index, radius=8)
                actor_nearby = [near for near in local if "0x59db30" in near["asm"]]
                writes_touching_5a.append({
                    "vaHex": row["vaHex"],
                    "fieldHex": f"+0x{field:02x}",
                    "access": access,
                    "size": size,
                    "asm": asm,
                    "imm": imm,
                    "byteWrittenTo5a": byte5a,
                    "actorArrayNearby": bool(actor_nearby),
                    "nearestActorArrayVaHex": actor_nearby[0]["vaHex"] if actor_nearby else "",
                    "classification": known_touching_5a_classification(row["va"]),
                    "snippet": [near["raw"] for near in local],
                })
        if any(field_access_kind(asm, field) in {"write", "readwrite"} for field in fields):
            local = nearby_rows(rows, index, radius=5)
            actor_nearby = [near for near in local if "0x59db30" in near["asm"]]
            if actor_nearby:
                actor_array_nearby_writes.append({
                    "vaHex": row["vaHex"],
                    "fields": [f"+0x{field:02x}" for field in fields],
                    "asm": asm,
                    "nearestActorArrayVaHex": actor_nearby[0]["vaHex"],
                    "classification": known_touching_5a_classification(row["va"]),
                })

    nonzero_direct_5a_writes = [
        row for row in writes_touching_5a
        if row["fieldHex"] == "+0x5a" and row.get("byteWrittenTo5a") not in (0, None)
    ]
    dynamic_touching_5a = [
        row for row in writes_touching_5a
        if row.get("byteWrittenTo5a") is None and row["fieldHex"] in {"+0x58", "+0x59", "+0x5a"}
    ]
    battle_context_touching_5a = [
        row for row in writes_touching_5a
        if row.get("actorArrayNearby")
    ]
    return {
        "status": "no-static-battle-actor5a-producer",
        "instructionCount": len(rows),
        "actorArrayReferenceCount": len(actor_refs),
        "fieldAccessCounts": dict(sorted(field_access_counts.items())),
        "fieldWriteCounts": dict(sorted(field_write_counts.items())),
        "direct5aReferences": direct_5a_refs,
        "writesTouching5aCount": len(writes_touching_5a),
        "writesTouching5a": writes_touching_5a,
        "unalignedPre58WritesTouching5aCount": len(unaligned_pre58_writes_touching_5a),
        "unalignedPre58WritesTouching5a": unaligned_pre58_writes_touching_5a,
        "dynamicTouching5aWrites": dynamic_touching_5a,
        "actorArrayNearbyWrites": actor_array_nearby_writes,
        "actorArrayNearbyTouching5aWrites": battle_context_touching_5a,
        "nonzeroDirect5aWrites": nonzero_direct_5a_writes,
        "conclusion": [
            "The .text direct-offset audit still finds no nonzero write connected to the battle actor +0x5a field.",
            "The only explicit +0x5a write is 0x0040cf4c, which clears it to zero on the player/owned action path.",
            "Dword writes that could implicitly touch the +0x5a byte are either non-battle/script-context writes or write an immediate whose +0x5a byte is zero.",
            "A wider unaligned scan found no pre-+0x58 word/dword write, such as +0x57, that would silently touch the +0x5a byte.",
            "Dynamic dword writes to unrelated +0x58 struct fields exist elsewhere in .text, but they are not linked to the battle actor array in this static audit.",
            "Therefore a nonzero monster local display slot producer is not present as a direct battle-actor-field write in the scanned EXE code.",
        ],
    }


def indirect_actor5a_copy_search() -> dict[str, Any]:
    rows = objdump_text_instructions()
    lea_candidates: list[dict[str, Any]] = []
    non_stack_lea_candidates: list[dict[str, Any]] = []
    pattern = re.compile(r"lea\s+[^,]+,\[[^\]]*\+0x(58|59|5a)\]")
    for index, row in enumerate(rows):
        asm = row["asm"]
        if not pattern.search(asm):
            continue
        snippet_rows = nearby_rows(rows, index, radius=6)
        is_stack = "[esp+" in asm or "[ebp+" in asm
        candidate = {
            "vaHex": row["vaHex"],
            "asm": asm,
            "stackLocal": is_stack,
            "snippet": [near["raw"] for near in snippet_rows],
        }
        lea_candidates.append(candidate)
        if not is_stack:
            non_stack_lea_candidates.append(candidate)
    return {
        "status": "no-explicit-indirect-copy-destination-for-actor5a",
        "leaCandidateCount": len(lea_candidates),
        "nonStackLeaCandidateCount": len(non_stack_lea_candidates),
        "leaCandidates": lea_candidates,
        "nonStackLeaCandidates": non_stack_lea_candidates,
        "interpretation": [
            "A non-direct producer could have appeared as lea reg,[actor+0x58/+0x59/+0x5a] followed by a copy/write helper call.",
            "The static scan found no non-stack lea destination for +0x58/+0x59/+0x5a. Observed +0x58/+0x59 lea rows are stack-local buffer handling in the C runtime area.",
            "This does not mathematically rule out every pointer-arithmetic producer, but it removes the common memcpy/struct-copy explanation for actor+0x5a.",
        ],
    }


def raw_actor5a_opcode_pattern_scan(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    text = next(section for section in sections if section["name"] == ".text")
    start = text["raw"]
    end = min(text["raw"] + text["raw_size"], len(raw))
    rows: list[dict[str, Any]] = []

    def is_disp8_no_sib(modrm: int) -> bool:
        # We are looking for [reg+disp8], where the next byte is the
        # structure-field displacement.  mod=0/rm=5 is absolute disp32, and
        # rm=4 means a SIB byte follows, so b[2] is not the field offset.
        return (modrm >> 6) == 1 and (modrm & 0x07) != 0x04

    def raw_false_positive_reason(modrm: int) -> str:
        if (modrm >> 6) == 0 and (modrm & 0x07) == 0x05:
            return "absolute-address-form-not-actor-offset"
        if (modrm & 0x07) == 0x04:
            return "sib-address-form-next-byte-is-not-offset"
        return "not-disp8-structure-field-access"

    for off in range(start, max(start, end - 8)):
        b = raw[off:off + 8]
        va = text["va"] + (off - start)
        if len(b) >= 4 and b[0] == 0xC6 and b[2] == 0x5A:
            actor_like = is_disp8_no_sib(b[1])
            rows.append({
                "vaHex": hex32(va),
                "bytesHex": b[:4].hex(" "),
                "pattern": "c6 /0 r/m8,imm8 disp8=0x5a",
                "classification": (
                    "explicit-byte-store-to-actor+0x5a"
                    if actor_like
                    else f"raw-byte-false-positive-{raw_false_positive_reason(b[1])}"
                ),
            })
        elif len(b) >= 3 and b[0] == 0x88 and b[2] == 0x5A:
            actor_like = is_disp8_no_sib(b[1])
            rows.append({
                "vaHex": hex32(va),
                "bytesHex": b[:3].hex(" "),
                "pattern": "88 /r mov r/m8,r8 disp8=0x5a",
                "classification": (
                    "explicit-byte-store-to-actor+0x5a"
                    if actor_like
                    else f"raw-byte-false-positive-{raw_false_positive_reason(b[1])}"
                ),
            })
        elif len(b) >= 3 and b[0] == 0x8A and b[2] == 0x5A:
            actor_like = is_disp8_no_sib(b[1])
            rows.append({
                "vaHex": hex32(va),
                "bytesHex": b[:3].hex(" "),
                "pattern": "8a /r mov r8,r/m8 disp8=0x5a",
                "classification": (
                    "explicit-byte-read-from-actor+0x5a"
                    if actor_like
                    else f"raw-byte-false-positive-{raw_false_positive_reason(b[1])}"
                ),
            })
        elif len(b) >= 7 and b[0] == 0xC7 and b[2] == 0x58:
            actor_like = is_disp8_no_sib(b[1])
            imm = struct.unpack_from("<I", b, 3)[0]
            rows.append({
                "vaHex": hex32(va),
                "bytesHex": b[:7].hex(" "),
                "pattern": "c7 /0 r/m32,imm32 disp8=0x58",
                "immHex": hex32(imm),
                "byteWrittenTo5a": (imm >> 16) & 0xFF if actor_like else None,
                "classification": (
                    "packed-dword-store-touching-actor+0x5a"
                    if actor_like
                    else f"raw-byte-false-positive-{raw_false_positive_reason(b[1])}"
                ),
            })
        elif len(b) >= 3 and b[0] == 0x89 and b[2] == 0x58:
            actor_like = is_disp8_no_sib(b[1])
            rows.append({
                "vaHex": hex32(va),
                "bytesHex": b[:3].hex(" "),
                "pattern": "89 /r mov r/m32,r32 disp8=0x58",
                "classification": (
                    "packed-dword-register-store-touching-actor+0x5a"
                    if actor_like
                    else f"raw-byte-false-positive-{raw_false_positive_reason(b[1])}"
                ),
            })
    write_rows = [
        row for row in rows
        if row["classification"] in {
            "explicit-byte-store-to-actor+0x5a",
            "packed-dword-store-touching-actor+0x5a",
            "packed-dword-register-store-touching-actor+0x5a",
        }
    ]
    explicit_write_rows = [
        row for row in rows
        if row["classification"] == "explicit-byte-store-to-actor+0x5a"
    ]
    nonzero_immediate_packed = [
        row for row in rows
        if row["classification"] == "packed-dword-store-touching-actor+0x5a"
        and row.get("byteWrittenTo5a") not in (0, None)
    ]
    false_positive_rows = [
        row for row in rows
        if str(row.get("classification") or "").startswith("raw-byte-false-positive")
    ]
    return {
        "status": "raw-text-pattern-scan-found-no-nonzero-explicit-actor5a-store",
        "textRangeHex": f"{hex32(text['va'])}..{hex32(text['va'] + text['raw_size'])}",
        "matchCount": len(rows),
        "writeMatchCount": len(write_rows),
        "explicitActor5aWriteCount": len(explicit_write_rows),
        "nonzeroImmediatePackedWriteCount": len(nonzero_immediate_packed),
        "rawFalsePositiveCount": len(false_positive_rows),
        "rows": rows,
        "rawFalsePositiveRows": false_positive_rows,
        "interpretation": [
            "This raw .text scan does not rely on objdump line parsing; it searches instruction byte patterns directly.",
            "The only explicit +0x5a byte store pattern is the already-known zero reset at 0x0040cf4c.",
            "The only explicit +0x5a byte read pattern is the already-known reader at 0x0040cfbe.",
            "Immediate packed dword writes to +0x58 all write zero into the +0x5a byte.",
            "Register packed dword writes to +0x58 still need value-width classification, handled by the separate packed write audit.",
            "Absolute-address and SIB byte patterns that merely contain byte 0x5a are retained as false positives, not actor-field evidence.",
        ],
    }


def register_zero_extended_before(rows: list[dict[str, Any]], index: int, reg: str, lookback: int = 8) -> bool:
    low8 = {"eax": "al", "ebx": "bl", "ecx": "cl", "edx": "dl", "esi": "sil", "edi": "dil"}
    low16 = {"eax": "ax", "ebx": "bx", "ecx": "cx", "edx": "dx", "esi": "si", "edi": "di"}
    snippet = rows[max(0, index - lookback):index]
    saw_zero = False
    saw_partial_load = False
    for row in snippet:
        asm = row["asm"]
        if re.fullmatch(fr"xor\s+{reg},{reg}", asm):
            saw_zero = True
            saw_partial_load = False
            continue
        if not saw_zero:
            continue
        if re.search(fr"\bmov\s+{low8.get(reg, '')}\b", asm) or re.search(fr"\bmov\s+{low16.get(reg, '')}\b", asm):
            saw_partial_load = True
            continue
        if re.search(fr"\bmovzx\s+{reg},", asm):
            saw_partial_load = True
            continue
        # A full-register write after zeroing invalidates this simple proof.
        if re.search(fr"\b(?:mov|add|sub|xor|and|or|lea)\s+{reg}\b", asm) and not re.fullmatch(fr"xor\s+{reg},{reg}", asm):
            saw_zero = False
            saw_partial_load = False
    return saw_zero and saw_partial_load


def packed_actor58_write_width_audit() -> dict[str, Any]:
    rows = objdump_text_instructions()
    packed_rows: list[dict[str, Any]] = []
    unknown_rows: list[dict[str, Any]] = []
    for index, row in enumerate(rows):
        asm = row["asm"]
        match = re.match(r"mov\s+DWORD PTR \[[a-z]+(?:\+0x58)\],(.+)$", asm)
        if not match:
            continue
        src = match.group(1).strip()
        classification = "unknown-register-width"
        byte5a = None
        if src.startswith("0x") or re.fullmatch(r"\d+", src):
            value = int(src, 0)
            byte5a = (value >> 16) & 0xFF
            classification = "immediate-packed-write-byte5a-zero" if byte5a == 0 else "immediate-packed-write-byte5a-nonzero"
        elif src in {"eax", "ebx", "ecx", "edx", "esi", "edi"} and register_zero_extended_before(rows, index, src):
            byte5a = 0
            classification = "zero-extended-byte-or-word-source-byte5a-zero"
        elif src in {"eax", "ebx", "ecx", "edx", "esi", "edi"}:
            # Many early generic VM helpers return values in eax.  Keep them as
            # unknown unless already classified elsewhere as non-battle context.
            classification = known_touching_5a_classification(row["va"])
            if "not battle actor" in classification or "context" in classification:
                byte5a = None
            else:
                unknown_rows.append({
                    "vaHex": row["vaHex"],
                    "asm": asm,
                    "snippet": [near["raw"] for near in nearby_rows(rows, index, radius=6)],
                    "classification": classification,
                })
        packed_rows.append({
            "vaHex": row["vaHex"],
            "asm": asm,
            "source": src,
            "byteWrittenTo5a": byte5a,
            "classification": classification,
            "snippet": [near["raw"] for near in nearby_rows(rows, index, radius=4)],
        })
    nonzero_rows = [
        row for row in packed_rows
        if row.get("byteWrittenTo5a") not in (0, None)
    ]
    proven_zero_rows = [
        row for row in packed_rows
        if row.get("byteWrittenTo5a") == 0
    ]
    return {
        "status": "packed-actor58-dword-writes-do-not-produce-nonzero-actor5a",
        "packedWriteCount": len(packed_rows),
        "provenZeroByte5aCount": len(proven_zero_rows),
        "nonzeroByte5aCount": len(nonzero_rows),
        "unknownRegisterWidthCount": len(unknown_rows),
        "nonzeroRows": nonzero_rows,
        "unknownRows": unknown_rows,
        "rows": packed_rows,
        "interpretation": [
            "Every immediate dword write to +0x58 writes zero into the +0x5a byte.",
            "Most register dword writes to +0x58 are preceded by xor reg,reg plus an 8-bit or 16-bit load, so the +0x5a byte is also zero.",
            "Remaining unknown-width +0x58 writes are in generic/script/display context helpers already classified as non-battle-actor paths, not the confirmed enemy actor record.",
            "Therefore packed dword writes to +0x58 do not currently explain a nonzero monster local display slot.",
        ],
    }


def phase60_direct_write_audit() -> dict[str, Any]:
    rows = objdump_text_instructions()
    phase_rows: list[dict[str, Any]] = []
    for index, row in enumerate(rows):
        asm = row["asm"]
        if "+0x60" not in asm:
            continue
        if "BYTE PTR" in asm and "mov" in asm and "[" in first_operand(asm):
            imm = immediate_after_comma(asm)
            phase_rows.append({
                "vaHex": row["vaHex"],
                "asm": asm,
                "phaseValue": imm,
                "phaseValueHex": f"0x{imm:02x}" if isinstance(imm, int) else "",
                "classification": (
                    "direct-reaction-or-status-phase"
                    if isinstance(imm, int) and imm < 0x0A
                    else "dynamic-phase-or-action-phase"
                ),
                "snippet": [near["raw"] for near in nearby_rows(rows, index, radius=5)],
            })
    direct_values = sorted({
        row["phaseValue"]
        for row in phase_rows
        if isinstance(row.get("phaseValue"), int)
    })
    direct_ge_action_base = [
        row for row in phase_rows
        if isinstance(row.get("phaseValue"), int) and row["phaseValue"] >= 0x0A
    ]
    dynamic_rows = [
        row for row in phase_rows
        if row.get("phaseValue") is None
    ]
    return {
        "status": "no-direct-phase60-local-action-slot-bypass-found",
        "directPhaseWriteCount": len(phase_rows),
        "directPhaseValuesHex": [f"0x{value:02x}" for value in direct_values],
        "directPhaseGte0aCount": len(direct_ge_action_base),
        "dynamicPhaseWriteCount": len(dynamic_rows),
        "directPhaseGte0aRows": direct_ge_action_base,
        "dynamicPhaseRows": dynamic_rows,
        "rows": phase_rows,
        "interpretation": [
            "Direct actor+0x60 phase writes set phases below 0x0a, such as hit/death/status/reaction phases.",
            "The only action-slot phase setup at or above 0x0a is dynamic: actor+0x59+0x0a for type 1 and actor+0x5a+0x0a for type 2 in opcode 0xa4.",
            "No direct write such as actor+0x60=0x0b/0x0c was found, so there is no static bypass that selects local monster slots 1..4 without actor+0x5a.",
        ],
    }


def dynamic_generic_write_opcode_audit(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Audit generic script opcodes that can write to [context+0xa8 + stream[n]].

    These opcodes are the strongest remaining static candidate for an indirect
    actor+0x5a producer because their destination field is script-controlled.
    The scan intentionally reports only exact destination bytes; raw opcode
    bytes inside pointer/data regions are not promoted to semantics by themselves.
    """
    data = next(section for section in sections if section["name"] == ".data")
    scan_sections = [
        section for section in sections
        if section.get("raw_size", 0) > 0 and section.get("raw", 0) < len(raw)
    ]
    watched_fields = set(range(ACTOR_FIELD_SCAN_START, ACTOR_FIELD_SCAN_END + 1))
    opcode_specs = [
        {
            "opcode": 0x8C,
            "handlerVaHex": "0x0040b55f",
            "destOperandIndex": 2,
            "role": "selector ring write",
            "summary": "Chooses an index from 0x59e360/0x59e370 and writes it to [context+0xa8 + stream[2]].",
        },
        {
            "opcode": 0x8D,
            "handlerVaHex": "0x0040b696",
            "destOperandIndex": 2,
            "role": "active descriptor selector write",
            "summary": "Compares active descriptor bytes at record+0x48/+0x4a and writes the matched 0..11 index to [context+0xa8 + stream[2]].",
        },
        {
            "opcode": 0x8F,
            "handlerVaHex": "0x0040b84a",
            "destOperandIndex": 1,
            "role": "conditional state write",
            "summary": "Reads a script-selected source byte and writes a condition/result byte to [context+0xa8 + stream[1]].",
        },
    ]
    rows: list[dict[str, Any]] = []
    exact_actor5a_candidates: list[dict[str, Any]] = []
    all_section_exact_actor5a_hits: list[dict[str, Any]] = []
    text_section_exact_actor5a_hits: list[dict[str, Any]] = []
    non_data_exact_actor5a_hits: list[dict[str, Any]] = []
    watched_dest_candidates: list[dict[str, Any]] = []
    for spec in opcode_specs:
        opcode = spec["opcode"]
        dest_index = spec["destOperandIndex"]
        all_count_in_data = 0
        all_count_by_section: dict[str, int] = {}
        watched: list[dict[str, Any]] = []
        exact: list[dict[str, Any]] = []
        exact_all_for_opcode: list[dict[str, Any]] = []
        for section in scan_sections:
            section_name = section["name"]
            start = section["raw"]
            end = min(section["raw"] + section["raw_size"], len(raw))
            if end - start < 8:
                continue
            for off in range(start, end - 8):
                if raw[off] != opcode:
                    continue
                all_count_by_section[section_name] = all_count_by_section.get(section_name, 0) + 1
                if section_name == ".data":
                    all_count_in_data += 1
                va = section["va"] + (off - start)
                payload = raw[off:off + 8]
                dest = payload[dest_index]
                classification = "script-data-candidate" if section_name == ".data" else "raw-byte-hit-outside-script-data"
                if section_name == ".text":
                    classification = "code-byte-false-positive-not-bytecode"
                row = {
                    "section": section_name,
                    "vaHex": hex32(va),
                    "bytesHex": payload.hex(" "),
                    "destOffset": dest,
                    "destOffsetHex": f"0x{dest:02x}",
                    "classification": classification,
                }
                if section_name == ".data" and dest in watched_fields:
                    watched.append(row)
                    watched_dest_candidates.append({
                        **row,
                        "opcodeHex": f"0x{opcode:02x}",
                        "handlerVaHex": spec["handlerVaHex"],
                        "role": spec["role"],
                    })
                if dest == 0x5A:
                    exact_all_for_opcode.append(row)
                    all_section_exact_actor5a_hits.append({
                        **row,
                        "opcodeHex": f"0x{opcode:02x}",
                        "handlerVaHex": spec["handlerVaHex"],
                        "role": spec["role"],
                    })
                    if section_name == ".text":
                        text_section_exact_actor5a_hits.append({
                            **row,
                            "opcodeHex": f"0x{opcode:02x}",
                            "handlerVaHex": spec["handlerVaHex"],
                            "role": spec["role"],
                        })
                    elif section_name == ".data":
                        exact.append(row)
                        exact_actor5a_candidates.append({
                            **row,
                            "opcodeHex": f"0x{opcode:02x}",
                            "handlerVaHex": spec["handlerVaHex"],
                            "role": spec["role"],
                        })
                    else:
                        non_data_exact_actor5a_hits.append({
                            **row,
                            "opcodeHex": f"0x{opcode:02x}",
                            "handlerVaHex": spec["handlerVaHex"],
                            "role": spec["role"],
                        })
        rows.append({
            **spec,
            "opcodeHex": f"0x{opcode:02x}",
            "rawOpcodeByteCountInData": all_count_in_data,
            "rawOpcodeByteCountBySection": dict(sorted(all_count_by_section.items())),
            "rawOpcodeByteCountInScannedSections": sum(all_count_by_section.values()),
            "watchedDestinationCount": len(watched),
            "exactActor5aDestinationCount": len(exact),
            "allSectionExactActor5aRawByteHitCount": len(exact_all_for_opcode),
            "watchedDestinations": watched[:80],
            "exactActor5aDestinations": exact[:80],
            "allSectionExactActor5aRawByteHits": exact_all_for_opcode[:80],
        })
    return {
        "status": "no-generic-script-dynamic-write-to-actor5a-found",
        "dataVaHex": hex32(data["va"]),
        "dataRawSize": data["raw_size"],
        "scannedSections": [section["name"] for section in scan_sections],
        "watchedDestinationOffsets": [f"0x{field:02x}" for field in sorted(watched_fields)],
        "opcodeRows": rows,
        "watchedDestinationCandidateCount": len(watched_dest_candidates),
        "watchedDestinationCandidates": watched_dest_candidates,
        "exactActor5aCandidateCount": len(exact_actor5a_candidates),
        "exactActor5aCandidates": exact_actor5a_candidates,
        "allSectionExactActor5aRawByteHitCount": len(all_section_exact_actor5a_hits),
        "allSectionExactActor5aRawByteHits": all_section_exact_actor5a_hits[:120],
        "textSectionExactActor5aFalsePositiveCount": len(text_section_exact_actor5a_hits),
        "textSectionExactActor5aFalsePositiveHits": text_section_exact_actor5a_hits[:80],
        "nonDataExactActor5aRawByteHitCount": len(non_data_exact_actor5a_hits),
        "nonDataExactActor5aRawByteHits": non_data_exact_actor5a_hits[:80],
        "interpretation": [
            "Opcode 0x8c/0x8d/0x8f can dynamically write a byte to context+0xa8 plus a script operand, so these were the strongest remaining indirect producer candidates.",
            "A full .data scan found no script occurrence where the dynamic destination operand is 0x5a.",
            "The scan now also records raw 0x5a-looking byte patterns in every mapped section. Hits in .text are classified as code-byte false positives and are not promoted as script bytecode.",
            "One 0x8c occurrence targets watched offset 0x63, but none target 0x5a; 0x8d and 0x8f have no watched-field destinations at all.",
            "This does not prove that no runtime object can ever hold +0x5a by another mechanism, but it removes the main static bytecode-destination path for actor+0x5a.",
        ],
    }


def referenced_action_script_candidate_scan(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Find pointer-referenced .data blocks that look like top-level action VM scripts.

    A raw byte search for opcodes such as 0x9e/0xa4 is too noisy: pointer tables
    naturally contain low bytes that look like opcodes.  This scan starts from
    actual pointers into .data, then classifies each target before treating it as
    a possible action script.
    """
    data = next(section for section in sections if section["name"] == ".data")
    data_start = data["va"]
    data_end = data["va"] + data["raw_size"]
    scan_sections = [
        section for section in sections
        if section.get("raw_size", 0) > 0 and section.get("raw", 0) < len(raw)
    ]
    pointer_refs: dict[int, list[dict[str, Any]]] = {}
    all_pointer_value_count = 0
    for section in scan_sections:
        start = section["raw"]
        end = min(section["raw"] + section["raw_size"], len(raw))
        for off in range(start, max(start, end - 3), 4):
            value = struct.unpack_from("<I", raw, off)[0]
            if not (data_start <= value < data_end):
                continue
            all_pointer_value_count += 1
            pointer_refs.setdefault(value, []).append({
                "section": section["name"],
                "vaHex": hex32(section["va"] + (off - start)),
            })

    watched_opcodes = {0x9E, 0xA4, 0xA6}
    candidate_rows: list[dict[str, Any]] = []
    classification_counts: dict[str, int] = {}
    for target_va, refs in sorted(pointer_refs.items()):
        off = va_to_offset(sections, target_va)
        if off is None or off + 4 > len(raw):
            continue
        max_dwords = min(40, (len(raw) - off) // 4)
        if max_dwords <= 0:
            continue
        dwords = [struct.unpack_from("<I", raw, off + index * 4)[0] for index in range(max_dwords)]
        opcode_bytes = [value & 0xFF for value in dwords]
        if 0x9E not in opcode_bytes or not ({0xA4, 0xA6} & set(opcode_bytes)):
            continue
        mapped_pointer_count = sum(1 for value in dwords if is_readable_static_pointer(sections, value))
        nonzero_count = sum(1 for value in dwords if value != 0)
        if ACTION_VM_HANDLER_TABLE <= target_va < ACTION_VM_HANDLER_TABLE + 0x100 * 4:
            classification = "handler-table-false-positive"
        elif 0x00454C10 <= target_va < 0x00454C10 + 0x100 * 4:
            classification = "display-handler-table-false-positive"
        elif mapped_pointer_count >= 10 or (nonzero_count and mapped_pointer_count / nonzero_count >= 0.50):
            classification = "pointer-table-false-positive-low-byte-opcodes"
        else:
            classification = "unclassified-action-script-candidate"
        classification_counts[classification] = classification_counts.get(classification, 0) + 1
        row = {
            "targetVaHex": hex32(target_va),
            "firstRefVaHex": refs[0]["vaHex"],
            "firstRefSection": refs[0]["section"],
            "refCount": len(refs),
            "classification": classification,
            "opcodeBytesHex": [f"0x{opcode:02x}" for opcode in opcode_bytes[:24]],
            "watchedOpcodeOffsets": [
                {
                    "dwordIndex": index,
                    "opcodeHex": f"0x{opcode:02x}",
                    "dwordHex": hex32(dwords[index]),
                }
                for index, opcode in enumerate(opcode_bytes)
                if opcode in watched_opcodes
            ],
            "mappedPointerDwordCount": mapped_pointer_count,
            "nonzeroDwordCount": nonzero_count,
            "firstDwordsHex": [hex32(value) for value in dwords[:16]],
        }
        candidate_rows.append(row)

    unclassified = [
        row for row in candidate_rows
        if row["classification"] == "unclassified-action-script-candidate"
    ]
    false_positive_rows = [
        row for row in candidate_rows
        if row["classification"] != "unclassified-action-script-candidate"
    ]
    return {
        "status": (
            "no-pointer-referenced-action-script-candidate"
            if not unclassified
            else "unclassified-action-script-candidates-found"
        ),
        "dataVaRangeHex": f"{hex32(data_start)}..{hex32(data_end)}",
        "scanSectionCount": len(scan_sections),
        "dataPointerValueCount": all_pointer_value_count,
        "uniqueDataPointerTargetCount": len(pointer_refs),
        "matchingTargetCount": len(candidate_rows),
        "unclassifiedCandidateCount": len(unclassified),
        "classificationCounts": dict(sorted(classification_counts.items())),
        "unclassifiedCandidates": unclassified[:80],
        "falsePositiveSamples": false_positive_rows[:80],
        "interpretation": [
            "This scan starts from real pointer references into .data instead of raw opcode bytes.",
            "A candidate must contain opcode-byte 0x9e and either 0xa4 or 0xa6 on 4-byte instruction boundaries.",
            "Pointer tables and handler tables are rejected because their low bytes can accidentally look like action opcodes.",
            "No unclassified pointer-referenced top-level action script candidate was found in the current static scan.",
            "This means the descriptor producer is not exposed as a top-level 0x9e -> 0xa4/0xa6 action VM stream; the promoted producer is instead the descriptor script3 opcode 0x10 pair recorded in out/battle_monster_action_selection_review.json.",
        ],
    }


def actor_initialization_block_copy_audit() -> dict[str, Any]:
    paths = [
        {
            "vaHex": "0x0040be38",
            "role": "main battle actor materializer",
            "evidence": "copies only the first 0x48 bytes from the active descriptor path, then clears actor+0x48..0x9f with memset-style zeroing.",
            "impact": "actor+0x5a is inside the cleared range, so it is initialized to 0 here.",
        },
        {
            "vaHex": "0x0040c084",
            "role": "secondary/enemy actor allocation",
            "evidence": "zeroes the new actor body and fills stats/coordinates/identity fields; no assignment to actor+0x5a is present.",
            "impact": "secondary actors also start with local display slot 0 unless a later producer writes +0x5a.",
        },
        {
            "vaHex": "0x0040f5a9",
            "role": "clone/action helper",
            "evidence": "copies only the first 0x48 actor bytes, clears actor+0x48..0x9f, then writes actor+0x59 for the selected action id.",
            "impact": "this path explicitly does not inherit actor+0x5a from a source actor and does not produce a nonzero slot.",
        },
    ]
    return {
        "status": "known-actor-allocation-and-clone-paths-clear-actor5a",
        "paths": paths,
        "conclusion": [
            "The known actor creation/clone paths do not seed actor+0x5a from descriptor memory.",
            "The common block-copy explanation is therefore rejected for the confirmed paths: +0x5a sits in the zeroed tail, not in the copied first 0x48 bytes.",
            "If a nonzero local monster slot exists, it must be assigned after actor creation by a path not yet found, not by descriptor block copy.",
        ],
    }


def monster_slot0_reachability_review(asset_rows: list[dict[str, Any]]) -> dict[str, Any]:
    slot0_rows: list[dict[str, Any]] = []
    rows_with_jump = []
    rows_with_spawn = []
    rows_with_movement = []
    rows_with_position_write = []
    for asset in asset_rows:
        slot0 = next(
            (
                entry for entry in asset.get("entries") or []
                if entry.get("index") == 10 or entry.get("monsterActionSlotIfPhaseMinus0x0a") == 0
            ),
            None,
        )
        if not slot0:
            continue
        categories = set(slot0.get("categories") or [])
        row = {
            "asset": asset.get("asset") or "",
            "slot": 0,
            "entryIndex": slot0.get("index"),
            "categories": sorted(categories),
            "frames": slot0.get("frameSelectorSequence") or [],
            "sounds": [sound.get("summary") for sound in slot0.get("sounds") or []],
            "movements": [movement.get("summary") for movement in slot0.get("movements") or []],
            "positionWrites": [write.get("summary") for write in slot0.get("positionWrites") or []],
        }
        slot0_rows.append(row)
        if "jump" in categories:
            rows_with_jump.append(row)
        if "spawn-child-vm" in categories:
            rows_with_spawn.append(row)
        if row["movements"]:
            rows_with_movement.append(row)
        if row["positionWrites"]:
            rows_with_position_write.append(row)
    return {
        "status": "slot0-is-real-display-script-not-dispatcher",
        "slot0EntryCount": len(slot0_rows),
        "slot0WithJumpCategoryCount": len(rows_with_jump),
        "slot0WithSpawnChildVmCount": len(rows_with_spawn),
        "slot0WithMovementCount": len(rows_with_movement),
        "slot0WithPositionWriteCount": len(rows_with_position_write),
        "jumpSamples": rows_with_jump[:20],
        "spawnSamples": rows_with_spawn[:20],
        "movementSamples": rows_with_movement[:20],
        "positionWriteSamples": rows_with_position_write[:20],
        "sampleRows": slot0_rows[:40],
        "interpretation": [
            "Slot 0/table index 10 is the default when actor+0x5a remains zero, but descriptor script3 can now statically select nonzero local slots through opcode 0x10.",
            "Slot 0 entries contain actual frame/sound/movement scripts. They are not a generic dispatcher that obviously branches to slots 1..4.",
            "Some slot 0 scripts contain local movement or jump categories; nonzero slot binding is handled outside these slot scripts by descriptor script3 action-selection bytecode.",
        ],
    }


def active_descriptor_selection_review(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    base_va = ACTIVE_DESCRIPTOR_BASE
    record_size = ACTIVE_DESCRIPTOR_STRIDE
    sample_rows: list[dict[str, Any]] = []
    for index in range(8):
        va = base_va + index * record_size
        off = va_to_offset(sections, va)
        if off is None or off + record_size > len(raw):
            continue
        record = raw[off:off + record_size]
        sample_rows.append({
            "recordIndex": index,
            "recordVaHex": hex32(va),
            "recordInterpretation": (
                "active-descriptor-candidate"
                if va < ENEMY_STAT_BASE
                else "overlaps-enemy-stat-row-do-not-use-as-descriptor"
            ),
            "selectorModeByteRecord40Hex": f"0x{record[0x40]:02x}",
            "selectorIndexByteRecord41Hex": f"0x{record[0x41]:02x}",
            "rowSelectorBytes48To4fHex": record[0x48:0x50].hex(" "),
            "comparisonBytes4aTo55Hex": record[0x4A:0x56].hex(" "),
        })
    return {
        "status": "active-descriptor-table-feeds-action-id-not-local-display-slot",
        "descriptorTableVaHex": hex32(base_va),
        "recordSizeHex": "0x00d8",
        "keyHandlers": [
            {
                "vaHex": "0x0040b752",
                "summary": "Opcode 0x8e loads/saves active descriptor record+0x40 and record+0x41 through globals 0x59e340 and 0x59e34a.",
            },
            {
                "vaHex": "0x0043329f",
                "summary": "The action selection helper consumes globals 0x59e340/0x59e34a and writes actor+0x58/+0x59 action category/id.",
            },
            {
                "vaHex": "0x0040b696",
                "summary": "Opcode 0x8d compares active descriptor record+0x48/+0x4a bytes and writes a selector to a script-selected destination; no +0x5a destination was found in .data.",
            },
        ],
        "sampleRecords": sample_rows,
        "interpretation": [
            "The active descriptor table explains how a scene/script selects action category/id through 0x59e340 and 0x59e34a.",
            "That path ends at actor+0x58 and actor+0x59, which are already confirmed as category and shared/player action id.",
            "The same descriptor table has selector bytes used by opcode 0x8d, but static script operands do not route that selector into actor+0x5a.",
            "Only the early records are safe to interpret as active descriptors in this context. At index 6 the 0x00d8 stride lands exactly on the enemy stat table base 0x00457c60, so continuing this stride would read stat rows as fake descriptor/skill data.",
            "Therefore the descriptor table is useful for action-id selection, but it still does not prove shared monster skill -> local CNS action slot binding.",
        ],
    }


def active_descriptor_actor_slot_review() -> dict[str, Any]:
    return {
        "status": "active-descriptor-actor-slot-lifecycle-confirmed-not-enemy-local-slot",
        "trackedGlobals": [
            {
                "vaHex": "0x004576e8",
                "label": "active descriptor count",
                "role": "number of active descriptor entries materialized into runtime actor/object slots.",
            },
            {
                "vaHex": "0x004576e9",
                "label": "active descriptor order bytes",
                "role": "slot -> active descriptor id order table. This selects rows at 0x00457750 + id*0x00d8.",
            },
            {
                "vaHex": "0x0059db30",
                "label": "battle actor pointer array",
                "role": "runtime actor pointer array. Enemy actors are separately stored from slot 3 at 0x0059db3c.",
            },
            {
                "vaHex": "0x0059e33e",
                "label": "current actor slot",
                "role": "current battle actor slot used by action selection/display setup.",
            },
            {
                "vaHex": "0x0059e340 / 0x0059e34a",
                "label": "action selection globals",
                "role": "mode/index pair consumed by 0x0043329f to write actor+0x58/+0x59.",
            },
        ],
        "lifecycle": [
            {
                "vaHex": "0x0043215c",
                "role": "active descriptor add/materialize entry point",
                "summary": "Adds a descriptor id to 0x004576e9[count], stores 0x00457750 + id*0x00d8 in 0x0059db30[count], increments 0x004576e8, then materializes descriptors.",
                "slotConclusion": "This selects an active descriptor row and actor pointer slot; it does not assign actor+0x5a.",
            },
            {
                "vaHex": "0x00432167",
                "role": "active order byte write",
                "summary": "Writes the incoming descriptor id into 0x004576e9[current count].",
                "slotConclusion": "The byte is a descriptor id, not a monster local action slot.",
            },
            {
                "vaHex": "0x00432645 / 0x004326a3",
                "role": "active descriptor remove/compact",
                "summary": "Removes an active descriptor entry and shifts 0x004576e9 order bytes down.",
                "slotConclusion": "This mutates active actor/object ordering only.",
            },
            {
                "vaHex": "0x0040be38",
                "role": "main actor materializer",
                "summary": "Uses active descriptor rows when 0x0059db24 == 0. It copies descriptor front data, clears actor+0x48..0x9f, and therefore leaves actor+0x5a as zero.",
                "slotConclusion": "Descriptor materialization does not seed a nonzero enemy local display slot.",
            },
            {
                "vaHex": "0x0040c084",
                "role": "enemy actor allocation",
                "summary": "Creates enemy actors from enemy stat rows, then stores them at 0x0059db3c[enemyIndex], i.e. battle actor slots 3+.",
                "slotConclusion": "Enemy actors are allocated from stat rows, not from 0x4576e9 active descriptor order bytes.",
            },
            {
                "vaHex": "0x0040b752",
                "role": "opcode 0x8e active selector save/restore",
                "summary": "For the current actor slot, maps 0x4576e9[current slot] to 0x457750 + id*0xd8, then loads/saves record+0x40/+0x41 through 0x59e340/0x59e34a.",
                "slotConclusion": "This produces action-selection globals for actor+0x58/+0x59, not actor+0x5a.",
            },
        ],
        "conclusion": [
            "0x4576e9 is an active descriptor order table, not a monster attack-slot table.",
            "The active descriptor path explains scripted/player/object actor materialization and action id selection through 0x59e340/0x59e34a.",
            "Enemy battle actors are created by the separate 0x0040c084 stat-row path and placed at actor slots 3+.",
            "This lifecycle review does not itself bind shared monster actions to CNS-local slots; that binding is now attributed to descriptor script3 opcode 0x10 target blocks.",
        ],
    }


def enemy_actor_stat_row_review(raw: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    exported_rows: list[dict[str, Any]] = []
    if ENEMY_STAT_TABLE.exists():
        exported_rows = json.loads(ENEMY_STAT_TABLE.read_text(encoding="utf-8")).get("rows") or []
    exported_by_actual_base = {
        int(row["vaHex"], 16) - 8: row
        for row in exported_rows
        if row.get("vaHex")
    }

    samples: list[dict[str, Any]] = []
    for internal_id in range(4, 14):
        row_va = ENEMY_STAT_BASE + internal_id * ENEMY_STAT_STRIDE
        off = va_to_offset(sections, row_va)
        if off is None or off + ENEMY_STAT_STRIDE > len(raw):
            continue
        row = raw[off:off + ENEMY_STAT_STRIDE]
        exported = exported_by_actual_base.get(row_va) or {}
        samples.append({
            "internalEnemyId": internal_id,
            "actualRowVaHex": hex32(row_va),
            "exportedHpViewVaHex": hex32(row_va + 8),
            "exportedIndex": exported.get("index"),
            "name": exported.get("cleanName") or exported.get("name") or "",
            "prefix8Hex": row[:8].hex(" "),
            "hp": read_u16_at(row, 0x08),
            "mpOrSecondaryHpCandidate": read_u16_at(row, 0x0A),
            "frontNumericActualOffsets": {
                "+0x0c": read_u16_at(row, 0x0C),
                "+0x0e": read_u16_at(row, 0x0E),
                "+0x10": read_u16_at(row, 0x10),
                "+0x12": read_u16_at(row, 0x12),
                "+0x14": read_u16_at(row, 0x14),
                "+0x16": read_u16_at(row, 0x16),
                "+0x18": read_u16_at(row, 0x18),
            },
            "goldRewardActualOffset36": read_u16_at(row, 0x36),
            "goldRewardExportedHpViewOffset2e": exported.get("gold"),
            "nameIdActualOffset34Hex": f"0x{read_u16_at(row, 0x34):04x}",
        })

    return {
        "status": "enemy-stat-row-confirmed-not-action-slot-table",
        "statRowBaseVaHex": hex32(ENEMY_STAT_BASE),
        "rowStrideHex": f"0x{ENEMY_STAT_STRIDE:02x}",
        "exportedEnemyStatTable": str(ENEMY_STAT_TABLE),
        "importantOffsetCorrection": [
            "out/enemy_stat_table.json rows start at the HP word, which is actual stat row +0x08.",
            "The real row base used by the EXE is 0x00457c60 + enemyId*0x38.",
            "The confirmed reward/gold field is actual row +0x36, which appears as +0x2e in the exported HP-view rows.",
            "Any exported field at HP-view +0x36 crosses into the next real stat row and must not be promoted as a monster skill/class/action field.",
        ],
        "creationPaths": [
            {
                "vaHex": "0x0040c084",
                "summary": "secondary/enemy actor allocation reads enemyId from script, copies 8 bytes from 0x457c60+enemyId*0x38, sets HP/current/max fields from row+0x08, and copies row+0x0e..+0x37 to actor+0x1c..+0x45.",
                "conclusion": "This seeds stats/rewards/resistance-like fields, not actor+0x59 action id or actor+0x5a local display slot.",
            },
            {
                "vaHex": "0x0040be38",
                "summary": "main actor materializer has a separate stat-row branch when 0x59db24 != 0; this is not the normal active-descriptor action-id table path.",
                "conclusion": "Do not merge active descriptor rows and enemy stat rows into one skill table.",
            },
            {
                "vaHex": "0x00435736",
                "summary": "reads actor+0x44, which corresponds to actual stat row +0x36, and accumulates it into global 0x4576e0 capped at 0x0f423f.",
                "conclusion": "This is reward/gold-like accumulation, not a skill id.",
            },
            {
                "vaHex": "0x00411a1c",
                "summary": "also gates reward/display helper scripts on actor+0x44 being nonzero.",
                "conclusion": "This reinforces actor+0x44/stat row +0x36 as reward/display data, not attack-slot binding.",
            },
        ],
        "sampleRows": samples,
        "conclusion": [
            "Enemy stat rows explain HP, gold/reward, name id, and numeric/rate-like battle fields.",
            "They do not contain a proven list of monster skills or a shared-action-id to CNS-local-slot binding.",
            "The normal enemy visible action path still requires actor+0x5a, but the producer is descriptor script3 bytecode rather than the enemy stat row.",
        ],
    }


def rejected_monster_skill_binding_hypotheses() -> dict[str, Any]:
    overlap_index = (ENEMY_STAT_BASE - ACTIVE_DESCRIPTOR_BASE) // ACTIVE_DESCRIPTOR_STRIDE
    overlap_va = ACTIVE_DESCRIPTOR_BASE + overlap_index * ACTIVE_DESCRIPTOR_STRIDE
    return {
        "status": "rejected-static-hypotheses-recorded",
        "rejections": [
            {
                "hypothesis": "0x00457750 with 0x00d8 stride is a full monster skill/action table.",
                "status": "rejected",
                "evidence": (
                    f"0x00457750 + {overlap_index}*0x00d8 = {hex32(overlap_va)}, "
                    "which is exactly the enemy stat table base 0x00457c60."
                ),
                "conclusion": "Continuing that stride past the early active descriptor records reads monster stat rows as fake descriptor/skill rows.",
            },
            {
                "hypothesis": "enemy stat row actual +0x36 / actor+0x44 is a monster skill id or local slot id.",
                "status": "rejected",
                "evidence": "0x00435736 accumulates actor+0x44 into global 0x4576e0, and 0x00411a1c uses actor+0x44 to spawn reward/display helper scripts.",
                "conclusion": "This field is reward/gold-like, not visible action selection.",
            },
            {
                "hypothesis": "the exported HP-view tailCandidateField +0x36 is a stable monster class/skill field.",
                "status": "rejected",
                "evidence": "The exported rows start at actual row +0x08. HP-view +0x36 therefore points beyond the 0x38-byte real row into the next row.",
                "conclusion": "Do not use the exported tailCandidateField as a semantic field until the exporter is corrected or separately justified.",
            },
            {
                "hypothesis": "monster rate/resistance-like byte fields are local CNS action slots.",
                "status": "not-promoted",
                "evidence": "These bytes are copied from stat row +0x0e..+0x37 to actor+0x1c..+0x45 during actor creation and participate in stat/result calculations.",
                "conclusion": "They may be battle coefficients/resistances, but they do not currently prove skill selection or frame-slot binding.",
            },
        ],
        "currentWorkingModel": [
            "actor+0x59 is the shared/player action id used for name/payload lookup.",
            "actor+0x5a is the type-2 local CNS display slot selector read by opcode 0xa4 display phase setup.",
            "enemy stat rows are data for actor stats/rewards/coefficient fields, not the missing producer for actor+0x5a.",
        ],
    }


def actor59_producer_review() -> dict[str, Any]:
    return {
        "status": "actor59-producers-classified-no-actor5a-binding",
        "directWrites": [
            {
                "vaHex": "0x004332e1 / 0x00433313 / 0x00433345 / 0x004333b8",
                "producer": "0x0043329f mode 0/1/2/5",
                "source": "0x4577a6 / 0x4577ac / 0x4577b2 / 0x4577b8 + actor[0x05]*0x00d8 + global 0x59e34a",
                "writes": "actor+0x59",
                "classification": "shared/player action id producer; not local display slot producer",
            },
            {
                "vaHex": "0x00433365",
                "producer": "0x0043329f mode 3",
                "source": "0x4576ec + global 0x59e34a*2",
                "writes": "actor+0x58=1, actor+0x59",
                "classification": "special action id producer; not local display slot producer",
            },
            {
                "vaHex": "0x00433377",
                "producer": "0x0043329f mode 4",
                "source": "global 0x59e34a + 1",
                "writes": "actor+0x58=2, actor+0x59=0, actor+0x2a",
                "classification": "item/action category producer; not local display slot producer",
            },
            {
                "vaHex": "0x0040f612",
                "producer": "clone/action helper 0x0040f5a9",
                "source": "0x4577a6 + actor[0x05]*0x00d8 + 0x59e341*6 + 0x59e342",
                "writes": "actor+0x59",
                "classification": "scripted clone/action id path; actor+0x5a is cleared and not copied",
            },
            {
                "vaHex": "0x0040f6c1",
                "producer": "special action branch in 0x0040f5a9 family",
                "source": "0x4576ec + global 0x59e343*2",
                "writes": "actor+0x59",
                "classification": "special action id path; not local display slot producer",
            },
            {
                "vaHex": "0x0040ce76",
                "producer": "player skill growth/update path",
                "source": "increments selected actor+0x59 after skill growth table increment",
                "writes": "actor+0x59",
                "classification": "player-owned skill progression; not monster AI",
            },
        ],
        "conclusion": [
            "There are multiple confirmed producers for actor+0x59.",
            "Every confirmed producer writes action id/category data used by text/payload/result paths.",
            "None of these producers writes actor+0x5a or proves shared action id -> monster local CNS slot mapping.",
        ],
    }


def enemy_action_selection_opcode_review() -> dict[str, Any]:
    return {
        "status": "enemy-action-selection-flow-confirms-action-id-not-local-slot",
        "flow": [
            {
                "vaHex": "0x00402321",
                "role": "action VM runner",
                "summary": "Reads opcode bytes from the script object bytecode stream and dispatches through table 0x00440538.",
                "confirmedOutput": "control flow only",
            },
            {
                "opcodeHex": "0x9e",
                "handlerVaHex": "0x0040c513",
                "role": "action selection / target latch opcode",
                "summary": "Requires 0x59e34d == 1, resolves the current actor from 0x59e33e/0x59db30, and branches on script operand [stream+1].",
                "confirmedOutput": "actor+0x58/actor+0x59 or target latch updates depending on branch",
            },
            {
                "vaHex": "0x0043329f",
                "role": "mode/index action-id helper called by opcode 0x9e branch 0",
                "summary": "Consumes globals 0x59e340 and 0x59e34a. Modes 0/1/2/5 read action ids from 0x4577a6/0x4577ac/0x4577b2/0x4577b8 + actor[5]*0xd8 + index. Mode 3 reads 0x4576ec + index*2. Mode 4 creates item/action category state.",
                "confirmedOutput": "actor+0x58 category and actor+0x59 shared/player action id",
            },
            {
                "opcodeHex": "0xa4",
                "handlerVaHex": "0x0040cf0a",
                "role": "normal action display phase setup",
                "summary": "For type 1 actors, clears actor+0x5a and sets actor+0x60 = actor+0x59 + 0x0a. For type 2 actors, reads actor+0x5a and sets actor+0x60 = actor+0x5a + 0x0a.",
                "confirmedOutput": "display phase only; type-2 local slot reader",
            },
            {
                "opcodeHex": "0xa6",
                "handlerVaHex": "0x0040d080",
                "role": "hit/damage/status application loop",
                "summary": "Consumes selected action payload/result state and applies target result processing through 0x00433f0e.",
                "confirmedOutput": "damage/result/status, not display-slot binding",
            },
        ],
        "branchDetails": [
            {
                "handlerVaHex": "0x0040c513",
                "branch": "operand 0",
                "summary": "Calls 0x0043329f for current actor and therefore writes actor+0x58/+0x59.",
                "actor5aResult": "no write",
            },
            {
                "handlerVaHex": "0x0040c513",
                "branch": "operand 1 / 2",
                "summary": "Uses target/selection globals and actor+0x67-derived result state to update target latch behavior.",
                "actor5aResult": "no write",
            },
        ],
        "openGap": [
            "The confirmed route is: script/global mode/index -> actor+0x58/+0x59 -> payload/name/result.",
            "The confirmed display route for enemy/type-2 is: actor+0x5a -> actor+0x60 phase -> descriptor-local CNS table index 0x0a+slot.",
            "The missing route is still: shared action id actor+0x59 or AI decision -> actor+0x5a nonzero local slot.",
        ],
        "conclusion": [
            "Opcode 0x9e does select the enemy action id used by payload/text processing, but not the visible CNS-local action slot.",
            "Opcode 0xa4 proves why table index 10/local slot 0 works by default for enemies, but it only reads actor+0x5a.",
            "Therefore static analysis has not yet connected monster AI/shared action ids to local slots 1..4; those local scripts remain real but unbound candidates.",
        ],
    }


def shared_charge_actions() -> list[dict[str, Any]]:
    mapping = json.loads(ACTION_MAPPING.read_text(encoding="utf-8"))
    names = {"육탄돌격", "저돌맹진", "돌진"}
    rows = []
    for row in mapping.get("sharedRows") or []:
        if row.get("name") not in names:
            continue
        rows.append({
            "skillIdHex": row.get("skillIdHex"),
            "name": row.get("name"),
            "payloadVaHex": row.get("payloadVaHex"),
            "prefixBytesHex": row.get("prefixBytesHex"),
            "unitsHex": row.get("unitsHex"),
            "targetScopes": row.get("targetScopes"),
            "families": row.get("families"),
            "statuses": row.get("statuses"),
            "note": "shared payload describes damage/status semantics; visible lunge must come from the monster-local display action slot.",
        })
    return rows


def local_motion_focus_examples(asset_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    focus_assets = {
        "zs_maoh": "마왕슬라임: user-observed charge/lunge class",
        "zs_sl": "슬라임 base comparison",
        "zk_maoh": "마왕박쥐: similar name but different family; keep separate from 마왕슬라임",
        "zi_ibo": "저돌맹진-like boar comparison",
        "zi_ikkak": "element/boar comparison",
    }
    rows = []
    for row in asset_rows:
        reason = focus_assets.get(row.get("asset"))
        if reason is None:
            continue
        for entry in row.get("entries") or []:
            slot = entry.get("monsterActionSlotIfPhaseMinus0x0a")
            if slot is None:
                continue
            movements = entry.get("movements") or []
            meaningful_movements = [
                movement for movement in movements
                if (
                    movement.get("selector") not in (None, 0)
                    or movement.get("movementMode") not in (None, 0)
                    or movement.get("divisor") not in (None, 0)
                )
            ]
            has_reset_only = bool(movements) and not meaningful_movements
            has_motion = bool(meaningful_movements or entry.get("positionWrites"))
            has_non_idle_frames = len(set(entry.get("frameSelectorSequence") or [])) > 1
            if not has_motion and not has_non_idle_frames:
                continue
            rows.append({
                "asset": row.get("asset"),
                "reason": reason,
                "slot": slot,
                "tableIndex": entry.get("index"),
                "frames": entry.get("frameSelectorSequence") or [],
                "sounds": [sound.get("summary") for sound in entry.get("sounds") or []],
                "movements": [movement.get("summary") for movement in movements],
                "positionWrites": [write.get("summary") for write in entry.get("positionWrites") or []],
                "interpretation": (
                    "movement/placement local slot"
                    if has_motion
                    else "frame-only local slot with reset"
                    if has_reset_only
                    else "frame-only local slot"
                ),
            })
    return rows


def monster_motion_examples(asset_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    examples = []
    preferred_assets = {"zk_maoh", "zs_sl", "zs_maoh", "zsl_yoi"}
    for row in asset_rows:
        for entry in row.get("entries") or []:
            slot = entry.get("monsterActionSlotIfPhaseMinus0x0a")
            if slot is None:
                continue
            if not entry.get("movements") and not entry.get("positionWrites"):
                continue
            if row.get("asset") not in preferred_assets and not entry.get("positionWrites"):
                continue
            examples.append({
                "asset": row.get("asset"),
                "slot": slot,
                "tableIndex": entry.get("index"),
                "frames": entry.get("frameSelectorSequence") or [],
                "sounds": [sound.get("summary") for sound in entry.get("sounds") or []],
                "movements": [movement.get("summary") for movement in entry.get("movements") or []],
                "positionWrites": [write.get("summary") for write in entry.get("positionWrites") or []],
            })
    return examples[:40]


def selector_interpretation(entries: list[dict[str, Any]]) -> str:
    frames = [frame for entry in entries for frame in entry.get("frames") or []]
    if not frames:
        return "no-frame-writes"
    low_zero = sum(1 for frame in frames if frame.get("selectorLowWord") == 0)
    high_values = {frame.get("selectorHighWord") for frame in frames if frame.get("selectorHighWord") is not None}
    if low_zero / len(frames) >= 0.80 and len(high_values) > 1:
        return "monster-local-frame-index-appears-in-selector-high-word"
    return "player-style-or-mixed-sprite-frame-selector"


def load_monster_assets() -> list[dict[str, Any]]:
    data = json.loads(MONSTER_RECTS.read_text(encoding="utf-8"))
    rows = []
    seen_cns: set[str] = set()
    for row in data.get("localRows") or []:
        descriptor_va = parse_hex(row.get("descriptorRefVaHex"))
        best_table = row.get("bestAdjacentTable") or {}
        rect_table_va = parse_hex(best_table.get("tableStartVaHex"))
        if descriptor_va is None:
            continue
        cns = row.get("cns") or ""
        rows.append({
            "asset": row.get("asset") or "",
            "cns": cns,
            "descriptorVa": descriptor_va,
            "descriptorVaHex": hex32(descriptor_va),
            "rectTableVaHex": hex32(rect_table_va),
            "targetFrameCount": row.get("targetFrameCount"),
            "rectSetSource": row.get("rectSetSource") or "",
        })
        seen_cns.add(cns)
    if MONSTER_ACTION_SELECTION.exists():
        selection = json.loads(MONSTER_ACTION_SELECTION.read_text(encoding="utf-8"))
        for descriptor in (selection.get("descriptorScriptProducer") or {}).get("descriptorRows") or []:
            cns = descriptor.get("cns") or ""
            if not cns.startswith("btl_") or cns in seen_cns:
                continue
            descriptor_va = parse_hex(descriptor.get("descriptorVaHex"))
            if descriptor_va is None:
                continue
            rows.append({
                "asset": cns.removesuffix(".cns"),
                "cns": cns,
                "descriptorVa": descriptor_va,
                "descriptorVaHex": hex32(descriptor_va),
                "rectTableVaHex": "",
                "targetFrameCount": None,
                "rectSetSource": "battle-action-selection-descriptor-only",
            })
            seen_cns.add(cns)
    return rows


def nearest_asset(table_va: int, assets: list[dict[str, Any]]) -> dict[str, Any] | None:
    if not assets:
        return None
    return min(assets, key=lambda row: abs(table_va - row["descriptorVa"]))


def best_asset_table(
    raw: bytes,
    sections: list[dict[str, Any]],
    cache: dict[int, dict[str, Any]],
    asset: dict[str, Any],
) -> dict[str, Any] | None:
    descriptor = asset["descriptorVa"]
    best: dict[str, Any] | None = None
    start = descriptor - MONSTER_TABLE_SEARCH_BEFORE
    end = descriptor + MONSTER_TABLE_SEARCH_AFTER
    for table_va in range(start - (start % 4), end, 4):
        if in_player_display_range(table_va):
            continue
        run = table_run(raw, sections, cache, table_va)
        if run is None:
            continue
        delta = table_va - descriptor
        # Favor descriptor-adjacent 15-entry action tables, but keep score generic.
        bonus = 0
        if 0 <= delta <= 0x180:
            bonus += 25
        if run["entryCount"] >= 15:
            bonus += 15
        if run["actionSlotFrameCount"] >= 3:
            bonus += 20
        candidate_score = run["score"] + bonus
        if best is None or candidate_score > best["assetTableScore"]:
            best = {**run, "assetTableScore": candidate_score}
    return best


def scan_global_tables(
    raw: bytes,
    sections: list[dict[str, Any]],
    cache: dict[int, dict[str, Any]],
    assets: list[dict[str, Any]],
    limit: int = 40,
) -> list[dict[str, Any]]:
    data_section = next(section for section in sections if section["name"] == ".data")
    start_va = data_section["va"]
    start_off = data_section["raw"]
    end_off = data_section["raw"] + data_section["raw_size"] - 16
    candidates: list[dict[str, Any]] = []
    for offset in range(start_off, end_off, 4):
        table_va = start_va + (offset - start_off)
        if in_player_display_range(table_va):
            continue
        run = table_run(raw, sections, cache, table_va)
        if run is None:
            continue
        near = nearest_asset(table_va, assets)
        asset_delta = None if near is None else table_va - near["descriptorVa"]
        candidates.append({**run, "nearestAsset": near, "nearestAssetDelta": asset_delta})
    selected: list[dict[str, Any]] = []
    occupied: list[tuple[int, int]] = []
    for row in sorted(candidates, key=lambda item: (-item["score"], item["tableVa"])):
        span = (row["tableVa"], row["tableVa"] + row["entryCount"] * 4)
        if any(not (span[1] <= used[0] or span[0] >= used[1]) for used in occupied):
            continue
        selected.append(row)
        occupied.append(span)
        if len(selected) >= limit:
            break
    return selected


def shared_action_sample() -> list[dict[str, Any]]:
    mapping = json.loads(ACTION_MAPPING.read_text(encoding="utf-8"))
    rows = []
    for row in mapping.get("sharedRows") or []:
        rows.append({
            "skillIdHex": row.get("skillIdHex"),
            "name": row.get("name"),
            "targetScopes": row.get("targetScopes"),
            "families": row.get("families"),
            "statuses": row.get("statuses"),
            "payloadVaHex": row.get("payloadVaHex"),
        })
    return rows


def build() -> dict[str, Any]:
    raw = EXE.read_bytes()
    sections = read_sections(raw)
    cache: dict[int, dict[str, Any]] = {}
    assets = load_monster_assets()

    asset_rows: list[dict[str, Any]] = []
    for asset in assets:
        run = best_asset_table(raw, sections, cache, asset)
        if run is None:
            asset_rows.append({
                **asset,
                "displayActionTableStatus": "not-found",
                "displayActionTableVaHex": "",
                "displayActionTableDelta": None,
                "entries": [],
            })
            continue
        delta = run["tableVa"] - asset["descriptorVa"]
        asset_rows.append({
            **asset,
            "displayActionTableStatus": "descriptor-adjacent-candidate",
            "displayActionTableVaHex": run["tableVaHex"],
            "displayActionTableDelta": delta,
            "displayActionTableDeltaHex": hex(delta) if delta >= 0 else f"-{hex(-delta)}",
            "entryCount": run["entryCount"],
            "uniquePointerCount": run["uniquePointerCount"],
            "frameEntryCount": run["frameEntryCount"],
            "actionSlotFrameCount": run["actionSlotFrameCount"],
            "selectorInterpretation": run["selectorInterpretation"],
            "entries": run["entries"],
        })

    global_tables = scan_global_tables(raw, sections, cache, assets)
    movement_summary = monster_movement_summary(asset_rows)
    display_vm_write_search = monster_display_vm_write_search(raw, sections, cache, asset_rows)
    action_field_static_audit = actor_action_field_static_audit()
    action_vm_review = action_vm_handler_review(raw, sections)
    indirect_5a_copy_search = indirect_actor5a_copy_search()
    raw_5a_opcode_scan = raw_actor5a_opcode_pattern_scan(raw, sections)
    packed_actor58_width_audit = packed_actor58_write_width_audit()
    phase60_write_audit = phase60_direct_write_audit()
    dynamic_write_opcode_audit = dynamic_generic_write_opcode_audit(raw, sections)
    referenced_action_script_scan = referenced_action_script_candidate_scan(raw, sections)
    actor_init_block_copy_audit = actor_initialization_block_copy_audit()
    slot0_reachability_review = monster_slot0_reachability_review(asset_rows)
    active_descriptor_review = active_descriptor_selection_review(raw, sections)
    active_descriptor_actor_slot = active_descriptor_actor_slot_review()
    enemy_stat_review = enemy_actor_stat_row_review(raw, sections)
    rejected_hypotheses = rejected_monster_skill_binding_hypotheses()
    actor59_review = actor59_producer_review()
    enemy_action_opcode_review = enemy_action_selection_opcode_review()

    return {
        "version": 1,
        "kind": "hwanse-battle-monster-action-frame-probe",
        "source": [
            "Hwanse2.exe",
            "out/battle_action_mapping.json",
            "out/monster_frame_rect_exe_pattern_scan.json",
        ],
        "status": "static-monster-local-slot-reader-with-vm-script-producer",
        "supersedesOldConclusion": (
            "Earlier revisions of this probe only found the type-2 actor+0x5a reader "
            "and failed to find a direct x86 producer. The current producer evidence "
            "is in out/battle_monster_action_selection_review.json: descriptor "
            "script3 target blocks write actor/object+0x59 and +0x5a with VM opcode 0x10."
        ),
        "latestProducerReport": "out/battle_monster_action_selection_review.json",
        "codeEvidence": [
            {
                "vaHex": "0x0040cfb8",
                "summary": "enemy/shared actor display phase = actor+0x5a + 0x0a, then 0x411754(actor+0x88, phase)",
                "classification": "confirmed-control-flow",
            },
            {
                "vaHex": "0x0041505d",
                "summary": "enemy/shared 0x7d helper uses actor+0x59 to read dword[dword[0x004d2494] + skillId*8], i.e. shared action/name/payload record",
                "classification": "confirmed-shared-action-record-selection",
            },
            {
                "vaHex": "0x004d2494",
                "summary": "shared action table pointer variable; current value points to 0x004d299c",
                "classification": "confirmed-pointer-variable",
            },
            {
                "vaHex": "0x0040cf49",
                "summary": "player/owned actor display path resets actor+0x5a=0 and uses actor+0x59+0x0a for display phase, proving +0x5a is not the general action id",
                "classification": "confirmed-player-vs-enemy-split",
            },
            {
                "vaHex": "0x0043329f",
                "summary": "selection helper writes actor+0x58 category and actor+0x59 action id from 0x4577a6/0x4577ac/0x4577b2/0x4577b8 families, but does not directly assign actor+0x5a",
                "classification": "confirmed-action-id-producer-not-display-slot-producer",
            },
            {
                "vaHex": "0x0040b752",
                "summary": "generic opcode 0x8e loads/saves active descriptor record+0x40/+0x41 through globals 0x59e340/0x59e34a; these globals feed action id selection, not actor+0x5a",
                "classification": "confirmed-active-descriptor-action-id-path",
            },
            {
                "vaHex": "0x00402d2e",
                "summary": "generic VM opcode 0x10 writes byte values into the active object/actor field chosen by the script operand; descriptor script3 target blocks use `10 c0 59 xx` and `10 c0 5a yy`.",
                "classification": "confirmed-vm-script-action-and-display-slot-producer",
            },
            {
                "vaHex": "0x0040b696",
                "summary": "generic opcode 0x8d can write a descriptor-derived selector to a script-selected destination, but .data scan found no 0x8d script destination 0x5a",
                "classification": "confirmed-no-actor5a-destination-in-this-bytecode-path",
            },
            {
                "vaHex": "0x00433649",
                "summary": "action payload accessor branches by actor+0x58; for shared/enemy category it reads dword[dword[0x004d2494]+actor+0x59*8], then returns payload byte at +0x1a+unitIndex*8",
                "classification": "confirmed-payload-byte-accessor",
            },
            {
                "vaHex": "0x00433545",
                "summary": "stores the payload accessor result into actor+0x67; this is a derived action/result selector, not the visible action slot",
                "classification": "confirmed-derived-selector",
            },
            {
                "vaHex": "0x0040f5a9",
                "summary": "clone/action helper copies only the first 0x48 actor bytes, clears 0x48..0x9f, and then writes actor+0x59; it does not copy or produce actor+0x5a",
                "classification": "confirmed-no-inherited-display-slot-in-this-path",
            },
            {
                "vaHex": "multi",
                "summary": "apparent +0x58 writes in VM opcode handlers are often script-context writes, not actor writes; do not promote them as actor+0x5a producers without runtime/context proof",
                "classification": "confirmed-context-caveat",
            },
            {
                "vaHex": "0x00433f0e",
                "summary": "result/special dispatch routes damage/status/recovery through 0x00546970 or 0x00546a38; it is not the shared skillId -> local monster action slot binding",
                "classification": "confirmed-non-producer",
            },
            {
                "vaHex": "0x0040be38",
                "summary": "actor initialization zeroes/fills enemy actor records but does not seed actor+0x5a; without a later producer, enemy normal action defaults to local slot 0/table index 10",
                "classification": "confirmed-non-producer",
            },
        ],
        "slotHypothesis": [
            "Per-monster display tables are separate from sharedRows payload records.",
            "Table indexes 0/1 are idle loop candidates; 2 is an alternate idle/state loop in many assets.",
            "Table index 3 is a one-frame hit/reaction candidate; 4 is a repeated shake/reaction candidate.",
            "Table indexes 10..14 line up with the reader formula actor+0x5a + 0x0a.",
            "Descriptor script3 target blocks now bind shared action id to local visible slot: `10 c0 59 xx` writes actor+0x59 and `10 c0 5a yy` writes actor+0x5a.",
            "Repeated pointers in the script3 choice table are retained as static weight candidates; exact RNG branch semantics are still a separate opcode question.",
            "Monster action scripts are mostly local frame/sound/effect scripts. Some actions contain short motion or nonzero placement selectors, including explicit x/y position writes. This is action-local lunge/offset/return rather than the player-style global repositioning rule.",
            "Charge-like shared actions such as 육탄돌격/저돌맹진/돌진 do not themselves prove visible movement; the movement must be present in the selected monster-local display action slot.",
            "Direct x86 code references still show player reset and type-2 phase read only. The producer is descriptor VM bytecode, so older direct-write scans should not be read as producer absence.",
            "The earlier bytecode-destination candidate opcodes 0x8c/0x8d/0x8f were scanned in .data and were not the relevant producer path.",
            "Raw all-section byte scans can show 0x5a-looking patterns inside .text import-call bytes. These are code-byte false positives, not script operands.",
        ],
        "actorFieldSemantics": actor_field_semantics(),
        "displaySlotProducerSearch": display_slot_producer_search(),
        "monsterDisplayVmWriteSearch": display_vm_write_search,
        "actorActionFieldStaticAudit": action_field_static_audit,
        "indirectActor5aCopySearch": indirect_5a_copy_search,
        "rawActor5aOpcodePatternScan": raw_5a_opcode_scan,
        "packedActor58WriteWidthAudit": packed_actor58_width_audit,
        "phase60DirectWriteAudit": phase60_write_audit,
        "dynamicGenericWriteOpcodeAudit": dynamic_write_opcode_audit,
        "referencedActionScriptCandidateScan": referenced_action_script_scan,
        "actorInitializationBlockCopyAudit": actor_init_block_copy_audit,
        "monsterSlot0ReachabilityReview": slot0_reachability_review,
        "activeDescriptorSelectionReview": active_descriptor_review,
        "activeDescriptorActorSlotReview": active_descriptor_actor_slot,
        "enemyActorStatRowReview": enemy_stat_review,
        "rejectedMonsterSkillBindingHypotheses": rejected_hypotheses,
        "actor59ProducerReview": actor59_review,
        "enemyActionSelectionOpcodeReview": enemy_action_opcode_review,
        "actionVmHandlerReview": action_vm_review,
        "displayDispatchReview": display_dispatch_review(),
        "selectorInterpretationNote": "For many monster tables the VM opcode 0x21 writes selector high-word as the local frame index and selector low-word remains 0. The existing decoder labels this high-word as sprite because player tables use a fixed sprite id and changing low-word frame.",
        "assetTableCount": len(asset_rows),
        "assetRowsWithTable": sum(1 for row in asset_rows if row["displayActionTableStatus"] != "not-found"),
        "assetRows": asset_rows,
        "monsterMovementSummary": movement_summary,
        "localMotionFocusExamples": local_motion_focus_examples(asset_rows),
        "monsterMotionExamples": monster_motion_examples(asset_rows),
        "globalCandidateTables": global_tables,
        "sharedActionRows": shared_action_sample(),
        "sharedChargeActionRows": shared_charge_actions(),
        "notes": [
            "This is static analysis only; no runtime monster AI path was executed.",
            "Opening/runtime probes are excluded from this report because the intro does not contain a monster attack turn.",
            "Descriptor-local display VM scripts do not choose the action by themselves; descriptor script3 target blocks choose actor+0x59/+0x5a, then the display VM consumes actor+0x5a + 0x0a.",
            "The frame tables are strong descriptor-adjacent candidates because they sit directly beside monster CNS descriptors and use the same local frame ordinals as monster rect tables.",
            "Do not treat shared skillId as the table index. The visible type-2 phase reader uses actor+0x5a + 0x0a, while shared skillId actor+0x59 selects damage/effect/action-name payload records.",
            "The user's 마왕 슬라임 observation is consistent with the updated model: asset zs_maoh has a movement-capable local slot, so monsters cannot be assumed to stay fixed in place.",
            "Do not confuse zk_maoh(마왕박쥐 family) with zs_maoh(마왕슬라임 family) when validating charge/lunge behavior.",
        ],
    }


def compact_entry(entry: dict[str, Any]) -> str:
    frames = entry.get("frameSelectorSequence") or []
    sounds = [f"WLK id {int(sound.get('wlkNo')):02d}" for sound in entry.get("sounds") or [] if sound.get("wlkNo") is not None]
    slot = entry.get("monsterActionSlotIfPhaseMinus0x0a")
    slot_text = "" if slot is None else f" slot={slot}"
    sound_text = "" if not sounds else f" sound={'/'.join(sounds)}"
    return f"{entry.get('index'):02d}{slot_text}: {frames}{sound_text}"


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Monster action frame probe",
        "",
        f"- status: `{report['status']}`",
        f"- asset tables: `{report['assetRowsWithTable']}/{report['assetTableCount']}`",
        "",
        "## Confirmed control flow",
    ]
    for row in report["codeEvidence"]:
        lines.append(f"- `{row['vaHex']}` {row['summary']} ({row['classification']})")
    lines += ["", "## Slot hypothesis"]
    lines += [f"- {line}" for line in report["slotHypothesis"]]
    lines += ["", "## Actor field split"]
    for row in report.get("actorFieldSemantics") or []:
        lines.append(f"- `{row['field']}` {row['label']} / `{row['status']}`: {row['evidence']}")
    dispatch = report.get("displayDispatchReview") or []
    if dispatch:
        lines += ["", "## Display dispatcher review"]
        for row in dispatch:
            lines.append(
                f"- `{row.get('vaHex')}` {row.get('role')}: "
                f"{row.get('evidence')} / {row.get('interpretation')}"
            )
    action_vm = report.get("actionVmHandlerReview") or {}
    if action_vm:
        lines += [
            "",
            "## Action VM handler review",
            f"- status: `{action_vm.get('status')}`",
            f"- runner: `{action_vm.get('runnerVaHex')}`",
            f"- handler table: `{action_vm.get('handlerTableVaHex')}`",
            f"- dispatch: {action_vm.get('dispatchRule')}",
        ]
        lines += [f"- {line}" for line in action_vm.get("conclusion") or []]
        for row in action_vm.get("keyHandlers") or []:
            lines.append(
                f"- `{row.get('opcodeHex')}` `{row.get('handlerVaHex')}` "
                f"{row.get('role')}: {row.get('summary')} / {row.get('slotRelevance')}"
            )
    producer = report.get("displaySlotProducerSearch") or {}
    if producer:
        lines += [
            "",
            "## Display slot producer search",
            f"- status: `{producer.get('status')}`",
            f"- static conclusion: {producer.get('staticConclusion')}",
            f"- conclusion: {producer.get('currentConclusion')}",
            "- direct actor+0x5a references:",
        ]
        for row in producer.get("directActor5aReferences") or []:
            lines.append(f"  - `{row.get('vaHex')}` `{row.get('access')}`: {row.get('summary')}")
        lines.append("- confirmed non-producers:")
        for row in producer.get("confirmedNonProducers") or []:
            lines.append(f"  - `{row.get('vaHex')}` {row.get('summary')}")
        lines.append("- caveats:")
        for line in producer.get("contextCaveats") or []:
            lines.append(f"  - {line}")
    vm_write = report.get("monsterDisplayVmWriteSearch") or {}
    if vm_write:
        lines += [
            "",
            "## Monster display VM write search",
            f"- status: `{vm_write.get('status')}`",
            f"- tables/entries/write rows: `{vm_write.get('tableCount')}/{vm_write.get('entryCount')}/{vm_write.get('writeRowCount')}`",
            f"- watched actor fields: `{', '.join(vm_write.get('watchedDests') or [])}`",
            f"- matched watched writes: `{len(vm_write.get('interestingWrites') or [])}`",
        ]
        lines += [f"- {line}" for line in vm_write.get("interpretation") or []]
        counts = vm_write.get("writeDestCounts") or {}
        if counts:
            lines.append("- write destination counts:")
            for dest, count in counts.items():
                lines.append(f"  - `{dest}`: `{count}`")
    static_audit = report.get("actorActionFieldStaticAudit") or {}
    if static_audit:
        lines += [
            "",
            "## Actor action-field static audit",
            f"- status: `{static_audit.get('status')}`",
            f"- scanned instructions: `{static_audit.get('instructionCount')}`",
            f"- actor-array refs: `{static_audit.get('actorArrayReferenceCount')}`",
            f"- writes touching +0x5a byte: `{static_audit.get('writesTouching5aCount')}`",
            f"- unaligned pre-+0x58 writes touching +0x5a: `{static_audit.get('unalignedPre58WritesTouching5aCount')}`",
            f"- nonzero direct +0x5a writes: `{len(static_audit.get('nonzeroDirect5aWrites') or [])}`",
        ]
        lines += [f"- {line}" for line in static_audit.get("conclusion") or []]
        lines.append("- direct +0x5a references:")
        for row in static_audit.get("direct5aReferences") or []:
            lines.append(f"  - `{row.get('vaHex')}` `{row.get('access')}` `{row.get('asm')}`")
        lines.append("- writes that touch byte +0x5a:")
        for row in static_audit.get("writesTouching5a") or []:
            lines.append(
                f"  - `{row.get('vaHex')}` `{row.get('fieldHex')}` `{row.get('asm')}` "
                f"byte5a=`{row.get('byteWrittenTo5a')}` / {row.get('classification')}"
            )
    indirect = report.get("indirectActor5aCopySearch") or {}
    if indirect:
        lines += [
            "",
            "## Indirect actor+0x5a copy search",
            f"- status: `{indirect.get('status')}`",
            f"- lea candidates: `{indirect.get('leaCandidateCount')}`",
            f"- non-stack lea candidates: `{indirect.get('nonStackLeaCandidateCount')}`",
        ]
        lines += [f"- {line}" for line in indirect.get("interpretation") or []]
        non_stack = indirect.get("nonStackLeaCandidates") or []
        if non_stack:
            lines.append("- non-stack candidates:")
            for row in non_stack:
                lines.append(f"  - `{row.get('vaHex')}` `{row.get('asm')}`")
        else:
            lines.append("- non-stack candidates: none")
    raw_5a = report.get("rawActor5aOpcodePatternScan") or {}
    if raw_5a:
        lines += [
            "",
            "## Raw actor+0x5a opcode pattern scan",
            f"- status: `{raw_5a.get('status')}`",
            f"- .text range: `{raw_5a.get('textRangeHex')}`",
            f"- matches / writes / explicit +0x5a writes: `{raw_5a.get('matchCount')}/{raw_5a.get('writeMatchCount')}/{raw_5a.get('explicitActor5aWriteCount')}`",
            f"- nonzero immediate packed writes: `{raw_5a.get('nonzeroImmediatePackedWriteCount')}`",
            f"- raw false positives: `{raw_5a.get('rawFalsePositiveCount')}`",
        ]
        lines += [f"- {line}" for line in raw_5a.get("interpretation") or []]
        for row in raw_5a.get("rows") or []:
            lines.append(
                f"- `{row.get('vaHex')}` `{row.get('bytesHex')}` "
                f"{row.get('pattern')} / {row.get('classification')} "
                f"byte5a=`{row.get('byteWrittenTo5a', '')}`"
            )
    packed_58 = report.get("packedActor58WriteWidthAudit") or {}
    if packed_58:
        lines += [
            "",
            "## Packed actor+0x58 write width audit",
            f"- status: `{packed_58.get('status')}`",
            f"- packed writes: `{packed_58.get('packedWriteCount')}`",
            f"- proven zero +0x5a byte: `{packed_58.get('provenZeroByte5aCount')}`",
            f"- nonzero +0x5a byte: `{packed_58.get('nonzeroByte5aCount')}`",
            f"- unknown register-width rows: `{packed_58.get('unknownRegisterWidthCount')}`",
        ]
        lines += [f"- {line}" for line in packed_58.get("interpretation") or []]
        unknown_rows = packed_58.get("unknownRows") or []
        if unknown_rows:
            lines.append("- unknown rows:")
            for row in unknown_rows[:16]:
                lines.append(
                    f"  - `{row.get('vaHex')}` `{row.get('asm')}` / {row.get('classification')}"
                )
        else:
            lines.append("- unknown rows: none")
    phase60 = report.get("phase60DirectWriteAudit") or {}
    if phase60:
        lines += [
            "",
            "## actor+0x60 direct phase write audit",
            f"- status: `{phase60.get('status')}`",
            f"- direct phase writes: `{phase60.get('directPhaseWriteCount')}`",
            f"- direct phase values: `{', '.join(phase60.get('directPhaseValuesHex') or [])}`",
            f"- direct phase >= 0x0a: `{phase60.get('directPhaseGte0aCount')}`",
            f"- dynamic phase writes: `{phase60.get('dynamicPhaseWriteCount')}`",
        ]
        lines += [f"- {line}" for line in phase60.get("interpretation") or []]
        for row in phase60.get("dynamicPhaseRows") or []:
            lines.append(
                f"- dynamic `{row.get('vaHex')}` `{row.get('asm')}` / {row.get('classification')}"
            )
    init_audit = report.get("actorInitializationBlockCopyAudit") or {}
    if init_audit:
        lines += [
            "",
            "## Actor initialization/block-copy audit",
            f"- status: `{init_audit.get('status')}`",
        ]
        lines += [f"- {line}" for line in init_audit.get("conclusion") or []]
        for row in init_audit.get("paths") or []:
            lines.append(
                f"- `{row.get('vaHex')}` {row.get('role')}: "
                f"{row.get('evidence')} / {row.get('impact')}"
            )
    dynamic_audit = report.get("dynamicGenericWriteOpcodeAudit") or {}
    if dynamic_audit:
        lines += [
            "",
            "## Dynamic generic write opcode audit",
            f"- status: `{dynamic_audit.get('status')}`",
            f"- scanned sections: `{', '.join(dynamic_audit.get('scannedSections') or [])}`",
            f"- watched destinations: `{', '.join(dynamic_audit.get('watchedDestinationOffsets') or [])}`",
            f"- watched destination candidates: `{dynamic_audit.get('watchedDestinationCandidateCount')}`",
            f"- exact actor+0x5a candidates: `{dynamic_audit.get('exactActor5aCandidateCount')}`",
            f"- all-section raw +0x5a byte-pattern hits: `{dynamic_audit.get('allSectionExactActor5aRawByteHitCount')}`",
            f"- .text false-positive hits: `{dynamic_audit.get('textSectionExactActor5aFalsePositiveCount')}`",
        ]
        lines += [f"- {line}" for line in dynamic_audit.get("interpretation") or []]
        for row in dynamic_audit.get("opcodeRows") or []:
            lines.append(
                f"- `{row.get('opcodeHex')}` `{row.get('handlerVaHex')}` "
                f"{row.get('role')}: raw `{row.get('rawOpcodeByteCountInData')}`, "
                f"watched `{row.get('watchedDestinationCount')}`, "
                f"exact +0x5a `{row.get('exactActor5aDestinationCount')}`, "
                f"all-section raw +0x5a `{row.get('allSectionExactActor5aRawByteHitCount')}`"
            )
        watched_rows = dynamic_audit.get("watchedDestinationCandidates") or []
        if watched_rows:
            lines.append("- watched destination candidates:")
            for row in watched_rows[:20]:
                lines.append(
                    f"  - `{row.get('opcodeHex')}` `{row.get('vaHex')}` "
                    f"dest `{row.get('destOffsetHex')}` bytes `{row.get('bytesHex')}`"
                )
        false_rows = dynamic_audit.get("textSectionExactActor5aFalsePositiveHits") or []
        if false_rows:
            lines.append("- .text raw byte false positives:")
            for row in false_rows[:12]:
                lines.append(
                    f"  - `{row.get('opcodeHex')}` `{row.get('vaHex')}` "
                    f"dest `{row.get('destOffsetHex')}` bytes `{row.get('bytesHex')}` / {row.get('classification')}"
                )
    referenced_scan = report.get("referencedActionScriptCandidateScan") or {}
    if referenced_scan:
        lines += [
            "",
            "## Referenced action script candidate scan",
            f"- status: `{referenced_scan.get('status')}`",
            f"- .data range: `{referenced_scan.get('dataVaRangeHex')}`",
            f"- pointer values / unique targets: `{referenced_scan.get('dataPointerValueCount')}/{referenced_scan.get('uniqueDataPointerTargetCount')}`",
            f"- matching targets: `{referenced_scan.get('matchingTargetCount')}`",
            f"- unclassified candidates: `{referenced_scan.get('unclassifiedCandidateCount')}`",
        ]
        lines += [f"- {line}" for line in referenced_scan.get("interpretation") or []]
        counts = referenced_scan.get("classificationCounts") or {}
        if counts:
            lines.append("- classification counts:")
            for key, count in counts.items():
                lines.append(f"  - `{key}`: `{count}`")
        candidates = referenced_scan.get("unclassifiedCandidates") or []
        if candidates:
            lines.append("- unclassified candidates:")
            for row in candidates[:20]:
                lines.append(
                    f"  - `{row.get('targetVaHex')}` refs `{row.get('refCount')}` "
                    f"first ref `{row.get('firstRefVaHex')}` opcodes `{', '.join(row.get('opcodeBytesHex') or [])}`"
                )
        false_positive_samples = referenced_scan.get("falsePositiveSamples") or []
        if false_positive_samples:
            lines.append("- false-positive samples:")
            for row in false_positive_samples[:20]:
                lines.append(
                    f"  - `{row.get('targetVaHex')}` `{row.get('classification')}` "
                    f"mapped-ptrs `{row.get('mappedPointerDwordCount')}` "
                    f"watched `{row.get('watchedOpcodeOffsets')}`"
                )
    slot0_review = report.get("monsterSlot0ReachabilityReview") or {}
    if slot0_review:
        lines += [
            "",
            "## Monster slot 0 reachability review",
            f"- status: `{slot0_review.get('status')}`",
            f"- slot0 entries: `{slot0_review.get('slot0EntryCount')}`",
            f"- with jump category: `{slot0_review.get('slot0WithJumpCategoryCount')}`",
            f"- with child VM: `{slot0_review.get('slot0WithSpawnChildVmCount')}`",
            f"- with movement: `{slot0_review.get('slot0WithMovementCount')}`",
            f"- with position write: `{slot0_review.get('slot0WithPositionWriteCount')}`",
        ]
        lines += [f"- {line}" for line in slot0_review.get("interpretation") or []]
        for row in (slot0_review.get("sampleRows") or [])[:12]:
            lines.append(
                f"- `{row.get('asset')}` slot0 frames `{row.get('frames')}` "
                f"categories `{', '.join(row.get('categories') or [])}`"
            )
    active_descriptor = report.get("activeDescriptorSelectionReview") or {}
    if active_descriptor:
        lines += [
            "",
            "## Active descriptor selection review",
            f"- status: `{active_descriptor.get('status')}`",
            f"- descriptor table: `{active_descriptor.get('descriptorTableVaHex')}` record size `{active_descriptor.get('recordSizeHex')}`",
        ]
        lines += [f"- {line}" for line in active_descriptor.get("interpretation") or []]
        for row in active_descriptor.get("keyHandlers") or []:
            lines.append(f"- `{row.get('vaHex')}` {row.get('summary')}")
        samples = active_descriptor.get("sampleRecords") or []
        if samples:
            lines.append("- sample records:")
            for row in samples[:8]:
                lines.append(
                    f"  - `{row.get('recordVaHex')}` mode `{row.get('selectorModeByteRecord40Hex')}` "
                    f"idx `{row.get('selectorIndexByteRecord41Hex')}` "
                    f"+48 `{row.get('rowSelectorBytes48To4fHex')}` "
                    f"+4a `{row.get('comparisonBytes4aTo55Hex')}` "
                    f"/ `{row.get('recordInterpretation')}`"
                )
    active_slot = report.get("activeDescriptorActorSlotReview") or {}
    if active_slot:
        lines += [
            "",
            "## Active descriptor actor-slot lifecycle review",
            f"- status: `{active_slot.get('status')}`",
        ]
        lines += [f"- {line}" for line in active_slot.get("conclusion") or []]
        lines.append("- tracked globals:")
        for row in active_slot.get("trackedGlobals") or []:
            lines.append(
                f"  - `{row.get('vaHex')}` {row.get('label')}: {row.get('role')}"
            )
        lines.append("- lifecycle:")
        for row in active_slot.get("lifecycle") or []:
            lines.append(
                f"  - `{row.get('vaHex')}` {row.get('role')}: "
                f"{row.get('summary')} / {row.get('slotConclusion')}"
            )
    actor59 = report.get("actor59ProducerReview") or {}
    if actor59:
        lines += [
            "",
            "## actor+0x59 producer review",
            f"- status: `{actor59.get('status')}`",
        ]
        lines += [f"- {line}" for line in actor59.get("conclusion") or []]
        for row in actor59.get("directWrites") or []:
            lines.append(
                f"- `{row.get('vaHex')}` {row.get('producer')}: "
                f"source `{row.get('source')}`, writes `{row.get('writes')}` / {row.get('classification')}"
            )
    enemy_opcode = report.get("enemyActionSelectionOpcodeReview") or {}
    if enemy_opcode:
        lines += [
            "",
            "## Enemy action selection opcode review",
            f"- status: `{enemy_opcode.get('status')}`",
        ]
        lines += [f"- {line}" for line in enemy_opcode.get("conclusion") or []]
        lines.append("- flow:")
        for row in enemy_opcode.get("flow") or []:
            address = row.get("opcodeHex") or row.get("vaHex") or ""
            lines.append(
                f"  - `{address}` `{row.get('handlerVaHex') or ''}` "
                f"{row.get('role')}: {row.get('summary')} / {row.get('confirmedOutput')}"
            )
        lines.append("- branches:")
        for row in enemy_opcode.get("branchDetails") or []:
            lines.append(
                f"  - `{row.get('handlerVaHex')}` {row.get('branch')}: "
                f"{row.get('summary')} / {row.get('actor5aResult')}"
            )
        lines.append("- open gap:")
        lines += [f"  - {line}" for line in enemy_opcode.get("openGap") or []]
    enemy_stat = report.get("enemyActorStatRowReview") or {}
    if enemy_stat:
        lines += [
            "",
            "## Enemy actor stat row review",
            f"- status: `{enemy_stat.get('status')}`",
            f"- stat row base: `{enemy_stat.get('statRowBaseVaHex')}` stride `{enemy_stat.get('rowStrideHex')}`",
        ]
        lines += [f"- {line}" for line in enemy_stat.get("importantOffsetCorrection") or []]
        lines += [f"- {line}" for line in enemy_stat.get("conclusion") or []]
        for row in enemy_stat.get("creationPaths") or []:
            lines.append(f"- `{row.get('vaHex')}` {row.get('summary')} / {row.get('conclusion')}")
        samples = enemy_stat.get("sampleRows") or []
        if samples:
            lines.append("- sample rows:")
            for row in samples[:12]:
                lines.append(
                    f"  - internal `{row.get('internalEnemyId')}` `{row.get('actualRowVaHex')}` "
                    f"{row.get('name')} HP `{row.get('hp')}` reward `{row.get('goldRewardActualOffset36')}` "
                    f"prefix `{row.get('prefix8Hex')}`"
                )
    rejected = report.get("rejectedMonsterSkillBindingHypotheses") or {}
    if rejected:
        lines += [
            "",
            "## Rejected monster skill-binding hypotheses",
            f"- status: `{rejected.get('status')}`",
        ]
        for row in rejected.get("rejections") or []:
            lines.append(
                f"- `{row.get('status')}` {row.get('hypothesis')} "
                f"Evidence: {row.get('evidence')} Conclusion: {row.get('conclusion')}"
            )
        lines += [f"- {line}" for line in rejected.get("currentWorkingModel") or []]
    runtime = report.get("openingRuntimeObservation") or {}
    if runtime:
        lines += [
            "",
            "## Opening runtime observation",
            f"- status: `{runtime.get('status')}`",
            f"- source: `{runtime.get('source')}`",
            f"- runtime crashed: `{runtime.get('runtimeCrashed')}`",
            f"- battle samples: `{runtime.get('battleSampleCount')}/{runtime.get('sampleCount')}`",
        ]
        lines += [f"- {line}" for line in runtime.get("observations") or []]
        lines.append("- caveats:")
        for line in runtime.get("caveats") or []:
            lines.append(f"  - {line}")
        rows = runtime.get("stateRows") or []
        if rows:
            lines.append("- top runtime actor states:")
            for row in rows[:12]:
                lines.append(
                    f"  - `{row.get('slot')}` type `{row.get('typeHex')}` char `{row.get('charHex')}` "
                    f"+58 `{row.get('cmd58Hex')}` +59 `{row.get('action59Hex')}` +5a `{row.get('localSlot5aHex')}` "
                    f"+60 `{row.get('phase60Hex')}` hits `{row.get('hits63Hex')}` flags `{row.get('flags5cHex')}` "
                    f"count `{row.get('count')}`"
                )
    movement = report.get("monsterMovementSummary") or {}
    lines += [
        "",
        "## Monster movement summary",
        f"- action entries: `{movement.get('actionEntryCount')}`",
        f"- entries with movement opcode: `{movement.get('entriesWithMovementOpcode')}`",
        f"- entries with nonzero placement selector: `{movement.get('entriesWithNonzeroPlacementSelector')}`",
        f"- entries with x/y position writes: `{movement.get('entriesWithPositionWrite')}`",
    ]
    for line in movement.get("interpretation") or []:
        lines.append(f"- {line}")
    signatures = movement.get("movementSignatureCounts") or {}
    if signatures:
        lines.append("- movement signatures:")
        for key, count in list(signatures.items())[:12]:
            lines.append(f"  - `{key}`: `{count}`")
    examples = report.get("monsterMotionExamples") or []
    focus = report.get("localMotionFocusExamples") or []
    if focus:
        lines += ["", "## Local motion focus examples"]
        for row in focus:
            lines.append(
                f"- `{row['asset']}` slot `{row['slot']}` / {row['reason']} / `{row['interpretation']}`: "
                f"frames `{row['frames']}` move `{'; '.join(row.get('movements') or [])}` "
                f"pos `{'; '.join(row.get('positionWrites') or [])}`"
            )
    if examples:
        lines += ["", "## Motion examples"]
        for row in examples[:20]:
            lines.append(
                f"- `{row['asset']}` slot `{row['slot']}` frames `{row['frames']}` "
                f"move `{'; '.join(row.get('movements') or [])}` "
                f"pos `{'; '.join(row.get('positionWrites') or [])}`"
            )
    charges = report.get("sharedChargeActionRows") or []
    if charges:
        lines += ["", "## Shared charge-like action payloads"]
        for row in charges:
            lines.append(
                f"- `{row['skillIdHex']}` {row['name']}: prefix `{row.get('prefixBytesHex')}`, units `{row.get('unitsHex')}`"
            )
    lines += ["", "## Asset table sample"]
    for row in report["assetRows"][:40]:
        if row["displayActionTableStatus"] == "not-found":
            lines.append(f"- `{row['asset']}`: no table found")
            continue
        entries = "; ".join(compact_entry(entry) for entry in row.get("entries", []) if entry["index"] in {0, 3, 4, 10, 11, 12, 13, 14})
        lines.append(
            f"- `{row['asset']}` `{row['displayActionTableVaHex']}` delta `{signed_delta(row['displayActionTableDelta'])}` "
            f"entries `{row.get('entryCount')}`: {entries}"
        )
    lines += ["", "## Notes"]
    lines += [f"- {line}" for line in report["notes"]]
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    rows = []
    for row in report["assetRows"]:
        if row["displayActionTableStatus"] == "not-found":
            entries = ""
        else:
            entries = "<br>".join(
                html.escape(compact_entry(entry))
                for entry in row.get("entries", [])
                if entry["index"] in {0, 1, 2, 3, 4, 10, 11, 12, 13, 14}
            )
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['asset'])}</code><br><small>{html.escape(row['cns'])}</small></td>"
            f"<td><code>{html.escape(row.get('descriptorVaHex') or '')}</code></td>"
            f"<td><code>{html.escape(row.get('displayActionTableVaHex') or '')}</code><br><small>{html.escape(signed_delta(row.get('displayActionTableDelta')))}</small></td>"
            f"<td>{html.escape(str(row.get('targetFrameCount') or ''))}</td>"
            f"<td>{html.escape(row.get('selectorInterpretation') or '')}</td>"
            f"<td>{entries}</td>"
            "</tr>"
        )
    movement = report.get("monsterMovementSummary") or {}
    movement_rows = "".join(
        f"<tr><td><code>{html.escape(str(key))}</code></td><td>{html.escape(str(value))}</td></tr>"
        for key, value in (movement.get("movementSignatureCounts") or {}).items()
    )
    notable_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('asset') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('slot')))}</td>"
        f"<td>{html.escape(', '.join(str(frame) for frame in row.get('frames') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('movements') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('positionWrites') or []))}</td>"
        "</tr>"
        for row in movement.get("notableNonzeroSelectorSamples") or []
    )
    field_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('field') or '')}</code></td>"
        f"<td>{html.escape(row.get('label') or '')}</td>"
        f"<td><code>{html.escape(row.get('status') or '')}</code></td>"
        f"<td>{html.escape(row.get('evidence') or '')}</td>"
        "</tr>"
        for row in report.get("actorFieldSemantics") or []
    )
    dispatch_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('vaHex') or '')}</code></td>"
        f"<td>{html.escape(row.get('role') or '')}</td>"
        f"<td>{html.escape(row.get('evidence') or '')}</td>"
        f"<td>{html.escape(row.get('interpretation') or '')}</td>"
        "</tr>"
        for row in report.get("displayDispatchReview") or []
    )
    action_vm = report.get("actionVmHandlerReview") or {}
    action_vm_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('handlerVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('slotRelevance') or ''))}</td>"
        "</tr>"
        for row in action_vm.get("keyHandlers") or []
    )
    action_vm_nearby_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('handlerVaHex') or ''))}</code></td>"
        "</tr>"
        for row in action_vm.get("nearbyOpcodeTable") or []
    )
    producer = report.get("displaySlotProducerSearch") or {}
    producer_direct_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('vaHex') or '')}</code></td>"
        f"<td><code>{html.escape(row.get('access') or '')}</code></td>"
        f"<td>{html.escape(row.get('summary') or '')}</td>"
        "</tr>"
        for row in producer.get("directActor5aReferences") or []
    )
    producer_non_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('vaHex') or '')}</code></td>"
        f"<td>{html.escape(row.get('summary') or '')}</td>"
        "</tr>"
        for row in producer.get("confirmedNonProducers") or []
    )
    vm_write = report.get("monsterDisplayVmWriteSearch") or {}
    vm_write_count_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(dest))}</code></td>"
        f"<td>{html.escape(str(count))}</td>"
        "</tr>"
        for dest, count in (vm_write.get("writeDestCounts") or {}).items()
    )
    vm_write_interesting_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('asset') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('entryIndex')))}</td>"
        f"<td><code>{html.escape(str(row.get('rowVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('destHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        "</tr>"
        for row in vm_write.get("interestingWrites") or []
    )
    static_audit = report.get("actorActionFieldStaticAudit") or {}
    static_direct_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('access') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        "</tr>"
        for row in static_audit.get("direct5aReferences") or []
    )
    static_touch_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('fieldHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('byteWrittenTo5a')))}</td>"
        f"<td>{html.escape(str(row.get('actorArrayNearby')))}</td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in static_audit.get("writesTouching5a") or []
    )
    static_nearby_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(', '.join(row.get('fields') or []))}</td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('nearestActorArrayVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in static_audit.get("actorArrayNearbyWrites") or []
    )
    indirect = report.get("indirectActor5aCopySearch") or {}
    indirect_non_stack_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        "</tr>"
        for row in indirect.get("nonStackLeaCandidates") or []
    )
    indirect_all_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('stackLocal')))}</td>"
        "</tr>"
        for row in indirect.get("leaCandidates") or []
    )
    raw_5a = report.get("rawActor5aOpcodePatternScan") or {}
    raw_5a_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('bytesHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('pattern') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        f"<td>{html.escape(str(row.get('byteWrittenTo5a', '')))}</td>"
        "</tr>"
        for row in raw_5a.get("rows") or []
    )
    packed_58 = report.get("packedActor58WriteWidthAudit") or {}
    packed_58_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('source') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('byteWrittenTo5a')))}</td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in packed_58.get("rows") or []
    )
    packed_58_unknown_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in packed_58.get("unknownRows") or []
    )
    phase60 = report.get("phase60DirectWriteAudit") or {}
    phase60_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('asm') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('phaseValueHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in phase60.get("rows") or []
    )
    init_audit = report.get("actorInitializationBlockCopyAudit") or {}
    init_path_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        f"<td>{html.escape(str(row.get('evidence') or ''))}</td>"
        f"<td>{html.escape(str(row.get('impact') or ''))}</td>"
        "</tr>"
        for row in init_audit.get("paths") or []
    )
    dynamic_audit = report.get("dynamicGenericWriteOpcodeAudit") or {}
    dynamic_opcode_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('handlerVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('rawOpcodeByteCountInData')))}</td>"
        f"<td>{html.escape(str(row.get('watchedDestinationCount')))}</td>"
        f"<td>{html.escape(str(row.get('exactActor5aDestinationCount')))}</td>"
        f"<td>{html.escape(str(row.get('allSectionExactActor5aRawByteHitCount')))}</td>"
        "</tr>"
        for row in dynamic_audit.get("opcodeRows") or []
    )
    dynamic_candidate_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('destOffsetHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('bytesHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        "</tr>"
        for row in dynamic_audit.get("watchedDestinationCandidates") or []
    )
    dynamic_false_positive_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('section') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('destOffsetHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('bytesHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in dynamic_audit.get("textSectionExactActor5aFalsePositiveHits") or []
    )
    referenced_scan = report.get("referencedActionScriptCandidateScan") or {}
    referenced_classification_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(key))}</code></td>"
        f"<td>{html.escape(str(value))}</td>"
        "</tr>"
        for key, value in (referenced_scan.get("classificationCounts") or {}).items()
    )
    referenced_candidate_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('targetVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('refCount')))}</td>"
        f"<td><code>{html.escape(str(row.get('firstRefSection') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('firstRefVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('mappedPointerDwordCount')))}</td>"
        f"<td><code>{html.escape(' '.join(row.get('opcodeBytesHex') or []))}</code></td>"
        "</tr>"
        for row in referenced_scan.get("unclassifiedCandidates") or []
    )
    referenced_false_positive_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('targetVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('classification') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('refCount')))}</td>"
        f"<td>{html.escape(str(row.get('mappedPointerDwordCount')))}</td>"
        f"<td><code>{html.escape(str(row.get('firstRefVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(' '.join(row.get('opcodeBytesHex') or []))}</code></td>"
        "</tr>"
        for row in referenced_scan.get("falsePositiveSamples") or []
    )
    slot0_review = report.get("monsterSlot0ReachabilityReview") or {}
    slot0_sample_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('asset') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('entryIndex') or ''))}</td>"
        f"<td>{html.escape(', '.join(str(frame) for frame in row.get('frames') or []))}</td>"
        f"<td>{html.escape(', '.join(row.get('categories') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('sounds') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('movements') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('positionWrites') or []))}</td>"
        "</tr>"
        for row in slot0_review.get("sampleRows") or []
    )
    active_descriptor = report.get("activeDescriptorSelectionReview") or {}
    active_descriptor_handler_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        "</tr>"
        for row in active_descriptor.get("keyHandlers") or []
    )
    active_descriptor_sample_rows = "".join(
        "<tr>"
        f"<td>{html.escape(str(row.get('recordIndex')))}</td>"
        f"<td><code>{html.escape(str(row.get('recordVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('recordInterpretation') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('selectorModeByteRecord40Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('selectorIndexByteRecord41Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('rowSelectorBytes48To4fHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('comparisonBytes4aTo55Hex') or ''))}</code></td>"
        "</tr>"
        for row in active_descriptor.get("sampleRecords") or []
    )
    active_slot = report.get("activeDescriptorActorSlotReview") or {}
    active_slot_global_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('label') or ''))}</td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        "</tr>"
        for row in active_slot.get("trackedGlobals") or []
    )
    active_slot_lifecycle_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('slotConclusion') or ''))}</td>"
        "</tr>"
        for row in active_slot.get("lifecycle") or []
    )
    actor59 = report.get("actor59ProducerReview") or {}
    actor59_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('producer') or ''))}</td>"
        f"<td><code>{html.escape(str(row.get('source') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('writes') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('classification') or ''))}</td>"
        "</tr>"
        for row in actor59.get("directWrites") or []
    )
    enemy_opcode = report.get("enemyActionSelectionOpcodeReview") or {}
    enemy_opcode_flow_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('opcodeHex') or row.get('vaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('handlerVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('role') or ''))}</td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('confirmedOutput') or ''))}</td>"
        "</tr>"
        for row in enemy_opcode.get("flow") or []
    )
    enemy_opcode_branch_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('handlerVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('branch') or ''))}</td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('actor5aResult') or ''))}</td>"
        "</tr>"
        for row in enemy_opcode.get("branchDetails") or []
    )
    enemy_stat = report.get("enemyActorStatRowReview") or {}
    enemy_stat_path_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('vaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('summary') or ''))}</td>"
        f"<td>{html.escape(str(row.get('conclusion') or ''))}</td>"
        "</tr>"
        for row in enemy_stat.get("creationPaths") or []
    )
    enemy_stat_sample_rows = "".join(
        "<tr>"
        f"<td>{html.escape(str(row.get('internalEnemyId')))}</td>"
        f"<td><code>{html.escape(str(row.get('actualRowVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('exportedHpViewVaHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('name') or ''))}</td>"
        f"<td>{html.escape(str(row.get('hp')))}</td>"
        f"<td>{html.escape(str(row.get('goldRewardActualOffset36')))}</td>"
        f"<td><code>{html.escape(str(row.get('prefix8Hex') or ''))}</code></td>"
        "</tr>"
        for row in enemy_stat.get("sampleRows") or []
    )
    rejected = report.get("rejectedMonsterSkillBindingHypotheses") or {}
    rejected_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('status') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('hypothesis') or ''))}</td>"
        f"<td>{html.escape(str(row.get('evidence') or ''))}</td>"
        f"<td>{html.escape(str(row.get('conclusion') or ''))}</td>"
        "</tr>"
        for row in rejected.get("rejections") or []
    )
    focus_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('asset') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('slot')))}</td>"
        f"<td>{html.escape(str(row.get('reason') or ''))}</td>"
        f"<td><code>{html.escape(str(row.get('interpretation') or ''))}</code></td>"
        f"<td>{html.escape(', '.join(str(frame) for frame in row.get('frames') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('sounds') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('movements') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('positionWrites') or []))}</td>"
        "</tr>"
        for row in report.get("localMotionFocusExamples") or []
    )
    motion_example_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('asset') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('slot')))}</td>"
        f"<td>{html.escape(', '.join(str(frame) for frame in row.get('frames') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('sounds') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('movements') or []))}</td>"
        f"<td>{html.escape('; '.join(row.get('positionWrites') or []))}</td>"
        "</tr>"
        for row in report.get("monsterMotionExamples") or []
    )
    charge_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('skillIdHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('name') or ''))}</td>"
        f"<td><code>{html.escape(str(row.get('payloadVaHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('prefixBytesHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('unitsHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('note') or ''))}</td>"
        "</tr>"
        for row in report.get("sharedChargeActionRows") or []
    )
    runtime = report.get("openingRuntimeObservation") or {}
    runtime_state_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('slot') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('typeHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('charHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('cmd58Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('action59Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('localSlot5aHex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('phase60Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('target61Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('hits63Hex') or ''))}</code></td>"
        f"<td><code>{html.escape(str(row.get('flags5cHex') or ''))}</code></td>"
        f"<td>{html.escape(str(row.get('count') or ''))}</td>"
        f"<td>{html.escape(str(row.get('firstSeenMs') or ''))}</td>"
        "</tr>"
        for row in (runtime.get("stateRows") or [])[:24]
    )
    return f"""<!doctype html>
<html lang=\"ko\">
<head>
  <meta charset=\"utf-8\">
  <title>Monster action frame probe</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; line-height: 1.45; color: #202124; }}
    table {{ border-collapse: collapse; width: 100%; font-size: 13px; }}
    th, td {{ border: 1px solid #ddd; padding: 6px 8px; vertical-align: top; }}
    th {{ position: sticky; top: 0; background: #f5f5f5; z-index: 1; }}
    code {{ white-space: nowrap; }}
    .note {{ background: #fff8d8; border: 1px solid #ead27b; padding: 10px 12px; margin: 12px 0; }}
  </style>
</head>
<body>
  <p><a href=\"../web/index.html\">홈</a> · <a href=\"battle_monster_action_frame_probe.json\">JSON</a> · <a href=\"battle_monster_action_frame_probe.md\">MD</a></p>
  <h1>Monster action frame probe</h1>
  <div class=\"note\">
    <p><strong>상태:</strong> {html.escape(report['status'])}</p>
    <p>몬스터 프레임 테이블과 로컬 이동 opcode는 확인됐지만, <code>actor+0x59</code> 기술 ID에서 <code>actor+0x5a</code> 액션 슬롯으로 가는 직접 producer는 아직 미확정입니다.</p>
  </div>
  <h2>확정 제어 흐름</h2>
  <ul>
    {''.join(f'<li><code>{html.escape(row["vaHex"])}</code> {html.escape(row["summary"])}</li>' for row in report['codeEvidence'])}
  </ul>
  <h2>슬롯 가설</h2>
  <ul>
    {''.join(f'<li>{html.escape(line)}</li>' for line in report['slotHypothesis'])}
  </ul>
  <h2>actor 필드 분리</h2>
  <table>
    <thead><tr><th>field</th><th>label</th><th>status</th><th>evidence</th></tr></thead>
    <tbody>{field_rows}</tbody>
  </table>
  <h2>display dispatcher 리뷰</h2>
  <table>
    <thead><tr><th>VA</th><th>role</th><th>evidence</th><th>interpretation</th></tr></thead>
    <tbody>{dispatch_rows}</tbody>
  </table>
  <h2>action VM handler 리뷰</h2>
  <div class=\"note\">
    <p><strong>상태:</strong> <code>{html.escape(str(action_vm.get('status') or ''))}</code></p>
    <p><strong>runner:</strong> <code>{html.escape(str(action_vm.get('runnerVaHex') or ''))}</code>,
    <strong>handler table:</strong> <code>{html.escape(str(action_vm.get('handlerTableVaHex') or ''))}</code></p>
    <p>{html.escape(str(action_vm.get('dispatchRule') or ''))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in action_vm.get('conclusion') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>opcode</th><th>handler</th><th>role</th><th>summary</th><th>slot relevance</th></tr></thead>
    <tbody>{action_vm_rows}</tbody>
  </table>
  <details>
    <summary>0x98..0xa6 opcode table 주변</summary>
    <table><thead><tr><th>opcode</th><th>handler</th></tr></thead><tbody>{action_vm_nearby_rows}</tbody></table>
  </details>
  <h2>display slot producer 탐색</h2>
  <div class=\"note\">
    <p><strong>상태:</strong> <code>{html.escape(str(producer.get('status') or ''))}</code></p>
    <p>{html.escape(str(producer.get('currentConclusion') or ''))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in producer.get('contextCaveats') or [])}</ul>
  </div>
  <details open>
    <summary>actor+0x5a 직접 참조</summary>
    <table><thead><tr><th>VA</th><th>access</th><th>summary</th></tr></thead><tbody>{producer_direct_rows}</tbody></table>
  </details>
  <details>
    <summary>producer가 아닌 것으로 제외한 후보</summary>
    <table><thead><tr><th>VA</th><th>summary</th></tr></thead><tbody>{producer_non_rows}</tbody></table>
  </details>
  <h2>monster display VM write scan</h2>
  <div class=\"note\">
    <p><strong>상태:</strong> <code>{html.escape(str(vm_write.get('status') or ''))}</code></p>
    <p>tables={html.escape(str(vm_write.get('tableCount')))}, entries={html.escape(str(vm_write.get('entryCount')))}, write rows={html.escape(str(vm_write.get('writeRowCount')))}, watched hits={html.escape(str(len(vm_write.get('interestingWrites') or [])))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in vm_write.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>write destination counts</summary>
    <table><thead><tr><th>dest</th><th>count</th></tr></thead><tbody>{vm_write_count_rows}</tbody></table>
  </details>
  <details>
    <summary>watched actor-field writes</summary>
    <table><thead><tr><th>asset</th><th>entry</th><th>VA</th><th>dest</th><th>summary</th></tr></thead><tbody>{vm_write_interesting_rows}</tbody></table>
  </details>
  <h2>actor action-field 정적 audit</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(static_audit.get('status') or ''))}</code></p>
    <p>instructions={html.escape(str(static_audit.get('instructionCount')))}, actor-array refs={html.escape(str(static_audit.get('actorArrayReferenceCount')))}, writes touching +0x5a={html.escape(str(static_audit.get('writesTouching5aCount')))}, unaligned pre-+0x58 writes touching +0x5a={html.escape(str(static_audit.get('unalignedPre58WritesTouching5aCount')))}, nonzero direct +0x5a writes={html.escape(str(len(static_audit.get('nonzeroDirect5aWrites') or [])))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in static_audit.get('conclusion') or [])}</ul>
  </div>
  <details open>
    <summary>direct +0x5a references</summary>
    <table><thead><tr><th>VA</th><th>access</th><th>asm</th></tr></thead><tbody>{static_direct_rows}</tbody></table>
  </details>
  <details open>
    <summary>writes that touch byte +0x5a</summary>
    <table><thead><tr><th>VA</th><th>field</th><th>asm</th><th>byte5a</th><th>actor nearby</th><th>classification</th></tr></thead><tbody>{static_touch_rows}</tbody></table>
  </details>
  <details>
    <summary>actor-array-nearby field writes</summary>
    <table><thead><tr><th>VA</th><th>fields</th><th>asm</th><th>near actor-array ref</th><th>classification</th></tr></thead><tbody>{static_nearby_rows}</tbody></table>
  </details>
  <h2>actor+0x5a 간접 copy 탐색</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(indirect.get('status') or ''))}</code></p>
    <p>lea candidates={html.escape(str(indirect.get('leaCandidateCount')))}, non-stack lea candidates={html.escape(str(indirect.get('nonStackLeaCandidateCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in indirect.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>non-stack +0x58/+0x59/+0x5a lea candidates</summary>
    <table><thead><tr><th>VA</th><th>asm</th></tr></thead><tbody>{indirect_non_stack_rows}</tbody></table>
  </details>
  <details>
    <summary>all +0x58/+0x59/+0x5a lea candidates</summary>
    <table><thead><tr><th>VA</th><th>asm</th><th>stack local</th></tr></thead><tbody>{indirect_all_rows}</tbody></table>
  </details>
  <h2>raw actor+0x5a opcode pattern scan</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(raw_5a.get('status') or ''))}</code></p>
    <p>.text=<code>{html.escape(str(raw_5a.get('textRangeHex') or ''))}</code>,
    matches={html.escape(str(raw_5a.get('matchCount')))},
    writes={html.escape(str(raw_5a.get('writeMatchCount')))},
    explicit +0x5a writes={html.escape(str(raw_5a.get('explicitActor5aWriteCount')))},
    nonzero immediate packed writes={html.escape(str(raw_5a.get('nonzeroImmediatePackedWriteCount')))},
    raw false positives={html.escape(str(raw_5a.get('rawFalsePositiveCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in raw_5a.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>raw byte-pattern matches</summary>
    <table><thead><tr><th>VA</th><th>bytes</th><th>pattern</th><th>classification</th><th>byte +0x5a</th></tr></thead><tbody>{raw_5a_rows}</tbody></table>
  </details>
  <h2>packed actor+0x58 write width audit</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(packed_58.get('status') or ''))}</code></p>
    <p>packed writes={html.escape(str(packed_58.get('packedWriteCount')))},
    proven zero +0x5a byte={html.escape(str(packed_58.get('provenZeroByte5aCount')))},
    nonzero +0x5a byte={html.escape(str(packed_58.get('nonzeroByte5aCount')))},
    unknown register-width rows={html.escape(str(packed_58.get('unknownRegisterWidthCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in packed_58.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>packed +0x58 writes</summary>
    <table><thead><tr><th>VA</th><th>asm</th><th>source</th><th>byte +0x5a</th><th>classification</th></tr></thead><tbody>{packed_58_rows}</tbody></table>
  </details>
  <details>
    <summary>unknown-width rows</summary>
    <table><thead><tr><th>VA</th><th>asm</th><th>classification</th></tr></thead><tbody>{packed_58_unknown_rows}</tbody></table>
  </details>
  <h2>actor+0x60 direct phase write audit</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(phase60.get('status') or ''))}</code></p>
    <p>direct phase writes={html.escape(str(phase60.get('directPhaseWriteCount')))},
    direct values={html.escape(', '.join(phase60.get('directPhaseValuesHex') or []))},
    direct phase &gt;= 0x0a={html.escape(str(phase60.get('directPhaseGte0aCount')))},
    dynamic phase writes={html.escape(str(phase60.get('dynamicPhaseWriteCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in phase60.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>phase writes</summary>
    <table><thead><tr><th>VA</th><th>asm</th><th>value</th><th>classification</th></tr></thead><tbody>{phase60_rows}</tbody></table>
  </details>
  <h2>actor 초기화/block-copy audit</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(init_audit.get('status') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in init_audit.get('conclusion') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>VA</th><th>role</th><th>evidence</th><th>impact</th></tr></thead>
    <tbody>{init_path_rows}</tbody>
  </table>
  <h2>dynamic generic write opcode audit</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(dynamic_audit.get('status') or ''))}</code></p>
    <p>sections={html.escape(', '.join(dynamic_audit.get('scannedSections') or []))}</p>
    <p>watched destination candidates={html.escape(str(dynamic_audit.get('watchedDestinationCandidateCount')))}, exact actor+0x5a candidates={html.escape(str(dynamic_audit.get('exactActor5aCandidateCount')))}, all-section raw +0x5a hits={html.escape(str(dynamic_audit.get('allSectionExactActor5aRawByteHitCount')))}, .text false positives={html.escape(str(dynamic_audit.get('textSectionExactActor5aFalsePositiveCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in dynamic_audit.get('interpretation') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>opcode</th><th>handler</th><th>role</th><th>summary</th><th>.data raw count</th><th>.data watched dest</th><th>.data exact +0x5a</th><th>all-section raw +0x5a</th></tr></thead>
    <tbody>{dynamic_opcode_rows}</tbody>
  </table>
  <details open>
    <summary>watched destination candidates</summary>
    <table><thead><tr><th>opcode</th><th>VA</th><th>dest</th><th>bytes</th><th>role</th></tr></thead><tbody>{dynamic_candidate_rows}</tbody></table>
  </details>
  <details open>
    <summary>.text raw byte false positives</summary>
    <table><thead><tr><th>opcode</th><th>section</th><th>VA</th><th>dest</th><th>bytes</th><th>classification</th></tr></thead><tbody>{dynamic_false_positive_rows}</tbody></table>
  </details>
  <h2>referenced action script candidate scan</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(referenced_scan.get('status') or ''))}</code></p>
    <p>.data range=<code>{html.escape(str(referenced_scan.get('dataVaRangeHex') or ''))}</code>,
    pointer values={html.escape(str(referenced_scan.get('dataPointerValueCount')))},
    unique targets={html.escape(str(referenced_scan.get('uniqueDataPointerTargetCount')))},
    matching targets={html.escape(str(referenced_scan.get('matchingTargetCount')))},
    unclassified={html.escape(str(referenced_scan.get('unclassifiedCandidateCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in referenced_scan.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>classification counts</summary>
    <table><thead><tr><th>classification</th><th>count</th></tr></thead><tbody>{referenced_classification_rows}</tbody></table>
  </details>
  <details open>
    <summary>unclassified action-script candidates</summary>
    <table><thead><tr><th>target</th><th>refs</th><th>first ref section</th><th>first ref</th><th>mapped pointer dwords</th><th>opcode bytes</th></tr></thead><tbody>{referenced_candidate_rows}</tbody></table>
  </details>
  <details>
    <summary>false-positive samples</summary>
    <table><thead><tr><th>target</th><th>classification</th><th>refs</th><th>mapped pointer dwords</th><th>first ref</th><th>opcode bytes</th></tr></thead><tbody>{referenced_false_positive_rows}</tbody></table>
  </details>
  <h2>monster slot 0 reachability review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(slot0_review.get('status') or ''))}</code></p>
    <p>slot0={html.escape(str(slot0_review.get('slot0EntryCount')))}, jump={html.escape(str(slot0_review.get('slot0WithJumpCategoryCount')))}, child VM={html.escape(str(slot0_review.get('slot0WithSpawnChildVmCount')))}, movement={html.escape(str(slot0_review.get('slot0WithMovementCount')))}, position write={html.escape(str(slot0_review.get('slot0WithPositionWriteCount')))}</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in slot0_review.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>slot0 sample rows</summary>
    <table><thead><tr><th>asset</th><th>entry</th><th>frames</th><th>categories</th><th>sound</th><th>movement</th><th>x/y writes</th></tr></thead><tbody>{slot0_sample_rows}</tbody></table>
  </details>
  <h2>active descriptor selection review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(active_descriptor.get('status') or ''))}</code></p>
    <p>descriptor table=<code>{html.escape(str(active_descriptor.get('descriptorTableVaHex') or ''))}</code>, record size=<code>{html.escape(str(active_descriptor.get('recordSizeHex') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in active_descriptor.get('interpretation') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>VA</th><th>summary</th></tr></thead>
    <tbody>{active_descriptor_handler_rows}</tbody>
  </table>
  <details open>
    <summary>descriptor sample records</summary>
    <table><thead><tr><th>#</th><th>record VA</th><th>interpretation</th><th>+0x40 mode</th><th>+0x41 idx</th><th>+0x48 bytes</th><th>+0x4a compare bytes</th></tr></thead><tbody>{active_descriptor_sample_rows}</tbody></table>
  </details>
  <h2>active descriptor actor-slot lifecycle review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(active_slot.get('status') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in active_slot.get('conclusion') or [])}</ul>
  </div>
  <details open>
    <summary>tracked globals</summary>
    <table><thead><tr><th>VA</th><th>label</th><th>role</th></tr></thead><tbody>{active_slot_global_rows}</tbody></table>
  </details>
  <details open>
    <summary>lifecycle paths</summary>
    <table><thead><tr><th>VA</th><th>role</th><th>summary</th><th>slot conclusion</th></tr></thead><tbody>{active_slot_lifecycle_rows}</tbody></table>
  </details>
  <h2>actor+0x59 producer review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(actor59.get('status') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in actor59.get('conclusion') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>VA</th><th>producer</th><th>source</th><th>writes</th><th>classification</th></tr></thead>
    <tbody>{actor59_rows}</tbody>
  </table>
  <h2>enemy action selection opcode review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(enemy_opcode.get('status') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in enemy_opcode.get('conclusion') or [])}</ul>
    <p><strong>open gap:</strong></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in enemy_opcode.get('openGap') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>opcode/VA</th><th>handler</th><th>role</th><th>summary</th><th>confirmed output</th></tr></thead>
    <tbody>{enemy_opcode_flow_rows}</tbody>
  </table>
  <details open>
    <summary>opcode 0x9e branches</summary>
    <table><thead><tr><th>handler</th><th>branch</th><th>summary</th><th>actor+0x5a result</th></tr></thead><tbody>{enemy_opcode_branch_rows}</tbody></table>
  </details>
  <h2>enemy actor stat row review</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(enemy_stat.get('status') or ''))}</code></p>
    <p>stat row base=<code>{html.escape(str(enemy_stat.get('statRowBaseVaHex') or ''))}</code>, stride=<code>{html.escape(str(enemy_stat.get('rowStrideHex') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in enemy_stat.get('importantOffsetCorrection') or [])}</ul>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in enemy_stat.get('conclusion') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>VA</th><th>summary</th><th>conclusion</th></tr></thead>
    <tbody>{enemy_stat_path_rows}</tbody>
  </table>
  <details open>
    <summary>enemy stat row samples</summary>
    <table><thead><tr><th>internal id</th><th>actual row</th><th>exported HP view</th><th>name</th><th>HP</th><th>reward</th><th>prefix8</th></tr></thead><tbody>{enemy_stat_sample_rows}</tbody></table>
  </details>
  <h2>rejected monster skill-binding hypotheses</h2>
  <div class="note">
    <p><strong>상태:</strong> <code>{html.escape(str(rejected.get('status') or ''))}</code></p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in rejected.get('currentWorkingModel') or [])}</ul>
  </div>
  <table>
    <thead><tr><th>status</th><th>hypothesis</th><th>evidence</th><th>conclusion</th></tr></thead>
    <tbody>{rejected_rows}</tbody>
  </table>
  <h2>몬스터 이동 요약</h2>
  <div class=\"note\">
    <p>액션 슬롯 {html.escape(str(movement.get('actionEntryCount')))}개 중 movement opcode 포함 {html.escape(str(movement.get('entriesWithMovementOpcode')))}개, nonzero placement selector 포함 {html.escape(str(movement.get('entriesWithNonzeroPlacementSelector')))}개, x/y position write 포함 {html.escape(str(movement.get('entriesWithPositionWrite')))}개입니다.</p>
    <ul>{''.join(f'<li>{html.escape(line)}</li>' for line in movement.get('interpretation') or [])}</ul>
  </div>
  <details open>
    <summary>마왕 슬라임/돌진류 집중 예시</summary>
    <table><thead><tr><th>asset</th><th>slot</th><th>reason</th><th>interpretation</th><th>frames</th><th>sound</th><th>movement</th><th>x/y writes</th></tr></thead><tbody>{focus_rows}</tbody></table>
  </details>
  <details open>
    <summary>실제 이동 opcode 예시 보기</summary>
    <table><thead><tr><th>asset</th><th>slot</th><th>frames</th><th>sound</th><th>movement</th><th>x/y writes</th></tr></thead><tbody>{motion_example_rows}</tbody></table>
  </details>
  <details open>
    <summary>공유 돌진류 payload 보기</summary>
    <table><thead><tr><th>id</th><th>name</th><th>payload</th><th>prefix</th><th>units</th><th>note</th></tr></thead><tbody>{charge_rows}</tbody></table>
  </details>
  <details>
    <summary>movement signature 보기</summary>
    <table><thead><tr><th>signature</th><th>count</th></tr></thead><tbody>{movement_rows}</tbody></table>
  </details>
  <details>
    <summary>nonzero selector 샘플 보기</summary>
    <table><thead><tr><th>asset</th><th>slot</th><th>frames</th><th>movement</th><th>x/y writes</th></tr></thead><tbody>{notable_rows}</tbody></table>
  </details>
  <h2>몬스터별 디스플레이 액션 테이블</h2>
  <table>
    <thead><tr><th>asset</th><th>descriptor</th><th>action table</th><th>rect frames</th><th>selector</th><th>key entries</th></tr></thead>
    <tbody>{''.join(rows)}</tbody>
  </table>
</body>
</html>
"""


def main() -> None:
    report = build()
    (OUT / "battle_monster_action_frame_probe.json").write_text(
        json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (OUT / "battle_monster_action_frame_probe.md").write_text(markdown(report), encoding="utf-8")
    (OUT / "battle_monster_action_frame_probe.html").write_text(html_page(report), encoding="utf-8")
    print(f"wrote {OUT / 'battle_monster_action_frame_probe.json'}")
    print(f"wrote {OUT / 'battle_monster_action_frame_probe.md'}")
    print(f"wrote {OUT / 'battle_monster_action_frame_probe.html'}")


if __name__ == "__main__":
    main()
