#!/usr/bin/env python3
"""Run a browser-engine smoke test for the mobile virtual joystick.

This test uses WebKitGTK WebDriver directly through the W3C WebDriver HTTP
protocol.  It intentionally avoids Selenium's WebKitGTK wrapper because the
Ubuntu package can be incompatible with the installed Selenium core API.
Run it under Xvfb in headless VMs:

    xvfb-run -a python3 tools/verify_mobile_browser_controls.py --base http://127.0.0.1:8013
"""
from __future__ import annotations

import argparse
import json
import os
import shutil
import socket
import subprocess
import time
import zipfile
from pathlib import Path
from typing import Any
from urllib.parse import urlencode, urljoin
from urllib.error import HTTPError
from urllib.request import Request, urlopen


ROOT = Path(__file__).resolve().parents[1]


class WebDriverError(RuntimeError):
    pass


def browser_display_available() -> bool:
    return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))


def mini_browser_session_requested(path: str, payload: dict[str, Any] | None) -> bool:
    if path != "/session" or not isinstance(payload, dict):
        return False
    capabilities = payload.get("capabilities") or {}
    always_match = capabilities.get("alwaysMatch") or {}
    return always_match.get("browserName") == "MiniBrowser"


PLAYER_BATTLE_TEXT_TABLES = {
    ("atahoActions", 0): {
        "nameSource": "exe-text-table-atahoActions",
        "textTableKey": "atahoActions",
        "textTableIndex": 0,
        "textTableRefVaHex": "0x004d24ac",
        "textTableTextVaHex": "0x004d2d8c",
    },
    ("atahoActions", 1): {
        "nameSource": "exe-text-table-atahoActions",
        "textTableKey": "atahoActions",
        "textTableIndex": 1,
        "textTableRefVaHex": "0x004d24b4",
        "textTableTextVaHex": "0x004d2daa",
    },
    ("atahoActions", 2): {
        "nameSource": "exe-text-table-atahoActions",
        "textTableKey": "atahoActions",
        "textTableIndex": 2,
        "textTableRefVaHex": "0x004d24bc",
        "textTableTextVaHex": "0x004d2dc8",
    },
    ("skillNamesB", 0): {
        "nameSource": "exe-text-table-skillNamesB",
        "textTableKey": "skillNamesB",
        "textTableIndex": 0,
        "textTableRefVaHex": "0x004d2c04",
        "textTableTextVaHex": "0x004d4d68",
    },
    ("skillNamesB", 2): {
        "nameSource": "exe-text-table-skillNamesB",
        "textTableKey": "skillNamesB",
        "textTableIndex": 2,
        "textTableRefVaHex": "0x004d2c2c",
        "textTableTextVaHex": "0x004d4e1e",
    },
    ("skillNamesB", 7): {
        "nameSource": "exe-text-table-skillNamesB",
        "textTableKey": "skillNamesB",
        "textTableIndex": 7,
        "textTableRefVaHex": "0x004d2c5c",
        "textTableTextVaHex": "0x004d4ee2",
    },
    ("skillNamesB", 8): {
        "nameSource": "exe-text-table-skillNamesB",
        "textTableKey": "skillNamesB",
        "textTableIndex": 8,
        "textTableRefVaHex": "0x004d2c6c",
        "textTableTextVaHex": "0x004d4f2e",
    },
    ("actionSetB", 0): {
        "nameSource": "exe-text-table-actionSetB",
        "textTableKey": "actionSetB",
        "textTableIndex": 0,
        "textTableRefVaHex": "0x004d2864",
        "textTableTextVaHex": "0x004d3e3a",
    },
    ("actionSetB", 1): {
        "nameSource": "exe-text-table-actionSetB",
        "textTableKey": "actionSetB",
        "textTableIndex": 1,
        "textTableRefVaHex": "0x004d286c",
        "textTableTextVaHex": "0x004d3e58",
    },
}
ITEM_TEXT_TABLES = {
    "herb": {
        "nameSource": "exe-text-table-items",
        "textTableKey": "items",
        "textTableIndex": 0,
        "textTableRefVaHex": "0x0048b984",
        "textTableTextVaHex": "0x0048ba7c",
    },
    "item_2": {
        "nameSource": "exe-text-table-items",
        "textTableKey": "items",
        "textTableIndex": 1,
        "textTableRefVaHex": "0x0048b98c",
        "textTableTextVaHex": "0x0048ba8e",
    },
    "mp_recovery": {
        "nameSource": "exe-text-table-items",
        "textTableKey": "items",
        "textTableIndex": 3,
        "textTableRefVaHex": "0x0048b99c",
        "textTableTextVaHex": "0x0048bab2",
    },
    "item_6": {
        "nameSource": "exe-text-table-items",
        "textTableKey": "items",
        "textTableIndex": 5,
        "textTableRefVaHex": "0x0048b9ac",
        "textTableTextVaHex": "0x0048bad6",
    },
}


def assert_player_battle_text_table(row: dict[str, Any], table: str, index: int, *, prefix: str = "") -> None:
    expected = PLAYER_BATTLE_TEXT_TABLES[(table, index)]
    def key(name: str) -> str:
        return f"{prefix}{name[:1].upper()}{name[1:]}" if prefix else name

    if (
        row.get(key("nameSource")) != expected["nameSource"]
        or row.get(key("textTableKey")) != expected["textTableKey"]
        or row.get(key("textTableIndex")) != expected["textTableIndex"]
        or row.get(key("textTableRefVaHex")) != expected["textTableRefVaHex"]
        or row.get(key("textTableTextVaHex")) != expected["textTableTextVaHex"]
    ):
        raise WebDriverError(f"battle text table provenance mismatch for {table}[{index}]: {row!r}")


def item_text_provenance_matches(row: dict[str, Any], item_key: str, *, prefix: str = "item") -> bool:
    expected = ITEM_TEXT_TABLES[item_key]

    def key(name: str) -> str:
        return f"{prefix}{name[:1].upper()}{name[1:]}" if prefix else name

    return (
        row.get(key("nameSource")) == expected["nameSource"]
        and row.get(key("textTableKey")) == expected["textTableKey"]
        and row.get(key("textTableIndex")) == expected["textTableIndex"]
        and row.get(key("textTableRefVaHex")) == expected["textTableRefVaHex"]
        and row.get(key("textTableTextVaHex")) == expected["textTableTextVaHex"]
    )


def assert_item_text_provenance(row: dict[str, Any], item_key: str, *, prefix: str = "item") -> None:
    if not item_text_provenance_matches(row, item_key, prefix=prefix):
        raise WebDriverError(f"item text table provenance mismatch for {item_key}: {row!r}")


def free_port() -> int:
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
        sock.bind(("127.0.0.1", 0))
        return int(sock.getsockname()[1])


def request_json(port: int, method: str, path: str, payload: dict[str, Any] | None = None, timeout: float = 10) -> dict[str, Any] | None:
    if mini_browser_session_requested(path, payload) and not browser_display_available():
        raise WebDriverError(
            "MiniBrowser requires DISPLAY/WAYLAND_DISPLAY. "
            "Run this browser smoke test with `xvfb-run -a ...` in headless VMs."
        )
    data = None if payload is None else json.dumps(payload).encode("utf-8")
    request = Request(
        f"http://127.0.0.1:{port}{path}",
        data=data,
        method=method,
        headers={"Content-Type": "application/json"},
    )
    try:
        with urlopen(request, timeout=timeout) as response:
            body = response.read().decode("utf-8")
    except HTTPError as error:
        body = error.read().decode("utf-8", errors="replace")
        raise WebDriverError(f"WebDriver HTTP {error.code} {error.reason}: {body}") from error
    except TimeoutError as exc:
        raise WebDriverError(f"WebDriver request timed out after {timeout}s: {method} {path}") from exc
    return json.loads(body) if body else None


def wait_for_driver(port: int, proc: subprocess.Popen[bytes], timeout: float = 8) -> None:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if proc.poll() is not None:
            raise WebDriverError(f"WebKitWebDriver exited early with status {proc.returncode}")
        try:
            status = request_json(port, "GET", "/status", timeout=0.5)
        except Exception:
            time.sleep(0.1)
            continue
        if status and status.get("value", {}).get("ready") is True:
            return
        time.sleep(0.1)
    raise WebDriverError("WebKitWebDriver did not become ready")


def execute_js(port: int, session_id: str, script: str, timeout: float = 8) -> Any:
    response = request_json(
        port,
        "POST",
        f"/session/{session_id}/execute/sync",
        {"script": script, "args": []},
        timeout=timeout,
    )
    if not response or "value" not in response:
        raise WebDriverError(f"bad execute/sync response: {response!r}")
    return response["value"]


def canvas_checksum_script() -> str:
    return """
const canvas = document.getElementById('screen');
const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data;
let checksum = 0;
for (let index = 0; index < data.length; index += 16) {
  checksum = (((checksum * 131) >>> 0) + data[index] + data[index + 1] * 3 + data[index + 2] * 7 + data[index + 3] * 11) >>> 0;
}
return checksum;
"""


def wait_for_page(port: int, session_id: str) -> None:
    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        ready = execute_js(
            port,
            session_id,
            "return document.readyState === 'complete' && !!document.getElementById('screen');",
            timeout=3,
        )
        if ready:
            return
        time.sleep(0.2)
    raise WebDriverError("web runtime did not finish loading in WebKit")


def wait_for_map_runtime(port: int, session_id: str, expected_map: str | None = None) -> None:
    expected = json.dumps(expected_map)
    deadline = time.monotonic() + 10
    while time.monotonic() < deadline:
        ready = execute_js(
            port,
            session_id,
            f"""
const expected = {expected};
return (
  typeof scene !== 'undefined' &&
  scene === 'map' &&
  typeof map !== 'undefined' &&
  !!map &&
  (!expected || map.name === expected) &&
  !!document.getElementById('virtualJoystick')
);
""",
            timeout=3,
        )
        if ready:
            return
        time.sleep(0.2)
    state = execute_js(
        port,
        session_id,
        """
return {
  readyState: document.readyState,
  hasScreen: !!document.getElementById('screen'),
  sceneType: typeof scene,
  scene: typeof scene === 'undefined' ? '' : scene,
  mapType: typeof map,
  map: typeof map === 'undefined' || !map ? '' : map.name,
  bootError: window.HWANSE_BOOT_ERROR || null,
  errorText: document.body ? document.body.innerText.slice(0, 300) : '',
};
""",
        timeout=3,
    )
    raise WebDriverError(f"web map runtime did not finish loading: expected={expected_map!r} state={state!r}")


def start_touch_on_canvas_script() -> str:
    return """
const canvas = document.getElementById('screen');
const rect = canvas.getBoundingClientRect();
const x = rect.left + rect.width * 0.45;
const y = rect.top + rect.height * 0.45;
canvas.dispatchEvent(new PointerEvent('pointerdown', {
  bubbles: true,
  cancelable: true,
  pointerId: 77,
  pointerType: 'touch',
  isPrimary: true,
  clientX: x,
  clientY: y,
  buttons: 1,
}));
window.dispatchEvent(new PointerEvent('pointermove', {
  bubbles: true,
  cancelable: true,
  pointerId: 77,
  pointerType: 'touch',
  isPrimary: true,
  clientX: x + 90,
  clientY: y,
  buttons: 1,
}));
const joystick = document.getElementById('virtualJoystick');
const style = getComputedStyle(joystick);
return {
  active: joystick.classList.contains('active'),
  knobX: style.getPropertyValue('--knob-x'),
  knobY: style.getPropertyValue('--knob-y'),
};
"""


def end_touch_script(pointer_id: int = 77) -> str:
    return f"""
const canvas = document.getElementById('screen');
const rect = canvas.getBoundingClientRect();
const x = rect.left + rect.width * 0.45;
const y = rect.top + rect.height * 0.45;
window.dispatchEvent(new PointerEvent('pointerup', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: x + 90,
  clientY: y,
  buttons: 0,
}}));
return document.getElementById('virtualJoystick').classList.contains('active');
"""


def start_touch_on_document_script() -> str:
    return """
document.body.dispatchEvent(new PointerEvent('pointerdown', {
  bubbles: true,
  cancelable: true,
  pointerId: 78,
  pointerType: 'touch',
  isPrimary: true,
  clientX: 18,
  clientY: 18,
  buttons: 1,
}));
window.dispatchEvent(new PointerEvent('pointermove', {
  bubbles: true,
  cancelable: true,
  pointerId: 78,
  pointerType: 'touch',
  isPrimary: true,
  clientX: 18,
  clientY: 92,
  buttons: 1,
}));
const joystick = document.getElementById('virtualJoystick');
const style = getComputedStyle(joystick);
return {
  active: joystick.classList.contains('active'),
  knobX: style.getPropertyValue('--knob-x'),
  knobY: style.getPropertyValue('--knob-y'),
};
"""


def start_touch_on_canvas_down_script(pointer_id: int = 80) -> str:
    return f"""
const canvas = document.getElementById('screen');
const rect = canvas.getBoundingClientRect();
const x = rect.left + rect.width * 0.5;
const y = rect.top + rect.height * 0.55;
canvas.dispatchEvent(new PointerEvent('pointerdown', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: x,
  clientY: y,
  buttons: 1,
}}));
window.dispatchEvent(new PointerEvent('pointermove', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: x,
  clientY: y + 90,
  buttons: 1,
}}));
const joystick = document.getElementById('virtualJoystick');
const style = getComputedStyle(joystick);
return {{
  active: joystick.classList.contains('active'),
  knobX: style.getPropertyValue('--knob-x'),
  knobY: style.getPropertyValue('--knob-y'),
}};
"""


def start_touch_on_canvas_up_script(pointer_id: int = 83) -> str:
    return f"""
const canvas = document.getElementById('screen');
const rect = canvas.getBoundingClientRect();
const x = rect.left + rect.width * 0.5;
const y = rect.top + rect.height * 0.45;
canvas.dispatchEvent(new PointerEvent('pointerdown', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: x,
  clientY: y,
  buttons: 1,
}}));
window.dispatchEvent(new PointerEvent('pointermove', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: x,
  clientY: y - 90,
  buttons: 1,
}}));
const joystick = document.getElementById('virtualJoystick');
const style = getComputedStyle(joystick);
return {{
  active: joystick.classList.contains('active'),
  knobX: style.getPropertyValue('--knob-x'),
  knobY: style.getPropertyValue('--knob-y'),
}};
"""


def tap_action_button_script(pointer_id: int = 79) -> str:
    return f"""
const button = document.getElementById('virtualActionButton');
button.dispatchEvent(new PointerEvent('pointerdown', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: 320,
  clientY: 760,
  buttons: 1,
}}));
button.dispatchEvent(new PointerEvent('pointerup', {{
  bubbles: true,
  cancelable: true,
  pointerId: {pointer_id},
  pointerType: 'touch',
  isPrimary: true,
  clientX: 320,
  clientY: 760,
  buttons: 0,
}}));
return true;
"""


def click_route_assist_button_script() -> str:
    return """
const button = document.getElementById('routeAssistButton');
button.click();
const frontier = document.getElementById('routeFrontierSelect');
const path = document.getElementById('routePathSelect');
const next = document.getElementById('routeNextButton');
return {
  active: button.classList.contains('active'),
  text: button.textContent,
  title: button.title,
  search: window.location.search,
  map: document.getElementById('mapSelect').value,
  frontierHidden: frontier.hidden,
  frontierValue: frontier.value,
  selectedTarget: frontier.selectedOptions[0]?.dataset.transitionTarget || '',
  pathHidden: path.hidden,
  pathTargetCount: [...path.options].map((option) => option.value).filter(Boolean).length,
  nextHidden: next.hidden,
};
"""


def route_assist_controls_state_script() -> str:
    return """
const frontier = document.getElementById('routeFrontierSelect');
const path = document.getElementById('routePathSelect');
const next = document.getElementById('routeNextButton');
return {
  search: window.location.search,
  frontierHidden: frontier.hidden,
  pathHidden: path.hidden,
  pathTargetCount: [...path.options].map((option) => option.value).filter(Boolean).length,
  nextHidden: next.hidden,
};
"""


def click_dialogue_button_script() -> str:
    return """
if (typeof updateDialogueButton === 'function') updateDialogueButton();
const button = document.getElementById('dialogueButton');
if (!button) return { ok: false, reason: 'missing dialogueButton' };
const before = { hidden: button.hidden, text: button.textContent, title: button.title };
button.click();
return {
  ok: true,
  before,
  after: { hidden: button.hidden, text: button.textContent, title: button.title },
};
"""


def click_battle_button_script() -> str:
    return """
if (typeof updateBattleButton === 'function') updateBattleButton();
const button = document.getElementById('battleButton');
if (!button) return { ok: false, reason: 'missing battleButton' };
const before = { hidden: button.hidden, text: button.textContent, title: button.title };
button.click();
return {
  ok: true,
  before,
  after: { hidden: button.hidden, text: button.textContent, title: button.title },
};
"""


def route_goal_next_state_script() -> str:
    return """
const select = document.getElementById('routePathSelect');
const button = document.getElementById('routeNextButton');
const targets = [...(select?.options || [])].map((option) => option.value).filter(Boolean);
return {
  scene,
  map: map?.name || '',
  search: window.location.search,
  selectedGoal: select?.value || '',
  selectedText: select?.selectedOptions?.[0]?.textContent || '',
  targetCount: targets.length,
  targets,
  nextHidden: button?.hidden ?? null,
  nextDisabled: button?.disabled ?? null,
  nextText: button?.textContent || '',
  nextTitle: button?.title || '',
};
"""


def click_route_next_button_script() -> str:
    return """
const button = document.getElementById('routeNextButton');
if (!button) return { ok: false, reason: 'missing routeNextButton' };
const before = {
  hidden: button.hidden,
  disabled: button.disabled,
  text: button.textContent,
  title: button.title,
  search: window.location.search,
};
button.click();
return { ok: true, before };
"""


def wait_for_map_value(port: int, session_id: str, expected: str, timeout: float = 4) -> str:
    deadline = time.monotonic() + timeout
    value = ""
    while time.monotonic() < deadline:
        value = str(execute_js(port, session_id, "return document.getElementById('mapSelect').value;", timeout=3))
        if value == expected:
            return value
        time.sleep(0.15)
    raise WebDriverError(f"mapSelect did not become {expected!r}; last value was {value!r}")


def wait_for_search_contains(port: int, session_id: str, needles: list[str], timeout: float = 4) -> str:
    deadline = time.monotonic() + timeout
    value = ""
    while time.monotonic() < deadline:
        value = str(execute_js(port, session_id, current_search_script(), timeout=3))
        if all(needle in value for needle in needles):
            return value
        time.sleep(0.15)
    raise WebDriverError(f"location search did not include {needles!r}; last value was {value!r}")


def wait_for_battle_scene(port: int, session_id: str, timeout: float = 6) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, savedat_battle_item_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("scene") == "battle" and state.get("commands"):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"battle scene did not become ready; last state was {state!r}")


def wait_for_savedat_menu_state(port: int, session_id: str, timeout: float = 6) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, savedat_menu_item_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("scene") == "map" and dict(state.get("itemCounts") or []).get("약초") == 3:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"savedat menu state did not become ready; last state was {state!r}")


def wait_for_menu_item_quick_load_state(port: int, session_id: str, timeout: float = 6) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, menu_item_quick_load_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("ok") is not None:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"savedat menu item quick-load did not finish; last state was {state!r}")


def wait_for_savedat_menu_item_title_continue(port: int, session_id: str, timeout: float = 8) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, savedat_menu_item_title_continue_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("done") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"savedat menu item title continue did not finish; last state was {state!r}")


def wait_for_savedat_battle_skill_title_continue(port: int, session_id: str, timeout: float = 8) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, savedat_battle_skill_title_continue_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("done") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"savedat battle skill title continue did not finish; last state was {state!r}")


def wait_for_savedat_battle_item_title_continue(port: int, session_id: str, timeout: float = 8) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, savedat_battle_item_title_continue_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        if state.get("done") is True:
            return state
        time.sleep(0.2)
    raise WebDriverError(f"savedat battle item title continue did not finish; last state was {state!r}")


def battle_item_feedback_entries(state: dict[str, Any], *, rendered: bool = False) -> list[dict[str, Any]]:
    key = "itemFeedbackRender" if rendered else "itemFeedbackLog"
    return [entry for entry in (state.get(key) or []) if isinstance(entry, dict)]


def battle_item_feedback_match(
    state: dict[str, Any],
    *,
    source: str,
    item_key: str,
    text: str,
    rendered: bool = False,
) -> dict[str, Any]:
    for entry in battle_item_feedback_entries(state, rendered=rendered):
        if (
            entry.get("source") == source
            and entry.get("itemKey") == item_key
            and entry.get("text") == text
        ):
            return entry
    return {}


def battle_item_feedback_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('source')}:{entry.get('itemKey')}:{entry.get('text')}"
        for entry in battle_item_feedback_entries(state)
    )


def battle_item_feedback_rendered(state: dict[str, Any]) -> bool:
    return any(
        entry.get("browserBattleItemFeedbackImplemented") is True
        for entry in battle_item_feedback_entries(state, rendered=True)
    )


def battle_item_feedback_sound_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('battleItemSound')}:{entry.get('battleItemSoundSrc')}:{entry.get('battleItemSoundPlayed')}"
        for entry in battle_item_feedback_entries(state)
        if entry.get("battleItemSound")
    )


def verify_battle_item_feedback_sound(feedback: dict[str, Any]) -> None:
    if (
        feedback.get("battleItemSound") != "item"
        or not str(feedback.get("battleItemSoundSrc") or "").endswith("/extract_wlk/11.wav")
        or feedback.get("battleItemSoundPlayed") is not True
    ):
        raise WebDriverError(f"missing battle item feedback sound marker: {feedback!r}")


def battle_skill_feedback_entries(state: dict[str, Any], *, rendered: bool = False) -> list[dict[str, Any]]:
    key = "skillFeedbackRender" if rendered else "skillFeedbackLog"
    return [entry for entry in (state.get(key) or []) if isinstance(entry, dict)]


def battle_skill_feedback_match(
    state: dict[str, Any],
    *,
    source: str,
    actor_name: str,
    skill_name: str,
    text: str,
    rendered: bool = False,
) -> dict[str, Any]:
    for entry in battle_skill_feedback_entries(state, rendered=rendered):
        if (
            entry.get("source") == source
            and entry.get("actorName") == actor_name
            and entry.get("skillName") == skill_name
            and entry.get("text") == text
        ):
            return entry
    return {}


def battle_skill_feedback_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('source')}:{entry.get('actorName')}:{entry.get('skillName')}:{entry.get('text')}"
        for entry in battle_skill_feedback_entries(state)
    )


def battle_skill_feedback_rendered(state: dict[str, Any]) -> bool:
    return any(
        entry.get("browserBattleSkillFeedbackImplemented") is True
        for entry in battle_skill_feedback_entries(state, rendered=True)
    )


def battle_skill_feedback_sound_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('battleSkillSound')}:{entry.get('battleSkillSoundSrc')}:{entry.get('battleSkillSoundPlayed')}"
        for entry in battle_skill_feedback_entries(state)
        if entry.get("battleSkillSound")
    )


def verify_battle_skill_feedback_sound(
    feedback: dict[str, Any],
    expected_sound: str,
    expected_src: str,
) -> None:
    expected_suffix = expected_src.replace("..", "")
    if (
        feedback.get("battleSkillSound") != expected_sound
        or not str(feedback.get("battleSkillSoundSrc") or "").endswith(expected_suffix)
        or feedback.get("battleSkillSoundPlayed") is not True
    ):
        raise WebDriverError(f"missing battle skill feedback sound marker: {feedback!r}")


def inventory_item_feedback_entries(state: dict[str, Any], *, rendered: bool = False) -> list[dict[str, Any]]:
    key = "inventoryFeedbackRender" if rendered else "inventoryFeedbackLog"
    return [entry for entry in (state.get(key) or []) if isinstance(entry, dict)]


def inventory_item_feedback_match(
    state: dict[str, Any],
    *,
    source: str,
    item_key: str,
    text: str,
    rendered: bool = False,
) -> dict[str, Any]:
    for entry in inventory_item_feedback_entries(state, rendered=rendered):
        if (
            entry.get("source") == source
            and entry.get("itemKey") == item_key
            and entry.get("text") == text
        ):
            return entry
    return {}


def inventory_item_feedback_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('source')}:{entry.get('itemKey')}:{entry.get('text')}"
        for entry in inventory_item_feedback_entries(state)
    )


def inventory_item_feedback_rendered(state: dict[str, Any]) -> bool:
    return any(
        entry.get("browserInventoryItemFeedbackImplemented") is True
        for entry in inventory_item_feedback_entries(state, rendered=True)
    )


def inventory_item_feedback_sound_summary(state: dict[str, Any]) -> str:
    return ",".join(
        f"{entry.get('inventoryItemSound')}:{entry.get('inventoryItemSoundSrc')}:{entry.get('inventoryItemSoundPlayed')}"
        for entry in inventory_item_feedback_entries(state)
        if entry.get("inventoryItemSound")
    )


def verify_inventory_item_feedback_sound(feedback: dict[str, Any]) -> None:
    if (
        feedback.get("inventoryItemSound") != "item"
        or not str(feedback.get("inventoryItemSoundSrc") or "").endswith("/extract_wlk/11.wav")
        or feedback.get("inventoryItemSoundPlayed") is not True
    ):
        raise WebDriverError(f"missing inventory item feedback sound marker: {feedback!r}")


def wait_for_save_point_candidate_menu_state(port: int, session_id: str, timeout: float = 6) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, save_point_candidate_menu_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        active_id = str(state.get("activeId") or "")
        if active_id.startswith("save-point-candidates:"):
            return state
        if state.get("menuMode") == "save-point-candidate":
            execute_js(
                port,
                session_id,
                """
if (!activeDialogue && typeof startSavePointCandidateReview === 'function') {
  startSavePointCandidateReview();
}
return true;
""",
                timeout=3,
            )
        time.sleep(0.2)
    raise WebDriverError(f"save-point candidate menu did not open; last state was {state!r}")


def wait_for_event_object_render_state(port: int, session_id: str, timeout: float = 6) -> dict[str, Any]:
    deadline = time.monotonic() + timeout
    state: dict[str, Any] = {}
    while time.monotonic() < deadline:
        value = execute_js(port, session_id, event_object_render_state_script(), timeout=3)
        state = value if isinstance(value, dict) else {}
        render = state.get("render") or {}
        if (
            state.get("scene") == "map"
            and state.get("map") == "map2_03l"
            and render.get("enabled") is True
            and render.get("candidateCount", 0) >= 3
            and render.get("loadedCount", 0) >= 3
            and render.get("renderedCount", 0) >= 1
        ):
            return state
        time.sleep(0.2)
    raise WebDriverError(f"event object overlay did not render; last state was {state!r}")


def current_search_script() -> str:
    return "return window.location.search;"


def overview_button_state_script() -> str:
    return """
const button = document.getElementById('overviewButton');
const before = {
  hidden: button.hidden,
  text: button.textContent,
  active: button.classList.contains('active'),
  overview: overviewMode,
  search: window.location.search,
};
button.click();
const afterOn = {
  hidden: button.hidden,
  text: button.textContent,
  active: button.classList.contains('active'),
  overview: overviewMode,
  search: window.location.search,
};
button.click();
const afterOff = {
  hidden: button.hidden,
  text: button.textContent,
  active: button.classList.contains('active'),
  overview: overviewMode,
  search: window.location.search,
};
return { before, afterOn, afterOff };
"""


def map_filter_state_script() -> str:
    return """
const toggle = document.getElementById('debugControlsToggle');
const input = document.getElementById('mapFilterInput');
const select = document.getElementById('mapSelect');
const openedForTest = document.body.classList.contains('debug-ui-collapsed');
if (openedForTest) toggle.click();
const visibleDisplay = getComputedStyle(input).display;
input.value = 'map2_02d';
input.dispatchEvent(new Event('input', { bubbles: true }));
const values = [...select.options].map((option) => option.value);
const labels = [...select.options].map((option) => option.textContent);
const result = {
  display: visibleDisplay,
  count: values.length,
  values,
  labels,
  selected: select.value,
  title: select.title,
};
if (openedForTest) toggle.click();
return result;
"""


def mobile_menu_button_state_script() -> str:
    return """
const button = document.getElementById('virtualMenuButton');
if (!button) return { ok: false, reason: 'missing virtualMenuButton' };
window.HWANSE_SOUND_COUNTS = {};
window.HWANSE_SOUND_LOG = [];
window.HWANSE_LAST_SOUND = null;
window.HWANSE_RUNTIME_MENU_SOUND_LOG = [];
window.HWANSE_LAST_RUNTIME_MENU_SOUND = null;
const before = { hidden: button.hidden, menuOpen, menuMode };
button.dispatchEvent(new PointerEvent('pointerdown', {
  bubbles: true,
  cancelable: true,
  pointerId: 61,
  pointerType: 'touch',
  isPrimary: true,
}));
button.dispatchEvent(new PointerEvent('pointerup', {
  bubbles: true,
  cancelable: true,
  pointerId: 61,
  pointerType: 'touch',
  isPrimary: true,
}));
const afterOpen = { hidden: button.hidden, menuOpen, menuMode };
window.dispatchEvent(new KeyboardEvent('keydown', {
  bubbles: true,
  cancelable: true,
  code: 'ArrowDown',
  key: 'ArrowDown',
}));
const afterMove = {
  hidden: button.hidden,
  menuOpen,
  menuMode,
  selectedMenuItemIndex,
  selectedLabel: menuItemLabel(menuItems()[selectedMenuItemIndex]),
};
button.dispatchEvent(new PointerEvent('pointerdown', {
  bubbles: true,
  cancelable: true,
  pointerId: 62,
  pointerType: 'touch',
  isPrimary: true,
}));
button.dispatchEvent(new PointerEvent('pointerup', {
  bubbles: true,
  cancelable: true,
  pointerId: 62,
  pointerType: 'touch',
  isPrimary: true,
}));
const afterClose = { hidden: button.hidden, menuOpen, menuMode };
const soundState = {
  counts: { ...(window.HWANSE_SOUND_COUNTS || {}) },
  last: window.HWANSE_LAST_SOUND || null,
  log: Array.isArray(window.HWANSE_SOUND_LOG) ? [...window.HWANSE_SOUND_LOG] : [],
  menuLog: Array.isArray(window.HWANSE_RUNTIME_MENU_SOUND_LOG)
    ? [...window.HWANSE_RUNTIME_MENU_SOUND_LOG]
    : [],
  menuLast: window.HWANSE_LAST_RUNTIME_MENU_SOUND || null,
};
return { ok: true, before, afterOpen, afterMove, afterClose, soundState };
"""


def action_dialogue_state_script() -> str:
    return """
return {
  scene,
  map: map?.name || '',
  dialogueBlockCount: eventDialogueBlocks.length,
  exactIds: exactDialogueCandidatesForMap().map((block) => block.blockId),
  actionId: activeActionDialogueCandidate()?.blockId || '',
  activeId: activeDialogue?.block?.blockId || '',
	  activeIndex: activeDialogue?.index ?? null,
	  activeLine: activeDialogue?.lines?.[activeDialogue.index] || '',
	  activeVmRenderEvent: activeDialogue?.vmReplay?.renderEvents?.[activeDialogue.index] || null,
	  vmReplay: window.HWANSE_LAST_EVENT_VM_REPLAY || null,
	  dialogueBoxRender: window.HWANSE_LAST_DIALOGUE_BOX_RENDER || null,
	  menuOpen,
	  notice: menuNotice,
	};
"""


def cycle_party_from_menu_script() -> str:
    return """
menuOpen = true;
menuMode = 'main';
const items = menuItems();
const partyIndex = items.findIndex((item) => item.command === 'cyclePartyMembers');
if (partyIndex < 0) return `missing-cyclePartyMembers:${items.map((item) => item.command || item.key).join(',')}`;
selectedMenuItemIndex = partyIndex;
useSelectedMenuItem();
return window.location.search;
"""


def quick_save_load_manual_party_script() -> str:
    return """
if (loadedSaveSummary) return { ok: false, reason: 'expected prototype party mode' };
manualPartyModeIndex = partyModeIndexForNames(['rinshan']);
manualPartyMembers = partyMembersForMode(manualPartyModeIndex);
if (typeof ensurePrototypeRuntimeState === 'function') ensurePrototypeRuntimeState();
partyTrail = [];
syncPartyQuery();
const before = {
  members: manualPartyMembers.map((member) => member.name),
  activeLabels: activePartyMembers().map((member) => member.label),
  partyModeIndex: manualPartyModeIndex,
  search: window.location.search,
  runtimeCharacters: (runtimeState?.characters || []).map((character) => character.name),
};
const saved = quickSaveRuntime();
const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
manualPartyModeIndex = 0;
manualPartyMembers = partyMembersForMode(manualPartyModeIndex);
if (typeof ensurePrototypeRuntimeState === 'function') ensurePrototypeRuntimeState();
partyTrail = [];
syncPartyQuery();
const mutated = {
  members: manualPartyMembers.map((member) => member.name),
  activeLabels: activePartyMembers().map((member) => member.label),
  partyModeIndex: manualPartyModeIndex,
  search: window.location.search,
  runtimeCharacters: (runtimeState?.characters || []).map((character) => character.name),
};
window.__hwanseManualPartyQuickLoad = null;
quickLoadRuntime().then((loaded) => {
  window.__hwanseManualPartyQuickLoad = {
    ok: true,
    loaded,
    scene,
    map: map?.name || '',
    search: window.location.search,
    members: manualPartyMembers.map((member) => member.name),
    activeLabels: activePartyMembers().map((member) => member.label),
    partyModeIndex: manualPartyModeIndex,
    runtimeCharacters: (runtimeState?.characters || []).map((character) => character.name),
    payloadManualParty: payload.manualParty || null,
  };
}).catch((error) => {
  window.__hwanseManualPartyQuickLoad = { ok: false, error: String(error?.message || error) };
});
return {
  ok: true,
  saved,
  before,
  payloadManualParty: payload.manualParty || null,
  payloadRuntimeCharacters: (payload.runtimeState?.characters || []).map((character) => character.name),
  mutated,
};
"""


def manual_party_quick_load_state_script() -> str:
    return "return window.__hwanseManualPartyQuickLoad || null;"


def battle_party_state_script() -> str:
    return """
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const commands = battleCommandItems();
return {
  scene,
  names: (battleState?.party || []).map((member) => member.name),
  activeName: (battleState?.party || [])[battleState?.activeActorIndex || 0]?.name || "",
  partyHp: (battleState?.party || []).map((member) => ({ name: member.name, hp: member.hp, hpMax: member.hpMax })),
  commands: commands.map((command) => command.name),
  commandDetails: commands.map((command) => ({
    key: command.key || '',
    kind: command.kind || '',
    name: command.name || '',
    nameSource: command.nameSource || '',
    textTableKey: command.textTableKey || '',
    textTableIndex: command.textTableIndex ?? null,
    textTableRefVaHex: command.textTableRefVaHex || '',
    textTableTextVaHex: command.textTableTextVaHex || '',
  })),
  enemyHp: battleState?.enemy?.hp ?? null,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave: window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null,
  savedPayload,
};
"""


def savedat_battle_item_state_script() -> str:
    return """
const actor = battleState?.party?.[0] || null;
const actorCommands = actor ? battleCommandItems(actor) : [];
const attackCommand = actorCommands.find((command) => command.key === 'attack') || null;
const skillCommand = actorCommands.find((command) => command.kind === 'skill') || null;
const skillCommands = actor
  ? actorCommands
      .filter((command) => command.kind === 'skill')
      .map((command) => ({
        name: command.name,
        nameSource: command.nameSource || '',
        textTableKey: command.textTableKey || '',
        textTableIndex: command.textTableIndex ?? null,
        textTableRefVaHex: command.textTableRefVaHex || '',
        textTableTextVaHex: command.textTableTextVaHex || '',
        skillBlock: command.skillBlock,
        skillIndex: command.skillIndex,
        skillLevel: command.skillLevel,
        skillValueHex: command.skillValueHex,
        mpCost: command.mpCost,
      }))
  : [];
return {
  scene,
  names: (battleState?.party || []).map((member) => member.name),
  commands: actorCommands.map((command) => command.name),
  commandDetails: actorCommands.map((command) => ({
    key: command.key || '',
    kind: command.kind || '',
    name: command.name || '',
    nameSource: command.nameSource || '',
    textTableKey: command.textTableKey || '',
    textTableIndex: command.textTableIndex ?? null,
    textTableRefVaHex: command.textTableRefVaHex || '',
    textTableTextVaHex: command.textTableTextVaHex || '',
  })),
  itemCounts: (runtimeState?.items || []).map((item) => [item.name, item.count]),
  actorHp: actor?.hp ?? null,
  actorHpMax: actor?.hpMax ?? null,
  actorMp: actor?.mp ?? null,
  actorMpMax: actor?.mpMax ?? null,
  actorBaseStats: actor?.baseStats || null,
  actorEnhanceStats: actor?.enhanceStats || null,
  actorSkillSummary: actor?.skillSummary || null,
  actorSkillCommands: skillCommands,
  actorAttackCommand: attackCommand ? {
    name: attackCommand.name || '',
    nameSource: attackCommand.nameSource || '',
    textTableKey: attackCommand.textTableKey || '',
    textTableIndex: attackCommand.textTableIndex ?? null,
    textTableRefVaHex: attackCommand.textTableRefVaHex || '',
    textTableTextVaHex: attackCommand.textTableTextVaHex || '',
  } : null,
  actorAttackDamage: actor ? battleAttackDamage(actor, attackCommand) : null,
  actorSkillDamage: actor && skillCommand ? battleAttackDamage(actor, skillCommand) : null,
  actorSkillMpCost: skillCommand?.mpCost ?? null,
  actorIncomingDamage: actor ? battleIncomingDamage(actor, false) : null,
  money: runtimeState?.money ?? null,
};
"""


def use_savedat_battle_skill_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_SKILL_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_SKILL_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK_RENDER = null;
if (typeof activeBattleSkillFeedbacks !== 'undefined') activeBattleSkillFeedbacks = [];
const actor = battleState.party[0];
battleState.activeActorIndex = 0;
if (battleState.enemy) {
  battleState.enemy.hpMax = Math.max(battleState.enemy.hpMax || 0, 300);
  battleState.enemy.hp = battleState.enemy.hpMax;
}
const commands = battleCommandItems(actor);
const skillIndex = commands.findIndex((command) => command.kind === 'skill');
if (skillIndex < 0) return { ok: false, reason: 'missing skill command', commands: commands.map((command) => command.name) };
const skill = commands[skillIndex];
const before = {
  actor: actor.name,
  mp: actor.mp,
  mpMax: actor.mpMax,
  enemyHp: battleState.enemy.hp,
  command: skill.name,
  mpCost: skill.mpCost,
  damage: battleAttackDamage(actor, skill),
  skillBlock: skill.skillBlock,
  skillIndex: skill.skillIndex,
  skillLevel: skill.skillLevel,
  skillValueHex: skill.skillValueHex,
  nameSource: skill.nameSource || '',
  textTableKey: skill.textTableKey || '',
  textTableIndex: skill.textTableIndex ?? null,
  textTableRefVaHex: skill.textTableRefVaHex || '',
  textTableTextVaHex: skill.textTableTextVaHex || '',
  commands: commands.map((command) => command.name),
};
battleState.selectedCommandIndex = skillIndex;
useSelectedBattleCommand();
if (typeof render === 'function') render();
const autoSave = window.HWANSE_LAST_BATTLE_SKILL_USE_AUTO_SAVE || window.HWANSE_LAST_BATTLE_SKILL_USE?.autoSave || null;
const turnAutoSave = window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const savedActor = (savedPayload?.runtimeState?.characters || []).find((character) => character.name === actor.name) || null;
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_SKILL_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
if (typeof render === 'function') render();
return {
  ok: true,
  before,
  after: {
    mp: actor.mp,
    hp: actor.hp,
    statuses: [...(actor.statuses || [])],
    enemyHp: battleState.enemy.hp,
    sourceMp: actor.source?.mp ?? null,
    sourceHp: actor.source?.hp ?? null,
  },
  marker: window.HWANSE_LAST_BATTLE_SKILL_USE || null,
  skillFeedbackLog: window.HWANSE_BATTLE_SKILL_FEEDBACK_LOG || [],
  skillFeedbackRender: window.HWANSE_BATTLE_SKILL_FEEDBACK_RENDER || [],
  skillFeedbackLast: window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK || null,
  skillFeedbackLastRender: window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK_RENDER || null,
  autoSave,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave,
  savedPayload,
  savedActor,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def start_savedat_battle_skill_title_continue_script() -> str:
    return """
window.__hwanseBattleSkillTitleContinue = null;
if (typeof returnToTitleScreen !== 'function') {
  return { ok: false, reason: 'missing returnToTitleScreen' };
}
returnToTitleScreen();
if (typeof updateRuntimeSaveControls === 'function') updateRuntimeSaveControls();
const items = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
const button = document.getElementById('quickLoadButton');
const index = items.findIndex((item) => item.key === 'continue');
const titleState = {
  scene,
  quickLoadHidden: button?.hidden ?? null,
  quickLoadText: button?.textContent || '',
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
};
if (!button || button.hidden || button.textContent !== '이어하기' || index < 0) {
  window.__hwanseBattleSkillTitleContinue = {
    ok: false,
    pending: false,
    reason: 'continue not ready',
    titleState,
  };
  return window.__hwanseBattleSkillTitleContinue;
}
selectedTitleMenuIndex = index;
const clicked = activateSelectedTitleMenuItem();
window.__hwanseBattleSkillTitleContinue = {
  ok: clicked === true,
  pending: true,
  clicked,
  selectedTitleMenuIndex,
  titleState,
};
return window.__hwanseBattleSkillTitleContinue;
"""


def savedat_battle_skill_title_continue_state_script() -> str:
    return """
const current = window.__hwanseBattleSkillTitleContinue || null;
if (!current) return null;
if (current.done) return current;
if (scene !== 'map' || !map) {
  return {
    ...current,
    pending: true,
    scene,
    map: map?.name || '',
  };
}
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const completion = publishPrototypeCompletionState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_SKILL_COMPLETION_NOTICE || null;
const done = {
  ...current,
  ok: current.ok === true,
  pending: false,
  done: true,
  titleContinue: true,
  scene,
  map: map?.name || '',
  tile: footTile(),
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  progressCounts: ensurePrototypeProgressState().counts || {},
  completion,
  battleSkill: completion?.battleSkill || null,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  activeDialogueBlock: activeDialogue?.block || null,
  savedPayload,
};
window.__hwanseBattleSkillTitleContinue = done;
return done;
"""


def start_savedat_battle_item_title_continue_script() -> str:
    return """
window.__hwanseBattleItemTitleContinue = null;
if (typeof returnToTitleScreen !== 'function') {
  return { ok: false, reason: 'missing returnToTitleScreen' };
}
returnToTitleScreen();
if (typeof updateRuntimeSaveControls === 'function') updateRuntimeSaveControls();
const items = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
const button = document.getElementById('quickLoadButton');
const index = items.findIndex((item) => item.key === 'continue');
const titleState = {
  scene,
  quickLoadHidden: button?.hidden ?? null,
  quickLoadText: button?.textContent || '',
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
};
if (!button || button.hidden || button.textContent !== '이어하기' || index < 0) {
  window.__hwanseBattleItemTitleContinue = {
    ok: false,
    pending: false,
    reason: 'continue not ready',
    titleState,
  };
  return window.__hwanseBattleItemTitleContinue;
}
selectedTitleMenuIndex = index;
const clicked = activateSelectedTitleMenuItem();
window.__hwanseBattleItemTitleContinue = {
  ok: clicked === true,
  pending: true,
  clicked,
  selectedTitleMenuIndex,
  titleState,
};
return window.__hwanseBattleItemTitleContinue;
"""


def savedat_battle_item_title_continue_state_script() -> str:
    return """
const current = window.__hwanseBattleItemTitleContinue || null;
if (!current) return null;
if (current.done) return current;
if (scene !== 'map' || !map) {
  return {
    ...current,
    pending: true,
    scene,
    map: map?.name || '',
  };
}
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const completion = publishPrototypeCompletionState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_ITEM_COMPLETION_NOTICE || null;
const done = {
  ...current,
  ok: current.ok === true,
  pending: false,
  done: true,
  titleContinue: true,
  scene,
  map: map?.name || '',
  tile: footTile(),
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  progressCounts: ensurePrototypeProgressState().counts || {},
  completion,
  battleItem: completion?.battleItem || null,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  activeDialogueBlock: activeDialogue?.block || null,
  savedPayload,
};
window.__hwanseBattleItemTitleContinue = done;
return done;
"""


def use_savedat_battle_skill_no_mp_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_SKILL_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_SKILL_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK_RENDER = null;
if (typeof activeBattleSkillFeedbacks !== 'undefined') activeBattleSkillFeedbacks = [];
const actor = battleState.party[0];
battleState.activeActorIndex = 0;
battleState.itemMenuOpen = false;
if (battleState.enemy) {
  battleState.enemy.hpMax = Math.max(battleState.enemy.hpMax || 0, 700);
  battleState.enemy.hp = battleState.enemy.hpMax;
}
const commands = battleCommandItems(actor);
const skillIndex = commands.findIndex((command) => command.kind === 'skill');
if (skillIndex < 0) return { ok: false, reason: 'missing skill command', commands: commands.map((command) => command.name) };
const skill = commands[skillIndex];
actor.mp = Math.max(0, (skill.mpCost || 0) - 1);
syncBattleMember(actor);
const before = {
  actor: actor.name,
  mp: actor.mp,
  sourceMp: actor.source?.mp ?? null,
  enemyHp: battleState.enemy.hp,
  command: skill.name,
  mpCost: skill.mpCost,
  damage: battleAttackDamage(actor, skill),
  nameSource: skill.nameSource || '',
  textTableKey: skill.textTableKey || '',
  textTableIndex: skill.textTableIndex ?? null,
  textTableRefVaHex: skill.textTableRefVaHex || '',
  textTableTextVaHex: skill.textTableTextVaHex || '',
};
battleState.selectedCommandIndex = skillIndex;
useSelectedBattleCommand();
if (typeof render === 'function') render();
return {
  ok: true,
  before,
  after: {
    mp: actor.mp,
    sourceMp: actor.source?.mp ?? null,
    enemyHp: battleState.enemy.hp,
  },
  skillFeedbackLog: window.HWANSE_BATTLE_SKILL_FEEDBACK_LOG || [],
  skillFeedbackRender: window.HWANSE_BATTLE_SKILL_FEEDBACK_RENDER || [],
  skillFeedbackLast: window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK || null,
  skillFeedbackLastRender: window.HWANSE_LAST_BATTLE_SKILL_FEEDBACK_RENDER || null,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def savedat_party_skill_state_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
return {
  ok: true,
  members: (battleState.party || []).map((member) => ({
    name: member.name,
    skills: battleCommandItems(member)
      .filter((command) => command.kind === 'skill')
      .map((command) => ({
        name: command.name,
        nameSource: command.nameSource || '',
        textTableKey: command.textTableKey || '',
        textTableIndex: command.textTableIndex ?? null,
        textTableRefVaHex: command.textTableRefVaHex || '',
        textTableTextVaHex: command.textTableTextVaHex || '',
        skillBlock: command.skillBlock,
        skillIndex: command.skillIndex,
        skillLevel: command.skillLevel,
        skillValueHex: command.skillValueHex,
        mpCost: command.mpCost,
        damage: battleAttackDamage(member, command),
      })),
  })),
};
"""


def use_savedat_party_skill_execution_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
const runSkill = (actorName, skillName) => {
  const actorIndex = battleState.party.findIndex((member) => member.name === actorName);
  if (actorIndex < 0) return { ok: false, reason: `missing actor ${actorName}` };
  const actor = battleState.party[actorIndex];
  battleState.activeActorIndex = actorIndex;
  battleState.itemMenuOpen = false;
  actor.hp = actor.hpMax;
  syncBattleMember(actor);
  if (battleState.enemy) {
    battleState.enemy.hpMax = Math.max(battleState.enemy.hpMax || 0, 700);
    battleState.enemy.hp = battleState.enemy.hpMax;
  }
  const commands = battleCommandItems(actor);
  const skillIndex = commands.findIndex((command) => command.kind === 'skill' && (!skillName || command.name === skillName));
  if (skillIndex < 0) return { ok: false, reason: `missing skill ${actorName} ${skillName}`, commands: commands.map((command) => command.name) };
  const skill = commands[skillIndex];
  const before = {
    actor: actor.name,
    mp: actor.mp,
    mpMax: actor.mpMax,
    enemyHp: battleState.enemy.hp,
    command: skill.name,
    mpCost: skill.mpCost,
    damage: battleAttackDamage(actor, skill),
    skillBlock: skill.skillBlock,
    skillIndex: skill.skillIndex,
    skillLevel: skill.skillLevel,
    skillValueHex: skill.skillValueHex,
    nameSource: skill.nameSource || '',
    textTableKey: skill.textTableKey || '',
    textTableIndex: skill.textTableIndex ?? null,
    textTableRefVaHex: skill.textTableRefVaHex || '',
    textTableTextVaHex: skill.textTableTextVaHex || '',
    commands: commands.map((command) => command.name),
  };
  battleState.selectedCommandIndex = skillIndex;
  useSelectedBattleCommand();
  return {
    ok: true,
    before,
    after: {
      mp: actor.mp,
      enemyHp: battleState.enemy.hp,
      sourceMp: actor.source?.mp ?? null,
    },
    log: [...(battleState.log || [])],
    nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
  };
};
return {
  ok: true,
  rinshan: runSkill('Rinshan', '장궁'),
  smashu: runSkill('Smashu', '투구쪼개기'),
};
"""


def use_savedat_battle_item_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER = null;
if (typeof activeBattleItemFeedbacks !== 'undefined') activeBattleItemFeedbacks = [];
const actor = battleState.party[0];
battleState.activeActorIndex = 0;
actor.hp = Math.max(1, actor.hpMax - 20);
syncBattleMember(actor);
const itemCommandIndex = battleCommandItems(actor).findIndex((command) => command.key === 'item');
battleState.selectedCommandIndex = Math.max(0, itemCommandIndex);
const firstItem = runtimeState.items.find((item) => item.key === 'herb' && item.count > 0)
  || runtimeState.items.find((item) => item.count > 0);
const before = {
  hp: actor.hp,
  hpMax: actor.hpMax,
  count: firstItem?.count ?? 0,
  item: firstItem?.name || '',
};
const commandResult = useSelectedBattleCommand();
const openedItemMenu = battleState.itemMenuOpen === true;
const itemMenuLabels = battleInventoryItems().map((item) => `${item.name} x${item.count}`);
if (battleState.itemMenuOpen) {
  battleState.selectedItemIndex = Math.max(0, battleInventoryItems().findIndex((item) => item.key === firstItem?.key));
  useSelectedBattleCommand();
}
const targetMenuLabels = battleState.targetMenuOpen
  ? battleItemTargetItems().map((target) => target.name)
  : [];
if (battleState.targetMenuOpen) {
  const targetIndex = battleItemTargetItems().findIndex((target) => target.member?.name === actor.name);
  battleState.selectedTargetIndex = Math.max(0, targetIndex);
  useSelectedBattleCommand();
}
if (typeof render === 'function') render();
const afterItem = runtimeState.items.find((item) => item.name === before.item) || {};
const autoSave = window.HWANSE_LAST_BATTLE_ITEM_USE_AUTO_SAVE || window.HWANSE_LAST_BATTLE_ITEM_USE?.autoSave || null;
const turnAutoSave = window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_ITEM_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
return {
  ok: true,
  before,
  after: {
    hp: actor.hp,
    hpMax: actor.hpMax,
    count: afterItem.count ?? null,
  },
  commandResult,
  openedItemMenu,
  itemMenuLabels,
  targetMenuLabels,
  targetSelectionMarker: window.HWANSE_LAST_BATTLE_ITEM_TARGET_SELECTION || null,
  targetSelectionLog: window.HWANSE_BATTLE_ITEM_TARGET_SELECTION_LOG || [],
  marker: window.HWANSE_LAST_BATTLE_ITEM_USE || null,
  itemFeedbackLog: window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG || [],
  itemFeedbackRender: window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER || [],
  itemFeedbackLast: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK || null,
  itemFeedbackLastRender: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER || null,
  autoSave,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave,
  savedPayload,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def use_savedat_battle_item_menu_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER = null;
if (typeof activeBattleItemFeedbacks !== 'undefined') activeBattleItemFeedbacks = [];
const rinshanIndex = battleState.party.findIndex((member) => member.name === 'Rinshan');
if (rinshanIndex >= 0) battleState.activeActorIndex = rinshanIndex;
const actor = battleState.party[battleState.activeActorIndex] || battleState.party[0];
actor.mp = Math.max(0, actor.mpMax - 60);
syncBattleMember(actor);
let potion = runtimeState.items.find((item) => item.key === 'mp_recovery');
if (!potion) {
  potion = { key: 'mp_recovery', name: saveItemName('mp_recovery', '마법의 물약'), count: 0 };
  runtimeState.items.push(potion);
}
potion.count = 1;
const herb = runtimeState.items.find((item) => item.key === 'herb');
if (herb && herb.count <= 0) herb.count = 1;
const itemCommandIndex = battleCommandItems(actor).findIndex((command) => command.key === 'item');
battleState.selectedCommandIndex = Math.max(0, itemCommandIndex);
battleState.selectedItemIndex = 0;
battleState.itemMenuOpen = false;
const before = {
  actor: actor.name,
  mp: actor.mp,
  mpMax: actor.mpMax,
  potionCount: potion.count,
  inventoryLabels: battleInventoryItems().map((item) => `${item.name} x${item.count}`),
};
useSelectedBattleCommand();
const opened = battleState.itemMenuOpen === true;
const labels = battleInventoryItems().map((item) => `${item.name} x${item.count}`);
battleState.selectedItemIndex = Math.max(0, battleInventoryItems().findIndex((item) => item.key === 'mp_recovery'));
useSelectedBattleCommand();
const targetLabels = battleState.targetMenuOpen
  ? battleItemTargetItems().map((target) => target.name)
  : [];
if (battleState.targetMenuOpen) {
  const targetIndex = battleItemTargetItems().findIndex((target) => target.member?.name === actor.name);
  battleState.selectedTargetIndex = Math.max(0, targetIndex);
  useSelectedBattleCommand();
}
if (typeof render === 'function') render();
const afterPotion = runtimeState.items.find((item) => item.key === 'mp_recovery') || {};
const autoSave = window.HWANSE_LAST_BATTLE_ITEM_USE_AUTO_SAVE || window.HWANSE_LAST_BATTLE_ITEM_USE?.autoSave || null;
const turnAutoSave = window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_ITEM_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
return {
  ok: true,
  opened,
  before,
  labels,
  targetLabels,
  targetSelectionMarker: window.HWANSE_LAST_BATTLE_ITEM_TARGET_SELECTION || null,
  targetSelectionLog: window.HWANSE_BATTLE_ITEM_TARGET_SELECTION_LOG || [],
  after: {
    mp: actor.mp,
    mpMax: actor.mpMax,
    potionCount: afterPotion.count ?? null,
    itemMenuOpen: battleState.itemMenuOpen === true,
  },
  marker: window.HWANSE_LAST_BATTLE_ITEM_USE || null,
  itemFeedbackLog: window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG || [],
  itemFeedbackRender: window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER || [],
  itemFeedbackLast: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK || null,
  itemFeedbackLastRender: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER || null,
  autoSave,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave,
  savedPayload,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def use_savedat_battle_status_item_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER = null;
if (typeof activeBattleItemFeedbacks !== 'undefined') activeBattleItemFeedbacks = [];
const actor = battleState.party[battleState.activeActorIndex] || battleState.party[0];
actor.statuses = ['poison'];
syncBattleMember(actor);
let antidote = runtimeState.items.find((item) => item.key === 'item_2');
if (!antidote) {
  antidote = { key: 'item_2', name: saveItemName('item_2', '해독초'), count: 0 };
  runtimeState.items.push(antidote);
}
antidote.count = 1;
const itemCommandIndex = battleCommandItems(actor).findIndex((command) => command.key === 'item');
battleState.selectedCommandIndex = Math.max(0, itemCommandIndex);
battleState.itemMenuOpen = true;
battleState.selectedItemIndex = Math.max(0, battleInventoryItems().findIndex((item) => item.key === 'item_2'));
const before = {
  actor: actor.name,
  statuses: [...(actor.statuses || [])],
  antidoteCount: antidote.count,
  labels: battleInventoryItems().map((item) => `${item.name} x${item.count}`),
};
useSelectedBattleCommand();
const targetLabels = battleState.targetMenuOpen
  ? battleItemTargetItems().map((target) => target.name)
  : [];
if (battleState.targetMenuOpen) {
  const targetIndex = battleItemTargetItems().findIndex((target) => target.member?.name === actor.name);
  battleState.selectedTargetIndex = Math.max(0, targetIndex);
  useSelectedBattleCommand();
}
if (typeof render === 'function') render();
const afterAntidote = runtimeState.items.find((item) => item.key === 'item_2') || {};
const autoSave = window.HWANSE_LAST_BATTLE_ITEM_USE_AUTO_SAVE || window.HWANSE_LAST_BATTLE_ITEM_USE?.autoSave || null;
const turnAutoSave = window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_ITEM_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
return {
  ok: true,
  before,
  targetLabels,
  targetSelectionMarker: window.HWANSE_LAST_BATTLE_ITEM_TARGET_SELECTION || null,
  targetSelectionLog: window.HWANSE_BATTLE_ITEM_TARGET_SELECTION_LOG || [],
  after: {
    statuses: [...(actor.statuses || [])],
    sourceStatuses: [...(actor.source?.statuses || [])],
    antidoteCount: afterAntidote.count ?? null,
    itemMenuOpen: battleState.itemMenuOpen === true,
  },
  marker: window.HWANSE_LAST_BATTLE_ITEM_USE || null,
  itemFeedbackLog: window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG || [],
  itemFeedbackRender: window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER || [],
  itemFeedbackLast: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK || null,
  itemFeedbackLastRender: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER || null,
  autoSave,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave,
  savedPayload,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def use_savedat_battle_revive_item_script() -> str:
    return """
if (!battleState || !runtimeState) return { ok: false, reason: 'missing state' };
window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG = [];
window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER = [];
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK = null;
window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER = null;
if (typeof activeBattleItemFeedbacks !== 'undefined') activeBattleItemFeedbacks = [];
const actor = battleState.party[battleState.activeActorIndex] || battleState.party[0];
const fallen = battleState.party.find((member) => member.name === 'Smashu') || battleState.party[battleState.party.length - 1];
fallen.hp = 0;
fallen.mp = 0;
syncBattleMember(fallen);
let herb = runtimeState.items.find((item) => item.key === 'herb');
if (!herb) {
  herb = { key: 'herb', name: saveItemName('herb', '약초'), count: 0 };
  runtimeState.items.push(herb);
}
herb.count = Math.max(1, herb.count || 0);
let stone = runtimeState.items.find((item) => item.key === 'item_6');
if (!stone) {
  stone = { key: 'item_6', name: saveItemName('item_6', '마수석'), count: 0 };
  runtimeState.items.push(stone);
}
stone.count = 1;
const herbTarget = itemEffectTargetCharacter('herb', battleState.party, actor);
const itemCommandIndex = battleCommandItems(actor).findIndex((command) => command.key === 'item');
battleState.selectedCommandIndex = Math.max(0, itemCommandIndex);
battleState.itemMenuOpen = true;
battleState.selectedItemIndex = Math.max(0, battleInventoryItems().findIndex((item) => item.key === 'item_6'));
const before = {
  actor: actor.name,
  fallen: fallen.name,
  fallenHp: fallen.hp,
  fallenHpMax: fallen.hpMax,
  fallenMp: fallen.mp,
  fallenMpMax: fallen.mpMax,
  herbTarget: herbTarget?.name || '',
  stoneCount: stone.count,
  labels: battleInventoryItems().map((item) => `${item.name} x${item.count}`),
};
useSelectedBattleCommand();
const targetLabels = battleState.targetMenuOpen
  ? battleItemTargetItems().map((target) => target.name)
  : [];
if (battleState.targetMenuOpen) {
  const targetIndex = battleItemTargetItems().findIndex((target) => target.member?.name === fallen.name);
  battleState.selectedTargetIndex = Math.max(0, targetIndex);
  useSelectedBattleCommand();
}
if (typeof render === 'function') render();
const afterStone = runtimeState.items.find((item) => item.key === 'item_6') || {};
const autoSave = window.HWANSE_LAST_BATTLE_ITEM_USE_AUTO_SAVE || window.HWANSE_LAST_BATTLE_ITEM_USE?.autoSave || null;
const turnAutoSave = window.HWANSE_LAST_BATTLE_TURN_AUTO_SAVE || window.HWANSE_LAST_BATTLE_TURN_RESULT?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_BATTLE_ITEM_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
return {
  ok: true,
  before,
  targetLabels,
  targetSelectionMarker: window.HWANSE_LAST_BATTLE_ITEM_TARGET_SELECTION || null,
  targetSelectionLog: window.HWANSE_BATTLE_ITEM_TARGET_SELECTION_LOG || [],
  after: {
    hp: fallen.hp,
    hpMax: fallen.hpMax,
    mp: fallen.mp,
    mpMax: fallen.mpMax,
    sourceHp: fallen.source?.hp ?? null,
    sourceMp: fallen.source?.mp ?? null,
    stoneCount: afterStone.count ?? null,
    itemMenuOpen: battleState.itemMenuOpen === true,
  },
  marker: window.HWANSE_LAST_BATTLE_ITEM_USE || null,
  itemFeedbackLog: window.HWANSE_BATTLE_ITEM_FEEDBACK_LOG || [],
  itemFeedbackRender: window.HWANSE_BATTLE_ITEM_FEEDBACK_RENDER || [],
  itemFeedbackLast: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK || null,
  itemFeedbackLastRender: window.HWANSE_LAST_BATTLE_ITEM_FEEDBACK_RENDER || null,
  autoSave,
  turnMarker: window.HWANSE_LAST_BATTLE_TURN_RESULT || null,
  turnAutoSave,
  savedPayload,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  log: battleState.log || [],
  nextActor: battleState.party[battleState.activeActorIndex]?.name || '',
};
"""


def savedat_menu_item_state_script() -> str:
    return """
return {
  scene,
  map: map?.name || "",
  menuOpen,
  itemCounts: (runtimeState?.items || []).map((item) => [item.name, item.count]),
  ataho: runtimeState?.characters?.find((character) => character.name === 'Ataho') || null,
  characterLines: menuCharacters().flatMap((character) => [
    `${character.name} ${character.detail}`,
    character.subdetail ? `  ${character.subdetail}` : '',
  ]).filter(Boolean),
  labels: menuItems().map((item) => menuItemLabel(item)),
  notice: menuNotice,
};
"""


def start_save_point_candidate_menu_script() -> str:
    return """
menuOpen = true;
activeDialogue = null;
window.HWANSE_SAVE_POINT_FEEDBACK_LOG = [];
window.HWANSE_SAVE_POINT_FEEDBACK_RENDER = [];
window.HWANSE_LAST_SAVE_POINT_FEEDBACK = null;
window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER = null;
if (typeof activeSavePointFeedbacks !== 'undefined') activeSavePointFeedbacks = [];
const items = menuItems();
const directIndex = items.findIndex((item) => item.command === 'showSavePointCandidates');
const index = items.findIndex((item) => item.command === 'openSavePointCandidateMenu');
const labels = items.map((item) => menuItemLabel(item));
if (index < 0) {
  window.__hwanseSavePointCandidateMenu = { ok: false, reason: 'missing save-point candidate menu command', labels, directIndex };
  return window.__hwanseSavePointCandidateMenu;
}
selectedMenuItemIndex = index;
const commandResult = useSelectedMenuItem();
window.__hwanseSavePointCandidateMenu = {
  ok: true,
  index,
  directIndex,
  labels,
  commandResult,
  notice: menuNotice,
};
return window.__hwanseSavePointCandidateMenu;
"""


def save_point_candidate_menu_state_script() -> str:
    return """
if (typeof render === 'function') render();
const last = window.HWANSE_LAST_SAVE_POINT_CANDIDATES || null;
let savedPayload = null;
try {
  const text = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = text ? JSON.parse(text) : null;
} catch (error) {
  savedPayload = { error: String(error && error.message || error) };
}
return {
  marker: window.__hwanseSavePointCandidateMenu || null,
  scene,
  map: map?.name || '',
  menuOpen,
  menuMode,
  notice: menuNotice,
  loadedCount: window.HWANSE_SAVE_POINT_CANDIDATES?.candidateCount ?? 0,
  originalSavePointRuntimeImplemented: window.HWANSE_SAVE_POINT_CANDIDATES?.originalSavePointRuntimeImplemented ?? null,
  last,
  runtimeSave: window.HWANSE_LAST_SAVE_POINT_RUNTIME_SAVE || null,
  completion: window.HWANSE_LAST_PROTOTYPE_COMPLETION || null,
  progress: window.HWANSE_LAST_PROTOTYPE_PROGRESS || null,
  savePointFeedbackLog: window.HWANSE_SAVE_POINT_FEEDBACK_LOG || [],
  savePointFeedbackRender: window.HWANSE_SAVE_POINT_FEEDBACK_RENDER || [],
  savePointFeedbackLast: window.HWANSE_LAST_SAVE_POINT_FEEDBACK || null,
  savePointFeedbackLastRender: window.HWANSE_LAST_SAVE_POINT_FEEDBACK_RENDER || null,
  savedPayload,
  activeId: activeDialogue?.block?.blockId || '',
  activeIndex: activeDialogue?.index ?? null,
  activeLines: activeDialogue?.lines?.slice(0, 12) || [],
  labels: menuItems().map((item) => menuItemLabel(item)),
};
"""


def event_object_render_state_script() -> str:
    return """
return {
  scene,
  map: map?.name || '',
  showEventHotspots,
  search: window.location.search,
  assetKeys: Object.keys(window.HWANSE_OBJECT_ASSETS || {}).sort(),
  render: window.HWANSE_LAST_EVENT_OBJECT_RENDER || null,
};
"""


def use_savedat_menu_item_script() -> str:
    return """
if (!runtimeState) return { ok: false, reason: 'missing runtimeState' };
window.HWANSE_INVENTORY_ITEM_FEEDBACK_LOG = [];
window.HWANSE_INVENTORY_ITEM_FEEDBACK_RENDER = [];
window.HWANSE_LAST_INVENTORY_ITEM_FEEDBACK = null;
window.HWANSE_LAST_INVENTORY_ITEM_FEEDBACK_RENDER = null;
if (typeof activeInventoryItemFeedbacks !== 'undefined') activeInventoryItemFeedbacks = [];
menuOpen = true;
menuMode = 'main';
selectedMenuItemIndex = 0;
const ataho = runtimeState.characters.find((character) => character.name === 'Ataho');
ataho.hp = Math.max(1, ataho.hpMax - 30);
const beforeItem = runtimeState.items.find((item) => item.name === '약초') || runtimeState.items[0];
const before = { hp: ataho.hp, hpMax: ataho.hpMax, count: beforeItem?.count ?? 0 };
const mainItems = menuItems();
const inventoryMenuIndex = mainItems.findIndex((item) => item.command === 'openInventoryItemMenu');
if (inventoryMenuIndex < 0) {
  return { ok: false, reason: 'missing inventory menu', labels: mainItems.map((item) => menuItemLabel(item)) };
}
selectedMenuItemIndex = inventoryMenuIndex;
const menuOpenResult = useSelectedMenuItem();
const inventoryOpenMarker = window.HWANSE_LAST_INVENTORY_ITEM_SELECT_MENU || null;
const inventoryItems = menuItems();
const itemIndex = inventoryItems.findIndex((item) => !item.command && item.key === beforeItem.key);
if (itemIndex < 0) {
  return {
    ok: false,
    reason: 'missing savedat inventory item',
    menuOpenResult,
    inventoryOpenMarker,
    inventoryLabels: inventoryItems.map((item) => menuItemLabel(item)),
  };
}
selectedMenuItemIndex = itemIndex;
useSelectedMenuItem();
if (typeof render === 'function') render();
const itemUseNotice = menuNotice;
const afterItem = runtimeState.items.find((item) => item.key === beforeItem.key) || {};
const autoSave = window.HWANSE_LAST_INVENTORY_ITEM_USE_AUTO_SAVE || window.HWANSE_LAST_INVENTORY_ITEM_USE?.autoSave || null;
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_ITEM_USE_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
activeDialogue = null;
return {
	  ok: true,
	  selectedMenuItemIndex: itemIndex,
	  menuOpenResult,
	  inventoryOpenMarker,
	  inventorySelectionMarker: window.HWANSE_LAST_INVENTORY_ITEM_MENU_SELECTION || null,
	  inventoryLabels: inventoryItems.map((item) => menuItemLabel(item)),
	  before,
  after: {
    hp: ataho.hp,
    hpMax: ataho.hpMax,
    count: afterItem.count ?? null,
  },
  marker: window.HWANSE_LAST_INVENTORY_ITEM_USE || null,
  inventoryFeedbackLog: window.HWANSE_INVENTORY_ITEM_FEEDBACK_LOG || [],
  inventoryFeedbackRender: window.HWANSE_INVENTORY_ITEM_FEEDBACK_RENDER || [],
  inventoryFeedbackLast: window.HWANSE_LAST_INVENTORY_ITEM_FEEDBACK || null,
  inventoryFeedbackLastRender: window.HWANSE_LAST_INVENTORY_ITEM_FEEDBACK_RENDER || null,
  autoSave,
  savedPayload,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  notice: itemUseNotice,
  labels: menuItems().map((item) => menuItemLabel(item)),
};
"""


def use_savedat_menu_status_item_script() -> str:
    return """
if (!runtimeState) return { ok: false, reason: 'missing runtimeState' };
menuOpen = true;
menuMode = 'main';
selectedMenuItemIndex = 0;
const ataho = runtimeState.characters.find((character) => character.name === 'Ataho');
ataho.statuses = ['poison', 'paralysis'];
let antidote = runtimeState.items.find((item) => item.key === 'item_2');
if (!antidote) {
  antidote = { key: 'item_2', name: saveItemName('item_2', '해독초'), count: 0 };
  runtimeState.items.push(antidote);
}
antidote.count = 1;
const mainItems = menuItems();
const inventoryMenuIndex = mainItems.findIndex((item) => item.command === 'openInventoryItemMenu');
if (inventoryMenuIndex < 0) {
  return { ok: false, reason: 'missing inventory menu', labels: mainItems.map((item) => menuItemLabel(item)) };
}
selectedMenuItemIndex = inventoryMenuIndex;
const menuOpenResult = useSelectedMenuItem();
const inventoryOpenMarker = window.HWANSE_LAST_INVENTORY_ITEM_SELECT_MENU || null;
const items = menuItems();
const itemIndex = items.findIndex((item) => !item.command && item.key === 'item_2');
if (itemIndex < 0) {
  return {
    ok: false,
    reason: 'missing antidote inventory item',
    menuOpenResult,
    inventoryOpenMarker,
    labels: items.map((item) => menuItemLabel(item)),
  };
}
selectedMenuItemIndex = itemIndex;
const before = {
  statuses: [...(ataho.statuses || [])],
  antidoteCount: antidote.count,
  labels: items.map((item) => menuItemLabel(item)),
};
useSelectedMenuItem();
const afterAntidote = runtimeState.items.find((item) => item.key === 'item_2') || {};
return {
	  ok: true,
	  selectedMenuItemIndex: itemIndex,
	  menuOpenResult,
	  inventoryOpenMarker,
	  inventorySelectionMarker: window.HWANSE_LAST_INVENTORY_ITEM_MENU_SELECTION || null,
	  before,
  after: {
    statuses: [...(ataho.statuses || [])],
    antidoteCount: afterAntidote.count ?? null,
  },
  notice: menuNotice,
  characterLines: menuCharacters().flatMap((character) => [
    `${character.name} ${character.detail}`,
    character.subdetail ? `  ${character.subdetail}` : '',
  ]).filter(Boolean),
  labels: menuItems().map((item) => menuItemLabel(item)),
};
"""


def quick_save_load_savedat_menu_item_script() -> str:
    return """
if (!runtimeState) return { ok: false, reason: 'missing runtimeState' };
menuOpen = true;
const atahoBeforeSave = runtimeState.characters.find((character) => character.name === 'Ataho');
if (atahoBeforeSave) atahoBeforeSave.statuses = ['poison'];
const saveItems = menuItems();
const saveIndex = saveItems.findIndex((item) => item.command === 'quickSave');
if (saveIndex < 0) return { ok: false, reason: 'missing quickSave command', labels: saveItems.map((item) => menuItemLabel(item)) };
selectedMenuItemIndex = saveIndex;
const saveLabels = saveItems.map((item) => menuItemLabel(item));
const saveCommandResult = useSelectedMenuItem();
const saveNotice = menuNotice;
const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
const savedItem = (payload.runtimeState?.items || []).find((item) => item.name === '약초') || {};
const savedAtaho = (payload.runtimeState?.characters || []).find((character) => character.name === 'Ataho') || {};
const liveItem = runtimeState.items.find((item) => item.name === '약초') || runtimeState.items[0];
const liveAtaho = runtimeState.characters.find((character) => character.name === 'Ataho');
if (liveItem) liveItem.count = 0;
if (liveAtaho) liveAtaho.hp = 1;
if (liveAtaho) liveAtaho.statuses = [];
menuOpen = true;
const loadItems = menuItems();
const loadIndex = loadItems.findIndex((item) => item.command === 'quickLoad');
const loadLabels = loadItems.map((item) => menuItemLabel(item));
if (loadIndex < 0) return { ok: false, reason: 'missing quickLoad command', saveLabels, loadLabels };
window.__hwanseMenuItemQuickLoad = null;
selectedMenuItemIndex = loadIndex;
const loadCommandResult = useSelectedMenuItem();
const loadNoticeStart = menuNotice;
let loadPollCount = 0;
function finishMenuCommandQuickLoad(ok, extra = {}) {
  const loadedItem = (runtimeState?.items || []).find((item) => item.name === '약초') || {};
  const loadedAtaho = (runtimeState?.characters || []).find((character) => character.name === 'Ataho') || {};
  const saveMapValues = [...document.getElementById('saveMapSelect').options].map((option) => option.value).filter(Boolean);
  window.__hwanseMenuItemQuickLoad = {
    ok,
    scene,
    map: map?.name || '',
    loadedCount: loadedItem.count ?? null,
    loadedHp: loadedAtaho.hp ?? null,
    loadedStatus: [...(loadedAtaho.statuses || [])],
    fieldMaps: loadedSaveSummary?.fieldMaps || [],
    routeStatus: loadedSaveSummary?.routeEvidence?.status || '',
    saveMapHidden: document.getElementById('saveMapSelect').hidden,
    saveMapValues,
    notice: menuNotice,
    loadCommandResult,
    loadNoticeStart,
    loadLabelsAfter: menuItems().map((item) => menuItemLabel(item)),
    ...extra,
  };
}
function pollMenuCommandQuickLoad() {
  loadPollCount += 1;
  if (menuNotice === '임시 저장을 불러왔습니다.' || menuNotice === '불러올 임시 저장이 없습니다.' || loadPollCount >= 80) {
    finishMenuCommandQuickLoad(menuNotice === '임시 저장을 불러왔습니다.', { loadPollCount });
    return;
  }
  window.setTimeout(pollMenuCommandQuickLoad, 50);
}
try {
  pollMenuCommandQuickLoad();
} catch (error) {
  finishMenuCommandQuickLoad(false, { error: String(error?.message || error) });
}
return {
  ok: true,
  saveIndex,
  loadIndex,
  saveLabels,
  loadLabels,
  saveCommandResult,
  loadCommandResult,
  saveNotice,
  loadNoticeStart,
  saved: !!payload.version,
  savedCount: savedItem.count ?? null,
  savedHp: savedAtaho.hp ?? null,
  savedStatus: [...(savedAtaho.statuses || [])],
  savedFieldMaps: payload.loadedSaveSummary?.fieldMaps || [],
  savedRouteStatus: payload.loadedSaveSummary?.routeEvidence?.status || '',
  mutatedCount: liveItem?.count ?? null,
  mutatedHp: liveAtaho?.hp ?? null,
  mutatedStatus: [...(liveAtaho?.statuses || [])],
};
"""


def menu_item_quick_load_state_script() -> str:
    return "return window.__hwanseMenuItemQuickLoad || null;"


def start_savedat_menu_item_title_continue_script() -> str:
    return """
window.__hwanseSavedatMenuItemTitleContinue = null;
if (typeof returnToTitleScreen !== 'function') {
  return { ok: false, reason: 'missing returnToTitleScreen' };
}
returnToTitleScreen();
if (typeof updateRuntimeSaveControls === 'function') updateRuntimeSaveControls();
const items = typeof titleMenuItems === 'function' ? titleMenuItems() : [];
const button = document.getElementById('quickLoadButton');
const index = items.findIndex((item) => item.key === 'continue');
const titleState = {
  scene,
  quickLoadHidden: button?.hidden ?? null,
  quickLoadText: button?.textContent || '',
  titleMenuKeys: items.map((item) => item.key),
  titleMenuLabels: items.map((item) => item.label),
};
if (!button || button.hidden || button.textContent !== '이어하기' || index < 0) {
  window.__hwanseSavedatMenuItemTitleContinue = {
    ok: false,
    pending: false,
    reason: 'continue not ready',
    titleState,
  };
  return window.__hwanseSavedatMenuItemTitleContinue;
}
selectedTitleMenuIndex = index;
const clicked = activateSelectedTitleMenuItem();
window.__hwanseSavedatMenuItemTitleContinue = {
  ok: clicked === true,
  pending: true,
  clicked,
  selectedTitleMenuIndex,
  titleState,
};
return window.__hwanseSavedatMenuItemTitleContinue;
"""


def savedat_menu_item_title_continue_state_script() -> str:
    return """
const current = window.__hwanseSavedatMenuItemTitleContinue || null;
if (!current) return null;
if (current.done) return current;
if (scene !== 'map' || !map || !runtimeState) {
  return {
    ...current,
    pending: true,
    scene,
    map: map?.name || '',
  };
}
let savedPayload = null;
try {
  const savedText = localStorage.getItem(RUNTIME_SAVE_KEY) || '';
  savedPayload = savedText ? JSON.parse(savedText) : null;
} catch (error) {
  savedPayload = { error: String(error?.message || error) };
}
const item = (runtimeState.items || []).find((entry) => entry.name === '약초') || {};
const ataho = (runtimeState.characters || []).find((character) => character.name === 'Ataho') || {};
const saveMapValues = [...document.getElementById('saveMapSelect').options]
  .map((option) => option.value)
  .filter(Boolean);
const objectiveBefore = prototypeObjectiveState();
const objectiveResult = activatePrototypeObjectiveAction();
const objectiveAction = window.HWANSE_LAST_OBJECTIVE_ACTION || null;
const objectiveCompletionNotice = window.HWANSE_LAST_ITEM_USE_COMPLETION_NOTICE || null;
const objectiveActiveDialogueBlock = activeDialogue?.block || null;
activeDialogue = null;
const done = {
  ...current,
  ok: current.ok === true,
  pending: false,
  done: true,
  titleContinue: true,
  scene,
  map: map?.name || '',
  tile: footTile(),
  quickLoadText: document.getElementById('quickLoadButton')?.textContent || '',
  itemCount: item.count ?? null,
  atahoHp: ataho.hp ?? null,
  atahoStatuses: [...(ataho.statuses || [])],
  fieldMaps: loadedSaveSummary?.fieldMaps || [],
  routeStatus: loadedSaveSummary?.routeEvidence?.status || '',
  saveMapHidden: document.getElementById('saveMapSelect').hidden,
  saveMapValues,
  objectiveBefore,
  objectiveResult,
  objectiveAction,
  objectiveCompletionNotice,
  objectiveActiveDialogueBlock,
  savedPayload,
};
window.__hwanseSavedatMenuItemTitleContinue = done;
return done;
"""


def start_synthetic_savedat_file_load_script() -> str:
    return """
window.__hwanseSyntheticSavedatFileLoad = null;
fetch('../out/synthetic_savedat_selector_2_0.dat', { cache: 'no-store' })
  .then((response) => {
    if (!response.ok) throw new Error(`synthetic savedat fetch failed ${response.status}`);
    return response.arrayBuffer();
  })
  .then((buffer) => {
    const file = new File([buffer], 'synthetic_savedat_selector_2_0.dat', { type: 'application/octet-stream' });
    return loadSavedatFile(file);
  })
  .then(() => {
    const saveMapValues = [...document.getElementById('saveMapSelect').options]
      .map((option) => option.value)
      .filter(Boolean);
    const frontierOptions = [...document.getElementById('routeFrontierSelect').options]
      .map((option) => ({
        value: option.value,
        target: option.dataset.transitionTarget || '',
        tile: option.dataset.transitionTile || '',
        text: option.textContent || '',
      }))
      .filter((option) => option.value);
    window.__hwanseSyntheticSavedatFileLoad = {
      ok: true,
      scene,
      map: map?.name || '',
      tile: footTile(),
      notice: menuNotice,
      trialTransitions,
      selectedTransitionTarget,
      transitionTargetExplicit,
      routeEvidence: loadedSaveSummary?.routeEvidence || null,
      debugRouteEvidence: window.HWANSE_LAST_SAVEDAT_ROUTE_EVIDENCE || null,
      debugSummary: window.HWANSE_LAST_SAVEDAT_SUMMARY || null,
      fileName: loadedSaveSummary?.fileName || '',
      group: loadedSaveSummary?.group ?? null,
      slot: loadedSaveSummary?.slot ?? null,
      fieldMaps: loadedSaveSummary?.fieldMaps || [],
      saveMapHidden: document.getElementById('saveMapSelect').hidden,
      saveMapValues,
      routeFrontierHidden: document.getElementById('routeFrontierSelect').hidden,
      frontierOptions,
    };
  })
  .catch((error) => {
    window.__hwanseSyntheticSavedatFileLoad = { ok: false, error: String(error?.message || error) };
  });
return true;
"""


def synthetic_savedat_file_load_state_script() -> str:
    return "return window.__hwanseSyntheticSavedatFileLoad || null;"


def start_synthetic_savedat_drop_load_script() -> str:
    return """
window.__hwanseSyntheticSavedatDropLoad = null;
fetch('../out/synthetic_savedat_selector_2_0.dat', { cache: 'no-store' })
  .then((response) => {
    if (!response.ok) throw new Error(`synthetic savedat fetch failed ${response.status}`);
    return response.arrayBuffer();
  })
  .then((buffer) => {
    const file = new File([buffer], 'synthetic_drop_savedat2.dat', { type: 'application/octet-stream' });
    const transfer = new DataTransfer();
    transfer.items.add(file);
    const eventInit = {
      bubbles: true,
      cancelable: true,
      dataTransfer: transfer,
      clientX: Math.max(4, Math.floor(window.innerWidth / 2)),
      clientY: Math.max(4, Math.floor(window.innerHeight / 2)),
    };
    document.dispatchEvent(new DragEvent('dragenter', eventInit));
    const activeAfterEnter = document.body.classList.contains('save-drop-active');
    document.dispatchEvent(new DragEvent('dragover', eventInit));
    const activeAfterOver = document.body.classList.contains('save-drop-active');
    document.dispatchEvent(new DragEvent('drop', eventInit));
    const activeAfterDrop = document.body.classList.contains('save-drop-active');
    window.__hwanseSyntheticSavedatDropLoad = {
      ok: true,
      dragDispatched: true,
      activeAfterEnter,
      activeAfterOver,
      activeAfterDrop,
      scene,
      map: map?.name || '',
      tile: footTile(),
      notice: menuNotice,
      drop: window.HWANSE_LAST_SAVEDAT_DROP || null,
      routeEvidence: loadedSaveSummary?.routeEvidence || null,
      fileName: loadedSaveSummary?.fileName || '',
      group: loadedSaveSummary?.group ?? null,
      slot: loadedSaveSummary?.slot ?? null,
      fieldMaps: loadedSaveSummary?.fieldMaps || [],
      trialTransitions,
      selectedTransitionTarget,
    };
  })
  .catch((error) => {
    window.__hwanseSyntheticSavedatDropLoad = { ok: false, error: String(error?.message || error) };
  });
return true;
"""


def synthetic_savedat_drop_load_state_script() -> str:
    return """
const state = window.__hwanseSyntheticSavedatDropLoad || null;
if (!state) return null;
return {
  ...state,
  scene,
  map: map?.name || '',
  tile: footTile(),
  notice: menuNotice,
  drop: window.HWANSE_LAST_SAVEDAT_DROP || state.drop || null,
  routeEvidence: loadedSaveSummary?.routeEvidence || state.routeEvidence || null,
  fileName: loadedSaveSummary?.fileName || state.fileName || '',
  group: loadedSaveSummary?.group ?? state.group ?? null,
  slot: loadedSaveSummary?.slot ?? state.slot ?? null,
  fieldMaps: loadedSaveSummary?.fieldMaps || state.fieldMaps || [],
  trialTransitions,
  selectedTransitionTarget,
};
"""


def start_synthetic_savedat_zip_load_script() -> str:
    return """
window.__hwanseSyntheticSavedatZipLoad = null;
function makeStoredZip(name, payload) {
  const nameBytes = new TextEncoder().encode(name);
  const localOffset = 0;
  const localSize = 30 + nameBytes.length + payload.length;
  const centralOffset = localSize;
  const centralSize = 46 + nameBytes.length;
  const endOffset = centralOffset + centralSize;
  const zip = new Uint8Array(endOffset + 22);
  const view = new DataView(zip.buffer);
  const writeU16 = (offset, value) => view.setUint16(offset, value, true);
  const writeU32 = (offset, value) => view.setUint32(offset, value, true);
  writeU32(0, 0x04034b50);
  writeU16(4, 20);
  writeU16(6, 0);
  writeU16(8, 0);
  writeU32(10, 0);
  writeU32(14, 0);
  writeU32(18, payload.length);
  writeU32(22, payload.length);
  writeU16(26, nameBytes.length);
  writeU16(28, 0);
  zip.set(nameBytes, 30);
  zip.set(payload, 30 + nameBytes.length);
  writeU32(centralOffset, 0x02014b50);
  writeU16(centralOffset + 4, 20);
  writeU16(centralOffset + 6, 20);
  writeU16(centralOffset + 8, 0);
  writeU16(centralOffset + 10, 0);
  writeU32(centralOffset + 12, 0);
  writeU32(centralOffset + 16, 0);
  writeU32(centralOffset + 20, payload.length);
  writeU32(centralOffset + 24, payload.length);
  writeU16(centralOffset + 28, nameBytes.length);
  writeU16(centralOffset + 30, 0);
  writeU16(centralOffset + 32, 0);
  writeU32(centralOffset + 34, 0);
  writeU32(centralOffset + 38, 0);
  writeU32(centralOffset + 42, localOffset);
  zip.set(nameBytes, centralOffset + 46);
  writeU32(endOffset, 0x06054b50);
  writeU16(endOffset + 8, 1);
  writeU16(endOffset + 10, 1);
  writeU32(endOffset + 12, centralSize);
  writeU32(endOffset + 16, centralOffset);
  writeU16(endOffset + 20, 0);
  return zip;
}
fetch('../out/synthetic_savedat_selector_2_0.dat', { cache: 'no-store' })
  .then((response) => {
    if (!response.ok) throw new Error(`synthetic savedat fetch failed ${response.status}`);
    return response.arrayBuffer();
  })
  .then((buffer) => {
    const zip = makeStoredZip('SAVEDATA/savedat_zip.dat', new Uint8Array(buffer));
    const file = new File([zip], 'synthetic_savedat_bundle.zip', { type: 'application/zip' });
    return loadSavedatFile(file);
  })
  .then(() => {
    const archive = window.HWANSE_LAST_SAVEDAT_ARCHIVE || {};
    window.__hwanseSyntheticSavedatZipLoad = {
      ok: true,
      scene,
      map: map?.name || '',
      tile: footTile(),
      notice: menuNotice,
      trialTransitions,
      selectedTransitionTarget,
      transitionTargetExplicit,
      routeEvidence: loadedSaveSummary?.routeEvidence || null,
      fileName: loadedSaveSummary?.fileName || '',
      group: loadedSaveSummary?.group ?? null,
      slot: loadedSaveSummary?.slot ?? null,
      fieldMaps: loadedSaveSummary?.fieldMaps || [],
      archive: {
        fileName: archive.fileName || '',
        entryCount: archive.entryCount ?? null,
        loaded: archive.loaded ?? null,
        loadedMap: archive.loadedMap || '',
        selected: archive.selected || null,
        rows: (archive.rows || []).map((row) => ({
          name: row.name || '',
          valid: !!row.valid,
          selector: row.selector || '',
          routeStatus: row.routeStatus || '',
          routeProofCandidate: !!row.routeProofCandidate,
          realRouteProofCandidate: !!row.realRouteProofCandidate,
          syntheticDiagnostic: !!row.syntheticDiagnostic,
        })),
      },
    };
  })
  .catch((error) => {
    window.__hwanseSyntheticSavedatZipLoad = { ok: false, error: String(error?.message || error) };
  });
return true;
"""


def synthetic_savedat_zip_load_state_script() -> str:
    return """
const state = window.__hwanseSyntheticSavedatZipLoad || null;
if (!state) return null;
return {
  ...state,
  hudLines: window.HWANSE_LAST_SAVE_HUD_LINES || [],
};
"""


def start_synthetic_quick_save_load_script() -> str:
    return """
window.__hwanseSyntheticQuickLoad = null;
const saved = quickSaveRuntime();
const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || '{}');
const savedSummary = payload.loadedSaveSummary || {};
const before = {
  saved,
  status: savedSummary.routeEvidence?.status || '',
  routeProofCandidate: !!savedSummary.routeEvidence?.routeProofCandidate,
  realRouteProofCandidate: !!savedSummary.routeEvidence?.realRouteProofCandidate,
  routeSyntheticDiagnostic: !!savedSummary.routeEvidence?.syntheticDiagnostic,
  syntheticDiagnostic: savedSummary.syntheticDiagnostic === true,
  fingerprint: savedSummary.fingerprint || '',
};
loadedSaveSummary = null;
quickLoadRuntime()
  .then(() => {
    window.__hwanseSyntheticQuickLoad = {
      ok: true,
      before,
      map: map?.name || '',
      status: loadedSaveSummary?.routeEvidence?.status || '',
      routeProofCandidate: !!loadedSaveSummary?.routeEvidence?.routeProofCandidate,
      realRouteProofCandidate: !!loadedSaveSummary?.routeEvidence?.realRouteProofCandidate,
      routeSyntheticDiagnostic: !!loadedSaveSummary?.routeEvidence?.syntheticDiagnostic,
      syntheticDiagnostic: loadedSaveSummary?.syntheticDiagnostic === true,
      fingerprint: loadedSaveSummary?.fingerprint || '',
    };
  })
  .catch((error) => {
    window.__hwanseSyntheticQuickLoad = {
      ok: false,
      before,
      error: String(error?.message || error),
    };
  });
return before;
"""


def synthetic_quick_load_state_script() -> str:
    return "return window.__hwanseSyntheticQuickLoad || null;"


def finish_battle_state_script() -> str:
    return """
let guard = 0;
while (scene === 'battle' && battleState && !battleState.finished && guard < 12) {
  battleState.selectedCommandIndex = 0;
  useSelectedBattleCommand();
  guard += 1;
}
return {
  scene,
  guard,
  finished: !!battleState?.finished,
  rewardGranted: !!battleState?.rewardGranted,
  rewardMoney: battleState?.reward?.money ?? null,
  enemyHp: battleState?.enemy?.hp ?? null,
  log: battleState?.log || [],
};
"""


def battle_reward_quick_save_script() -> str:
    return """
const before = runtimeState?.money ?? null;
const canSave = canQuickSaveRuntime();
const victorySave = window.HWANSE_LAST_BATTLE_VICTORY_SAVE || null;
const payload = JSON.parse(localStorage.getItem(RUNTIME_SAVE_KEY) || 'null');
if (runtimeState) runtimeState.money = 0;
window.__hwanseQuickLoadMoney = null;
return {
  before,
  canSave,
  saved: victorySave?.saved === true,
  source: victorySave?.source || '',
  autoSave: victorySave,
  savedMoney: victorySave?.runtimeMoney ?? payload?.runtimeState?.money ?? null,
  savedScene: scene,
  payloadManualParty: payload?.manualParty || null,
  progress: victorySave?.progress || payload?.prototypeProgress || null,
};
"""


def scene_state_script() -> str:
    return """
return {
  scene,
  map: map?.name || "",
  money: runtimeState?.money ?? null,
  notice: menuNotice,
  battleActive: !!battleState,
};
"""


def start_quick_load_money_script() -> str:
    return """
window.__hwanseQuickLoadMoney = null;
quickLoadRuntime().then(() => {
  window.__hwanseQuickLoadMoney = {
    scene,
    map: map?.name || "",
    money: runtimeState?.money ?? null,
    activeLabels: activePartyMembers().map((member) => member.label),
    search: window.location.search,
  };
}).catch((error) => {
  window.__hwanseQuickLoadMoney = `error:${error.message || error}`;
});
return true;
"""


def quick_load_money_script() -> str:
    return "return window.__hwanseQuickLoadMoney;"


def mobile_review_controls_state_script() -> str:
    return """
const controls = document.getElementById('mobileReviewControls');
return {
  hidden: controls.hidden,
  buttonCount: controls.querySelectorAll('button').length,
};
"""


def route_assist_exit_summary_script() -> str:
    return """
const target = 'map2_02d';
const candidate = mapExitTrialCandidateForTarget(target);
return {
  targets: transitionTargetsAtFoot(),
  summary: mapExitCandidateSummary(candidate, target),
  autoTrigger: candidate?.autoTrigger === true,
  side: candidate?.side || '',
};
"""


def force_no_savedat_scan_hud_script() -> str:
    return """
window.HWANSE_LAST_SAVEDAT_SCAN = {
  paths: [],
  found: [],
  errors: [],
  loaded: false,
  loadedMap: '',
  selected: null,
};
window.HWANSE_LAST_SAVEDAT_SCAN_HUD_LINES = [];
return true;
"""


def no_savedat_scan_hud_state_script() -> str:
    return """
return {
  loaded: !!loadedSaveSummary,
  scan: window.HWANSE_LAST_SAVEDAT_SCAN || null,
  lines: window.HWANSE_LAST_SAVEDAT_SCAN_HUD_LINES || [],
};
"""


def sound_state_script() -> str:
    return """
return {
  keys: Object.keys(sounds || {}).sort(),
  volumes: Object.fromEntries(
    Object.entries(sounds || {}).map(([key, audio]) => [key, audio.volume])
  ),
  sources: Object.fromEntries(
    Object.entries(sounds || {}).map(([key, audio]) => [key, audio.getAttribute('src') || audio.src || ''])
  ),
  counts: window.HWANSE_SOUND_COUNTS || {},
  last: window.HWANSE_LAST_SOUND || null,
  log: (window.HWANSE_SOUND_LOG || []).slice(-12),
};
"""


def write_smoke_report(report: dict[str, Any]) -> None:
    out_dir = ROOT / "out"
    out_dir.mkdir(parents=True, exist_ok=True)
    json_path = out_dir / "mobile_browser_controls_smoke.json"
    md_path = out_dir / "mobile_browser_controls_smoke.md"
    json_path.write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")

    lines = [
        "# Mobile Browser Controls Smoke",
        "",
        f"- status: `{report.get('status')}`",
        f"- base: `{report.get('base')}`",
        f"- initial url: `{report.get('initialUrl')}`",
        f"- movement search: `{report.get('movementSearch')}`",
        f"- normal route search: `{report.get('normalRouteSearch')}`",
        f"- routeGoal search: `{report.get('routeGoalSearch')}`",
        f"- routeGoal blocker search: `{report.get('routeGoalBlockerSearch')}`",
        f"- routeGoal target search: `{report.get('routeGoalTargetSearch')}`",
        f"- routeGoal finish search: `{report.get('routeGoalFinishSearch')}`",
        f"- route return focus search: `{report.get('routeReturnFocusSearch')}`",
        f"- route return target search: `{report.get('routeReturnTargetSearch')}`",
        f"- routeAssist search: `{report.get('routeAssistSearch')}`",
        f"- route continuation search: `{report.get('routeContinueSearch')}`",
        "",
        "## Checks",
        "",
        "| check | evidence |",
        "| --- | --- |",
    ]
    checks = report.get("checks") or {}
    for name in [
        "canvasJoystick",
        "documentJoystick",
        "overviewButton",
        "mapFilter",
        "mobileMenuButton",
        "routeAssistControls",
        "savedatScanHud",
        "sounds",
        "dialogue",
        "menuParty",
        "normalRoute",
        "routeGoalNext",
        "routeGoalBlocker",
        "routeGoalClickThrough",
        "routeReturn",
        "battle",
        "savedatBattleItem",
        "savedatMenuItem",
        "savePointCandidate",
        "candidateSavePoint",
        "eventObjectPreview",
        "savedatUrl",
        "savedatUrlZip",
        "savedatScan",
        "savedatDrop",
        "syntheticSelector20",
        "savedatZip",
        "syntheticQuickSaveLoad",
        "routeAssist",
        "routeContinue",
    ]:
        lines.append(f"| {name} | {checks.get(name) or '-'} |")
    lines.append("")
    md_path.write_text("\n".join(lines), encoding="utf-8")


def verify_browser(base: str, keep_log: bool = False) -> None:
    driver_path = shutil.which("WebKitWebDriver")
    if not driver_path:
        raise WebDriverError("WebKitWebDriver is not installed; install webkit2gtk-driver and run under Xvfb")

    port = free_port()
    log_path = ROOT / "out" / "mobile_browser_webkitdriver.log"
    log_path.parent.mkdir(parents=True, exist_ok=True)
    with log_path.open("wb") as log:
        proc = subprocess.Popen(
            [
                driver_path,
                "--host=127.0.0.1",
                f"--port={port}",
                "--replace-on-new-session",
            ],
            stdout=log,
            stderr=subprocess.STDOUT,
        )
        session_id = ""
        scan_probe_path = ROOT / "SAVEDATA" / "savedat9.dat"
        scan_zip_probe_path = ROOT / "SAVEDATA" / "savedat8.zip"
        zip_probe_path = ROOT / "SAVEDATA" / "savedat_zip_probe.zip"
        remove_scan_probe = False
        remove_scan_zip_probe = False
        remove_zip_probe = False
        try:
            scan_probe_source = ROOT / "data" / "public_savedata" / "flack3r" / "savedat2.dat"
            if not scan_probe_path.exists():
                scan_probe_path.parent.mkdir(parents=True, exist_ok=True)
                shutil.copyfile(scan_probe_source, scan_probe_path)
                remove_scan_probe = True
            if not scan_zip_probe_path.exists():
                scan_zip_probe_path.parent.mkdir(parents=True, exist_ok=True)
                with zipfile.ZipFile(scan_zip_probe_path, "w", compression=zipfile.ZIP_STORED) as archive:
                    archive.write(ROOT / "out" / "synthetic_savedat_selector_2_0.dat", "SAVEDATA/savedat_zip.dat")
                remove_scan_zip_probe = True
            if not zip_probe_path.exists():
                zip_probe_path.parent.mkdir(parents=True, exist_ok=True)
                with zipfile.ZipFile(zip_probe_path, "w", compression=zipfile.ZIP_STORED) as archive:
                    archive.write(ROOT / "out" / "synthetic_savedat_selector_2_0.dat", "SAVEDATA/savedat_zip.dat")
                remove_zip_probe = True

            wait_for_driver(port, proc)
            session = request_json(
                port,
                "POST",
                "/session",
                {"capabilities": {"alwaysMatch": {"browserName": "MiniBrowser"}}},
                timeout=30,
            )
            session_id = str(session["value"]["sessionId"])
            request_json(
                port,
                "POST",
                f"/session/{session_id}/window/rect",
                {"x": 0, "y": 0, "width": 390, "height": 844},
                timeout=8,
            )
            params = urlencode({
                "map": "map1_02b",
                "startTile": "11,12",
                "party": "rinshan,smash",
                "spriteDebug": "1",
                "moveMs": "80",
            })
            url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": url}, timeout=30)
            wait_for_page(port, session_id)
            wait_for_map_runtime(port, session_id, "map1_02b")
            sound_state = execute_js(port, session_id, sound_state_script())
            required_sounds = {
                "step",
                "dialogue",
                "menuMove",
                "menuConfirm",
                "transition",
                "battleStart",
                "battleHit",
                "item",
                "victory",
            }
            sound_keys = set(sound_state.get("keys") or []) if isinstance(sound_state, dict) else set()
            sound_volumes = sound_state.get("volumes") if isinstance(sound_state, dict) else {}
            sound_sources = sound_state.get("sources") if isinstance(sound_state, dict) else {}
            if (
                not isinstance(sound_state, dict)
                or not required_sounds.issubset(sound_keys)
                or abs(float((sound_volumes or {}).get("step", -1)) - 0.35) > 0.001
                or abs(float((sound_volumes or {}).get("transition", -1)) - 0.55) > 0.001
                or not all(str((sound_sources or {}).get(key, "")).endswith(".wav") for key in required_sounds)
            ):
                raise WebDriverError(f"WLK sound assets were not initialized as expected: {sound_state!r}")
            time.sleep(0.8)
            execute_js(port, session_id, force_no_savedat_scan_hud_script())
            time.sleep(0.25)
            no_savedat_scan_hud = execute_js(port, session_id, no_savedat_scan_hud_state_script())
            no_savedat_lines = no_savedat_scan_hud.get("lines") or []
            if (
                no_savedat_scan_hud.get("loaded") is not False
                or not isinstance(no_savedat_scan_hud.get("scan"), dict)
                or "savedat scan: no captured saves" not in no_savedat_lines
                or not any("put savedat1-9 .dat/.zip in SAVEDATA" in line for line in no_savedat_lines)
                or not any("drag a savedat .dat/.zip" in line for line in no_savedat_lines)
                or not any("need 2:0 0x00540714" in line for line in no_savedat_lines)
            ):
                raise WebDriverError(
                    f"savedat no-save scan HUD did not render expected intake guidance: {no_savedat_scan_hud!r}"
                )
            mobile_menu_button = execute_js(port, session_id, mobile_menu_button_state_script())
            mobile_menu_sound = mobile_menu_button.get("soundState") or {}
            mobile_menu_sound_counts = mobile_menu_sound.get("counts") or {}
            mobile_menu_sound_log = mobile_menu_sound.get("menuLog") or []
            mobile_menu_sound_actions = [str(row.get("action") or "") for row in mobile_menu_sound_log]
            mobile_menu_sound_sources = [str(row.get("soundSrc") or "") for row in mobile_menu_sound_log]
            if (
                not mobile_menu_button.get("ok")
                or mobile_menu_button.get("before", {}).get("hidden") is not False
                or mobile_menu_button.get("before", {}).get("menuOpen") is not False
                or mobile_menu_button.get("afterOpen", {}).get("menuOpen") is not True
                or mobile_menu_button.get("afterOpen", {}).get("menuMode") != "main"
                or mobile_menu_button.get("afterMove", {}).get("menuOpen") is not True
                or mobile_menu_button.get("afterMove", {}).get("selectedMenuItemIndex") != 1
                or mobile_menu_button.get("afterClose", {}).get("menuOpen") is not False
                or mobile_menu_sound_counts.get("menuConfirm") != 2
                or mobile_menu_sound_counts.get("menuMove") != 1
                or mobile_menu_sound_actions != ["open", "move", "close"]
                or not all(source.endswith("/extract_wlk/04.wav") for source in [mobile_menu_sound_sources[0], mobile_menu_sound_sources[2]])
                or not mobile_menu_sound_sources[1].endswith("/extract_wlk/03.wav")
                or not all((row.get("browserRuntimeMenuSoundImplemented") is True) for row in mobile_menu_sound_log)
                or not all((row.get("originalMenuSoundRuntimeImplemented") is False) for row in mobile_menu_sound_log)
            ):
                raise WebDriverError(f"mobile menu button did not toggle runtime menu: {mobile_menu_button!r}")
            normal_assist_controls = execute_js(port, session_id, route_assist_controls_state_script())
            if (
                normal_assist_controls.get("frontierHidden") is not True
                or normal_assist_controls.get("pathHidden") is not True
                or normal_assist_controls.get("pathTargetCount") != 0
                or normal_assist_controls.get("nextHidden") is not True
            ):
                raise WebDriverError(f"route assist controls should stay hidden before routeAssist: {normal_assist_controls!r}")

            before = execute_js(port, session_id, canvas_checksum_script())
            canvas_state = execute_js(port, session_id, start_touch_on_canvas_script())
            if not canvas_state.get("active"):
                raise WebDriverError(f"canvas touch did not activate joystick: {canvas_state!r}")
            if canvas_state.get("knobX") != "48px":
                raise WebDriverError(f"canvas touch did not clamp joystick knob to radius: {canvas_state!r}")
            time.sleep(0.6)
            moved = execute_js(port, session_id, canvas_checksum_script())
            active_after_canvas = execute_js(port, session_id, end_touch_script())
            if active_after_canvas:
                raise WebDriverError("canvas touch release did not hide joystick")
            if moved == before:
                raise WebDriverError("canvas touch did not change the rendered map/player frame")
            movement_search = str(execute_js(port, session_id, current_search_script()))
            if "map=map1_02b" not in movement_search or "startTile=" not in movement_search:
                raise WebDriverError(f"canvas touch movement did not keep current map/start tile in URL: {movement_search!r}")
            if "party=rinshan%2Csmash" not in movement_search:
                raise WebDriverError(f"canvas touch movement did not preserve party query in URL: {movement_search!r}")
            if "startTile=11%2C12" in movement_search:
                raise WebDriverError(f"canvas touch movement did not update startTile in URL: {movement_search!r}")
            movement_sound_state = execute_js(port, session_id, sound_state_script())
            movement_sound_counts = (movement_sound_state or {}).get("counts") or {}
            if int(movement_sound_counts.get("step") or 0) < 1:
                raise WebDriverError(f"canvas touch movement did not request the step WLK sound: {movement_sound_state!r}")
            overview_button = execute_js(port, session_id, overview_button_state_script())
            if (
                overview_button.get("before", {}).get("hidden") is not False
                or overview_button.get("afterOn", {}).get("overview") is not True
                or overview_button.get("afterOn", {}).get("active") is not True
                or "overview=1" not in (overview_button.get("afterOn", {}).get("search") or "")
                or overview_button.get("afterOff", {}).get("overview") is not False
                or overview_button.get("afterOff", {}).get("active") is not False
                or "overview=1" in (overview_button.get("afterOff", {}).get("search") or "")
            ):
                raise WebDriverError(f"overview button did not toggle overview URL/state: {overview_button!r}")
            map_filter = execute_js(port, session_id, map_filter_state_script())
            if (
                map_filter.get("display") == "none"
                or "map2_02d" not in (map_filter.get("values") or [])
                or map_filter.get("selected") != "map1_02b"
                or not str(map_filter.get("title") or "").endswith("maps")
                or map_filter.get("count", 0) > 4
            ):
                raise WebDriverError(f"map filter did not narrow map dropdown while preserving current map: {map_filter!r}")

            document_state = execute_js(port, session_id, start_touch_on_document_script())
            if not document_state.get("active"):
                raise WebDriverError(f"document touch did not activate joystick: {document_state!r}")
            if document_state.get("knobY") != "48px":
                raise WebDriverError(f"document touch did not clamp joystick knob to radius: {document_state!r}")
            active_after_document = execute_js(port, session_id, end_touch_script(pointer_id=78))
            if active_after_document:
                raise WebDriverError("document touch release did not hide joystick")

            dialogue_params = urlencode({
                "map": "map4_08n",
                "startTile": "11,11",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            dialogue_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{dialogue_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": dialogue_url}, timeout=30)
            wait_for_page(port, session_id)
            action_dialogue_ready = execute_js(port, session_id, action_dialogue_state_script(), timeout=3)
            if action_dialogue_ready.get("map") != "map4_08n":
                raise WebDriverError(f"dialogue test map did not load: {action_dialogue_ready!r}")
            action_dialogue_before = execute_js(port, session_id, canvas_checksum_script())
            dialogue_button_click = execute_js(port, session_id, click_dialogue_button_script(), timeout=3)
            if (
                not dialogue_button_click.get("ok")
                or dialogue_button_click.get("before", {}).get("hidden")
                or "대사" not in str(dialogue_button_click.get("before", {}).get("text") or "")
            ):
                raise WebDriverError(f"dialogue button was not ready for the current map: {dialogue_button_click!r}")
            action_dialogue_open = {}
            deadline = time.monotonic() + 5
            while time.monotonic() < deadline:
                action_dialogue_open = execute_js(port, session_id, action_dialogue_state_script(), timeout=3)
                if action_dialogue_open.get("activeId") == "event-dialogue-block-005":
                    break
                time.sleep(0.15)
            action_dialogue_after = action_dialogue_before
            deadline = time.monotonic() + 2
            while time.monotonic() < deadline:
                action_dialogue_after = execute_js(port, session_id, canvas_checksum_script())
                if action_dialogue_after != action_dialogue_before:
                    break
                time.sleep(0.1)
            deadline = time.monotonic() + 2
            while time.monotonic() < deadline:
                dialogue_box_render = action_dialogue_open.get("dialogueBoxRender") or {}
                if (
                    action_dialogue_open.get("activeId") == "event-dialogue-block-005"
                    and (dialogue_box_render.get("vmReplay") or {}).get("renderCoverageStatus") == "partial-render-prefix"
                ):
                    break
                time.sleep(0.1)
                action_dialogue_open = execute_js(port, session_id, action_dialogue_state_script(), timeout=3)
            vm_replay = action_dialogue_open.get("vmReplay") or {}
            vm_render_event = action_dialogue_open.get("activeVmRenderEvent") or {}
            dialogue_box_render = action_dialogue_open.get("dialogueBoxRender") or {}
            dialogue_box_vm = dialogue_box_render.get("vmReplay") or {}
            if (
                action_dialogue_open.get("activeId") != "event-dialogue-block-005"
                or action_dialogue_open.get("activeIndex") != 0
                or not action_dialogue_open.get("activeLine")
                or "event-dialogue-block-005" not in (action_dialogue_open.get("exactIds") or [])
                or vm_replay.get("blockId") != "event-dialogue-block-005"
                or vm_replay.get("browserEventVmPartialReplayImplemented") is not True
                or vm_replay.get("browserEventVmFullImplementation") is not False
                or vm_replay.get("renderEventCount", 0) < 1
                or vm_replay.get("vmDrivenLineCount", 0) < 2
                or vm_replay.get("renderCoverageStatus") != "partial-render-prefix"
                or vm_replay.get("firstRenderTextSourceValueHex") != "0x00030000"
                or vm_render_event.get("lineIndex") != 0
                or vm_render_event.get("textSourceValueHex") != "0x00030000"
                or dialogue_box_vm.get("renderCoverageStatus") != "partial-render-prefix"
                or dialogue_box_render.get("vmRenderEvent", {}).get("lineIndex") != 0
                or "0x0d" not in (vm_replay.get("executedOpcodes") or [])
                or "0x0b" not in (vm_replay.get("executedOpcodes") or [])
                or action_dialogue_after == action_dialogue_before
            ):
                raise WebDriverError(
                    f"dialogue button did not open exact map-linked dialogue: before={action_dialogue_before} "
                    f"after={action_dialogue_after} state={action_dialogue_open!r}"
                )
            execute_js(port, session_id, tap_action_button_script(pointer_id=82))
            time.sleep(0.15)
            action_dialogue_advanced = execute_js(port, session_id, action_dialogue_state_script(), timeout=3)
            if (
                action_dialogue_advanced.get("activeId") != "event-dialogue-block-005"
                or action_dialogue_advanced.get("activeIndex") != 1
                or (action_dialogue_advanced.get("activeVmRenderEvent") or {}).get("lineIndex") != 1
            ):
                raise WebDriverError(f"mobile A did not advance dialogue: {action_dialogue_advanced!r}")
            dialogue_sound_state = execute_js(port, session_id, sound_state_script())
            dialogue_sound_counts = (dialogue_sound_state or {}).get("counts") or {}
            if int(dialogue_sound_counts.get("dialogue") or 0) < 2:
                raise WebDriverError(f"dialogue open/advance did not request dialogue WLK sounds: {dialogue_sound_state!r}")

            menu_params = urlencode({
                "map": "map1_02b",
                "startTile": "11,12",
                "menu": "1",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            menu_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{menu_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": menu_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.8)
            menu_party_search = str(execute_js(port, session_id, cycle_party_from_menu_script()))
            if "party=rinshan" not in menu_party_search:
                raise WebDriverError(f"menu party command did not persist party query: {menu_party_search!r}")
            manual_party_quick_save = execute_js(port, session_id, quick_save_load_manual_party_script())
            manual_party_quick_load = None
            deadline = time.monotonic() + 4
            while time.monotonic() < deadline:
                manual_party_quick_load = execute_js(port, session_id, manual_party_quick_load_state_script())
                if manual_party_quick_load is not None:
                    break
                time.sleep(0.15)
            if (
                not manual_party_quick_save.get("ok")
                or not manual_party_quick_save.get("saved")
                or manual_party_quick_save.get("payloadManualParty", {}).get("key") != "rinshan"
                or manual_party_quick_save.get("payloadManualParty", {}).get("members") != ["rinshan"]
                or "Rinshan" not in (manual_party_quick_save.get("payloadRuntimeCharacters") or [])
                or manual_party_quick_save.get("mutated", {}).get("members") != []
                or not isinstance(manual_party_quick_load, dict)
                or not manual_party_quick_load.get("ok")
                or manual_party_quick_load.get("scene") != "map"
                or manual_party_quick_load.get("map") != "map1_02b"
                or manual_party_quick_load.get("members") != ["rinshan"]
                or manual_party_quick_load.get("activeLabels") != ["Rinshan"]
                or "Rinshan" not in (manual_party_quick_load.get("runtimeCharacters") or [])
                or "party=rinshan" not in str(manual_party_quick_load.get("search") or "")
            ):
                raise WebDriverError(
                    f"manual party quick save/load did not restore party: "
                    f"save={manual_party_quick_save!r} load={manual_party_quick_load!r}"
                )

            assist_button = execute_js(port, session_id, click_route_assist_button_script())
            if not assist_button.get("active") or assist_button.get("text") != "후보 진행 켬":
                raise WebDriverError(f"route assist button did not enable routeAssist: {assist_button!r}")
            if (
                assist_button.get("pathHidden") is not False
                or assist_button.get("pathTargetCount") != 25
                or assist_button.get("nextHidden") is not False
            ):
                raise WebDriverError(f"route assist button did not expose route path controls: {assist_button!r}")
            if "후보 진행" not in str(assist_button.get("title") or ""):
                raise WebDriverError(f"route assist button title did not expose active mode: {assist_button!r}")
            if "trialTransitions=routeAssist" not in str(assist_button.get("search") or ""):
                raise WebDriverError(f"route assist button did not persist mode in URL: {assist_button!r}")

            normal_route_params = urlencode({
                "map": "map1_02b",
                "startTile": "11,12",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            normal_route_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{normal_route_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": normal_route_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.8)
            normal_route_before = execute_js(port, session_id, canvas_checksum_script())
            execute_js(port, session_id, tap_action_button_script())
            wait_for_map_value(port, session_id, "map1_01a")
            normal_route_after = execute_js(port, session_id, canvas_checksum_script())
            normal_route_search = str(execute_js(port, session_id, current_search_script()))
            if normal_route_after == normal_route_before:
                raise WebDriverError("confirmed normal transition did not change the rendered scene")
            if "map=map1_01a" not in normal_route_search or "startTile=11%2C11" not in normal_route_search:
                raise WebDriverError(f"confirmed normal transition did not persist current map/tile in URL: {normal_route_search!r}")

            source_assist_button = execute_js(port, session_id, click_route_assist_button_script())
            if (
                not source_assist_button.get("active")
                or source_assist_button.get("map") != "map1_01a"
                or source_assist_button.get("frontierHidden") is not False
                or source_assist_button.get("selectedTarget") != "map2_02d"
                or "map2_02d" not in str(source_assist_button.get("title") or "")
                or "trialTransitions=routeAssist" not in str(source_assist_button.get("search") or "")
                or "transitionTarget=map2_02d" not in str(source_assist_button.get("search") or "")
                or "focusTile=18%2C0" not in str(source_assist_button.get("search") or "")
            ):
                raise WebDriverError(f"route assist button did not activate first map1_01a frontier: {source_assist_button!r}")

            route_goal_params = urlencode({
                "map": "map1_02b",
                "startTile": "11,12",
                "trialTransitions": "routeAssist",
                "routeGoal": "map2_18d",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            route_goal_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{route_goal_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": route_goal_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.6)
            route_goal_before = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_goal_before.get("map") != "map1_02b"
                or route_goal_before.get("selectedGoal") != "map2_18d"
                or route_goal_before.get("nextHidden")
                or route_goal_before.get("nextDisabled")
                or route_goal_before.get("nextText") != "다음"
                or route_goal_before.get("targetCount") != 25
                or "map5_40f" not in (route_goal_before.get("targets") or [])
                or "map1_02b" not in (route_goal_before.get("targets") or [])
                or "map1_02b -> map1_01a" not in str(route_goal_before.get("nextTitle") or "")
            ):
                raise WebDriverError(f"routeGoal path did not select the first confirmed step: {route_goal_before!r}")
            route_goal_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if not route_goal_click.get("ok") or route_goal_click.get("before", {}).get("disabled"):
                raise WebDriverError(f"routeGoal next button was not clickable: {route_goal_click!r}")
            wait_for_map_value(port, session_id, "map1_01a", timeout=5)
            route_goal_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            route_goal_search = str(execute_js(port, session_id, current_search_script()))
            if (
                route_goal_after.get("map") != "map1_01a"
                or route_goal_after.get("selectedGoal") != "map2_18d"
                or route_goal_after.get("nextHidden")
                or route_goal_after.get("nextDisabled")
                or route_goal_after.get("nextText") != "후보"
                or "map1_01a -> map2_02d" not in str(route_goal_after.get("nextTitle") or "")
                or "map=map1_01a" not in route_goal_search
                or "startTile=11%2C11" not in route_goal_search
                or "trialTransitions=routeAssist" not in route_goal_search
                or "routeGoal=map2_18d" not in route_goal_search
            ):
                raise WebDriverError(f"routeGoal next button did not follow the confirmed edge: {route_goal_after!r} search={route_goal_search!r}")
            route_goal_blocker_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if not route_goal_blocker_click.get("ok") or route_goal_blocker_click.get("before", {}).get("disabled"):
                raise WebDriverError(f"routeGoal blocker button was not clickable: {route_goal_blocker_click!r}")
            route_goal_blocker_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map1_01a",
                    "startTile=18%2C0",
                    "focusTile=18%2C0",
                    "trialTransitions=routeAssist",
                    "transitionTarget=map2_02d",
                    "routeGoal=map2_18d",
                ],
                timeout=5,
            )
            route_goal_blocker_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_goal_blocker_after.get("map") != "map1_01a"
                or route_goal_blocker_after.get("selectedGoal") != "map2_18d"
                or route_goal_blocker_after.get("nextText") != "진입"
                or "map1_01a -> map2_02d" not in str(route_goal_blocker_after.get("nextTitle") or "")
            ):
                raise WebDriverError(
                    f"routeGoal blocker focus did not preserve the selected trial step: {route_goal_blocker_after!r}"
                )
            route_goal_target_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if (
                not route_goal_target_click.get("ok")
                or route_goal_target_click.get("before", {}).get("disabled")
                or route_goal_target_click.get("before", {}).get("text") != "진입"
            ):
                raise WebDriverError(f"routeGoal blocker target button was not clickable: {route_goal_target_click!r}")
            wait_for_map_value(port, session_id, "map2_02d", timeout=5)
            route_goal_target_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map2_02d",
                    "startTile=47%2C47",
                    "trialTransitions=routeAssist",
                    "routeGoal=map2_18d",
                ],
                timeout=5,
            )
            route_goal_target_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_goal_target_after.get("map") != "map2_02d"
                or route_goal_target_after.get("selectedGoal") != "map2_18d"
                or route_goal_target_after.get("nextText") != "후보"
                or "map2_02d -> map2_18d" not in str(route_goal_target_after.get("nextTitle") or "")
            ):
                raise WebDriverError(f"routeGoal target step did not select the post-blocker candidate: {route_goal_target_after!r}")
            route_goal_candidate_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if (
                not route_goal_candidate_click.get("ok")
                or route_goal_candidate_click.get("before", {}).get("disabled")
                or route_goal_candidate_click.get("before", {}).get("text") != "후보"
            ):
                raise WebDriverError(f"routeGoal post-blocker candidate button was not clickable: {route_goal_candidate_click!r}")
            route_goal_candidate_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map2_02d",
                    "trialTransitions=routeAssist",
                    "transitionTarget=map2_18d",
                    "routeGoal=map2_18d",
                ],
                timeout=5,
            )
            route_goal_candidate_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_goal_candidate_after.get("map") != "map2_02d"
                or route_goal_candidate_after.get("selectedGoal") != "map2_18d"
                or route_goal_candidate_after.get("nextText") != "진입"
                or "map2_02d -> map2_18d" not in str(route_goal_candidate_after.get("nextTitle") or "")
            ):
                raise WebDriverError(f"routeGoal post-blocker focus did not preserve the candidate: {route_goal_candidate_after!r}")
            route_goal_finish_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if (
                not route_goal_finish_click.get("ok")
                or route_goal_finish_click.get("before", {}).get("disabled")
                or route_goal_finish_click.get("before", {}).get("text") != "진입"
            ):
                raise WebDriverError(f"routeGoal finish button was not clickable: {route_goal_finish_click!r}")
            wait_for_map_value(port, session_id, "map2_18d", timeout=5)
            route_goal_finish_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map2_18d",
                    "trialTransitions=routeAssist",
                    "routeGoal=map2_18d",
                ],
                timeout=5,
            )
            route_goal_finish_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            route_goal_finish_next_text = route_goal_finish_after.get("nextText")
            if (
                route_goal_finish_after.get("map") != "map2_18d"
                or route_goal_finish_after.get("selectedGoal") != "map2_18d"
                or route_goal_finish_after.get("nextDisabled") is not True
                or route_goal_finish_next_text not in {"도착", "완료"}
            ):
                raise WebDriverError(f"routeGoal finish did not land on the selected route goal: {route_goal_finish_after!r}")

            route_return_params = urlencode({
                "map": "map1_01a",
                "startTile": "11,11",
                "trialTransitions": "routeAssist",
                "routeGoal": "map1_02b",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            route_return_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{route_return_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": route_return_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.6)
            route_return_before = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_return_before.get("map") != "map1_01a"
                or route_return_before.get("selectedGoal") != "map1_02b"
                or route_return_before.get("nextHidden")
                or route_return_before.get("nextDisabled")
                or route_return_before.get("nextText") != "후보"
                or "map1_01a -> map1_02b" not in str(route_return_before.get("nextTitle") or "")
            ):
                raise WebDriverError(f"route return path did not select the reciprocal candidate: {route_return_before!r}")
            route_return_focus_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if (
                not route_return_focus_click.get("ok")
                or route_return_focus_click.get("before", {}).get("disabled")
                or route_return_focus_click.get("before", {}).get("text") != "후보"
            ):
                raise WebDriverError(f"route return focus button was not clickable: {route_return_focus_click!r}")
            route_return_focus_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map1_01a",
                    "startTile=18%2C0",
                    "focusTile=18%2C0",
                    "trialTransitions=routeAssist",
                    "transitionTarget=map1_02b",
                    "routeGoal=map1_02b",
                ],
                timeout=5,
            )
            route_return_focus_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            if (
                route_return_focus_after.get("map") != "map1_01a"
                or route_return_focus_after.get("selectedGoal") != "map1_02b"
                or route_return_focus_after.get("nextText") != "진입"
                or "map1_01a -> map1_02b" not in str(route_return_focus_after.get("nextTitle") or "")
            ):
                raise WebDriverError(f"route return focus did not preserve the reciprocal candidate: {route_return_focus_after!r}")
            route_return_target_click = execute_js(port, session_id, click_route_next_button_script(), timeout=3)
            if (
                not route_return_target_click.get("ok")
                or route_return_target_click.get("before", {}).get("disabled")
                or route_return_target_click.get("before", {}).get("text") != "진입"
            ):
                raise WebDriverError(f"route return target button was not clickable: {route_return_target_click!r}")
            wait_for_map_value(port, session_id, "map1_02b", timeout=5)
            route_return_target_search = wait_for_search_contains(
                port,
                session_id,
                [
                    "map=map1_02b",
                    "startTile=58%2C95",
                    "trialTransitions=routeAssist",
                    "routeGoal=map1_02b",
                ],
                timeout=5,
            )
            route_return_target_after = execute_js(port, session_id, route_goal_next_state_script(), timeout=3)
            route_return_target_next_text = route_return_target_after.get("nextText")
            route_return_arrived = (
                route_return_target_after.get("nextDisabled") is True
                and route_return_target_next_text in {"도착", "완료"}
            )
            route_return_continuation = (
                route_return_target_after.get("nextDisabled") is False
                and route_return_target_next_text == "이어가기"
                and "map1_02b -> map1_01a" in str(route_return_target_after.get("nextTitle") or "")
            )
            if (
                route_return_target_after.get("map") != "map1_02b"
                or route_return_target_after.get("selectedGoal") != "map1_02b"
                or not (route_return_arrived or route_return_continuation)
            ):
                raise WebDriverError(f"route return did not land on the selected return goal: {route_return_target_after!r}")

            battle_params = urlencode({
                "map": "map1_02b",
                "party": "rinshan,smash",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            battle_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{battle_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": battle_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.8)
            battle_button_click = execute_js(port, session_id, click_battle_button_script(), timeout=3)
            if (
                not battle_button_click.get("ok")
                or battle_button_click.get("before", {}).get("hidden")
                or "전투" not in str(battle_button_click.get("before", {}).get("text") or "")
            ):
                raise WebDriverError(f"battle button was not ready for the current map: {battle_button_click!r}")
            wait_for_battle_scene(port, session_id)
            battle_before = execute_js(port, session_id, canvas_checksum_script())
            if battle_before == 0:
                raise WebDriverError("battle scene rendered a blank canvas")
            battle_party = execute_js(port, session_id, battle_party_state_script())
            assert_player_battle_text_table((battle_party.get("commandDetails") or [{}])[0], "atahoActions", 0)
            if (
                battle_party.get("scene") != "battle"
                or battle_party.get("names") != ["Ataho", "Rinshan", "Smashu"]
                or battle_party.get("activeName") != "Ataho"
                or (battle_party.get("commands") or [None])[0] != "정권"
            ):
                raise WebDriverError(f"battle scene did not use manual party members: {battle_party!r}")
            execute_js(port, session_id, tap_action_button_script())
            time.sleep(0.4)
            battle_after = execute_js(port, session_id, canvas_checksum_script())
            if battle_after == battle_before:
                raise WebDriverError("battle A-button action did not change the rendered scene")
            battle_after_state = execute_js(port, session_id, battle_party_state_script())
            normal_turn_auto = battle_after_state.get("turnAutoSave") or {}
            assert_player_battle_text_table(normal_turn_auto, "atahoActions", 0, prefix="command")
            assert_player_battle_text_table((battle_after_state.get("commandDetails") or [{}])[0], "skillNamesB", 0)
            normal_turn_payload = battle_after_state.get("savedPayload") or {}
            normal_turn_counts = ((normal_turn_payload.get("prototypeProgress") or {}).get("counts") or {})
            normal_turn_ataho = next(
                (row for row in ((normal_turn_payload.get("runtimeState") or {}).get("characters") or []) if row.get("name") == "Ataho"),
                {},
            )
            normal_turn_ataho_after = next(
                (row for row in (battle_after_state.get("partyHp") or []) if row.get("name") == "Ataho"),
                {},
            )
            if (
                battle_after_state.get("activeName") != "Rinshan"
                or battle_after_state.get("enemyHp", 999) >= battle_party.get("enemyHp", 0)
                or (battle_after_state.get("commands") or [None])[0] != "유미쌍조"
                or normal_turn_auto.get("saved") is not True
                or normal_turn_auto.get("source") != "battle-turn-prototype"
                or normal_turn_auto.get("scope") != "battle-turn"
                or normal_turn_auto.get("commandName") != "정권"
                or normal_turn_auto.get("targetName") != "Ataho"
                or normal_turn_auto.get("targetHpAfter") != normal_turn_ataho_after.get("hp")
                or (normal_turn_counts.get("battle-turn-prototype") or 0) < 1
                or normal_turn_ataho.get("hp") != normal_turn_ataho_after.get("hp")
            ):
                raise WebDriverError(f"battle A-button action did not advance party turn: {battle_after_state!r}")
            battle_finish = execute_js(port, session_id, finish_battle_state_script())
            if (
                not battle_finish.get("finished")
                or not battle_finish.get("rewardGranted")
                or battle_finish.get("enemyHp") != 0
                or not any("보상 소지금 +" in str(line) for line in battle_finish.get("log") or [])
                or "승리했다." not in [str(line) for line in battle_finish.get("log") or []]
            ):
                raise WebDriverError(f"battle attack loop did not grant victory reward: {battle_finish!r}")
            battle_sound_state = execute_js(port, session_id, sound_state_script())
            battle_sound_counts = (battle_sound_state or {}).get("counts") or {}
            if (
                int(battle_sound_counts.get("battleStart") or 0) < 1
                or int(battle_sound_counts.get("battleHit") or 0) < 1
                or int(battle_sound_counts.get("victory") or 0) < 1
            ):
                raise WebDriverError(f"battle start/hit/victory did not request WLK sounds: {battle_sound_state!r}")
            battle_save = execute_js(port, session_id, battle_reward_quick_save_script())
            battle_save_counts = ((battle_save.get("progress") or {}).get("counts") or {})
            if (
                not battle_save.get("canSave")
                or not battle_save.get("saved")
                or battle_save.get("source") != "battle-victory"
                or battle_save.get("savedMoney") != battle_save.get("before")
                or battle_save.get("savedMoney", 0) <= 0
                or battle_save.get("savedScene") != "battle"
                or battle_save.get("payloadManualParty", {}).get("key") != "rinshan,smash"
                or battle_save.get("payloadManualParty", {}).get("members") != ["rinshan", "smash"]
                or battle_save_counts.get("battle-victory") != 1
            ):
                raise WebDriverError(f"finished battle auto-save did not store reward state: {battle_save!r}")
            execute_js(port, session_id, tap_action_button_script())
            time.sleep(0.4)
            battle_exit = execute_js(port, session_id, scene_state_script())
            if (
                battle_exit.get("scene") != "map"
                or battle_exit.get("map") != "map1_02b"
                or battle_exit.get("battleActive")
                or battle_exit.get("notice") != "전투 종료"
                or battle_exit.get("money") != 0
            ):
                raise WebDriverError(f"finished battle A-button did not return to map with pending reward state: {battle_exit!r}")
            execute_js(port, session_id, start_quick_load_money_script())
            quick_load_money = None
            deadline = time.monotonic() + 4
            while time.monotonic() < deadline:
                quick_load_money = execute_js(port, session_id, quick_load_money_script())
                if quick_load_money is not None:
                    break
                time.sleep(0.15)
            if (
                not isinstance(quick_load_money, dict)
                or quick_load_money.get("money") != battle_save.get("savedMoney")
                or quick_load_money.get("scene") != "map"
                or quick_load_money.get("map") != "map1_02b"
                or quick_load_money.get("activeLabels") != ["Rinshan", "Smashu"]
                or "party=rinshan%2Csmash" not in str(quick_load_money.get("search") or "")
            ):
                raise WebDriverError(f"quick load did not restore reward money: saved={battle_save!r} loaded={quick_load_money!r}")

            savedat_battle_params = urlencode({
                "publicSave": "hhe-debug-savedat2",
                "battle": "1",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            savedat_battle_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{savedat_battle_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_battle_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_battle = wait_for_battle_scene(port, session_id)
            item_counts = dict(savedat_battle.get("itemCounts") or [])
            actor_base_stats = savedat_battle.get("actorBaseStats") or {}
            actor_hp_max = savedat_battle.get("actorHpMax")
            actor_incoming_damage = savedat_battle.get("actorIncomingDamage")
            skill_summary = savedat_battle.get("actorSkillSummary") or {}
            individual_entries = (skill_summary.get("individual") or {}).get("entries") or []
            group_entries = (skill_summary.get("group") or {}).get("entries") or []
            skill_commands = savedat_battle.get("actorSkillCommands") or []
            assert_player_battle_text_table(savedat_battle.get("actorAttackCommand") or {}, "atahoActions", 0)
            for row, table, text_index in zip(
                skill_commands[:2],
                ["atahoActions", "atahoActions"],
                [1, 2],
            ):
                assert_player_battle_text_table(row, table, text_index)
            if (
                savedat_battle.get("scene") != "battle"
                or savedat_battle.get("names") != ["Ataho", "Rinshan", "Smashu"]
                or "약초" not in (savedat_battle.get("commands") or [])
                or "돌려차기" not in (savedat_battle.get("commands") or [])
                or "던지기" not in (savedat_battle.get("commands") or [])
                or not individual_entries
                or individual_entries[0].get("skillIndex") != 0
                or individual_entries[0].get("level") != 1
                or individual_entries[0].get("valueHex") != "0x0a"
                or not group_entries
                or group_entries[0].get("skillIndex") != 0
                or group_entries[0].get("level") != 1
                or group_entries[0].get("valueHex") != "0x23"
                or [command.get("skillBlock") for command in skill_commands[:2]] != ["individual", "group"]
                or [command.get("skillIndex") for command in skill_commands[:2]] != [0, 0]
                or item_counts.get("약초") != 3
                or savedat_battle.get("money") != 200
                or actor_base_stats.get("atk") != 22
                or actor_base_stats.get("def") != 7
                or not isinstance(savedat_battle.get("actorAttackDamage"), int)
                or savedat_battle.get("actorAttackDamage") <= 7
                or not isinstance(savedat_battle.get("actorSkillDamage"), int)
                or savedat_battle.get("actorSkillDamage") <= savedat_battle.get("actorAttackDamage")
                or savedat_battle.get("actorSkillMpCost") != 2
                or not isinstance(actor_incoming_damage, int)
                or actor_incoming_damage < 1
                or (isinstance(actor_hp_max, int) and actor_incoming_damage >= actor_hp_max)
            ):
                raise WebDriverError(f"public savedat battle did not expose savedat party/items: {savedat_battle!r}")
            savedat_party_skills = execute_js(port, session_id, savedat_party_skill_state_script())
            party_skill_rows = {row.get("name"): row.get("skills") or [] for row in savedat_party_skills.get("members") or []}
            rinshan_skills = party_skill_rows.get("Rinshan") or []
            smashu_skills = party_skill_rows.get("Smashu") or []
            expected_rinshan_skills = [
                ("장궁", "individual", 0, 1, "0x09", 3, "skillNamesB", 7),
                ("충격파", "group", 0, 1, "0x1c", 4, "skillNamesB", 8),
            ]
            expected_smashu_skills = [
                ("마구베기", "individual", 0, 1, "0x05", 2, "actionSetB", 1),
                ("투구쪼개기", "group", 0, 1, "0x14", 3, "skillNamesB", 2),
            ]
            for rows, expected, label in (
                (rinshan_skills, expected_rinshan_skills, "Rinshan"),
                (smashu_skills, expected_smashu_skills, "Smashu"),
            ):
                if len(rows) < len(expected):
                    raise WebDriverError(f"{label} savedat skills are missing: {savedat_party_skills!r}")
                for row, (name, block, index, level, value_hex, mp_cost, table, text_index) in zip(rows, expected):
                    assert_player_battle_text_table(row, table, text_index)
                    if (
                        row.get("name") != name
                        or row.get("skillBlock") != block
                        or row.get("skillIndex") != index
                        or row.get("skillLevel") != level
                        or row.get("skillValueHex") != value_hex
                        or row.get("mpCost") != mp_cost
                        or not isinstance(row.get("damage"), int)
                        or row.get("damage") <= 0
                    ):
                        raise WebDriverError(f"{label} savedat skill command mismatch: {savedat_party_skills!r}")
            party_skill_summary = (
                f"Rinshan={','.join(row.get('name', '') for row in rinshan_skills[:2])}; "
                f"Smashu={','.join(row.get('name', '') for row in smashu_skills[:2])}"
            )
            savedat_party_skill_exec = execute_js(port, session_id, use_savedat_party_skill_execution_script())
            if not savedat_party_skill_exec.get("ok"):
                raise WebDriverError(f"public savedat party skill execution failed: {savedat_party_skill_exec!r}")
            for result_key, expected in (
                ("rinshan", ("Rinshan", "장궁", "individual", 0, 1, "0x09", 3, "Smashu", "skillNamesB", 7)),
                ("smashu", ("Smashu", "투구쪼개기", "group", 0, 1, "0x14", 3, "Ataho", "skillNamesB", 2)),
            ):
                result = savedat_party_skill_exec.get(result_key) or {}
                exec_before = result.get("before") or {}
                exec_after = result.get("after") or {}
                actor, name, block, index, level, value_hex, mp_cost, next_actor, table, text_index = expected
                assert_player_battle_text_table(exec_before, table, text_index)
                line = f"{actor} {name} {exec_before.get('damage')} MP-{mp_cost}"
                logs = [str(line) for line in result.get("log") or []]
                if (
                    not result.get("ok")
                    or exec_before.get("actor") != actor
                    or exec_before.get("command") != name
                    or exec_before.get("skillBlock") != block
                    or exec_before.get("skillIndex") != index
                    or exec_before.get("skillLevel") != level
                    or exec_before.get("skillValueHex") != value_hex
                    or exec_before.get("mpCost") != mp_cost
                    or exec_after.get("mp") != exec_before.get("mp", 0) - mp_cost
                    or exec_after.get("sourceMp") != exec_after.get("mp")
                    or exec_after.get("enemyHp") != exec_before.get("enemyHp", 0) - exec_before.get("damage", 0)
                    or line not in logs
                    or result.get("nextActor") != next_actor
                ):
                    raise WebDriverError(f"public savedat {actor} skill did not execute correctly: {savedat_party_skill_exec!r}")
            savedat_skill = execute_js(port, session_id, use_savedat_battle_skill_script())
            savedat_skill_log = [str(line) for line in savedat_skill.get("log") or []]
            skill_before = savedat_skill.get("before", {})
            skill_after = savedat_skill.get("after", {})
            skill_auto_save = savedat_skill.get("autoSave") or {}
            skill_turn_auto = savedat_skill.get("turnAutoSave") or {}
            skill_saved_payload = savedat_skill.get("savedPayload") or {}
            skill_saved_progress_counts = ((skill_saved_payload.get("prototypeProgress") or {}).get("counts") or {})
            skill_saved_actor = savedat_skill.get("savedActor") or {}
            skill_objective = savedat_skill.get("objectiveBefore") or {}
            skill_objective_action = savedat_skill.get("objectiveAction") or {}
            skill_objective_notice = savedat_skill.get("objectiveCompletionNotice") or {}
            skill_notice_lines = [str(line) for line in skill_objective_notice.get("lines") or []]
            skill_line = f"{skill_before.get('actor')} {skill_before.get('command')} {skill_before.get('damage')} MP-{skill_before.get('mpCost')}"
            skill_feedback_text = f"{skill_before.get('command')} -{skill_before.get('damage')} / MP -{skill_before.get('mpCost')}"
            skill_notice_feedback_text = "전투 기술 완료 1/1"
            skill_feedback = battle_skill_feedback_match(
                savedat_skill,
                source="battle-skill-use",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=skill_feedback_text,
            )
            skill_feedback_render = battle_skill_feedback_match(
                savedat_skill,
                source="battle-skill-use",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=skill_feedback_text,
                rendered=True,
            )
            skill_notice_feedback = battle_skill_feedback_match(
                savedat_skill,
                source="battle-skill-completion-notice-feedback",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=skill_notice_feedback_text,
            )
            skill_notice_feedback_render = battle_skill_feedback_match(
                savedat_skill,
                source="battle-skill-completion-notice-feedback",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=skill_notice_feedback_text,
                rendered=True,
            )
            assert_player_battle_text_table(skill_before, "atahoActions", 1)
            assert_player_battle_text_table(skill_feedback, "atahoActions", 1, prefix="skill")
            assert_player_battle_text_table(skill_auto_save, "atahoActions", 1, prefix="skill")
            assert_player_battle_text_table(skill_turn_auto, "atahoActions", 1, prefix="command")
            verify_battle_skill_feedback_sound(skill_feedback, "battleHit", "../extract_wlk/09.wav")
            verify_battle_skill_feedback_sound(skill_feedback_render, "battleHit", "../extract_wlk/09.wav")
            verify_battle_skill_feedback_sound(skill_notice_feedback, "menuConfirm", "../extract_wlk/04.wav")
            verify_battle_skill_feedback_sound(skill_notice_feedback_render, "menuConfirm", "../extract_wlk/04.wav")
            if (
                not savedat_skill.get("ok")
                or skill_before.get("command") != "돌려차기"
                or skill_before.get("skillBlock") != "individual"
                or skill_before.get("skillIndex") != 0
                or skill_before.get("skillLevel") != 1
                or skill_before.get("skillValueHex") != "0x0a"
                or skill_before.get("mpCost") != savedat_battle.get("actorSkillMpCost")
                or skill_after.get("mp") != skill_before.get("mp", 0) - skill_before.get("mpCost", 0)
                or skill_after.get("sourceMp") != skill_after.get("mp")
                or skill_after.get("enemyHp") != skill_before.get("enemyHp", 0) - skill_before.get("damage", 0)
                or skill_line not in savedat_skill_log
                or savedat_skill.get("nextActor") != "Rinshan"
                or skill_feedback.get("browserBattleSkillFeedbackImplemented") is not True
                or skill_feedback.get("prototypeSkillUseImplemented") is not True
                or skill_feedback.get("originalSkillFormulaImplemented") is not False
                or skill_feedback.get("originalCombatFormulaImplemented") is not False
                or skill_feedback.get("originalStoryFlagRuntimeImplemented") is not False
                or skill_feedback.get("mpBefore") != skill_before.get("mp")
                or skill_feedback.get("mpAfter") != skill_after.get("mp")
                or skill_feedback.get("mpCost") != skill_before.get("mpCost")
                or skill_feedback.get("enemyHpBefore") != skill_before.get("enemyHp")
                or skill_feedback.get("enemyHpAfter") != skill_after.get("enemyHp")
                or skill_feedback.get("damage") != skill_before.get("damage")
                or skill_feedback.get("durationMs") != 900
                or skill_feedback_render.get("browserBattleSkillFeedbackImplemented") is not True
                or skill_feedback_render.get("text") != skill_feedback_text
                or skill_auto_save.get("saved") is not True
                or skill_auto_save.get("source") != "battle-skill-prototype"
                or skill_auto_save.get("scope") != "battle-skill"
                or skill_auto_save.get("skillName") != "돌려차기"
                or skill_auto_save.get("actorName") != "Ataho"
                or skill_auto_save.get("mpBefore") != skill_before.get("mp")
                or skill_auto_save.get("mpAfter") != skill_after.get("mp")
                or skill_auto_save.get("enemyHpBefore") != skill_before.get("enemyHp")
                or skill_auto_save.get("enemyHpAfter") != skill_after.get("enemyHp")
                or skill_auto_save.get("payloadMap") != skill_saved_payload.get("map")
                or (skill_saved_progress_counts.get("battle-skill-prototype") or 0) < 1
                or skill_turn_auto.get("saved") is not True
                or skill_turn_auto.get("source") != "battle-turn-prototype"
                or skill_turn_auto.get("scope") != "battle-turn"
                or skill_turn_auto.get("commandName") != "돌려차기"
                or skill_turn_auto.get("targetName") != "Ataho"
                or skill_turn_auto.get("targetHpAfter") != skill_after.get("hp")
                or (skill_saved_progress_counts.get("battle-turn-prototype") or 0) < 1
                or skill_saved_actor.get("mp") != skill_after.get("mp")
                or skill_saved_actor.get("hp") != skill_after.get("hp")
                or skill_objective.get("title") != "후보 전투 기술 완료 btl_a1"
                or skill_objective.get("source") != "prototype-battle-skill-completion"
                or skill_objective.get("nextAction") != "완료 알림 확인"
                or "Ataho" not in str(skill_objective.get("detail") or "")
                or "돌려차기" not in str(skill_objective.get("detail") or "")
                or savedat_skill.get("objectiveResult") is not True
                or skill_objective_action.get("action") != "battle-skill-completion-notice"
                or skill_objective_action.get("handled") is not True
                or skill_objective_action.get("activeId") != "battle-skill-complete:map1_01a"
                or skill_objective_notice.get("blockId") != "battle-skill-complete:map1_01a"
                or not any("전투 기술 완료 1/1" in line for line in skill_notice_lines)
                or not any("btl_a1" in line for line in skill_notice_lines)
                or not any("Ataho 돌려차기" in line for line in skill_notice_lines)
                or not any(f"MP {skill_before.get('mp')}->{skill_after.get('mp')}" in line for line in skill_notice_lines)
                or not any(f"{skill_auto_save.get('enemyName')} HP {skill_before.get('enemyHp')}->{skill_after.get('enemyHp')}" in line for line in skill_notice_lines)
                or not any(f"피해 {skill_before.get('damage')}" in line for line in skill_notice_lines)
                or skill_notice_feedback.get("browserBattleSkillFeedbackImplemented") is not True
                or skill_notice_feedback.get("prototypeSkillUseImplemented") is not True
                or skill_notice_feedback.get("source") != "battle-skill-completion-notice-feedback"
                or skill_notice_feedback.get("text") != skill_notice_feedback_text
                or skill_notice_feedback.get("actorName") != "Ataho"
                or skill_notice_feedback.get("skillName") != "돌려차기"
                or skill_notice_feedback.get("mpBefore") != skill_before.get("mp")
                or skill_notice_feedback.get("mpAfter") != skill_after.get("mp")
                or skill_notice_feedback.get("mpCost") != skill_before.get("mpCost")
                or skill_notice_feedback.get("targetName") != skill_auto_save.get("enemyName")
                or skill_notice_feedback.get("enemyName") != skill_auto_save.get("enemyName")
                or skill_notice_feedback.get("enemyHpBefore") != skill_before.get("enemyHp")
                or skill_notice_feedback.get("enemyHpAfter") != skill_after.get("enemyHp")
                or skill_notice_feedback.get("damage") != skill_before.get("damage")
                or skill_notice_feedback.get("originalSkillFormulaImplemented") is not False
                or skill_notice_feedback.get("originalCombatFormulaImplemented") is not False
                or skill_notice_feedback.get("originalStoryFlagRuntimeImplemented") is not False
                or skill_notice_feedback_render.get("browserBattleSkillFeedbackImplemented") is not True
                or skill_notice_feedback_render.get("text") != skill_notice_feedback_text
            ):
                raise WebDriverError(f"public savedat battle skill command did not consume MP/damage enemy: {savedat_skill!r}")
            skill_title_continue_start = execute_js(
                port,
                session_id,
                start_savedat_battle_skill_title_continue_script(),
                timeout=3,
            )
            skill_title_continue_restore = wait_for_savedat_battle_skill_title_continue(port, session_id)
            skill_title_state = skill_title_continue_restore.get("titleState") or {}
            skill_title_labels = [str(label) for label in skill_title_state.get("titleMenuLabels") or []]
            skill_title_objective = skill_title_continue_restore.get("objectiveBefore") or {}
            skill_title_action = skill_title_continue_restore.get("objectiveAction") or {}
            skill_title_notice = skill_title_continue_restore.get("objectiveCompletionNotice") or {}
            skill_title_notice_lines = [str(line) for line in skill_title_notice.get("lines") or []]
            skill_title_counts = skill_title_continue_restore.get("progressCounts") or {}
            skill_title_completion = skill_title_continue_restore.get("battleSkill") or {}
            if (
                not isinstance(skill_title_continue_start, dict)
                or skill_title_continue_start.get("ok") is not True
                or (skill_title_continue_start.get("titleState") or {}).get("quickLoadText") != "이어하기"
                or not any("이어하기 map1_01a 1,2" in label for label in skill_title_labels)
                or skill_title_continue_restore.get("ok") is not True
                or skill_title_continue_restore.get("titleContinue") is not True
                or skill_title_continue_restore.get("quickLoadText") != "임시 불러오기"
                or skill_title_continue_restore.get("map") != "map1_01a"
                or (skill_title_counts.get("battle-skill-prototype") or 0) < 1
                or skill_title_completion.get("completed") is not True
                or skill_title_completion.get("skillName") != "돌려차기"
                or skill_title_completion.get("mpAfter") != skill_after.get("mp")
                or skill_title_completion.get("enemyHpAfter") != skill_after.get("enemyHp")
                or skill_title_objective.get("title") != "후보 전투 기술 완료 btl_a1"
                or skill_title_objective.get("source") != "prototype-battle-skill-completion"
                or skill_title_objective.get("nextAction") != "완료 알림 확인"
                or skill_title_continue_restore.get("objectiveResult") is not True
                or skill_title_action.get("action") != "battle-skill-completion-notice"
                or skill_title_action.get("handled") is not True
                or skill_title_action.get("activeId") != "battle-skill-complete:map1_01a"
                or skill_title_notice.get("blockId") != "battle-skill-complete:map1_01a"
                or not any("전투 기술 완료 1/1" in line for line in skill_title_notice_lines)
                or not any("Ataho 돌려차기" in line for line in skill_title_notice_lines)
                or not any(f"MP {skill_before.get('mp')}->{skill_after.get('mp')}" in line for line in skill_title_notice_lines)
                or not any(f"{skill_auto_save.get('enemyName')} HP {skill_before.get('enemyHp')}->{skill_after.get('enemyHp')}" in line for line in skill_title_notice_lines)
            ):
                raise WebDriverError(
                    "public savedat battle skill title continue did not restore completed objective: "
                    f"start={skill_title_continue_start!r} restore={skill_title_continue_restore!r}"
                )
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_battle_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_battle = wait_for_battle_scene(port, session_id)
            savedat_skill_no_mp = execute_js(port, session_id, use_savedat_battle_skill_no_mp_script())
            savedat_skill_no_mp_log = [str(line) for line in savedat_skill_no_mp.get("log") or []]
            no_mp_before = savedat_skill_no_mp.get("before", {})
            no_mp_after = savedat_skill_no_mp.get("after", {})
            no_mp_feedback_text = f"{no_mp_before.get('command')} MP 부족"
            no_mp_feedback = battle_skill_feedback_match(
                savedat_skill_no_mp,
                source="battle-skill-no-mp",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=no_mp_feedback_text,
            )
            no_mp_feedback_render = battle_skill_feedback_match(
                savedat_skill_no_mp,
                source="battle-skill-no-mp",
                actor_name="Ataho",
                skill_name="돌려차기",
                text=no_mp_feedback_text,
                rendered=True,
            )
            assert_player_battle_text_table(no_mp_before, "atahoActions", 1)
            assert_player_battle_text_table(no_mp_feedback, "atahoActions", 1, prefix="skill")
            verify_battle_skill_feedback_sound(no_mp_feedback, "menuConfirm", "../extract_wlk/04.wav")
            verify_battle_skill_feedback_sound(no_mp_feedback_render, "menuConfirm", "../extract_wlk/04.wav")
            if (
                not savedat_skill_no_mp.get("ok")
                or no_mp_before.get("command") != "돌려차기"
                or no_mp_before.get("mpCost") != savedat_battle.get("actorSkillMpCost")
                or no_mp_before.get("mp") >= no_mp_before.get("mpCost", 0)
                or no_mp_before.get("sourceMp") != no_mp_before.get("mp")
                or no_mp_after.get("mp") != no_mp_before.get("mp")
                or no_mp_after.get("sourceMp") != no_mp_before.get("sourceMp")
                or no_mp_after.get("enemyHp") != no_mp_before.get("enemyHp")
                or "Ataho MP가 부족하다." not in savedat_skill_no_mp_log
                or savedat_skill_no_mp.get("nextActor") != "Ataho"
                or no_mp_feedback.get("browserBattleSkillFeedbackImplemented") is not True
                or no_mp_feedback.get("prototypeSkillUseImplemented") is not True
                or no_mp_feedback.get("failed") is not True
                or no_mp_feedback.get("insufficientMp") is not True
                or no_mp_feedback.get("originalSkillFormulaImplemented") is not False
                or no_mp_feedback.get("originalCombatFormulaImplemented") is not False
                or no_mp_feedback.get("originalStoryFlagRuntimeImplemented") is not False
                or no_mp_feedback.get("mpBefore") != no_mp_before.get("mp")
                or no_mp_feedback.get("mpAfter") != no_mp_before.get("mp")
                or no_mp_feedback.get("mpCost") != no_mp_before.get("mpCost")
                or no_mp_feedback.get("enemyHpBefore") != no_mp_before.get("enemyHp")
                or no_mp_feedback.get("enemyHpAfter") != no_mp_before.get("enemyHp")
                or no_mp_feedback.get("damage") != 0
                or no_mp_feedback.get("durationMs") != 900
                or no_mp_feedback_render.get("browserBattleSkillFeedbackImplemented") is not True
                or no_mp_feedback_render.get("text") != no_mp_feedback_text
            ):
                raise WebDriverError(f"public savedat battle skill no-MP guard mutated state: {savedat_skill_no_mp!r}")
            savedat_item = execute_js(port, session_id, use_savedat_battle_item_script())
            savedat_item_log = [str(line) for line in savedat_item.get("log") or []]
            savedat_item_hp_max = savedat_item.get("before", {}).get("hpMax")
            savedat_item_enemy_damage = savedat_battle.get("actorIncomingDamage")
            savedat_item_expected_after_hp = (
                savedat_item_hp_max - savedat_item_enemy_damage
                if isinstance(savedat_item_hp_max, int) and isinstance(savedat_item_enemy_damage, int)
                else None
            )
            savedat_item_heal_line = f"약초 Ataho HP {savedat_item_hp_max}/{savedat_item_hp_max}"
            savedat_item_marker = savedat_item.get("marker") or {}
            savedat_item_progress_detail = ((savedat_item_marker.get("progressEvent") or {}).get("detail") or {})
            savedat_item_auto = savedat_item.get("autoSave") or {}
            savedat_item_auto_counts = ((savedat_item_auto.get("progress") or {}).get("counts") or {})
            savedat_item_auto_runtime = savedat_item_auto.get("runtimeState") or {}
            savedat_item_auto_ataho = next(
                (row for row in (savedat_item_auto_runtime.get("characters") or []) if row.get("name") == "Ataho"),
                {},
            )
            savedat_item_auto_herb = next(
                (row for row in (savedat_item_auto_runtime.get("items") or []) if row.get("name") == "약초"),
                {},
            )
            savedat_item_turn_auto = savedat_item.get("turnAutoSave") or {}
            savedat_item_payload = savedat_item.get("savedPayload") or {}
            savedat_item_payload_counts = ((savedat_item_payload.get("prototypeProgress") or {}).get("counts") or {})
            savedat_item_payload_ataho = next(
                (row for row in ((savedat_item_payload.get("runtimeState") or {}).get("characters") or []) if row.get("name") == "Ataho"),
                {},
            )
            savedat_item_payload_herb = next(
                (row for row in ((savedat_item_payload.get("runtimeState") or {}).get("items") or []) if row.get("name") == "약초"),
                {},
            )
            savedat_item_objective = savedat_item.get("objectiveBefore") or {}
            savedat_item_objective_action = savedat_item.get("objectiveAction") or {}
            savedat_item_objective_notice = savedat_item.get("objectiveCompletionNotice") or {}
            savedat_item_notice_lines = [str(line) for line in savedat_item_objective_notice.get("lines") or []]
            savedat_item_feedback = battle_item_feedback_match(
                savedat_item,
                source="battle-item-use",
                item_key="herb",
                text="HP +20",
            )
            savedat_item_feedback_render = battle_item_feedback_match(
                savedat_item,
                source="battle-item-use",
                item_key="herb",
                text="HP +20",
                rendered=True,
            )
            verify_battle_item_feedback_sound(savedat_item_feedback)
            verify_battle_item_feedback_sound(savedat_item_feedback_render)
            if (
                not savedat_item.get("ok")
                or savedat_item.get("before", {}).get("item") != "약초"
                or savedat_item.get("after", {}).get("count") != savedat_item.get("before", {}).get("count", 0) - 1
                or savedat_item.get("after", {}).get("hp") != savedat_item_expected_after_hp
                or savedat_item_heal_line not in savedat_item_log
                or savedat_item.get("nextActor") != "Rinshan"
                or savedat_item_auto.get("saved") is not True
                or savedat_item_auto.get("source") != "item-use-prototype"
                or savedat_item_auto.get("scope") != "battle-item"
                or savedat_item_auto.get("itemName") != "약초"
                or savedat_item_auto.get("targetName") != "Ataho"
                or savedat_item_auto.get("countAfter") != savedat_item.get("after", {}).get("count")
                or savedat_item_auto.get("hpAfter") != savedat_item_hp_max
                or not item_text_provenance_matches(savedat_item_marker, "herb")
                or not item_text_provenance_matches(savedat_item_auto, "herb")
                or not item_text_provenance_matches(savedat_item_progress_detail, "herb")
                or savedat_item_feedback.get("itemName") != "약초"
                or savedat_item_feedback.get("targetName") != "Ataho"
                or savedat_item_feedback.get("hpGain") != 20
                or savedat_item_feedback.get("durationMs") != 900
                or savedat_item_feedback.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_item_feedback.get("originalItemEffectFormulaImplemented") is not False
                or not item_text_provenance_matches(savedat_item_feedback, "herb")
                or savedat_item_feedback_render.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_item_feedback_render.get("text") != "HP +20"
                or not item_text_provenance_matches(savedat_item_feedback_render, "herb")
                or not isinstance(savedat_item_auto_counts.get("item-use-prototype"), int)
                or savedat_item_auto_counts.get("item-use-prototype", 0) < 1
                or savedat_item_payload.get("map") != savedat_item_auto.get("payloadMap")
                or savedat_item_auto_ataho.get("hp") != savedat_item_hp_max
                or savedat_item_auto_herb.get("count") != savedat_item.get("after", {}).get("count")
                or savedat_item_turn_auto.get("saved") is not True
                or savedat_item_turn_auto.get("source") != "battle-turn-prototype"
                or savedat_item_turn_auto.get("scope") != "battle-turn"
                or savedat_item_turn_auto.get("commandName") != "약초"
                or not item_text_provenance_matches(savedat_item_turn_auto, "herb", prefix="command")
                or savedat_item_turn_auto.get("targetName") != "Ataho"
                or savedat_item_turn_auto.get("targetHpAfter") != savedat_item.get("after", {}).get("hp")
                or (savedat_item_payload_counts.get("battle-turn-prototype") or 0) < 1
                or savedat_item_payload_ataho.get("hp") != savedat_item.get("after", {}).get("hp")
                or savedat_item_payload_herb.get("count") != savedat_item.get("after", {}).get("count")
                or savedat_item_objective.get("title") != "후보 전투 도구 완료 btl_a1"
                or savedat_item_objective.get("source") != "prototype-battle-item-completion"
                or savedat_item_objective.get("nextAction") != "완료 알림 확인"
                or "Ataho" not in str(savedat_item_objective.get("detail") or "")
                or "약초" not in str(savedat_item_objective.get("detail") or "")
                or savedat_item.get("objectiveResult") is not True
                or savedat_item_objective_action.get("action") != "battle-item-completion-notice"
                or savedat_item_objective_action.get("handled") is not True
                or savedat_item_objective_action.get("activeId") != "battle-item-complete:map1_01a"
                or savedat_item_objective_notice.get("blockId") != "battle-item-complete:map1_01a"
                or not any("전투 도구 완료 1/1" in line for line in savedat_item_notice_lines)
                or not any("btl_a1" in line for line in savedat_item_notice_lines)
                or not any("Ataho 약초" in line for line in savedat_item_notice_lines)
                or not any("약초 3->2" in line for line in savedat_item_notice_lines)
                or not any(f"Ataho HP {savedat_item.get('before', {}).get('hp')}->{savedat_item_hp_max}" in line for line in savedat_item_notice_lines)
            ):
                raise WebDriverError(f"public savedat battle item command did not consume/heal: {savedat_item!r}")
            item_title_continue_start = execute_js(
                port,
                session_id,
                start_savedat_battle_item_title_continue_script(),
                timeout=3,
            )
            item_title_continue_restore = wait_for_savedat_battle_item_title_continue(port, session_id)
            item_title_state = item_title_continue_restore.get("titleState") or {}
            item_title_labels = [str(label) for label in item_title_state.get("titleMenuLabels") or []]
            item_title_objective = item_title_continue_restore.get("objectiveBefore") or {}
            item_title_action = item_title_continue_restore.get("objectiveAction") or {}
            item_title_notice = item_title_continue_restore.get("objectiveCompletionNotice") or {}
            item_title_notice_lines = [str(line) for line in item_title_notice.get("lines") or []]
            item_title_counts = item_title_continue_restore.get("progressCounts") or {}
            item_title_completion = item_title_continue_restore.get("battleItem") or {}
            if (
                not isinstance(item_title_continue_start, dict)
                or item_title_continue_start.get("ok") is not True
                or (item_title_continue_start.get("titleState") or {}).get("quickLoadText") != "이어하기"
                or not any("이어하기 map1_01a 1,2" in label for label in item_title_labels)
                or item_title_continue_restore.get("ok") is not True
                or item_title_continue_restore.get("titleContinue") is not True
                or item_title_continue_restore.get("quickLoadText") != "임시 불러오기"
                or item_title_continue_restore.get("map") != "map1_01a"
                or (item_title_counts.get("item-use-prototype") or 0) < 1
                or item_title_completion.get("completed") is not True
                or item_title_completion.get("itemName") != "약초"
                or item_title_completion.get("countAfter") != savedat_item.get("after", {}).get("count")
                or item_title_completion.get("hpAfter") != savedat_item_hp_max
                or item_title_objective.get("title") != "후보 전투 도구 완료 btl_a1"
                or item_title_objective.get("source") != "prototype-battle-item-completion"
                or item_title_objective.get("nextAction") != "완료 알림 확인"
                or item_title_continue_restore.get("objectiveResult") is not True
                or item_title_action.get("action") != "battle-item-completion-notice"
                or item_title_action.get("handled") is not True
                or item_title_action.get("activeId") != "battle-item-complete:map1_01a"
                or item_title_notice.get("blockId") != "battle-item-complete:map1_01a"
                or not any("전투 도구 완료 1/1" in line for line in item_title_notice_lines)
                or not any("Ataho 약초" in line for line in item_title_notice_lines)
                or not any("약초 3->2" in line for line in item_title_notice_lines)
                or not any(f"Ataho HP {savedat_item.get('before', {}).get('hp')}->{savedat_item_hp_max}" in line for line in item_title_notice_lines)
            ):
                raise WebDriverError(
                    "public savedat battle item title continue did not restore completed objective: "
                    f"start={item_title_continue_start!r} restore={item_title_continue_restore!r}"
                )
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_battle_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_battle = wait_for_battle_scene(port, session_id)
            savedat_item_menu = execute_js(port, session_id, use_savedat_battle_item_menu_script())
            savedat_item_menu_log = [str(line) for line in savedat_item_menu.get("log") or []]
            savedat_item_menu_mp_max = savedat_item_menu.get("before", {}).get("mpMax")
            savedat_item_menu_expected_mp = (
                min(savedat_item_menu_mp_max, savedat_item_menu.get("before", {}).get("mp", 0) + 50)
                if isinstance(savedat_item_menu_mp_max, int)
                else None
            )
            savedat_item_menu_heal_line = f"마법의 물약 Rinshan MP {savedat_item_menu_expected_mp}/{savedat_item_menu_mp_max}"
            savedat_item_menu_marker = savedat_item_menu.get("marker") or {}
            savedat_item_menu_progress_detail = ((savedat_item_menu_marker.get("progressEvent") or {}).get("detail") or {})
            savedat_item_menu_auto = savedat_item_menu.get("autoSave") or {}
            savedat_item_menu_auto_counts = ((savedat_item_menu_auto.get("progress") or {}).get("counts") or {})
            savedat_item_menu_auto_runtime = savedat_item_menu_auto.get("runtimeState") or {}
            savedat_item_menu_auto_rinshan = next(
                (row for row in (savedat_item_menu_auto_runtime.get("characters") or []) if row.get("name") == "Rinshan"),
                {},
            )
            savedat_item_menu_auto_potion = next(
                (row for row in (savedat_item_menu_auto_runtime.get("items") or []) if row.get("key") == "mp_recovery"),
                {},
            )
            savedat_item_menu_turn_auto = savedat_item_menu.get("turnAutoSave") or {}
            savedat_item_menu_payload = savedat_item_menu.get("savedPayload") or {}
            savedat_item_menu_payload_counts = ((savedat_item_menu_payload.get("prototypeProgress") or {}).get("counts") or {})
            savedat_item_menu_payload_rinshan = next(
                (row for row in ((savedat_item_menu_payload.get("runtimeState") or {}).get("characters") or []) if row.get("name") == "Rinshan"),
                {},
            )
            savedat_item_menu_payload_potion = next(
                (row for row in ((savedat_item_menu_payload.get("runtimeState") or {}).get("items") or []) if row.get("key") == "mp_recovery"),
                {},
            )
            savedat_item_menu_objective = savedat_item_menu.get("objectiveBefore") or {}
            savedat_item_menu_objective_action = savedat_item_menu.get("objectiveAction") or {}
            savedat_item_menu_objective_notice = savedat_item_menu.get("objectiveCompletionNotice") or {}
            savedat_item_menu_notice_lines = [str(line) for line in savedat_item_menu_objective_notice.get("lines") or []]
            savedat_item_menu_feedback = battle_item_feedback_match(
                savedat_item_menu,
                source="battle-item-use",
                item_key="mp_recovery",
                text="MP +50",
            )
            savedat_item_menu_feedback_render = battle_item_feedback_match(
                savedat_item_menu,
                source="battle-item-use",
                item_key="mp_recovery",
                text="MP +50",
                rendered=True,
            )
            verify_battle_item_feedback_sound(savedat_item_menu_feedback)
            verify_battle_item_feedback_sound(savedat_item_menu_feedback_render)
            if (
                not savedat_item_menu.get("ok")
                or not savedat_item_menu.get("opened")
                or "마법의 물약 x1" not in (savedat_item_menu.get("labels") or [])
                or savedat_item_menu.get("after", {}).get("mp") != savedat_item_menu_expected_mp
                or savedat_item_menu.get("after", {}).get("potionCount") != 0
                or savedat_item_menu.get("after", {}).get("itemMenuOpen")
                or savedat_item_menu_heal_line not in savedat_item_menu_log
                or savedat_item_menu.get("nextActor") != "Smashu"
                or savedat_item_menu_auto.get("saved") is not True
                or savedat_item_menu_auto.get("source") != "item-use-prototype"
                or savedat_item_menu_auto.get("scope") != "battle-item"
                or savedat_item_menu_auto.get("itemName") != "마법의 물약"
                or savedat_item_menu_auto.get("targetName") != "Rinshan"
                or savedat_item_menu_auto.get("countAfter") != 0
                or savedat_item_menu_auto.get("mpAfter") != savedat_item_menu_expected_mp
                or not item_text_provenance_matches(savedat_item_menu_marker, "mp_recovery")
                or not item_text_provenance_matches(savedat_item_menu_auto, "mp_recovery")
                or not item_text_provenance_matches(savedat_item_menu_progress_detail, "mp_recovery")
                or savedat_item_menu_feedback.get("itemName") != "마법의 물약"
                or savedat_item_menu_feedback.get("targetName") != "Rinshan"
                or savedat_item_menu_feedback.get("mpGain") != 50
                or savedat_item_menu_feedback.get("durationMs") != 900
                or savedat_item_menu_feedback.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_item_menu_feedback.get("originalItemEffectFormulaImplemented") is not False
                or not item_text_provenance_matches(savedat_item_menu_feedback, "mp_recovery")
                or savedat_item_menu_feedback_render.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_item_menu_feedback_render.get("text") != "MP +50"
                or not item_text_provenance_matches(savedat_item_menu_feedback_render, "mp_recovery")
                or not isinstance(savedat_item_menu_auto_counts.get("item-use-prototype"), int)
                or savedat_item_menu_auto_counts.get("item-use-prototype", 0) < 1
                or savedat_item_menu_payload.get("map") != savedat_item_menu_auto.get("payloadMap")
                or savedat_item_menu_auto_rinshan.get("mp") != savedat_item_menu_expected_mp
                or savedat_item_menu_auto_potion.get("count") != 0
                or savedat_item_menu_turn_auto.get("saved") is not True
                or savedat_item_menu_turn_auto.get("source") != "battle-turn-prototype"
                or savedat_item_menu_turn_auto.get("scope") != "battle-turn"
                or savedat_item_menu_turn_auto.get("commandName") != "마법의 물약"
                or not item_text_provenance_matches(savedat_item_menu_turn_auto, "mp_recovery", prefix="command")
                or savedat_item_menu_turn_auto.get("targetName") != "Rinshan"
                or (savedat_item_menu_payload_counts.get("battle-turn-prototype") or 0) < 1
                or savedat_item_menu_payload_rinshan.get("mp") != savedat_item_menu_expected_mp
                or savedat_item_menu_payload_potion.get("count") != 0
                or savedat_item_menu_objective.get("title") != "후보 전투 도구 완료 btl_a1"
                or savedat_item_menu_objective.get("source") != "prototype-battle-item-completion"
                or savedat_item_menu_objective.get("nextAction") != "완료 알림 확인"
                or "Rinshan" not in str(savedat_item_menu_objective.get("detail") or "")
                or "마법의 물약" not in str(savedat_item_menu_objective.get("detail") or "")
                or savedat_item_menu.get("objectiveResult") is not True
                or savedat_item_menu_objective_action.get("action") != "battle-item-completion-notice"
                or savedat_item_menu_objective_action.get("handled") is not True
                or savedat_item_menu_objective_action.get("activeId") != "battle-item-complete:map1_01a"
                or savedat_item_menu_objective_notice.get("blockId") != "battle-item-complete:map1_01a"
                or not any("전투 도구 완료 1/1" in line for line in savedat_item_menu_notice_lines)
                or not any("btl_a1" in line for line in savedat_item_menu_notice_lines)
                or not any("Rinshan 마법의 물약" in line for line in savedat_item_menu_notice_lines)
                or not any("마법의 물약 1->0" in line for line in savedat_item_menu_notice_lines)
                or not any(f"Rinshan MP {savedat_item_menu.get('before', {}).get('mp')}->{savedat_item_menu.get('after', {}).get('mp')}" in line for line in savedat_item_menu_notice_lines)
            ):
                raise WebDriverError(f"public savedat battle item menu did not select/use potion: {savedat_item_menu!r}")
            savedat_status_item = execute_js(port, session_id, use_savedat_battle_status_item_script())
            savedat_status_log = [str(line) for line in savedat_status_item.get("log") or []]
            savedat_status_marker = savedat_status_item.get("marker") or {}
            savedat_status_progress_detail = ((savedat_status_marker.get("progressEvent") or {}).get("detail") or {})
            savedat_status_auto = savedat_status_item.get("autoSave") or {}
            savedat_status_turn_auto = savedat_status_item.get("turnAutoSave") or {}
            savedat_status_payload = savedat_status_item.get("savedPayload") or {}
            savedat_status_objective = savedat_status_item.get("objectiveBefore") or {}
            savedat_status_objective_action = savedat_status_item.get("objectiveAction") or {}
            savedat_status_item_objective_notice = savedat_status_item.get("objectiveCompletionNotice") or {}
            savedat_status_notice_lines = [
                str(line) for line in savedat_status_item_objective_notice.get("lines") or []
            ]
            savedat_status_feedback = battle_item_feedback_match(
                savedat_status_item,
                source="battle-item-use",
                item_key="item_2",
                text="독 회복",
            )
            savedat_status_feedback_render = battle_item_feedback_match(
                savedat_status_item,
                source="battle-item-use",
                item_key="item_2",
                text="독 회복",
                rendered=True,
            )
            verify_battle_item_feedback_sound(savedat_status_feedback)
            verify_battle_item_feedback_sound(savedat_status_feedback_render)
            if (
                not savedat_status_item.get("ok")
                or savedat_status_item.get("before", {}).get("statuses") != ["poison"]
                or "해독초 x1" not in (savedat_status_item.get("before", {}).get("labels") or [])
                or savedat_status_item.get("after", {}).get("statuses") != []
                or savedat_status_item.get("after", {}).get("sourceStatuses") != []
                or savedat_status_item.get("after", {}).get("antidoteCount") != 0
                or savedat_status_item.get("after", {}).get("itemMenuOpen")
                or "해독초 Smashu 독 회복" not in savedat_status_log
                or savedat_status_item.get("nextActor") != "Ataho"
                or savedat_status_auto.get("saved") is not True
                or savedat_status_auto.get("source") != "item-use-prototype"
                or savedat_status_auto.get("scope") != "battle-item"
                or savedat_status_auto.get("itemName") != "해독초"
                or savedat_status_auto.get("targetName") != "Smashu"
                or savedat_status_auto.get("countAfter") != 0
                or not item_text_provenance_matches(savedat_status_marker, "item_2")
                or not item_text_provenance_matches(savedat_status_auto, "item_2")
                or not item_text_provenance_matches(savedat_status_progress_detail, "item_2")
                or savedat_status_feedback.get("itemName") != "해독초"
                or savedat_status_feedback.get("targetName") != "Smashu"
                or savedat_status_feedback.get("curedStatuses") != ["poison"]
                or savedat_status_feedback.get("durationMs") != 900
                or savedat_status_feedback.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_status_feedback.get("originalItemEffectFormulaImplemented") is not False
                or savedat_status_feedback.get("originalStatusFormulaImplemented") is not False
                or not item_text_provenance_matches(savedat_status_feedback, "item_2")
                or savedat_status_feedback_render.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_status_feedback_render.get("text") != "독 회복"
                or not item_text_provenance_matches(savedat_status_feedback_render, "item_2")
                or savedat_status_auto.get("payloadMap") != savedat_status_payload.get("map")
                or savedat_status_turn_auto.get("saved") is not True
                or savedat_status_turn_auto.get("source") != "battle-turn-prototype"
                or savedat_status_turn_auto.get("scope") != "battle-turn"
                or savedat_status_turn_auto.get("commandName") != "해독초"
                or not item_text_provenance_matches(savedat_status_turn_auto, "item_2", prefix="command")
                or savedat_status_objective.get("title") != "후보 전투 도구 완료 btl_a1"
                or savedat_status_objective.get("source") != "prototype-battle-item-completion"
                or savedat_status_objective.get("nextAction") != "완료 알림 확인"
                or savedat_status_item.get("objectiveResult") is not True
                or savedat_status_objective_action.get("action") != "battle-item-completion-notice"
                or savedat_status_objective_action.get("handled") is not True
                or savedat_status_objective_action.get("activeId") != "battle-item-complete:map1_01a"
                or savedat_status_item_objective_notice.get("blockId") != "battle-item-complete:map1_01a"
                or not any("전투 도구 완료 1/1" in line for line in savedat_status_notice_lines)
                or not any("btl_a1" in line for line in savedat_status_notice_lines)
                or not any("Smashu 해독초" in line for line in savedat_status_notice_lines)
                or not any("해독초 1->0" in line for line in savedat_status_notice_lines)
                or not any("상태 poison->정상" in line for line in savedat_status_notice_lines)
            ):
                raise WebDriverError(f"public savedat battle antidote item did not cure status: {savedat_status_item!r}")
            savedat_revive_item = execute_js(port, session_id, use_savedat_battle_revive_item_script())
            savedat_revive_log = [str(line) for line in savedat_revive_item.get("log") or []]
            savedat_revive_before = savedat_revive_item.get("before", {})
            savedat_revive_after = savedat_revive_item.get("after", {})
            savedat_revive_marker = savedat_revive_item.get("marker") or {}
            savedat_revive_progress_detail = ((savedat_revive_marker.get("progressEvent") or {}).get("detail") or {})
            savedat_revive_auto = savedat_revive_item.get("autoSave") or {}
            savedat_revive_turn_auto = savedat_revive_item.get("turnAutoSave") or {}
            savedat_revive_payload = savedat_revive_item.get("savedPayload") or {}
            savedat_revive_objective = savedat_revive_item.get("objectiveBefore") or {}
            savedat_revive_objective_action = savedat_revive_item.get("objectiveAction") or {}
            savedat_revive_item_objective_notice = savedat_revive_item.get("objectiveCompletionNotice") or {}
            savedat_revive_notice_lines = [
                str(line) for line in savedat_revive_item_objective_notice.get("lines") or []
            ]
            savedat_revive_feedback = battle_item_feedback_match(
                savedat_revive_item,
                source="battle-item-use",
                item_key="item_6",
                text="부활",
            )
            savedat_revive_feedback_render = battle_item_feedback_match(
                savedat_revive_item,
                source="battle-item-use",
                item_key="item_6",
                text="부활",
                rendered=True,
            )
            verify_battle_item_feedback_sound(savedat_revive_feedback)
            verify_battle_item_feedback_sound(savedat_revive_feedback_render)
            savedat_revive_line = (
                f"마수석 {savedat_revive_before.get('fallen')} "
                f"HP {savedat_revive_after.get('hp')}/{savedat_revive_after.get('hpMax')} "
                f"MP {savedat_revive_after.get('mp')}/{savedat_revive_after.get('mpMax')}"
            )
            if (
                not savedat_revive_item.get("ok")
                or savedat_revive_before.get("fallen") != "Smashu"
                or savedat_revive_before.get("fallenHp") != 0
                or savedat_revive_before.get("fallenMp") != 0
                or savedat_revive_before.get("herbTarget") == "Smashu"
                or "마수석 x1" not in (savedat_revive_before.get("labels") or [])
                or savedat_revive_after.get("hp") != savedat_revive_after.get("hpMax")
                or savedat_revive_after.get("mp") != savedat_revive_after.get("mpMax")
                or savedat_revive_after.get("sourceHp") != savedat_revive_after.get("hpMax")
                or savedat_revive_after.get("sourceMp") != savedat_revive_after.get("mpMax")
                or savedat_revive_after.get("stoneCount") != 0
                or savedat_revive_after.get("itemMenuOpen")
                or savedat_revive_line not in savedat_revive_log
                or savedat_revive_item.get("nextActor") != "Rinshan"
                or savedat_revive_auto.get("saved") is not True
                or savedat_revive_auto.get("source") != "item-use-prototype"
                or savedat_revive_auto.get("scope") != "battle-item"
                or savedat_revive_auto.get("itemName") != "마수석"
                or savedat_revive_auto.get("targetName") != "Smashu"
                or savedat_revive_auto.get("countAfter") != 0
                or savedat_revive_auto.get("hpAfter") != savedat_revive_after.get("hpMax")
                or savedat_revive_auto.get("mpAfter") != savedat_revive_after.get("mpMax")
                or not item_text_provenance_matches(savedat_revive_marker, "item_6")
                or not item_text_provenance_matches(savedat_revive_auto, "item_6")
                or not item_text_provenance_matches(savedat_revive_progress_detail, "item_6")
                or savedat_revive_feedback.get("itemName") != "마수석"
                or savedat_revive_feedback.get("targetName") != "Smashu"
                or savedat_revive_feedback.get("revived") is not True
                or savedat_revive_feedback.get("hpGain") != savedat_revive_after.get("hpMax")
                or savedat_revive_feedback.get("mpGain") != savedat_revive_after.get("mpMax")
                or savedat_revive_feedback.get("durationMs") != 900
                or savedat_revive_feedback.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_revive_feedback.get("originalItemEffectFormulaImplemented") is not False
                or not item_text_provenance_matches(savedat_revive_feedback, "item_6")
                or savedat_revive_feedback_render.get("browserBattleItemFeedbackImplemented") is not True
                or savedat_revive_feedback_render.get("text") != "부활"
                or not item_text_provenance_matches(savedat_revive_feedback_render, "item_6")
                or savedat_revive_auto.get("payloadMap") != savedat_revive_payload.get("map")
                or savedat_revive_turn_auto.get("saved") is not True
                or savedat_revive_turn_auto.get("source") != "battle-turn-prototype"
                or savedat_revive_turn_auto.get("scope") != "battle-turn"
                or savedat_revive_turn_auto.get("commandName") != "마수석"
                or not item_text_provenance_matches(savedat_revive_turn_auto, "item_6", prefix="command")
                or savedat_revive_objective.get("title") != "후보 전투 도구 완료 btl_a1"
                or savedat_revive_objective.get("source") != "prototype-battle-item-completion"
                or savedat_revive_objective.get("nextAction") != "완료 알림 확인"
                or savedat_revive_item.get("objectiveResult") is not True
                or savedat_revive_objective_action.get("action") != "battle-item-completion-notice"
                or savedat_revive_objective_action.get("handled") is not True
                or savedat_revive_objective_action.get("activeId") != "battle-item-complete:map1_01a"
                or savedat_revive_item_objective_notice.get("blockId") != "battle-item-complete:map1_01a"
                or not any("전투 도구 완료 1/1" in line for line in savedat_revive_notice_lines)
                or not any("btl_a1" in line for line in savedat_revive_notice_lines)
                or not any("Ataho 마수석" in line for line in savedat_revive_notice_lines)
                or not any("마수석 1->0" in line for line in savedat_revive_notice_lines)
                or not any("Smashu HP 0->999" in line for line in savedat_revive_notice_lines)
                or not any("Smashu MP 0->999" in line for line in savedat_revive_notice_lines)
            ):
                raise WebDriverError(f"public savedat battle revive item did not revive fallen member: {savedat_revive_item!r}")

            savedat_menu_params = urlencode({
                "publicSave": "hhe-debug-savedat2",
                "menu": "1",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            savedat_menu_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{savedat_menu_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_menu_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_menu = wait_for_savedat_menu_state(port, session_id)
            if (
                savedat_menu.get("map") != "map1_01a"
                or not savedat_menu.get("menuOpen")
                or not any(str(label).startswith("도구 선택") for label in savedat_menu.get("labels") or [])
                or savedat_menu.get("ataho", {}).get("hp") != 999
                or not any("EXP " in str(line) and "ATK " in str(line) and "기술 " in str(line) for line in savedat_menu.get("characterLines") or [])
            ):
                raise WebDriverError(f"public savedat menu did not expose savedat inventory: {savedat_menu!r}")
            savedat_menu_item = execute_js(port, session_id, use_savedat_menu_item_script())
            savedat_menu_item_marker = savedat_menu_item.get("marker") or {}
            savedat_menu_item_progress_detail = ((savedat_menu_item_marker.get("progressEvent") or {}).get("detail") or {})
            savedat_menu_item_auto = savedat_menu_item.get("autoSave") or {}
            savedat_menu_item_payload = savedat_menu_item.get("savedPayload") or {}
            savedat_menu_item_counts = ((savedat_menu_item_payload.get("prototypeProgress") or {}).get("counts") or {})
            savedat_menu_item_objective = savedat_menu_item.get("objectiveBefore") or {}
            savedat_menu_item_objective_action = savedat_menu_item.get("objectiveAction") or {}
            savedat_menu_item_objective_notice = savedat_menu_item.get("objectiveCompletionNotice") or {}
            savedat_menu_item_notice_lines = [str(line) for line in savedat_menu_item_objective_notice.get("lines") or []]
            savedat_menu_item_feedback = inventory_item_feedback_match(
                savedat_menu_item,
                source="inventory-item-use",
                item_key="herb",
                text="약초 HP +30",
            )
            savedat_menu_item_feedback_render = inventory_item_feedback_match(
                savedat_menu_item,
                source="inventory-item-use",
                item_key="herb",
                text="약초 HP +30",
                rendered=True,
            )
            if (
                not savedat_menu_item.get("ok")
                or savedat_menu_item.get("after", {}).get("count") != savedat_menu_item.get("before", {}).get("count", 0) - 1
                or savedat_menu_item.get("after", {}).get("hp") != savedat_menu_item.get("before", {}).get("hpMax")
                or "약초 Ataho HP" not in str(savedat_menu_item.get("notice") or "")
                or "약초 x3" not in (savedat_menu_item.get("inventoryLabels") or [])
                or (savedat_menu_item.get("inventoryOpenMarker") or {}).get("source") != "prototype-inventory-item-menu"
                or (savedat_menu_item.get("inventorySelectionMarker") or {}).get("source") != "prototype-inventory-item-menu"
                or (savedat_menu_item.get("inventorySelectionMarker") or {}).get("itemKey") != "herb"
                or (savedat_menu_item.get("inventorySelectionMarker") or {}).get("countBefore") != savedat_menu_item.get("before", {}).get("count")
                or (savedat_menu_item.get("inventorySelectionMarker") or {}).get("afterMenuMode") != "main"
                or savedat_menu_item_feedback.get("browserInventoryItemFeedbackImplemented") is not True
                or savedat_menu_item_feedback.get("prototypeItemUseImplemented") is not True
                or savedat_menu_item_feedback.get("originalItemEffectFormulaImplemented") is not False
                or savedat_menu_item_feedback.get("originalStoryFlagRuntimeImplemented") is not False
                or savedat_menu_item_feedback.get("targetName") != "Ataho"
                or savedat_menu_item_feedback.get("hpGain") != 30
                or savedat_menu_item_feedback.get("mpGain") != 0
                or savedat_menu_item_feedback.get("countBefore") != savedat_menu_item.get("before", {}).get("count")
                or savedat_menu_item_feedback.get("countAfter") != savedat_menu_item.get("after", {}).get("count")
                or savedat_menu_item_feedback.get("durationMs") != 1100
                or not item_text_provenance_matches(savedat_menu_item_marker, "herb")
                or not item_text_provenance_matches(savedat_menu_item_progress_detail, "herb")
                or not item_text_provenance_matches(savedat_menu_item_feedback, "herb")
                or savedat_menu_item_feedback_render.get("browserInventoryItemFeedbackImplemented") is not True
                or savedat_menu_item_feedback_render.get("text") != "약초 HP +30"
                or not item_text_provenance_matches(savedat_menu_item_feedback_render, "herb")
                or savedat_menu_item_auto.get("saved") is not True
                or savedat_menu_item_auto.get("source") != "item-use-prototype"
                or savedat_menu_item_auto.get("itemName") != "약초"
                or savedat_menu_item_auto.get("targetName") != "Ataho"
                or savedat_menu_item_auto.get("countAfter") != savedat_menu_item.get("after", {}).get("count")
                or savedat_menu_item_auto.get("hpAfter") != savedat_menu_item.get("after", {}).get("hp")
                or not item_text_provenance_matches(savedat_menu_item_auto, "herb")
                or savedat_menu_item_auto.get("payloadMap") != savedat_menu_item_payload.get("map")
                or (savedat_menu_item_counts.get("item-use-prototype") or 0) < 1
                or savedat_menu_item_objective.get("title") != "후보 도구 완료 map1_01a"
                or savedat_menu_item_objective.get("source") != "prototype-item-use-completion"
                or savedat_menu_item_objective.get("nextAction") != "완료 알림 확인"
                or savedat_menu_item.get("objectiveResult") is not True
                or savedat_menu_item_objective_action.get("action") != "item-use-completion-notice"
                or savedat_menu_item_objective_action.get("handled") is not True
                or savedat_menu_item_objective_action.get("activeId") != "item-use-complete:map1_01a"
                or savedat_menu_item_objective_notice.get("blockId") != "item-use-complete:map1_01a"
                or not any("도구 사용 완료 1/1" in line for line in savedat_menu_item_notice_lines)
                or not any("Ataho 약초" in line for line in savedat_menu_item_notice_lines)
                or not any("HP 969 -> 999" in line for line in savedat_menu_item_notice_lines)
                or not any("약초 3 -> 2" in line for line in savedat_menu_item_notice_lines)
            ):
                raise WebDriverError(f"public savedat menu item did not consume/heal: {savedat_menu_item!r}")
            verify_inventory_item_feedback_sound(savedat_menu_item_feedback)
            verify_inventory_item_feedback_sound(savedat_menu_item_feedback_render)
            savedat_menu_status = execute_js(port, session_id, use_savedat_menu_status_item_script())
            if (
                not savedat_menu_status.get("ok")
                or savedat_menu_status.get("selectedMenuItemIndex", -1) < 0
                or savedat_menu_status.get("before", {}).get("statuses") != ["poison", "paralysis"]
                or savedat_menu_status.get("after", {}).get("statuses") != []
                or savedat_menu_status.get("after", {}).get("antidoteCount") != 0
                or "해독초 Ataho 독/마비 회복" not in str(savedat_menu_status.get("notice") or "")
                or any("상태 독" in str(line) or "상태 마비" in str(line) for line in savedat_menu_status.get("characterLines") or [])
            ):
                raise WebDriverError(f"public savedat menu antidote item did not cure status: {savedat_menu_status!r}")
            savedat_menu_quick_save = execute_js(port, session_id, quick_save_load_savedat_menu_item_script())
            if (
                not savedat_menu_quick_save.get("ok")
                or savedat_menu_quick_save.get("saveIndex", -1) < 0
                or savedat_menu_quick_save.get("loadIndex", -1) < 0
                or not savedat_menu_quick_save.get("saveCommandResult")
                or not savedat_menu_quick_save.get("loadCommandResult")
                or savedat_menu_quick_save.get("saveNotice") != "임시 저장했습니다."
                or savedat_menu_quick_save.get("loadNoticeStart") != "임시 저장을 불러옵니다."
                or "임시 저장" not in (savedat_menu_quick_save.get("saveLabels") or [])
                or "임시 불러오기" not in (savedat_menu_quick_save.get("loadLabels") or [])
                or not savedat_menu_quick_save.get("saved")
                or savedat_menu_quick_save.get("savedCount") != 2
                or savedat_menu_quick_save.get("savedHp") != savedat_menu_item.get("after", {}).get("hp")
                or savedat_menu_quick_save.get("savedStatus") != ["poison"]
                or set(savedat_menu_quick_save.get("savedFieldMaps") or []) != {"map1_01a", "map1_02b"}
                or savedat_menu_quick_save.get("savedRouteStatus") != "not-current-selector"
                or savedat_menu_quick_save.get("mutatedCount") != 0
                or savedat_menu_quick_save.get("mutatedHp") != 1
                or savedat_menu_quick_save.get("mutatedStatus") != []
            ):
                raise WebDriverError(f"public savedat menu item quick save did not persist payload: {savedat_menu_quick_save!r}")
            savedat_menu_quick_load = wait_for_menu_item_quick_load_state(port, session_id)
            if (
                not savedat_menu_quick_load.get("ok")
                or savedat_menu_quick_load.get("scene") != "map"
                or savedat_menu_quick_load.get("map") != "map1_01a"
                or savedat_menu_quick_load.get("loadedCount") != 2
                or savedat_menu_quick_load.get("loadedHp") != savedat_menu_item.get("after", {}).get("hp")
                or savedat_menu_quick_load.get("loadedStatus") != ["poison"]
                or set(savedat_menu_quick_load.get("fieldMaps") or []) != {"map1_01a", "map1_02b"}
                or savedat_menu_quick_load.get("routeStatus") != "not-current-selector"
                or savedat_menu_quick_load.get("saveMapHidden") is not False
                or set(savedat_menu_quick_load.get("saveMapValues") or []) != {"map1_01a", "map1_02b"}
                or savedat_menu_quick_load.get("notice") != "임시 저장을 불러왔습니다."
                or savedat_menu_quick_load.get("loadNoticeStart") != "임시 저장을 불러옵니다."
                or not savedat_menu_quick_load.get("loadCommandResult")
            ):
                raise WebDriverError(f"public savedat menu item quick load did not restore payload: {savedat_menu_quick_load!r}")
            savedat_menu_title_continue_start = execute_js(
                port,
                session_id,
                start_savedat_menu_item_title_continue_script(),
                timeout=3,
            )
            savedat_menu_title_continue = wait_for_savedat_menu_item_title_continue(port, session_id)
            savedat_menu_title_state = savedat_menu_title_continue.get("titleState") or {}
            savedat_menu_title_labels = [str(label) for label in savedat_menu_title_state.get("titleMenuLabels") or []]
            savedat_menu_title_objective = savedat_menu_title_continue.get("objectiveBefore") or {}
            savedat_menu_title_objective_action = savedat_menu_title_continue.get("objectiveAction") or {}
            savedat_menu_title_objective_notice = savedat_menu_title_continue.get("objectiveCompletionNotice") or {}
            savedat_menu_title_notice_lines = [
                str(line) for line in savedat_menu_title_objective_notice.get("lines") or []
            ]
            if (
                not isinstance(savedat_menu_title_continue_start, dict)
                or savedat_menu_title_continue_start.get("ok") is not True
                or (savedat_menu_title_continue_start.get("titleState") or {}).get("quickLoadText") != "이어하기"
                or not any("이어하기 map1_01a" in label and "세이브 0:0" in label for label in savedat_menu_title_labels)
                or savedat_menu_title_continue.get("ok") is not True
                or savedat_menu_title_continue.get("titleContinue") is not True
                or savedat_menu_title_continue.get("quickLoadText") != "임시 불러오기"
                or savedat_menu_title_continue.get("scene") != "map"
                or savedat_menu_title_continue.get("map") != "map1_01a"
                or savedat_menu_title_continue.get("itemCount") != savedat_menu_quick_load.get("loadedCount")
                or savedat_menu_title_continue.get("atahoHp") != savedat_menu_quick_load.get("loadedHp")
                or savedat_menu_title_continue.get("atahoStatuses") != savedat_menu_quick_load.get("loadedStatus")
                or set(savedat_menu_title_continue.get("fieldMaps") or []) != {"map1_01a", "map1_02b"}
                or savedat_menu_title_continue.get("routeStatus") != "not-current-selector"
                or savedat_menu_title_continue.get("saveMapHidden") is not False
                or set(savedat_menu_title_continue.get("saveMapValues") or []) != {"map1_01a", "map1_02b"}
                or savedat_menu_title_objective.get("title") != "후보 도구 완료 map1_01a"
                or savedat_menu_title_objective.get("source") != "prototype-item-use-completion"
                or savedat_menu_title_objective.get("detail") != "Ataho 해독초"
                or savedat_menu_title_objective.get("nextAction") != "완료 알림 확인"
                or savedat_menu_title_objective.get("progressTotal") != 2
                or savedat_menu_title_continue.get("objectiveResult") is not True
                or savedat_menu_title_objective_action.get("action") != "item-use-completion-notice"
                or savedat_menu_title_objective_action.get("handled") is not True
                or savedat_menu_title_objective_action.get("activeId") != "item-use-complete:map1_01a"
                or savedat_menu_title_objective_notice.get("blockId") != "item-use-complete:map1_01a"
                or not any("도구 사용 완료 1/1" in line for line in savedat_menu_title_notice_lines)
                or not any("Ataho 해독초" in line for line in savedat_menu_title_notice_lines)
                or not any("HP 999 -> 999" in line for line in savedat_menu_title_notice_lines)
                or not any("해독초 1 -> 0" in line for line in savedat_menu_title_notice_lines)
            ):
                raise WebDriverError(
                    "public savedat menu item title continue did not restore payload: "
                    f"start={savedat_menu_title_continue_start!r} restore={savedat_menu_title_continue!r}"
                )
            save_point_candidate_start = execute_js(port, session_id, start_save_point_candidate_menu_script())
            save_point_candidate_notice = str(save_point_candidate_start.get("notice") or "")
            if (
                not save_point_candidate_start.get("ok")
                or save_point_candidate_start.get("index", -1) < 0
                or save_point_candidate_start.get("directIndex") != -1
                or not any("세이브 선택" in str(label) for label in save_point_candidate_start.get("labels") or [])
                or not save_point_candidate_start.get("commandResult")
                or not save_point_candidate_notice.startswith("세이브 후보")
            ):
                raise WebDriverError(f"save-point candidate menu command did not start: {save_point_candidate_start!r}")
            save_point_candidate_menu = wait_for_save_point_candidate_menu_state(port, session_id)
            save_point_lines = [str(line) for line in save_point_candidate_menu.get("activeLines") or []]
            save_point_last = save_point_candidate_menu.get("last") or {}
            save_point_last_candidates = save_point_last.get("candidates") or []
            if (
                save_point_candidate_menu.get("scene") != "map"
                or save_point_candidate_menu.get("map") != "map1_01a"
                or save_point_candidate_menu.get("activeId") != "save-point-candidates:map1_01a"
                or save_point_candidate_menu.get("activeIndex") != 0
                or save_point_candidate_menu.get("loadedCount", 0) < 8
                or save_point_candidate_menu.get("originalSavePointRuntimeImplemented") is not False
                or save_point_last.get("scope") != "global-review"
                or save_point_last.get("count") != 6
                or save_point_last.get("originalSavePointRuntimeImplemented") is not False
                or "세이브 후보 map1_01a: 6개 (전체 후보 검토)" not in save_point_lines
                or "원본 저장 실행이 아니라 후보 텍스트 검토입니다." not in save_point_lines
                or "event opcode와 좌표가 미확정이라 임시 저장과 별개입니다." not in save_point_lines
                or not any(row.get("blockId") == "event-dialogue-block-046" for row in save_point_last_candidates)
                or any(
                    row.get("strength") in {"system-ui-not-savepoint", "false-positive-likely"}
                    for row in save_point_last_candidates
                )
            ):
                raise WebDriverError(f"save-point candidate menu did not open read-only review dialogue: {save_point_candidate_menu!r}")

            candidate_save_params = urlencode({
                "map": "map2_07e",
                "startTile": "11,11",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            candidate_save_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{candidate_save_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": candidate_save_url}, timeout=30)
            wait_for_page(port, session_id)
            wait_for_map_runtime(port, session_id, "map2_07e")
            candidate_save_start = execute_js(port, session_id, start_save_point_candidate_menu_script())
            candidate_save_notice = str(candidate_save_start.get("notice") or "")
            if (
                not candidate_save_start.get("ok")
                or candidate_save_start.get("index", -1) < 0
                or candidate_save_start.get("directIndex") != -1
                or not any("세이브 선택" in str(label) for label in candidate_save_start.get("labels") or [])
                or not candidate_save_start.get("commandResult")
                or not candidate_save_notice.startswith("세이브 후보")
            ):
                raise WebDriverError(f"candidate save-point prototype command did not start: {candidate_save_start!r}")
            candidate_save_menu = wait_for_save_point_candidate_menu_state(port, session_id)
            candidate_save_lines = [str(line) for line in candidate_save_menu.get("activeLines") or []]
            candidate_save_last = candidate_save_menu.get("last") or {}
            candidate_save_runtime = candidate_save_menu.get("runtimeSave") or {}
            candidate_save_payload = candidate_save_menu.get("savedPayload") or {}
            candidate_save_tile = candidate_save_runtime.get("tile") or {}
            candidate_save_payload_tile = candidate_save_payload.get("tile") or {}
            if (
                candidate_save_menu.get("scene") != "map"
                or candidate_save_menu.get("map") != "map2_07e"
                or candidate_save_menu.get("activeId") != "save-point-candidates:map2_07e"
                or candidate_save_last.get("scope") != "current-map"
                or candidate_save_last.get("count") != 1
                or candidate_save_last.get("originalSavePointRuntimeImplemented") is not False
                or candidate_save_runtime.get("eligible") is not True
                or candidate_save_runtime.get("saved") is not True
                or candidate_save_runtime.get("scope") != "current-map"
                or candidate_save_runtime.get("count") != 1
                or candidate_save_runtime.get("originalSavePointRuntimeImplemented") is not False
                or "event-dialogue-block-033" not in (candidate_save_runtime.get("blockIds") or [])
                or candidate_save_payload.get("map") != "map2_07e"
                or candidate_save_payload.get("version") != 1
                or candidate_save_payload_tile != candidate_save_tile
                or "후보 저장 map2_07e: 완료 (현재 맵 직접 연결)" not in candidate_save_lines
                or "원본 저장 실행 증명이 아니라 프로토타입 저장입니다." not in candidate_save_lines
                or "현재 위치와 상태를 임시 저장 슬롯에 보존했습니다." not in candidate_save_lines
            ):
                raise WebDriverError(f"candidate save-point prototype did not quick-save current-map candidate: {candidate_save_menu!r}")

            event_object_params = urlencode({
                "map": "map2_03l",
                "startTile": "11,11",
                "events": "1",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            event_object_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{event_object_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": event_object_url}, timeout=30)
            wait_for_page(port, session_id)
            wait_for_map_runtime(port, session_id, "map2_03l")
            event_object_preview = wait_for_event_object_render_state(port, session_id)
            event_object_render = event_object_preview.get("render") or {}
            event_object_asset_keys = set(event_object_preview.get("assetKeys") or [])
            event_object_rows = event_object_render.get("objects") or []
            if (
                event_object_preview.get("scene") != "map"
                or event_object_preview.get("map") != "map2_03l"
                or event_object_preview.get("showEventHotspots") is not True
                or event_object_render.get("enabled") is not True
                or event_object_render.get("candidateCount") != 3
                or event_object_render.get("loadedCount") != 3
                or event_object_render.get("renderedCount") < 1
                or not {"zm_2", "zg_kni", "zs_rg"} <= event_object_asset_keys
                or not {"zm_2", "zg_kni", "zs_rg"} <= {row.get("assetKey") for row in event_object_rows}
                or not all((row.get("anchor") or {}) == {"x": 11, "y": 11} for row in event_object_rows)
            ):
                raise WebDriverError(f"event object overlay did not expose linked object previews: {event_object_preview!r}")

            savedat_url_params = urlencode({
                "savedatUrl": "../data/public_savedata/flack3r/savedat2.dat",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            savedat_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{savedat_url_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_url_state = None
            deadline = time.monotonic() + 5
            while time.monotonic() < deadline:
                savedat_url_state = execute_js(port, session_id, """
return {
  scene,
  map: map?.name || '',
  loaded: !!loadedSaveSummary,
  fileName: loadedSaveSummary?.fileName || '',
  group: loadedSaveSummary?.group ?? null,
  slot: loadedSaveSummary?.slot ?? null,
  routeStatus: loadedSaveSummary?.routeEvidence?.status || '',
  debugRouteStatus: window.HWANSE_LAST_SAVEDAT_ROUTE_EVIDENCE?.status || '',
  debugSelector: window.HWANSE_LAST_SAVEDAT_SUMMARY?.selector || '',
  fieldMaps: loadedSaveSummary?.fieldMaps || [],
  saveMapHidden: document.getElementById('saveMapSelect').hidden,
};
""")
                if savedat_url_state and savedat_url_state.get("loaded"):
                    break
                time.sleep(0.15)
            if (
                not isinstance(savedat_url_state, dict)
                or not savedat_url_state.get("loaded")
                or savedat_url_state.get("scene") != "map"
                or savedat_url_state.get("map") != "map2_02d"
                or savedat_url_state.get("fileName") != "savedat2.dat"
                or savedat_url_state.get("group") != 1
                or savedat_url_state.get("slot") != 0
                or savedat_url_state.get("routeStatus") != "not-current-selector"
                or savedat_url_state.get("debugRouteStatus") != "not-current-selector"
                or savedat_url_state.get("debugSelector") != "1:0"
                or "map2_02d" not in (savedat_url_state.get("fieldMaps") or [])
                or savedat_url_state.get("saveMapHidden") is not False
            ):
                raise WebDriverError(f"savedatUrl did not load and classify flack3r savedat2: {savedat_url_state!r}")

            savedat_zip_url_params = urlencode({
                "savedatUrl": "../SAVEDATA/savedat_zip_probe.zip",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            savedat_zip_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{savedat_zip_url_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_zip_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_zip_url_state = None
            deadline = time.monotonic() + 5
            while time.monotonic() < deadline:
                savedat_zip_url_state = execute_js(port, session_id, """
const archive = window.HWANSE_LAST_SAVEDAT_ARCHIVE || {};
return {
  scene,
  map: map?.name || '',
  loaded: !!loadedSaveSummary,
  fileName: loadedSaveSummary?.fileName || '',
  group: loadedSaveSummary?.group ?? null,
  slot: loadedSaveSummary?.slot ?? null,
  routeStatus: loadedSaveSummary?.routeEvidence?.status || '',
  debugRouteStatus: window.HWANSE_LAST_SAVEDAT_ROUTE_EVIDENCE?.status || '',
  debugSelector: window.HWANSE_LAST_SAVEDAT_SUMMARY?.selector || '',
  fieldMaps: loadedSaveSummary?.fieldMaps || [],
  hudLines: window.HWANSE_LAST_SAVE_HUD_LINES || [],
  archive: {
    fileName: archive.fileName || '',
    entryCount: archive.entryCount ?? null,
    loaded: archive.loaded ?? null,
    loadedMap: archive.loadedMap || '',
    selected: archive.selected || null,
    rows: (archive.rows || []).map((row) => ({
      name: row.name || '',
      valid: !!row.valid,
      selector: row.selector || '',
      routeStatus: row.routeStatus || '',
      routeProofCandidate: !!row.routeProofCandidate,
    })),
  },
};
""")
                if (
                    savedat_zip_url_state
                    and savedat_zip_url_state.get("loaded")
                    and any("zip savedat_zip.dat" in line for line in savedat_zip_url_state.get("hudLines") or [])
                ):
                    break
                time.sleep(0.15)
            zip_url_archive = (savedat_zip_url_state or {}).get("archive") or {}
            if (
                not isinstance(savedat_zip_url_state, dict)
                or not savedat_zip_url_state.get("loaded")
                or savedat_zip_url_state.get("scene") != "map"
                or savedat_zip_url_state.get("map") != "map1_01a"
                or savedat_zip_url_state.get("fileName") != "savedat_zip_probe.zip::SAVEDATA/savedat_zip.dat"
                or savedat_zip_url_state.get("group") != 2
                or savedat_zip_url_state.get("slot") != 0
                or savedat_zip_url_state.get("routeStatus") != "synthetic-selector-evidence"
                or savedat_zip_url_state.get("debugRouteStatus") != "synthetic-selector-evidence"
                or savedat_zip_url_state.get("debugSelector") != "2:0"
                or zip_url_archive.get("fileName") != "savedat_zip_probe.zip"
                or zip_url_archive.get("entryCount") != 1
                or zip_url_archive.get("loaded") is not True
                or zip_url_archive.get("loadedMap") != "map1_01a"
                or (zip_url_archive.get("selected") or {}).get("name") != "SAVEDATA/savedat_zip.dat"
                or not any(row.get("name") == "SAVEDATA/savedat_zip.dat" and row.get("valid") for row in zip_url_archive.get("rows") or [])
                or not any("zip savedat_zip.dat 1 entry" in line for line in savedat_zip_url_state.get("hudLines") or [])
            ):
                raise WebDriverError(f"savedatUrl zip did not load and classify synthetic selector 2:0: {savedat_zip_url_state!r}")

            savedat_scan_params = urlencode({
                "savedatScan": "1",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            savedat_scan_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{savedat_scan_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": savedat_scan_url}, timeout=30)
            wait_for_page(port, session_id)
            savedat_scan_state = None
            deadline = time.monotonic() + 5
            while time.monotonic() < deadline:
                savedat_scan_state = execute_js(port, session_id, """
const scan = window.HWANSE_LAST_SAVEDAT_SCAN || null;
const archive = window.HWANSE_LAST_SAVEDAT_ARCHIVE || {};
return {
  scene,
  map: map?.name || '',
  loaded: !!loadedSaveSummary,
  fileName: loadedSaveSummary?.fileName || '',
  group: loadedSaveSummary?.group ?? null,
  slot: loadedSaveSummary?.slot ?? null,
  routeStatus: loadedSaveSummary?.routeEvidence?.status || '',
  debugRouteStatus: window.HWANSE_LAST_SAVEDAT_ROUTE_EVIDENCE?.status || '',
  debugSelector: window.HWANSE_LAST_SAVEDAT_SUMMARY?.selector || '',
  selectValue: document.getElementById('publicSaveSelect')?.value || '',
  hudLines: window.HWANSE_LAST_SAVE_HUD_LINES || [],
  archive: {
    fileName: archive.fileName || '',
    entryCount: archive.entryCount ?? null,
    loaded: archive.loaded ?? null,
    loadedMap: archive.loadedMap || '',
    selected: archive.selected || null,
  },
  scan,
};
""")
                if savedat_scan_state and (savedat_scan_state.get("scan") or {}).get("loaded"):
                    break
                time.sleep(0.15)
            savedat_scan_found = ((savedat_scan_state or {}).get("scan") or {}).get("found") or []
            savedat_scan_archive = (savedat_scan_state or {}).get("archive") or {}
            if (
                not isinstance(savedat_scan_state, dict)
                or not isinstance(savedat_scan_state.get("scan"), dict)
                or (remove_scan_probe and (savedat_scan_state.get("scan") or {}).get("loaded") is not True)
                or (remove_scan_zip_probe and (savedat_scan_state.get("scan") or {}).get("loadedMap") != "map1_01a")
                or (remove_scan_zip_probe and savedat_scan_state.get("map") != "map1_01a")
                or (remove_scan_zip_probe and savedat_scan_state.get("fileName") != "savedat8.zip::SAVEDATA/savedat_zip.dat")
                or (remove_scan_zip_probe and savedat_scan_state.get("debugRouteStatus") != "synthetic-selector-evidence")
                or (remove_scan_zip_probe and savedat_scan_state.get("debugSelector") != "2:0")
                or (remove_scan_zip_probe and savedat_scan_archive.get("fileName") != "savedat8.zip")
                or (remove_scan_zip_probe and savedat_scan_archive.get("loaded") is not True)
                or (remove_scan_zip_probe and (savedat_scan_archive.get("selected") or {}).get("name") != "SAVEDATA/savedat_zip.dat")
                or savedat_scan_state.get("selectValue") != "__savedatScan"
                or len(((savedat_scan_state.get("scan") or {}).get("paths") or [])) != 36
                or (remove_scan_probe and not savedat_scan_state.get("loaded"))
                or (remove_scan_probe and not any(row.get("fileName") == "savedat9.dat" for row in savedat_scan_found))
                or (remove_scan_zip_probe and not any(row.get("fileName") == "savedat8.zip::SAVEDATA/savedat_zip.dat" for row in savedat_scan_found))
                or (remove_scan_zip_probe and not any(row.get("fileName") == "savedat8.zip::SAVEDATA/savedat_zip.dat" and row.get("syntheticDiagnostic") is True and row.get("realRouteProofCandidate") is False for row in savedat_scan_found))
            ):
                raise WebDriverError(f"savedatScan did not scan and load a local SAVEDATA probe: {savedat_scan_state!r}")
            savedat_scan_state["localProbeSynthetic"] = bool(remove_scan_zip_probe)
            savedat_scan_state["localProbeDatSample"] = bool(remove_scan_probe)

            execute_js(port, session_id, start_synthetic_savedat_file_load_script())
            synthetic_savedat_file = None
            deadline = time.monotonic() + 6
            while time.monotonic() < deadline:
                synthetic_savedat_file = execute_js(port, session_id, synthetic_savedat_file_load_state_script(), timeout=3)
                if synthetic_savedat_file is not None:
                    break
                time.sleep(0.15)
            synthetic_route_evidence = (synthetic_savedat_file or {}).get("routeEvidence") or {}
            synthetic_debug_evidence = (synthetic_savedat_file or {}).get("debugRouteEvidence") or {}
            synthetic_debug_summary = (synthetic_savedat_file or {}).get("debugSummary") or {}
            synthetic_frontiers = synthetic_savedat_file.get("frontierOptions") if isinstance(synthetic_savedat_file, dict) else []
            if (
                not isinstance(synthetic_savedat_file, dict)
                or not synthetic_savedat_file.get("ok")
                or synthetic_savedat_file.get("map") != "map1_01a"
                or synthetic_savedat_file.get("tile") != {"x": 34, "y": 19}
                or synthetic_savedat_file.get("fileName") != "synthetic_savedat_selector_2_0.dat"
                or synthetic_savedat_file.get("group") != 2
                or synthetic_savedat_file.get("slot") != 0
                or not {"map1_01a", "map2_02d"}.issubset(set(synthetic_savedat_file.get("fieldMaps") or []))
                or synthetic_route_evidence.get("status") != "synthetic-selector-evidence"
                or synthetic_route_evidence.get("routeProofCandidate") is not True
                or synthetic_route_evidence.get("realRouteProofCandidate") is not False
                or synthetic_route_evidence.get("syntheticDiagnostic") is not True
                or synthetic_debug_evidence.get("status") != "synthetic-selector-evidence"
                or synthetic_debug_evidence.get("routeProofCandidate") is not True
                or synthetic_debug_evidence.get("realRouteProofCandidate") is not False
                or synthetic_debug_evidence.get("syntheticDiagnostic") is not True
                or synthetic_debug_summary.get("selector") != "2:0"
                or synthetic_savedat_file.get("trialTransitions") != "routeAssist"
                or synthetic_savedat_file.get("selectedTransitionTarget") != "map2_02d"
                or synthetic_savedat_file.get("transitionTargetExplicit") is not True
                or synthetic_savedat_file.get("saveMapHidden") is not False
                or not {"map1_01a", "map2_02d"}.issubset(set(synthetic_savedat_file.get("saveMapValues") or []))
                or synthetic_savedat_file.get("routeFrontierHidden") is not False
                or not any(option.get("target") == "map2_02d" for option in synthetic_frontiers or [])
                or "synthetic selector 2:0 routeAssist enabled" not in str(synthetic_savedat_file.get("notice") or "")
                or "strict hotspot still needed" not in str(synthetic_savedat_file.get("notice") or "")
            ):
                raise WebDriverError(f"synthetic selector 2:0 savedat file load did not expose route evidence: {synthetic_savedat_file!r}")

            execute_js(port, session_id, start_synthetic_savedat_drop_load_script())
            synthetic_savedat_drop = None
            deadline = time.monotonic() + 6
            while time.monotonic() < deadline:
                synthetic_savedat_drop = execute_js(port, session_id, synthetic_savedat_drop_load_state_script(), timeout=3)
                if (
                    synthetic_savedat_drop is not None
                    and ((synthetic_savedat_drop.get("drop") or {}).get("status") == "loaded")
                ):
                    break
                time.sleep(0.15)
            drop_state = (synthetic_savedat_drop or {}).get("drop") or {}
            drop_route_evidence = (synthetic_savedat_drop or {}).get("routeEvidence") or {}
            if (
                not isinstance(synthetic_savedat_drop, dict)
                or not synthetic_savedat_drop.get("ok")
                or synthetic_savedat_drop.get("dragDispatched") is not True
                or synthetic_savedat_drop.get("activeAfterEnter") is not True
                or synthetic_savedat_drop.get("activeAfterOver") is not True
                or synthetic_savedat_drop.get("activeAfterDrop") is not False
                or synthetic_savedat_drop.get("map") != "map1_01a"
                or synthetic_savedat_drop.get("fileName") != "synthetic_drop_savedat2.dat"
                or synthetic_savedat_drop.get("group") != 2
                or synthetic_savedat_drop.get("slot") != 0
                or drop_state.get("status") != "loaded"
                or drop_state.get("fileName") != "synthetic_drop_savedat2.dat"
                or drop_state.get("routeStatus") != "synthetic-selector-evidence"
                or drop_state.get("routeProofCandidate") is not True
                or drop_state.get("realRouteProofCandidate") is not False
                or drop_state.get("syntheticDiagnostic") is not True
                or drop_route_evidence.get("status") != "synthetic-selector-evidence"
                or drop_route_evidence.get("routeProofCandidate") is not True
                or drop_route_evidence.get("realRouteProofCandidate") is not False
                or drop_route_evidence.get("syntheticDiagnostic") is not True
                or synthetic_savedat_drop.get("trialTransitions") != "routeAssist"
                or synthetic_savedat_drop.get("selectedTransitionTarget") != "map2_02d"
                or "synthetic_drop_savedat2.dat 불러옴" not in str(synthetic_savedat_drop.get("notice") or "")
            ):
                raise WebDriverError(f"synthetic selector 2:0 savedat drop load did not expose drop route evidence: {synthetic_savedat_drop!r}")

            execute_js(port, session_id, start_synthetic_savedat_zip_load_script())
            synthetic_savedat_zip = None
            deadline = time.monotonic() + 6
            while time.monotonic() < deadline:
                synthetic_savedat_zip = execute_js(port, session_id, synthetic_savedat_zip_load_state_script(), timeout=3)
                if (
                    synthetic_savedat_zip is not None
                    and any("zip savedat_zip.dat" in line for line in synthetic_savedat_zip.get("hudLines") or [])
                    and any(
                        "save synthetic_savedat_bundle.zip::saved" in line
                        for line in synthetic_savedat_zip.get("hudLines") or []
                    )
                ):
                    break
                time.sleep(0.15)
            zip_archive = (synthetic_savedat_zip or {}).get("archive") or {}
            zip_route_evidence = (synthetic_savedat_zip or {}).get("routeEvidence") or {}
            if (
                not isinstance(synthetic_savedat_zip, dict)
                or not synthetic_savedat_zip.get("ok")
                or synthetic_savedat_zip.get("map") != "map1_01a"
                or synthetic_savedat_zip.get("fileName") != "synthetic_savedat_bundle.zip::SAVEDATA/savedat_zip.dat"
                or synthetic_savedat_zip.get("group") != 2
                or synthetic_savedat_zip.get("slot") != 0
                or zip_route_evidence.get("status") != "synthetic-selector-evidence"
                or zip_route_evidence.get("routeProofCandidate") is not True
                or zip_route_evidence.get("realRouteProofCandidate") is not False
                or zip_route_evidence.get("syntheticDiagnostic") is not True
                or zip_archive.get("fileName") != "synthetic_savedat_bundle.zip"
                or zip_archive.get("entryCount") != 1
                or zip_archive.get("loaded") is not True
                or zip_archive.get("loadedMap") != "map1_01a"
                or (zip_archive.get("selected") or {}).get("name") != "SAVEDATA/savedat_zip.dat"
                or not any(row.get("name") == "SAVEDATA/savedat_zip.dat" and row.get("valid") for row in zip_archive.get("rows") or [])
                or "synthetic selector 2:0 routeAssist enabled" not in str(synthetic_savedat_zip.get("notice") or "")
                or not any("save synthetic_savedat_bundle.zip::saved" in line for line in synthetic_savedat_zip.get("hudLines") or [])
                or not any("zip savedat_zip.dat 1 entry" in line for line in synthetic_savedat_zip.get("hudLines") or [])
            ):
                raise WebDriverError(f"synthetic selector 2:0 savedat zip load did not expose archive route evidence: {synthetic_savedat_zip!r}")

            synthetic_quick_save = execute_js(port, session_id, start_synthetic_quick_save_load_script(), timeout=3)
            if (
                not synthetic_quick_save.get("saved")
                or synthetic_quick_save.get("status") != "synthetic-selector-evidence"
                or synthetic_quick_save.get("routeProofCandidate") is not True
                or synthetic_quick_save.get("realRouteProofCandidate") is not False
                or synthetic_quick_save.get("routeSyntheticDiagnostic") is not True
                or synthetic_quick_save.get("syntheticDiagnostic") is not True
                or synthetic_quick_save.get("fingerprint") != "0xcc06ddd0"
            ):
                raise WebDriverError(f"synthetic selector quick save did not persist synthetic route metadata: {synthetic_quick_save!r}")
            synthetic_quick_load = None
            deadline = time.monotonic() + 4
            while time.monotonic() < deadline:
                synthetic_quick_load = execute_js(port, session_id, synthetic_quick_load_state_script(), timeout=3)
                if synthetic_quick_load is not None:
                    break
                time.sleep(0.15)
            if (
                not isinstance(synthetic_quick_load, dict)
                or not synthetic_quick_load.get("ok")
                or synthetic_quick_load.get("map") != "map1_01a"
                or synthetic_quick_load.get("status") != "synthetic-selector-evidence"
                or synthetic_quick_load.get("routeProofCandidate") is not True
                or synthetic_quick_load.get("realRouteProofCandidate") is not False
                or synthetic_quick_load.get("routeSyntheticDiagnostic") is not True
                or synthetic_quick_load.get("syntheticDiagnostic") is not True
                or synthetic_quick_load.get("fingerprint") != "0xcc06ddd0"
            ):
                raise WebDriverError(f"synthetic selector quick load did not restore synthetic route metadata: {synthetic_quick_load!r}")

            route_params = urlencode({
                "map": "map1_01a",
                "trialTransitions": "routeAssist",
                "startTile": "18,0",
                "moveMs": "80",
                "_": str(time.time_ns()),
            })
            route_url = urljoin(base.rstrip("/") + "/", f"/web/game.html?{route_params}")
            request_json(port, "POST", f"/session/{session_id}/url", {"url": route_url}, timeout=30)
            wait_for_page(port, session_id)
            time.sleep(0.8)
            route_before = execute_js(port, session_id, canvas_checksum_script())
            route_review_state = execute_js(port, session_id, mobile_review_controls_state_script())
            if route_review_state.get("hidden") or route_review_state.get("buttonCount", 0) < 2:
                raise WebDriverError(f"routeAssist did not expose mobile review controls: {route_review_state!r}")
            route_assist_summary = execute_js(port, session_id, route_assist_exit_summary_script())
            if (
                "map2_02d" not in route_assist_summary.get("targets", [])
                or "auto top" not in str(route_assist_summary.get("summary") or "")
                or "spawn 47,47" not in str(route_assist_summary.get("summary") or "")
                or not route_assist_summary.get("autoTrigger")
                or route_assist_summary.get("side") != "top"
            ):
                raise WebDriverError(f"routeAssist exit summary did not identify top auto spawn: {route_assist_summary!r}")
            route_state = execute_js(port, session_id, start_touch_on_canvas_up_script(pointer_id=80))
            if not route_state.get("active"):
                raise WebDriverError(f"routeAssist touch did not activate joystick: {route_state!r}")
            if route_state.get("knobY") != "-48px":
                raise WebDriverError(f"routeAssist touch did not clamp joystick up to radius: {route_state!r}")
            wait_for_map_value(port, session_id, "map2_02d")
            route_after = execute_js(port, session_id, canvas_checksum_script())
            route_search = str(execute_js(port, session_id, current_search_script()))
            execute_js(port, session_id, end_touch_script(pointer_id=80))
            if route_after == route_before:
                raise WebDriverError("routeAssist transition did not change the rendered scene")
            if "map=map2_02d" not in route_search or "trialTransitions=routeAssist" not in route_search:
                raise WebDriverError(f"routeAssist transition did not persist current route URL: {route_search!r}")
            if "transitionTarget=" in route_search:
                raise WebDriverError(f"routeAssist transition leaked an implicit transitionTarget into URL: {route_search!r}")
            post_frontier_button = execute_js(port, session_id, click_route_assist_button_script())
            post_frontier_target = post_frontier_button.get("selectedTarget")
            if (
                not post_frontier_button.get("active")
                or post_frontier_button.get("map") != "map2_02d"
                or post_frontier_button.get("frontierHidden") is not False
                or post_frontier_target != "map2_18d"
                or post_frontier_button.get("frontierValue") != "map2_18d:46,0"
                or str(post_frontier_target) not in str(post_frontier_button.get("title") or "")
                or "trialTransitions=routeAssist" not in str(post_frontier_button.get("search") or "")
                or "transitionTarget=" not in str(post_frontier_button.get("search") or "")
                or "focusTile=" not in str(post_frontier_button.get("search") or "")
            ):
                raise WebDriverError(f"route assist button did not continue from map2_02d candidate frontier: {post_frontier_button!r}")
            route_continue_before = execute_js(port, session_id, canvas_checksum_script())
            route_continue_state = execute_js(port, session_id, start_touch_on_canvas_up_script(pointer_id=83))
            if not route_continue_state.get("active"):
                raise WebDriverError(f"map2_02d route continuation touch did not activate joystick: {route_continue_state!r}")
            if route_continue_state.get("knobY") != "-48px":
                raise WebDriverError(f"map2_02d route continuation touch did not clamp joystick up to radius: {route_continue_state!r}")
            wait_for_map_value(port, session_id, "map2_18d", timeout=6)
            route_continue_after = execute_js(port, session_id, canvas_checksum_script())
            route_continue_search = str(execute_js(port, session_id, current_search_script()))
            execute_js(port, session_id, end_touch_script(pointer_id=83))
            if route_continue_after == route_continue_before:
                raise WebDriverError("map2_02d route continuation did not change the rendered scene")
            if "map=map2_18d" not in route_continue_search or "trialTransitions=routeAssist" not in route_continue_search:
                raise WebDriverError(f"map2_02d route continuation did not persist current route URL: {route_continue_search!r}")
            if "transitionTarget=" in route_continue_search:
                raise WebDriverError(f"map2_02d route continuation leaked transitionTarget into URL: {route_continue_search!r}")

            smoke_report = {
                "status": "passed",
                "base": base,
                "initialUrl": url,
                "movementSearch": movement_search,
                "normalRouteSearch": normal_route_search,
                "routeAssistSearch": route_search,
                "routeContinueSearch": route_continue_search,
                "routeGoalSearch": route_goal_search,
                "routeGoalBlockerSearch": route_goal_blocker_search,
                "routeGoalTargetSearch": route_goal_target_search,
                "routeGoalCandidateSearch": route_goal_candidate_search,
                "routeGoalFinishSearch": route_goal_finish_search,
                "routeReturnFocusSearch": route_return_focus_search,
                "routeReturnTargetSearch": route_return_target_search,
                "checks": {
                    "canvasJoystick": f"checksum {before}->{moved}; knobX={canvas_state.get('knobX')}",
                    "documentJoystick": f"active={document_state.get('active')}; knobY={document_state.get('knobY')}",
                    "overviewButton": (
                        f"{overview_button.get('afterOn', {}).get('search')} -> "
                        f"{overview_button.get('afterOff', {}).get('search')}"
                    ),
                    "mapFilter": f"{map_filter.get('count')} options; selected={map_filter.get('selected')}",
                    "mobileMenuButton": (
                        f"hidden={mobile_menu_button.get('before', {}).get('hidden')} "
                        f"{mobile_menu_button.get('before', {}).get('menuOpen')}->"
                        f"{mobile_menu_button.get('afterOpen', {}).get('menuOpen')}->"
                        f"{mobile_menu_button.get('afterClose', {}).get('menuOpen')} "
                        f"selectedAfterMove={mobile_menu_button.get('afterMove', {}).get('selectedMenuItemIndex')} "
                        f"menuConfirmSoundCount={mobile_menu_sound_counts.get('menuConfirm')} "
                        f"menuMoveSoundCount={mobile_menu_sound_counts.get('menuMove')} "
                        f"menuSound=runtime-menu-sound:{','.join(mobile_menu_sound_actions)} "
                        f"menuSoundSrcs={','.join(mobile_menu_sound_sources)}"
                    ),
                    "routeAssistControls": (
                        f"hiddenBefore={normal_assist_controls.get('pathHidden')}; "
                        f"shownAfter={assist_button.get('pathHidden') is False}; "
                        f"targets={assist_button.get('pathTargetCount')}"
                    ),
                    "savedatScanHud": "; ".join(no_savedat_lines),
                    "sounds": (
                        f"{len(sound_keys)} WAV keys; "
                        f"step={sound_volumes.get('step')} "
                        f"transition={sound_volumes.get('transition')} "
                        f"battle={sound_sources.get('battleStart')} "
                        f"movementStepCount={movement_sound_counts.get('step')} "
                        f"dialogueCount={dialogue_sound_counts.get('dialogue')} "
                        f"battleStartCount={battle_sound_counts.get('battleStart')} "
                        f"battleHitCount={battle_sound_counts.get('battleHit')} "
                        f"victoryCount={battle_sound_counts.get('victory')}"
                    ),
	                    "dialogue": (
	                        f"dialogueButton {action_dialogue_open.get('activeId')} "
	                        f"line {action_dialogue_open.get('activeIndex')}->{action_dialogue_advanced.get('activeIndex')} "
	                        f"vmPartial={(action_dialogue_open.get('vmReplay') or {}).get('browserEventVmPartialReplayImplemented')} "
	                        f"vmFull={(action_dialogue_open.get('vmReplay') or {}).get('browserEventVmFullImplementation')} "
	                        f"vmOpcodes={','.join((action_dialogue_open.get('vmReplay') or {}).get('executedOpcodes') or [])} "
	                        f"vmRender={(action_dialogue_open.get('vmReplay') or {}).get('renderEventCount')} "
	                        f"vmDriven={(action_dialogue_open.get('vmReplay') or {}).get('vmDrivenLineCount')}/"
	                        f"{(action_dialogue_open.get('vmReplay') or {}).get('lineCount')} "
	                        f"vmCoverage={(action_dialogue_open.get('vmReplay') or {}).get('renderCoverageStatus')} "
	                        f"vmSource={(action_dialogue_open.get('activeVmRenderEvent') or {}).get('textSourceValueHex')}"
	                    ),
                    "menuParty": (
                        f"{menu_party_search}; quickLoad="
                        f"{','.join(manual_party_quick_load.get('activeLabels') or [])} "
                        f"{manual_party_quick_load.get('search')}"
                    ),
                    "normalRoute": f"{normal_route_before}->{normal_route_after}; {normal_route_search}",
                    "routeGoalNext": (
                        f"{route_goal_before.get('selectedGoal')} "
                        f"{route_goal_before.get('nextTitle')} -> {route_goal_search}"
                    ),
                    "routeGoalBlocker": (
                        f"{route_goal_after.get('nextTitle')} -> {route_goal_blocker_search}"
                    ),
                    "routeGoalClickThrough": (
                        f"{route_goal_target_click.get('before', {}).get('text')} {route_goal_target_search}; "
                        f"{route_goal_candidate_click.get('before', {}).get('text')} {route_goal_candidate_search}; "
                        f"{route_goal_finish_click.get('before', {}).get('text')} {route_goal_finish_search}"
                    ),
                    "routeReturn": (
                        f"{route_return_before.get('nextTitle')} -> {route_return_focus_search}; "
                        f"{route_return_target_click.get('before', {}).get('text')} {route_return_target_search}"
                    ),
                    "battle": (
                        f"battleButton {battle_party.get('activeName')}->{battle_after_state.get('activeName')}; "
                        f"normalTurnAutoSource={(battle_after_state.get('turnAutoSave') or {}).get('source')}; "
                        f"normalTextTable={normal_turn_auto.get('commandTextTableKey')} "
                        f"normalTextRef={normal_turn_auto.get('commandTextTableRefVaHex')} "
                        f"normalTextVa={normal_turn_auto.get('commandTextTableTextVaHex')}; "
                        f"nextCommandTextTable={((battle_after_state.get('commandDetails') or [{}])[0]).get('textTableKey')} "
                        f"nextCommandTextRef={((battle_after_state.get('commandDetails') or [{}])[0]).get('textTableRefVaHex')} "
                        f"nextCommandTextVa={((battle_after_state.get('commandDetails') or [{}])[0]).get('textTableTextVaHex')}; "
                        f"rewardGranted={battle_finish.get('rewardGranted')}; "
                        f"autoSource={battle_save.get('source')}; "
                        f"quickLoadMoney={quick_load_money.get('money')}; "
                        f"quickLoadParty={','.join(quick_load_money.get('activeLabels') or [])}"
                    ),
                    "savedatBattleItem": (
                        f"ATK={actor_base_stats.get('atk')} DEF={actor_base_stats.get('def')} "
                        f"damage={savedat_battle.get('actorAttackDamage')}/"
                        f"{savedat_battle.get('actorIncomingDamage')}; "
                        f"attackTextTable={(savedat_battle.get('actorAttackCommand') or {}).get('textTableKey')} "
                        f"attackTextRef={(savedat_battle.get('actorAttackCommand') or {}).get('textTableRefVaHex')} "
                        f"attackTextVa={(savedat_battle.get('actorAttackCommand') or {}).get('textTableTextVaHex')}; "
                        f"{party_skill_summary}; "
                        f"partySkillExec="
                        f"{savedat_party_skill_exec.get('rinshan', {}).get('before', {}).get('command')}/"
                        f"{savedat_party_skill_exec.get('smashu', {}).get('before', {}).get('command')}; "
                        f"partySkillTextRefs="
                        f"Rinshan:{savedat_party_skill_exec.get('rinshan', {}).get('before', {}).get('textTableRefVaHex')}->"
                        f"{savedat_party_skill_exec.get('rinshan', {}).get('before', {}).get('textTableTextVaHex')},"
                        f"Smashu:{savedat_party_skill_exec.get('smashu', {}).get('before', {}).get('textTableRefVaHex')}->"
                        f"{savedat_party_skill_exec.get('smashu', {}).get('before', {}).get('textTableTextVaHex')}; "
                        f"skill={savedat_skill.get('before', {}).get('command')} "
                        f"{savedat_skill.get('before', {}).get('skillBlock')}#"
                        f"{savedat_skill.get('before', {}).get('skillIndex')} "
                        f"skillTextTable={savedat_skill.get('before', {}).get('textTableKey')} "
                        f"skillTextRef={savedat_skill.get('before', {}).get('textTableRefVaHex')} "
                        f"skillTextVa={savedat_skill.get('before', {}).get('textTableTextVaHex')} "
                        f"MP {savedat_skill.get('before', {}).get('mp')}->{savedat_skill.get('after', {}).get('mp')}; "
                        f"skillAutoSource={(savedat_skill.get('autoSave') or {}).get('source')}; "
                        f"skillFeedback={battle_skill_feedback_summary(savedat_skill)}; "
                        f"skillFeedbackRender={battle_skill_feedback_rendered(savedat_skill)}; "
                        f"skillFeedbackSound={battle_skill_feedback_sound_summary(savedat_skill)}; "
                        f"turnAutoSource={(savedat_skill.get('turnAutoSave') or {}).get('source')}; "
                        f"skillObjective={(savedat_skill.get('objectiveBefore') or {}).get('title')}; "
                        f"skillObjectiveAction={(savedat_skill.get('objectiveAction') or {}).get('action')}; "
                        f"skillObjectiveActiveId={(savedat_skill.get('objectiveAction') or {}).get('activeId')}; "
                        f"skillNoticeFeedback={skill_notice_feedback.get('source')}:{skill_notice_feedback.get('text')}; "
                        f"skillNoticeFeedbackRender={skill_notice_feedback_render.get('browserBattleSkillFeedbackImplemented') is True}; "
                        f"skillNoticeSound={skill_notice_feedback.get('battleSkillSound')}:"
                        f"{skill_notice_feedback.get('battleSkillSoundSrc')}:"
                        f"{skill_notice_feedback.get('battleSkillSoundPlayed')}; "
                        f"skillTitleContinue={(skill_title_continue_restore.get('objectiveBefore') or {}).get('title')}/"
                        f"{(skill_title_continue_restore.get('objectiveAction') or {}).get('action')}/"
                        f"{(skill_title_continue_restore.get('objectiveAction') or {}).get('activeId')}; "
                        f"noMP={savedat_skill_no_mp.get('before', {}).get('mp')}/"
                        f"{savedat_skill_no_mp.get('before', {}).get('mpCost')} "
                        f"enemy {savedat_skill_no_mp.get('before', {}).get('enemyHp')}->"
                        f"{savedat_skill_no_mp.get('after', {}).get('enemyHp')}; "
                        f"noMPFeedback={battle_skill_feedback_summary(savedat_skill_no_mp)}; "
                        f"noMPFeedbackRender={battle_skill_feedback_rendered(savedat_skill_no_mp)}; "
                        f"noMPFeedbackSound={battle_skill_feedback_sound_summary(savedat_skill_no_mp)}; "
                        f"{savedat_item.get('before', {}).get('item')} "
                        f"{savedat_item.get('before', {}).get('count')}->{savedat_item.get('after', {}).get('count')}; "
                        f"itemAutoSource={(savedat_item.get('autoSave') or {}).get('source')}; "
                        f"itemTextTable={savedat_item_marker.get('itemTextTableKey')} "
                        f"itemTextRef={savedat_item_marker.get('itemTextTableRefVaHex')} "
                        f"itemTextVa={savedat_item_marker.get('itemTextTableTextVaHex')}; "
                        f"itemFeedback={battle_item_feedback_summary(savedat_item)}; "
                        f"itemFeedbackRender={battle_item_feedback_rendered(savedat_item)}; "
                        f"itemFeedbackSound={battle_item_feedback_sound_summary(savedat_item)}; "
                        f"itemObjective={(savedat_item.get('objectiveBefore') or {}).get('title')}; "
                        f"itemObjectiveAction={(savedat_item.get('objectiveAction') or {}).get('action')}; "
                        f"itemObjectiveActiveId={(savedat_item.get('objectiveAction') or {}).get('activeId')}; "
                        f"itemTitleContinue={(item_title_continue_restore.get('objectiveBefore') or {}).get('title')}/"
                        f"{(item_title_continue_restore.get('objectiveAction') or {}).get('action')}/"
                        f"{(item_title_continue_restore.get('objectiveAction') or {}).get('activeId')}; "
                        f"next={savedat_item.get('nextActor')}; "
                        f"menuPotion={savedat_item_menu.get('after', {}).get('potionCount')}; "
                        f"menuAutoSource={(savedat_item_menu.get('autoSave') or {}).get('source')}; "
                        f"menuItemTextRef={savedat_item_menu_marker.get('itemTextTableRefVaHex')}; "
                        f"menuItemFeedback={battle_item_feedback_summary(savedat_item_menu)}; "
                        f"menuItemFeedbackRender={battle_item_feedback_rendered(savedat_item_menu)}; "
                        f"menuItemFeedbackSound={battle_item_feedback_sound_summary(savedat_item_menu)}; "
                        f"menuObjective={(savedat_item_menu.get('objectiveBefore') or {}).get('title')}; "
                        f"menuObjectiveAction={(savedat_item_menu.get('objectiveAction') or {}).get('action')}; "
                        f"menuObjectiveActiveId={(savedat_item_menu.get('objectiveAction') or {}).get('activeId')}; "
                        f"antidote={savedat_status_item.get('before', {}).get('statuses')}->"
                        f"{savedat_status_item.get('after', {}).get('statuses')}; "
                        f"statusItemTextRef={savedat_status_marker.get('itemTextTableRefVaHex')}; "
                        f"statusItemFeedback={battle_item_feedback_summary(savedat_status_item)}; "
                        f"statusItemFeedbackRender={battle_item_feedback_rendered(savedat_status_item)}; "
                        f"statusItemFeedbackSound={battle_item_feedback_sound_summary(savedat_status_item)}; "
                        f"statusObjective={(savedat_status_item.get('objectiveBefore') or {}).get('title')}; "
                        f"statusObjectiveAction={(savedat_status_item.get('objectiveAction') or {}).get('action')}; "
                        f"statusObjectiveActiveId={(savedat_status_item.get('objectiveAction') or {}).get('activeId')}; "
                        f"revive={savedat_revive_item.get('before', {}).get('fallen')} "
                        f"{savedat_revive_item.get('after', {}).get('hp')}/"
                        f"{savedat_revive_item.get('after', {}).get('hpMax')}; "
                        f"reviveItemTextRef={savedat_revive_marker.get('itemTextTableRefVaHex')}; "
                        f"reviveItemFeedback={battle_item_feedback_summary(savedat_revive_item)}; "
                        f"reviveItemFeedbackRender={battle_item_feedback_rendered(savedat_revive_item)}; "
                        f"reviveItemFeedbackSound={battle_item_feedback_sound_summary(savedat_revive_item)}; "
                        f"reviveObjective={(savedat_revive_item.get('objectiveBefore') or {}).get('title')}; "
                        f"reviveObjectiveAction={(savedat_revive_item.get('objectiveAction') or {}).get('action')}; "
                        f"reviveObjectiveActiveId={(savedat_revive_item.get('objectiveAction') or {}).get('activeId')}"
                    ),
                    "savedatMenuItem": (
                        f"count {savedat_menu_item.get('before', {}).get('count')}->{savedat_menu_item.get('after', {}).get('count')}; "
                        f"itemAutoSource={(savedat_menu_item.get('autoSave') or {}).get('source')}; "
                        f"itemTextRef={savedat_menu_item_marker.get('itemTextTableRefVaHex')}; "
                        f"inventoryFeedback={inventory_item_feedback_summary(savedat_menu_item)}; "
                        f"inventoryFeedbackRender={inventory_item_feedback_rendered(savedat_menu_item)}; "
                        f"inventoryFeedbackSound={inventory_item_feedback_sound_summary(savedat_menu_item)}; "
                        f"itemObjective={(savedat_menu_item.get('objectiveBefore') or {}).get('title')}; "
                        f"itemObjectiveAction={(savedat_menu_item.get('objectiveAction') or {}).get('action')}; "
                        f"itemObjectiveActiveId={(savedat_menu_item.get('objectiveAction') or {}).get('activeId')}; "
                        f"antidote={savedat_menu_status.get('before', {}).get('statuses')}->"
                        f"{savedat_menu_status.get('after', {}).get('statuses')}; "
                        f"statusSave={savedat_menu_quick_save.get('savedStatus')}->"
                        f"{savedat_menu_quick_load.get('loadedStatus')}; "
                        f"savedHp={savedat_menu_quick_save.get('savedHp')}; "
                        f"loadedHp={savedat_menu_quick_load.get('loadedHp')}; "
                        f"titleContinue={savedat_menu_title_continue.get('titleContinue')}; "
                        f"titleCount={savedat_menu_title_continue.get('itemCount')}; "
                        f"titleHp={savedat_menu_title_continue.get('atahoHp')}; "
                        f"titleStatus={savedat_menu_title_continue.get('atahoStatuses')}; "
                        f"titleObjective={(savedat_menu_title_continue.get('objectiveBefore') or {}).get('title')}; "
                        f"titleObjectiveAction={(savedat_menu_title_continue.get('objectiveAction') or {}).get('action')}; "
                        f"titleObjectiveActiveId={(savedat_menu_title_continue.get('objectiveAction') or {}).get('activeId')}"
                    ),
                    "savePointCandidate": (
                        f"{save_point_candidate_menu.get('activeId')} "
                        f"scope={save_point_last.get('scope')} "
                        f"count={save_point_last.get('count')} "
                        f"runtime={save_point_candidate_menu.get('originalSavePointRuntimeImplemented')} "
                        f"line={save_point_lines[0] if save_point_lines else ''}"
                    ),
                    "candidateSavePoint": (
                        f"{candidate_save_menu.get('activeId')} "
                        f"scope={candidate_save_last.get('scope')} "
                        f"count={candidate_save_last.get('count')} "
                        f"saved={candidate_save_runtime.get('saved')} "
                        f"payloadMap={candidate_save_payload.get('map')} "
                        f"blockIds={','.join(candidate_save_runtime.get('blockIds') or [])}"
                    ),
                    "eventObjectPreview": (
                        f"{event_object_render.get('map') or event_object_preview.get('map')} "
                        f"candidates={event_object_render.get('candidateCount')} "
                        f"loaded={event_object_render.get('loadedCount')} "
                        f"rendered={event_object_render.get('renderedCount')} "
                        f"assets={','.join(row.get('assetKey', '') for row in event_object_rows)} "
                        f"events={event_object_preview.get('showEventHotspots')}"
                    ),
                    "savedatUrl": (
                        f"{savedat_url_state.get('fileName')} selector "
                        f"{savedat_url_state.get('group')}:{savedat_url_state.get('slot')} "
                        f"map={savedat_url_state.get('map')} status={savedat_url_state.get('routeStatus')}"
                    ),
                    "savedatUrlZip": (
                        f"{(zip_url_archive.get('selected') or {}).get('name')} "
                        f"entries={zip_url_archive.get('entryCount')} "
                        f"selector={savedat_zip_url_state.get('group')}:{savedat_zip_url_state.get('slot')} "
                        f"status={savedat_zip_url_state.get('routeStatus')} "
                        f"hud={'; '.join(savedat_zip_url_state.get('hudLines') or [])}"
                    ),
                    "savedatScan": (
                        f"loaded={((savedat_scan_state.get('scan') or {}).get('loaded'))}; "
                        f"map={savedat_scan_state.get('map')}; "
                        f"selected={((savedat_scan_state.get('scan') or {}).get('selected') or {}).get('fileName')}; "
                        f"selector={savedat_scan_state.get('group')}:{savedat_scan_state.get('slot')}; "
                        f"syntheticLocalProbe={savedat_scan_state.get('localProbeSynthetic')}; "
                        f"found={len(savedat_scan_found)}"
                    ),
                    "savedatDrop": (
                        f"{drop_state.get('fileName')} "
                        f"selector={synthetic_savedat_drop.get('group')}:{synthetic_savedat_drop.get('slot')} "
                        f"status={drop_state.get('routeStatus')} "
                        f"enter={synthetic_savedat_drop.get('activeAfterEnter')} "
                        f"over={synthetic_savedat_drop.get('activeAfterOver')} "
                        f"dropActive={synthetic_savedat_drop.get('activeAfterDrop')} "
                        f"real={drop_state.get('realRouteProofCandidate')} "
                        f"synthetic={drop_state.get('syntheticDiagnostic')}"
                    ),
                    "syntheticSelector20": (
                        f"map={synthetic_savedat_file.get('map')} "
                        f"selector={synthetic_savedat_file.get('group')}:{synthetic_savedat_file.get('slot')} "
                        f"status={synthetic_route_evidence.get('status')}"
                    ),
                    "savedatZip": (
                        f"{(zip_archive.get('selected') or {}).get('name')} "
                        f"entries={zip_archive.get('entryCount')} "
                        f"selector={synthetic_savedat_zip.get('group')}:{synthetic_savedat_zip.get('slot')} "
                        f"status={zip_route_evidence.get('status')} "
                        f"hud={'; '.join(synthetic_savedat_zip.get('hudLines') or [])}"
                    ),
                    "syntheticQuickSaveLoad": (
                        f"save={synthetic_quick_save.get('status')} "
                        f"load={synthetic_quick_load.get('status')} "
                        f"real={synthetic_quick_load.get('realRouteProofCandidate')} "
                        f"synthetic={synthetic_quick_load.get('syntheticDiagnostic')}"
                    ),
                    "routeAssist": (
                        f"{route_assist_summary.get('summary')}; "
                        f"{route_before}->{route_after}; {route_search}"
                    ),
                    "routeContinue": (
                        f"target={post_frontier_target}; "
                        f"{route_continue_before}->{route_continue_after}; {route_continue_search}"
                    ),
                },
                "snapshots": {
                    "canvas": canvas_state,
                    "overviewButton": overview_button,
                    "document": document_state,
                    "sounds": sound_state,
                    "mapFilter": map_filter,
                    "mobileMenuButton": mobile_menu_button,
                    "savedatScanHud": no_savedat_scan_hud,
                    "dialogueButton": dialogue_button_click,
                    "dialogueReady": action_dialogue_ready,
                    "dialogueOpen": action_dialogue_open,
                    "dialogueAdvanced": action_dialogue_advanced,
                    "manualPartyQuickSave": manual_party_quick_save,
                    "manualPartyQuickLoad": manual_party_quick_load,
                    "battleButton": battle_button_click,
                    "routeGoalBefore": route_goal_before,
                    "routeGoalClick": route_goal_click,
                    "routeGoalAfter": route_goal_after,
                    "routeGoalBlockerClick": route_goal_blocker_click,
                    "routeGoalBlockerAfter": route_goal_blocker_after,
                    "routeGoalTargetClick": route_goal_target_click,
                    "routeGoalTargetAfter": route_goal_target_after,
                    "routeGoalCandidateClick": route_goal_candidate_click,
                    "routeGoalCandidateAfter": route_goal_candidate_after,
                    "routeGoalFinishClick": route_goal_finish_click,
                    "routeGoalFinishAfter": route_goal_finish_after,
                    "routeReturnBefore": route_return_before,
                    "routeReturnFocusClick": route_return_focus_click,
                    "routeReturnFocusAfter": route_return_focus_after,
                    "routeReturnTargetClick": route_return_target_click,
                    "routeReturnTargetAfter": route_return_target_after,
                    "battleParty": battle_party,
                    "battleAfterState": battle_after_state,
                    "battleFinish": battle_finish,
                    "battleExit": battle_exit,
                    "battleQuickSave": battle_save,
                    "battleQuickLoad": quick_load_money,
                    "savedatBattle": savedat_battle,
                    "savedatBattlePartySkills": savedat_party_skills,
                    "savedatBattlePartySkillExec": savedat_party_skill_exec,
                    "savedatBattleSkill": savedat_skill,
                    "savedatBattleSkillTitleContinueStart": skill_title_continue_start,
                    "savedatBattleSkillTitleContinue": skill_title_continue_restore,
                    "savedatBattleSkillNoMp": savedat_skill_no_mp,
                    "savedatBattleItem": savedat_item,
                    "savedatBattleItemTitleContinueStart": item_title_continue_start,
                    "savedatBattleItemTitleContinue": item_title_continue_restore,
                    "savedatBattleItemMenu": savedat_item_menu,
                    "savedatBattleStatusItem": savedat_status_item,
                    "savedatBattleReviveItem": savedat_revive_item,
                    "savedatMenu": savedat_menu,
                    "savedatMenuItem": savedat_menu_item,
                    "savedatMenuStatusItem": savedat_menu_status,
                    "savedatMenuQuickSave": savedat_menu_quick_save,
                    "savedatMenuQuickLoad": savedat_menu_quick_load,
                    "savedatMenuTitleContinueStart": savedat_menu_title_continue_start,
                    "savedatMenuTitleContinue": savedat_menu_title_continue,
                    "savePointCandidateStart": save_point_candidate_start,
                    "savePointCandidateMenu": save_point_candidate_menu,
                    "candidateSavePointStart": candidate_save_start,
                    "candidateSavePointMenu": candidate_save_menu,
                    "eventObjectPreview": event_object_preview,
                    "savedatUrl": savedat_url_state,
                    "savedatUrlZip": savedat_zip_url_state,
                    "savedatScan": savedat_scan_state,
                    "syntheticSavedat": synthetic_savedat_file,
                    "syntheticSavedatZip": synthetic_savedat_zip,
                    "syntheticQuickSave": synthetic_quick_save,
                    "syntheticQuickLoad": synthetic_quick_load,
                    "routeReview": route_review_state,
                    "routeSummary": route_assist_summary,
                    "postFrontier": post_frontier_button,
                    "routeContinueState": route_continue_state,
                },
            }
            write_smoke_report(smoke_report)

            checks = smoke_report["checks"]
            print(
                "ok mobile browser controls "
                f"{url} checksum {before}->{moved} "
                f"canvas={checks['canvasJoystick']} document={checks['documentJoystick']} "
                f"movementSearch={movement_search} "
                f"dialogue={checks['dialogue']} menuParty={checks['menuParty']} "
                f"battle={checks['battle']} savedatBattleItem={checks['savedatBattleItem']} "
                f"savedatMenuItem={checks['savedatMenuItem']} "
                f"savePointCandidate={checks['savePointCandidate']} "
                f"candidateSavePoint={checks['candidateSavePoint']} "
                f"eventObjectPreview={checks['eventObjectPreview']} "
                f"savedatScan={checks['savedatScanHud']} "
                f"syntheticQuick={checks['syntheticQuickSaveLoad']} "
                f"routeAssist={checks['routeAssist']} routeContinue={checks['routeContinue']}"
            )
        finally:
            if session_id:
                try:
                    request_json(port, "DELETE", f"/session/{session_id}", timeout=5)
                except Exception:
                    pass
            proc.terminate()
            try:
                proc.wait(timeout=3)
            except subprocess.TimeoutExpired:
                proc.kill()
                proc.wait()
            if remove_scan_probe:
                scan_probe_path.unlink(missing_ok=True)
            if remove_scan_zip_probe:
                scan_zip_probe_path.unlink(missing_ok=True)
            if remove_zip_probe:
                zip_probe_path.unlink(missing_ok=True)
    if not keep_log:
        log_path.unlink(missing_ok=True)


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--base", default="http://127.0.0.1:8013")
    parser.add_argument("--keep-log", action="store_true")
    args = parser.parse_args()
    verify_browser(args.base, keep_log=args.keep_log)


if __name__ == "__main__":
    main()
