#!/usr/bin/env python3
"""Run mn103s_emu.py against an explicit firmware binary without profiles.

This is intentionally direct-config only. It creates a temporary JSON config
from command-line arguments, then invokes mn103s_emu.py with that config.
Use this for quick smoke traces across many different firmware models.

v97 notes:
  * adds raw Xbox .xdvd.json sidecar response loading
  * uses captured locked/unlocked READ CAPACITY and MODE SENSE page 0x3E data
  * uses captured external READ DVD STRUCTURE payloads (.ad_c0/.pfi/.dmi)
  * keeps v89/stage6 branch excluded

v95 notes:
  * adds MODE SELECT data-out injection scaffold and READ CAPACITY before/after auth check
  * keeps v89/stage6 branch excluded

v90 notes:
  * stays based on v88; v89/stage6 reference branch intentionally excluded
  * adds Xbox auth-flow CDB aliases for runtime-only probing

v88 notes:
  * added auth/status candidate watchpoints and summaries for GDR packet traces
  * --auth-watch appends the expanded low-RAM flag watch set

v87 notes:
  * --packet-cdb now feeds the second 8-byte packet-service frame with CDB[6:12]
  * added gdr8050l-packet-full-cdb preset to continue past 0x900210C1
  * added a timer escape for the 0x9000880F DC54 wait seen after full packet feed

v83 notes:
  * added gdr8050l-packet-continue preset for the post-FE packet transfer path
  * models the D904 ready/completion waits and the 0xDC54 short timer wait

v82 notes:
  * added --packet-cdb and gdr8050l-packet-cdb for the FE packet-phase branch
  * packet-phase overrides package the previously manual DF30/DF31 sequence

v81 notes:
  * added stop_conditions support for host-CDB idle-loop completion
  * gdr8050l-host-cdb now stops after the consumed command returns to idle

v80 notes:
  * added --host-cdb for direct SCSI CDB injection into GDR DF30..DF37 latch
  * added gdr8050l-host-cdb preset for command-dispatch tracing

v77 notes:
  * watchpoints now accept @read/@write/@access or :read/:write/:access kinds
  * added gdr8050l-service-loop preset for host/status latch tracing

v76 notes:
  * comma-list options also tolerate PowerShell array splitting
  * repeatable --breakpoint / --watchpoint forms are supported
  * optional presets add known-good bring-up trace points without using profiles
"""
from __future__ import annotations

import argparse
import csv
import hashlib
import json
import subprocess
import sys
import tempfile
from collections import Counter
from pathlib import Path
from typing import Any, Iterable

from xdvd_sidecar import XDVDSidecar, bytes_to_hex, load_sidecar

VERSION = "v120-gdr-stream-match-sidecar-matrix"


CDB_ALIASES: dict[str, list[int]] = {
    # 6-byte command helpers
    "tur": [0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
    "test-unit-ready": [0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
    "inquiry": [0x12, 0x00, 0x00, 0x00, 0x24, 0x00],
    "read-capacity": [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
    "read-capacity-10": [0x25, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00],
    # Xbox auth-flow command shapes taken from the user-provided scsi1.pas reference,
    # not Stage6.  They are only packet CDB aliases; no emulator behavior is
    # inferred from outside metadata.
    "mode-sense-auth-xbox": [0x5A, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00],
    "xbox-mode-sense-auth": [0x5A, 0x00, 0x3E, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00],
    "mode-select-auth-xbox": [0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00],
    "xbox-mode-select-auth": [0x55, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x00],
    # START STOP UNIT: byte 4 LOEJ/START bits. 0x02 unload/eject, 0x03 load/inject.
    "eject": [0x1B, 0x00, 0x00, 0x00, 0x02, 0x00],
    "eject-tray": [0x1B, 0x00, 0x00, 0x00, 0x02, 0x00],
    "inject": [0x1B, 0x00, 0x00, 0x00, 0x03, 0x00],
    "inject-tray": [0x1B, 0x00, 0x00, 0x00, 0x03, 0x00],
    "load": [0x1B, 0x00, 0x00, 0x00, 0x03, 0x00],
    "start": [0x1B, 0x00, 0x00, 0x00, 0x01, 0x00],
    "stop": [0x1B, 0x00, 0x00, 0x00, 0x00, 0x00],
    # Project-specific convenience alias from scsi1.pas probing.
    "read-dvd-structure-xbox": [0xAD, 0x00, 0xFF, 0x02, 0xFD, 0xFF, 0xFE, 0x00, 0x06, 0x64, 0x00, 0xC0],
    "read-dvd-structure-pfi": [0xAD, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00],
    "read-dvd-structure-dmi": [0xAD, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x08, 0x00, 0x00, 0x00],
}

# Trace candidates for the GDR-8050L disc/auth state.  These are intentionally
# broad: 0x05B1 is the current strongest packet/auth candidate because the
# READ DVD STRUCTURE path sets bit 0x20 at 0x9001C83E, while the surrounding
# 0x05B*/0x06F*/0xDA*/0x014* bytes are neighbouring media/auth/packet state.
AUTH_WATCHPOINTS: list[str] = [
    # demoted but retained as controls
    "0x000005B1:1@access", "0x00000144:1@access",
    # v101: real-drive Red Faction locked->unlocked transition candidates
    "0x000005B4:1@access", "0x000005B6:2@access", "0x000005C6:1@access",
    "0x000005C9:1@access", "0x000005CD:2@access", "0x000005F0:1@access",
    "0x0000071E:1@access", "0x00000722:1@access", "0x00000724:1@access",
    "0x0000076D:2@access", "0x0000077C:4@access", "0x000007F8:2@access",
    "0x000006E1:1@access", "0x000006EC:2@access", "0x000006F1:2@access",
    "0x000006F4:4@access", "0x000006FC:2@access",
    "0x0000013B:1@access", "0x00000143:1@access", "0x0000014A:1@access",
    "0x0000037C:4@access",
    "0x0000DA00:1@access", "0x0000DA0C:1@access", "0x0000DA18:1@access",
    "0x0000DA24:1@access", "0x0000DA28:1@access", "0x0000DA3C:1@access",
    "0x0000D95C:1@access",
]

AUTH_BREAKPOINTS: list[str] = [
    # v101 real-drive auth-transition PC hypotheses
    "0x9001BC3B", "0x9001D086", "0x9001CFCA", "0x9001EF66", "0x9001EE4A",
    "0x9001B71B", "0x9001B75F", "0x9001B794", "0x9001B799",
    "0x9001B91B", "0x9001C83E", "0x9001C84B", "0x9001C873",
    "0x9001C89B", "0x9001C61F", "0x9001D61E", "0x9001D6D8",
    "0x9001D8D2", "0x9001DBC8", "0x9001DD7C", "0x90024544",
    "0x9002F56D",
]

AUTH_LABELS: dict[int, str] = {
    0x000005B1: "primary packet/auth candidate flags",
    0x000005B4: "media/mech state flags",
    0x000005B6: "secondary media/auth flags",
    0x000005B7: "packet result flags",
    0x000005C6: "global media/error state flags",
    0x000005CD: "command completion/error flags",
    0x000005CE: "command completion/error flags",
    0x000005F0: "packet status byte",
    0x000006E1: "packet/media state",
    0x000006EC: "packet response bytes",
    0x000006ED: "packet response bytes",
    0x000006F1: "packet phase/result state",
    0x000006F2: "packet sub-state",
    0x000006F4: "packet callback/handler pointer",
    0x000006FC: "packet transfer timer/length",
    0x0000013B: "host status/completion flag",
    0x00000143: "drive state mirror",
    0x0000014A: "drive state mirror",
    0x0000037C: "auth/procedure state candidate",
    0x0000037D: "auth/procedure state candidate",
    0x0000037E: "auth/procedure timer/counter candidate",
    0x0000037F: "auth/procedure timer/counter candidate",
    0x0000DA00: "hardware/media status input",
    0x0000DA0C: "packet/media state",
    0x0000DA18: "packet mode flag",
    0x0000DA24: "packet/media state",
    0x0000DA28: "packet/media state",
    0x0000DA3C: "packet mode/status byte",
}

PRESETS: dict[str, dict[str, list[str]]] = {
    "gdr8050l-reset": {
        # Avoid the tight clear-loop PCs by default; these are post/phase markers.
        "breakpoints": [
            "0x90000020",  # reset/vector entry used by the smoke run
            "0x9000005E",  # after low SRAM + 0x8003D000 clear loops
            "0x90000078",  # before early calls
            "0x90000084",  # between early init calls
            "0x90000090",  # post early init, hardware flag decision area
            "0x900000C4",  # reset path continuation
            "0x900000CD",  # early init callee
            "0x900000EB",  # early init callee
        ],
        "watchpoints": [
            "0x000005B1:1",  # command/status flag area seen in E7/HL work
            "0x000005D0:16", # low-RAM CDB mirror window
        ],
    },
    "gdr8050l-clear-loop": {
        "breakpoints": ["0x90000020", "0x90000044", "0x90000048", "0x90000058", "0x9000005C", "0x9000005E"],
        "watchpoints": ["0x000005B1:1", "0x000005D0:16"],
    },
    "gdr8050l-e7hl-watch": {
        "breakpoints": ["0x90000020"],
        "watchpoints": [
            "0x000005B1:1",
            "0x000005B6:1",
            "0x000005C6:1",
            "0x000005D0:16",
            "0x0000063F:2",
            "0x000006F1:1",
        ],
    },
    "gdr8050l-init64-ready": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x900099EA", "0x900099F0", "0x900099F7", "0x90009A0E",
            "0x90009A12", "0x90009A8A", "0x90009A91", "0x90009511",
            "0x90009526", "0x90009529", "0x9000604D", "0x9001BCD7"
        ],
        "watchpoints": [
            "0x000005D0:16", "0x000005B1:1", "0x000005B6:1", "0x000005C6:1",
            "0x0000063F:2", "0x0000D905:1", "0x0000DC54:2", "0x0000DF70:64"
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00"
        ],
    },
    "gdr8050l-service-loop": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x90024325", "0x9002432D", "0x90024335", "0x90024356",
            "0x9002438B", "0x90024393", "0x90024398", "0x9002439A",
            "0x9002439F", "0x9001F00A", "0x9001F029", "0x9001F02B",
            "0x9001F02E", "0x90009511", "0x90009526"
        ],
        "watchpoints": [
            "0x0000D904:1@access", "0x0000D905:1@access",
            "0x0000D950:1@access", "0x0000D954:1@access",
            "0x0000DF2A:1@access", "0x0000DF30:8@access",
            "0x0000DF70:64@access",
            "0x00000765:1@access", "0x00000784:1@access",
            "0x00000786:2@access", "0x00000798:4@access",
            "0x000007E8:8@access", "0x0000002C:2@access"
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00"
        ],
    },
    "gdr8050l-packet-cdb": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x900241D4", "0x900241E4", "0x90024325", "0x90024335",
            "0x90024346", "0x9002425D", "0x9002427E", "0x90024283",
            "0x900242B3", "0x900242CA", "0x900242D2", "0x9001B6D3",
            "0x9001B702", "0x9001C83E", "0x9001C873", "0x9001C89B",
            "0x9001B718"
        ],
        "watchpoints": [
            "0x0000D904:1@access", "0x0000D905:1@access",
            "0x0000D950:1@access", "0x0000D954:1@access",
            "0x0000DF2A:1@access", "0x0000DF30:8@access",
            "0x0000DF70:64@access", "0x0000DFB0:1@access",
            "0x0000DA18:1@access", "0x0000DA3C:1@access",
            "0x000005B1:1@access", "0x000007E8:8@access",
            "0x0000002C:2@access"
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00"
        ],
        "stop_conditions": [
            {"pc": "0x9001B718", "hit_count": 1, "min_cycle": 7400, "name": "gdr_packet_phase_complete"}
        ],
    },
    "gdr8050l-packet-continue": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x900241D4", "0x90024325", "0x90024335", "0x90024346",
            "0x9002425D", "0x900242B3", "0x900242CA", "0x900242D2",
            "0x9001B6D3", "0x9001B718", "0x90022309", "0x90022325",
            "0x9002232B", "0x90022334", "0x9002238F", "0x900223C0",
            "0x900223D6", "0x900210A8", "0x90021112", "0x90021131",
            "0x9002113E", "0x900210C1", "0x90006228", "0x90006237"
        ],
        "watchpoints": [
            "0x0000D904:1@access", "0x0000D905:1@access",
            "0x0000D950:1@access", "0x0000D954:1@access",
            "0x0000DF28:1@access", "0x0000DF2A:1@access",
            "0x0000DF30:8@access", "0x0000DF70:64@access",
            "0x0000DFB0:1@access", "0x0000DC54:2@access",
            "0x000005B1:1@access", "0x000007E8:8@access",
            "0x0000002C:2@access"
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00",
            "0x0000DC54@0x90006228=0x00",
            "0x0000DC55@0x90006228=0x00",
            "0x0000DC54@0x9000622C=0x10",
            "0x0000DC55@0x9000622C=0x00",
            "0x0000D904@0x9002113E=0x02",
            "0x0000D904@0x9002232B=0x02",
            "0x0000D904@0x900223C0=0x00"
        ],
        "stop_conditions": [
            {"pc": "0x900210C1", "hit_count": 1, "min_cycle": 7900, "name": "gdr_packet_transfer_service_returned"}
        ],
    },
    "gdr8050l-packet-full-cdb": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x900241D4", "0x90024325", "0x90024335", "0x90024346",
            "0x9002425D", "0x900242B3", "0x900242CA", "0x900242D2",
            "0x9001B6D3", "0x9001B718", "0x90022309", "0x90022325",
            "0x9002232B", "0x90022334", "0x9002238F", "0x900223C0",
            "0x900223D6", "0x900210A8", "0x90021112", "0x90021131",
            "0x9002113E", "0x900210C1", "0x90006228", "0x90006237",
            "0x900222FA", "0x900222FF", "0x90022301", "0x9001CD4D",
            "0x9000880F", "0x9000881B", "0x90008824",
            *AUTH_BREAKPOINTS
        ],
        "watchpoints": [
            "0x0000D904:1@access", "0x0000D905:1@access",
            "0x0000D950:1@access", "0x0000D954:1@access",
            "0x0000DF28:1@access", "0x0000DF2A:1@access",
            "0x0000DF30:8@access", "0x0000DF70:64@access",
            "0x0000DFB0:1@access", "0x0000DC54:2@access",
            "0x000005B1:1@access", "0x000007E8:8@access",
            "0x0000002C:2@access", "0x0000B030:2@access",
            *AUTH_WATCHPOINTS
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00",
            "0x0000DC54@0x90006228=0x00",
            "0x0000DC55@0x90006228=0x00",
            "0x0000DC54@0x9000622C=0x10",
            "0x0000DC55@0x9000622C=0x00",
            "0x0000D904@0x9002113E=0x02",
            "0x0000D904@0x9002232B=0x02",
            "0x0000D904@0x900223C0=0x00",
            "0x0000DC54@0x9000880F=0x00",
            "0x0000DC55@0x9000880F=0x20"
        ],
        "stop_conditions": [
            {"pc": "0x90008824", "hit_count": 1, "min_cycle": 9000, "name": "gdr_packet_full_cdb_timer_done"}
        ],
    },
    "gdr8050l-host-cdb": {
        "breakpoints": [
            "0x900000C4", "0x90006040", "0x9000647C", "0x9000D9E5",
            "0x90024325", "0x9002432D", "0x90024335", "0x90024356",
            "0x9002438B", "0x90024391", "0x9002439A", "0x9002439F",
            "0x9002068B", "0x9002069C", "0x900206BB", "0x900206C3",
            "0x900206EC", "0x900206F3", "0x90020700", "0x9002070F",
            "0x90020716", "0x90020729", "0x9002072D", "0x90020743",
            "0x900207C8", "0x9001F030", "0x9001F068", "0x9001F0B1",
            "0x9001F0E4", "0x9001F216"
        ],
        "watchpoints": [
            "0x0000D904:1@access", "0x0000D905:1@access",
            "0x0000D950:1@access", "0x0000D954:1@access",
            "0x0000DF2A:1@access", "0x0000DF30:8@access",
            "0x0000DF70:64@access",
            "0x00000770:16@access", "0x00000780:1@access",
            "0x00000784:1@access", "0x00000786:2@access",
            "0x00000798:4@access", "0x000007AC:4@access",
            "0x000007E8:8@access", "0x0000002C:2@access"
        ],
        "read_overrides": [
            "0x0000D905@0x900099F0=0x03",
            "0x0000D905@0x90009A12=0x01",
            "0x0000D905@0x90009A8A=0x00",
            "0x0000DC54@0x90009509=0x00",
            "0x0000DC55@0x90009509=0x60",
            "0x0000DC54@0x90009511=0x00",
            "0x0000DC55@0x90009511=0x00"
        ],
        "stop_conditions": [
            {"pc": "0x900241FA", "hit_count": 3, "min_cycle": 7600, "name": "gdr_host_cdb_consumed_idle"}
        ],
    },
    "gcc4241n-e7hl-watch": {
        "breakpoints": ["0x9003109A", "0x900311FB", "0x90031216", "0x90031288"],
        "watchpoints": [
            "0x0000085D:1",
            "0x00000864:2",
            "0x00000866:2",
            "0x00000DBC:2",
            "0x000005D0:16",
        ],
    },
}



def parse_int(value: Any, default: int = 0) -> int:
    if value is None:
        return default
    if isinstance(value, int):
        return value
    text = str(value).strip()
    if not text:
        return default
    return int(text, 0)

def resolve_trace_csv_path(raw: str | None) -> str | None:
    """Resolve --trace-csv relative to the caller's current directory."""
    if not raw:
        return None
    p = Path(raw)
    if not p.is_absolute():
        p = Path.cwd() / p
    return str(p.resolve())


def flatten_cli_values(values: Any) -> list[str]:
    """Flatten values from argparse options that may be strings or lists.

    PowerShell can split comma-separated values into multiple argv tokens for
    native commands.  Accept both of these forms:
      --breakpoints 0x1,0x2
      --breakpoints 0x1 0x2
    and the repeatable singular form:
      --breakpoint 0x1 --breakpoint 0x2
    """
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]
    out: list[str] = []
    for item in values:
        if item is None:
            continue
        for part in str(item).replace(";", ",").split(","):
            part = part.strip()
            if part:
                out.append(part)
    return out



def flatten_read_override_values(values: Any) -> list[str]:
    """Flatten read override CLI values without splitting VAL[,VAL...] lists.

    Generic flatten_cli_values intentionally splits commas for address lists.
    Read override specs use commas on the right-hand side for byte sequences,
    so preserve any token that already contains '='.
    """
    if values is None:
        return []
    if isinstance(values, str):
        values = [values]
    out: list[str] = []
    for item in values:
        if item is None:
            continue
        text = str(item).strip()
        if not text:
            continue
        if "=" in text:
            out.append(text)
        else:
            for part in text.replace(";", ",").split(","):
                part = part.strip()
                if part:
                    out.append(part)
    return out

def merge_unique(*groups: Iterable[str]) -> list[str]:
    seen: set[str] = set()
    out: list[str] = []
    for group in groups:
        for raw in group:
            key = raw.strip().lower()
            if not key or key in seen:
                continue
            seen.add(key)
            out.append(raw.strip())
    return out


def default_model_from_path(path: Path) -> str:
    parts = path.parts
    for i, part in enumerate(parts):
        if part.lower() == "firmware" and i + 1 < len(parts):
            return parts[i + 1]
    return path.stem


def _fmt_byte_from_trace_value(raw: str | None) -> str | None:
    if raw is None:
        return None
    text = str(raw).strip()
    if not text:
        return None
    try:
        return f"{int(text, 0) & 0xFF:02X}"
    except Exception:
        return None



def _row_addr(row: dict[str, str]) -> int | None:
    try:
        return int(str(row.get("addr") or ""), 0)
    except Exception:
        return None


def _row_cycle(row: dict[str, str]) -> int:
    try:
        return int(str(row.get("cycle") or "0"), 0)
    except Exception:
        return 0


def _auth_candidate_summary(rows: list[dict[str, str]], packet_start_cycle: int | None) -> list[dict[str, Any]]:
    by_addr: dict[int, list[dict[str, str]]] = {}
    for row in rows:
        if not str(row.get("event", "")).startswith("watch"):
            continue
        addr = _row_addr(row)
        if addr is None or addr not in AUTH_LABELS:
            continue
        by_addr.setdefault(addr, []).append(row)
    out: list[dict[str, Any]] = []
    for addr, evs in sorted(by_addr.items()):
        writes = [r for r in evs if r.get("event") == "watch_write"]
        reads = [r for r in evs if r.get("event") == "watch_read"]
        post_writes = [r for r in writes if packet_start_cycle is None or _row_cycle(r) >= packet_start_cycle]
        if not writes and not reads:
            continue
        last = writes[-1] if writes else evs[-1]
        vals = []
        for r in writes[-8:]:
            b = _fmt_byte_from_trace_value(r.get("value"))
            if b is not None:
                vals.append(b)
        out.append({
            "addr": addr,
            "label": AUTH_LABELS.get(addr, "auth/status candidate"),
            "reads": len(reads),
            "writes": len(writes),
            "post_packet_writes": len(post_writes),
            "last_cycle": _row_cycle(last),
            "last_pc": last.get("pc"),
            "last_value": _fmt_byte_from_trace_value(last.get("value")),
            "recent_write_values": vals,
        })
    return out


def summarize_auth_candidates(rows: list[dict[str, str]], packet_start_cycle: int | None, limit: int = 10) -> None:
    cands = _auth_candidate_summary(rows, packet_start_cycle)
    if not cands:
        return
    ranked = sorted(cands, key=lambda r: (r["post_packet_writes"], r["writes"], r["reads"]), reverse=True)
    for item in ranked[:limit]:
        print(
            f"[AUTH] 0x{item['addr']:04X} {item['label']} "
            f"writes={item['writes']} post={item['post_packet_writes']} reads={item['reads']} "
            f"last={item['last_value']} pc={item['last_pc']} cycle={item['last_cycle']} "
            f"recent={','.join(item['recent_write_values'])}"
        )

def summarize_trace_csv(path: Path, limit: int = 8) -> None:
    if not path.exists():
        return
    try:
        with path.open("r", newline="", encoding="utf-8", errors="replace") as f:
            rows = list(csv.DictReader(f))
    except Exception as exc:
        print(f"[WARN] could not summarize trace CSV: {exc}")
        return
    events = Counter(row.get("event", "") for row in rows)
    names = Counter(row.get("name", "") for row in rows)
    pcs = Counter(row.get("pc", "") for row in rows)
    watch_rows = [r for r in rows if str(r.get("event", "")).startswith("watch")]
    print(f"[TRACE] rows={len(rows)} events={dict(events)}")
    if pcs:
        print("[TRACE] top_pc=" + ", ".join(f"{pc}:{count}" for pc, count in pcs.most_common(limit) if pc))
    if names:
        print("[TRACE] top_names=" + ", ".join(f"{name}:{count}" for name, count in names.most_common(limit) if name))
    if watch_rows:
        print(f"[TRACE] watch_events={len(watch_rows)} first_watch={watch_rows[0].get('name')} addr={watch_rows[0].get('addr')} pc={watch_rows[0].get('pc')}")

    # v87: make packet/eject/inject probes distinguishable in terminal output.
    # The packet phase copies DF30..DF37 into 07E8..07EF at these PCs.  The
    # user's last-20 trace usually shows the later idle/service copy, so surface
    # the packet-phase frame explicitly here.
    packet_copy_pcs = {
        "0x90024286", "0x9002428C", "0x90024292", "0x90024298",
        "0x9002429E", "0x900242A4", "0x900242AA", "0x900242B0",
    }
    packet_bytes: dict[int, str] = {}
    for row in rows:
        if row.get("event") != "watch_write":
            continue
        if row.get("pc") not in packet_copy_pcs:
            continue
        addr_text = str(row.get("addr") or "")
        try:
            addr = int(addr_text, 0)
        except Exception:
            continue
        if 0x07E8 <= addr <= 0x07EF:
            b = _fmt_byte_from_trace_value(row.get("value"))
            if b is not None:
                packet_bytes[addr - 0x07E8] = b
    if packet_bytes:
        frame = " ".join(packet_bytes.get(i, "??") for i in range(8))
        cdb0 = packet_bytes.get(2, "??")
        print(f"[TRACE] packet_phase_07E8_07EF={frame} cdb0={cdb0}")

    # v87: show the later packet-service frame copied by 0x90021161.
    tail_copy_pcs = {
        "0x9002116C", "0x90021172", "0x90021178", "0x9002117E",
        "0x90021184", "0x9002118A", "0x90021190", "0x90021196",
    }
    tail_bytes: dict[int, str] = {}
    for row in rows:
        if row.get("event") != "watch_write":
            continue
        if row.get("pc") not in tail_copy_pcs:
            continue
        addr_text = str(row.get("addr") or "")
        try:
            addr = int(addr_text, 0)
        except Exception:
            continue
        if 0x07E8 <= addr <= 0x07EF:
            b = _fmt_byte_from_trace_value(row.get("value"))
            if b is not None:
                tail_bytes[addr - 0x07E8] = b
    if tail_bytes:
        tail_frame = " ".join(tail_bytes.get(i, "??") for i in range(8))
        print(f"[TRACE] packet_service_07E8_07EF={tail_frame}")

    # v87: summarize the DF70 FIFO/write stream. This is the closest thing
    # in the current trace to a captured data-in payload from firmware to host.
    df70_rows = []
    packet_start_cycle = None
    for row in rows:
        if row.get("pc") in {"0x9002425D", "0x90024286", "0x9002428C", "0x90024292"}:
            try:
                cyc = int(str(row.get("cycle")), 0)
                packet_start_cycle = cyc if packet_start_cycle is None else min(packet_start_cycle, cyc)
            except Exception:
                pass
        if row.get("event") != "watch_write":
            continue
        try:
            addr = int(str(row.get("addr")), 0)
            val = int(str(row.get("value")), 0) & 0xFF
            cyc = int(str(row.get("cycle")), 0)
        except Exception:
            continue
        if 0x0000DF70 <= addr <= 0x0000DFAF:
            df70_rows.append((cyc, val))
    if df70_rows:
        all_bytes = bytes(v for _, v in df70_rows)
        print(f"[TRACE] df70_all_len={len(all_bytes)} sha256={hashlib.sha256(all_bytes).hexdigest()} first32={all_bytes[:32].hex(' ').upper()}")
        if packet_start_cycle is not None:
            post_bytes = bytes(v for cyc, v in df70_rows if cyc >= packet_start_cycle)
            print(f"[TRACE] df70_after_packet_len={len(post_bytes)} sha256={hashlib.sha256(post_bytes).hexdigest()} first32={post_bytes[:32].hex(' ').upper()}")

    summarize_auth_candidates(rows, packet_start_cycle)


def describe_cdb(cdb: list[int]) -> str:
    if not cdb:
        return ""
    hex_bytes = " ".join(f"{b & 0xFF:02X}" for b in cdb)
    op = cdb[0] & 0xFF
    name = {
        0x00: "TEST UNIT READY",
        0x12: "INQUIRY",
        0x1B: "START STOP UNIT",
        0x25: "READ CAPACITY(10)",
        0x55: "MODE SELECT(10)",
        0x5A: "MODE SENSE(10)",
        0xAD: "READ DVD STRUCTURE(12)",
    }.get(op, f"opcode 0x{op:02X}")
    extra = ""
    if op == 0x1B and len(cdb) >= 5:
        loej = bool(cdb[4] & 0x02)
        start = bool(cdb[4] & 0x01)
        if loej and start:
            extra = " load/inject"
        elif loej and not start:
            extra = " eject"
        elif start:
            extra = " start"
        else:
            extra = " stop"
    return f"{hex_bytes} ({name}{extra})"


def parse_addr_pc_value_spec(raw: str) -> dict[str, Any]:
    """Parse ADDR[@PC[,PC...]]=VAL[,VAL...] into a trace read_override."""
    if "=" not in raw:
        raise ValueError(f"missing '=' in read override: {raw}")
    lhs, rhs = raw.split("=", 1)
    lhs = lhs.strip()
    rhs = rhs.strip()
    if "@" in lhs:
        addr, pcs = lhs.split("@", 1)
        pc_values = [x.strip() for x in pcs.replace(";", ",").split(",") if x.strip()]
    else:
        addr = lhs
        pc_values = []
    vals = [x.strip() for x in rhs.replace(";", ",").split(",") if x.strip()]
    if not vals:
        raise ValueError(f"missing value list in read override: {raw}")
    item: dict[str, Any] = {
        "name": f"read_{addr.strip()}" + (f"_at_{'_'.join(pc_values)}" if pc_values else ""),
        "address": addr.strip(),
        "values": vals,
        "repeat_last": True,
        "advance": False,
    }
    if pc_values:
        item["pcs"] = pc_values
    return item


def parse_watchpoint_spec(raw: str) -> dict[str, str]:
    """Parse ADDR[:SIZE][@KIND] or ADDR[:SIZE]:KIND into a watchpoint dict.

    KIND may be write, read, access, rw, or readwrite.  The old ADDR[:SIZE]
    form remains write-only for compatibility.
    """
    text = str(raw).strip()
    kind = "write"
    if "@" in text:
        text, kind = text.rsplit("@", 1)
        kind = kind.strip().lower() or "write"
    parts = [p.strip() for p in text.split(":") if p.strip()]
    if len(parts) >= 3 and parts[-1].lower() in {"write", "read", "access", "rw", "readwrite"}:
        kind = parts[-1].lower()
        parts = parts[:-1]
    addr = parts[0] if parts else text
    size = parts[1] if len(parts) >= 2 else "1"
    return {"address": addr, "size": size, "kind": kind}


def parse_mem8_spec(raw: str) -> dict[str, Any]:
    """Parse ADDR=VAL[,VAL...] into a memory_injections entry."""
    if "=" not in raw:
        raise ValueError(f"missing '=' in memory injection: {raw}")
    addr, rhs = raw.split("=", 1)
    vals = [x.strip() for x in rhs.replace(";", ",").split(",") if x.strip()]
    if not vals:
        raise ValueError(f"missing byte list in memory injection: {raw}")
    return {
        "description": f"direct mem8 {addr.strip()}",
        "address": addr.strip(),
        "data": [parse_int(x) & 0xFF for x in vals],
    }


def parse_host_cdb_tokens(tokens: Any) -> list[int]:
    """Parse --host-cdb bytes.

    Accepts any mix of:
      --host-cdb 12 00 00 00 24 00
      --host-cdb "12 00 00 00 24 00"
      --host-cdb "12,00,00,00,24,00"
      --host-cdb 0x12 0x00 0x00 0x00 0x24 0x00
    """
    if tokens is None:
        return []
    if isinstance(tokens, str):
        tokens = [tokens]
    # A single named token can stand in for a common CDB.  This works for both
    # --host-cdb and --packet-cdb because packet parsing delegates here.
    flat_alias_probe: list[str] = []
    for item in tokens:
        flat_alias_probe.extend(str(item).replace(",", " ").replace(";", " ").split())
    if len(flat_alias_probe) == 1:
        alias = flat_alias_probe[0].strip().lower().replace("_", "-")
        if alias in CDB_ALIASES:
            out = list(CDB_ALIASES[alias])
            while len(out) < 8:
                out.append(0)
            return out
    out: list[int] = []
    for item in tokens:
        text = str(item).replace(",", " ").replace(";", " ")
        for part in text.split():
            part = part.strip()
            if not part:
                continue
            try:
                out.append(parse_int(part) & 0xFF)
                continue
            except Exception:
                pass
            try:
                out.append(int(part, 16) & 0xFF)
            except Exception as exc:
                raise SystemExit(f"[-] bad --host-cdb byte {part!r}: {exc}")
    if len(out) > 16:
        raise SystemExit("[-] --host-cdb accepts at most 16 byte values")
    while len(out) < 8:
        out.append(0)
    return out


def gdr8050l_host_cdb_read_overrides(cdb: list[int]) -> list[dict[str, Any]]:
    """Build read-overrides that present CDB bytes on the GDR DF30..DF37 latch.

    The service loop reads DF30..DF37 at 0x9002435B..0x90024385 and copies
    the bytes into 0x07E8..0x07EF.  For command-dispatch probing, force
    the 0x002C scheduler/service flag clear at the branch point so the code
    calls 0x9002068B instead of returning to the periodic 0x9001F00A path.
    These overrides are mostly one-shot: once the command has been consumed,
    later service-loop iterations see an idle DF30=0xFF command latch instead
    of an endlessly reasserted CDB.
    """
    copy_pcs = [
        0x9002435B, 0x90024361, 0x90024367, 0x9002436D,
        0x90024373, 0x90024379, 0x9002437F, 0x90024385,
    ]
    early_pcs = [
        0x90021169, 0x9002116F, 0x90021175, 0x9002117B,
        0x90021181, 0x90021187, 0x9002118D, 0x90021193,
    ]
    one_shot = {"repeat_last": False}
    cdb0 = f"0x{cdb[0] & 0xFF:02X}"
    out: list[dict[str, Any]] = [
        {"address": "0x0000002C", "pc": "0x9002438B", "values": ["0x00"], "name": "gdr_service_flag_lo_clear", **one_shot},
        {"address": "0x0000002D", "pc": "0x9002438B", "values": ["0x00"], "name": "gdr_service_flag_hi_clear", **one_shot},
        {"address": "0x000007AC", "pc": "0x9002068B", "values": ["0x01"], "name": "gdr_service_table_dispatch", **one_shot},
        {"address": "0x0000014A", "pc": "0x9002069C", "values": ["0x40"], "name": "gdr_command_gate_ready", **one_shot},
        {"address": "0x0000DF30", "pc": "0x900241E4", "values": [cdb0, "0xFF"], "name": "gdr_host_command_present_then_idle"},
        {"address": "0x0000DF30", "pc": "0x90024335", "values": [cdb0, "0xFF"], "name": "gdr_host_command_precheck_then_idle_a"},
        {"address": "0x0000DF30", "pc": "0x90024346", "values": [cdb0, "0xFF"], "name": "gdr_host_command_precheck_then_idle_b"},
    ]
    for i in range(8):
        val = f"0x{cdb[i] & 0xFF:02X}"
        addr = f"0x{0x0000DF30+i:08X}"
        out.append({"address": addr, "pc": f"0x{early_pcs[i]:08X}", "values": [val], "name": f"host_cdb_early_{i}", **one_shot})
        out.append({"address": addr, "pc": f"0x{copy_pcs[i]:08X}", "values": [val], "name": f"host_cdb_service_{i}", **one_shot})
    return out


def parse_packet_cdb_tokens(tokens: Any) -> list[int]:
    """Parse --packet-cdb bytes.

    This is intentionally separate from --host-cdb.  On GDR-8050L the FE
    packet branch first consumes an 8-byte phase/latch frame at DF30..DF37
    (FE, flags, ...), then reaches packet/data status handlers.  The CDB bytes
    are preserved in the generated config metadata for trace labeling and for
    later data/FIFO modeling, while this v87 helper drives the verified FE
    packet-phase branch.
    """
    return parse_host_cdb_tokens(tokens)


def gdr8050l_packet_phase_read_overrides(packet_cdb: list[int], packet_flags: str = "0x20") -> list[dict[str, Any]]:
    """Build read-overrides for the verified GDR-8050L FE packet branch.

    Verified by traces:
      0x90024335/0x90024346 see DF30 == FE and call 0x9002425D.
      0x90024283..0x900242AD copy DF30..DF37 to 0x07E8..0x07EF.
      bit 0x20 in the copied second byte routes to 0x9001B6D3.

    The actual SCSI/ATAPI packet bytes are not confused with DF30 itself.
    In v87, the first six packet bytes are placed after the FE/flags bytes
    in the 8-byte phase frame (DF32..DF37 -> 07EA..07EF), while the full
    byte list is retained in config metadata for later 12-byte FIFO modeling.
    """
    one_shot = {"repeat_last": False}
    # DF30..DF37 frame for the packet phase: FE, flags, six argument bytes.
    frame = [0xFE, parse_int(packet_flags) & 0xFF, 0, 0, 0, 0, 0, 0]
    # Keep a little of the requested CDB visible after the packet marker for
    # experiments, but do not overwrite the verified FE/flags bytes.
    for i, b in enumerate(packet_cdb[:6], start=2):
        if i < len(frame):
            frame[i] = b & 0xFF
    # For now preserve the known-good branch-driving frame by default for
    # opcodes whose low bytes would disturb packet routing.  The user can still
    # override individual DF3x reads explicitly with --read8.
    frame[0] = 0xFE
    frame[1] = parse_int(packet_flags) & 0xFF
    out: list[dict[str, Any]] = [
        {"address": "0x0000DF30", "pc": "0x900241E4", "values": ["0x00"], "name": "gdr_packet_present_outer", **one_shot},
        {"address": "0x0000DF30", "pc": "0x90024335", "values": ["0xFE"], "name": "gdr_packet_fe_precheck_a", **one_shot},
        {"address": "0x0000DF30", "pc": "0x90024346", "values": ["0xFE"], "name": "gdr_packet_fe_precheck_b", **one_shot},
        {"address": "0x0000DF30", "pc": "0x9002426D", "values": ["0xFE"], "name": "gdr_packet_fe_inner_check", **one_shot},
    ]
    copy_pcs = [
        0x90024283, 0x90024289, 0x9002428F, 0x90024295,
        0x9002429B, 0x900242A1, 0x900242A7, 0x900242AD,
    ]
    for i, pc in enumerate(copy_pcs):
        out.append({
            "address": f"0x{0x0000DF30 + i:08X}",
            "pc": f"0x{pc:08X}",
            "values": [f"0x{frame[i] & 0xFF:02X}"],
            "name": f"packet_phase_df3{i}",
            **one_shot,
        })

    # v87: the later 0x90021161 service copy is the next packet/data frame.
    # Feeding CDB[6:12] here is what lets READ DVD STRUCTURE and similar
    # 12-byte packets proceed beyond the old 0x900210C1 stop.  Keep these
    # values repeatable because the firmware may call the service copy more
    # than once while completing the packet transaction.
    tail = [0] * 8
    for i, b in enumerate(packet_cdb[6:14]):
        if i < len(tail):
            tail[i] = b & 0xFF
    tail_pcs = [
        0x90021169, 0x9002116F, 0x90021175, 0x9002117B,
        0x90021181, 0x90021187, 0x9002118D, 0x90021193,
    ]
    for i, pc in enumerate(tail_pcs):
        out.append({
            "address": f"0x{0x0000DF30 + i:08X}",
            "pc": f"0x{pc:08X}",
            "values": [f"0x{tail[i] & 0xFF:02X}"],
            "repeat_last": True,
            "advance": False,
            "name": f"packet_tail_df3{i}",
        })
    return out


def parse_hex_bytes(tokens: Any, *, default: list[int] | None = None, label: str = "bytes") -> list[int]:
    """Parse a byte stream from CLI tokens, a hex string, or @file."""
    if tokens is None:
        return list(default or [])
    if isinstance(tokens, str):
        tokens = [tokens]
    flat: list[str] = []
    for item in tokens:
        text = str(item).strip()
        if not text:
            continue
        if text.startswith("@"):
            raw = Path(text[1:]).read_bytes()
            return [b for b in raw]
        # If it names an existing file, accept it for convenience.
        maybe = Path(text)
        if len(tokens) == 1 and maybe.exists() and maybe.is_file():
            raw = maybe.read_bytes()
            return [b for b in raw]
        flat.extend(text.replace(",", " ").replace(";", " ").split())
    out: list[int] = []
    for part in flat:
        try:
            out.append(parse_int(part) & 0xFF)
        except Exception:
            try:
                out.append(int(part, 16) & 0xFF)
            except Exception as exc:
                raise SystemExit(f"[-] bad {label} byte {part!r}: {exc}")
    return out


def default_mode_select_auth_payload(stage: str = "challenge1") -> list[int]:
    """Return a 28-byte Xbox page-0x3E MODE SELECT scaffold.

    This is not a completed cryptographic response.  It models the transfer
    phase shape from the user-provided scsi1.pas reference so the emulator can
    prove the data-out path and expose where real challenge/response bytes must
    be supplied.  Use --packet-data-out to provide captured/calculated bytes.
    """
    stage = (stage or "challenge1").strip().lower().replace("_", "-")
    buf = [0] * 28
    buf[1] = 0x1A
    buf[8] = 0x3E
    buf[9] = 0x12
    if stage in {"challenge1", "first", "1"}:
        buf[11] = 0x01
        buf[13] = 0xD1
        buf[14] = 0x01
    elif stage in {"challenge2", "second", "2"}:
        buf[11] = 0x01
        buf[12] = 0x01
        buf[13] = 0xD1
        buf[14] = 0x01
    elif stage in {"partition1", "final", "3"}:
        buf[10] = 0x01
        buf[11] = 0x01
        buf[12] = 0x01
        buf[13] = 0xD1
        buf[14] = 0x01
    else:
        raise SystemExit(f"[-] unknown --mode-select-auth-stage {stage!r}")
    return buf


def gdr8050l_packet_data_out_read_overrides(data: list[int], *, name: str = "packet_data_out") -> list[dict[str, Any]]:
    """Feed host->drive data-out bytes through the observed DF90..DF97 reads.

    The packet/data helper repeatedly reads DF90..DF97 at 0x90009A59..0x90009A83.
    Model this as an 8-byte host FIFO lane.  Each address gets its own byte
    sequence: DF90 sees bytes 0,8,16,24; DF91 sees 1,9,17,25; etc.
    """
    read_pcs = [
        0x90009A59, 0x90009A5F, 0x90009A65, 0x90009A6B,
        0x90009A71, 0x90009A77, 0x90009A7D, 0x90009A83,
    ]
    out: list[dict[str, Any]] = []
    if not data:
        return out
    # pad to a whole 8-byte FIFO lane so the last read does not repeat stale data
    padded = list(data)
    while len(padded) % 8:
        padded.append(0)
    for lane, pc in enumerate(read_pcs):
        vals = [padded[i] for i in range(lane, len(padded), 8)]
        out.append({
            "address": f"0x{0x0000DF90 + lane:08X}",
            "pc": f"0x{pc:08X}",
            "values": [f"0x{x & 0xFF:02X}" for x in vals],
            "repeat_last": False,
            "advance": True,
            "name": f"{name}_df9{lane:x}",
        })
    return out




def be32_bytes(value: int) -> list[int]:
    value &= 0xFFFFFFFF
    return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF]


def read_capacity10_payload(last_lba: int, block_len: int = 2048) -> list[int]:
    """Return a standards-shaped READ CAPACITY(10) payload.

    SCSI READ CAPACITY(10) returns exactly 8 bytes: last logical block address
    and block length, both big-endian. This data-in source-buffer model fixes
    formatting sanity but is not proof of Xbox authentication/unlock by itself.
    """
    return be32_bytes(last_lba) + be32_bytes(block_len)


def gdr8050l_packet_data_in_source_read_overrides(
    data: list[int],
    *,
    source_addr: int = 0x000001C8,
    name: str = "packet_data_in",
    source_mode: str = "static",
    chunk_size: int = 8,
    prelude_len: int = 2,
) -> list[dict[str, Any]]:
    """Feed drive->host packet data through the firmware source-buffer copy.

    The helper at 0x900099F7 sets A0=0x01C8 and A1=0xDF70; the loop at
    0x900099FF reads from (A0) and writes to (A1). Earlier traces were reading
    stale helper-buffer bytes and mis-labeling them as READ CAPACITY output.

    v117 adds a chunked source mode for larger packet data-in responses.  The
    real helper first performs a 2-byte transfer, then repeatedly reuses the
    same 0x01C8 source window for 8-byte DF70 bursts.  Static per-address
    overrides therefore keep replaying the first 8 bytes.  Chunked mode maps
    each source-window lane to an advancing sequence:

        0x01C8: payload[0], payload[0], payload[8],  payload[16], ...
        0x01C9: payload[1], payload[1], payload[9],  payload[17], ...
        0x01CA: payload[2],             payload[10], payload[18], ...

    The duplicated first two bytes account for the initial 2-byte prelude before
    the first complete 8-byte burst.
    """
    mode = (source_mode or "static").strip().lower().replace("-", "_")
    out: list[dict[str, Any]] = []
    if mode in {"chunked", "burst", "windowed"}:
        chunk_size = max(1, int(chunk_size or 8))
        prelude_len = max(0, min(int(prelude_len or 0), chunk_size))
        for lane in range(chunk_size):
            vals: list[int] = []
            if lane < prelude_len and lane < len(data):
                vals.append(data[lane] & 0xFF)
            for off in range(lane, len(data), chunk_size):
                vals.append(data[off] & 0xFF)
            if not vals:
                continue
            out.append({
                "address": f"0x{(source_addr + lane) & 0xFFFFFFFF:08X}",
                "pc": "0x900099FF",
                "values": [f"0x{b & 0xFF:02X}" for b in vals],
                "repeat_last": True,
                "advance": True,
                "name": f"{name}_chunked_{lane:02x}",
            })
        return out

    for i, b in enumerate(data):
        out.append({
            "address": f"0x{(source_addr + i) & 0xFFFFFFFF:08X}",
            "pc": "0x900099FF",
            "values": [f"0x{b & 0xFF:02X}"],
            # Stable source buffer: the firmware may copy 0x01C8.. in multiple
            # passes while completing a packet.  v95's one-shot source bytes
            # made READ CAPACITY appear interleaved/misaligned.
            "repeat_last": True,
            "advance": False,
            "name": f"{name}_{i:04x}",
        })
    return out


def default_read_capacity_last_lba_from_iso(iso_path: str | None) -> int:
    if not iso_path:
        return 0x0022DF3F
    p = Path(iso_path)
    size = p.stat().st_size
    sectors = max(1, size // 2048)
    return min(0xFFFFFFFF, sectors - 1)

def _format_hex_stream(data: list[int], limit: int = 64) -> str:
    vals = data[:limit]
    suffix = " ..." if len(data) > limit else ""
    return " ".join(f"{b & 0xFF:02X}" for b in vals) + suffix


def _sidecar_state(ns: argparse.Namespace) -> str:
    return str(getattr(ns, "xdvd_state", "locked") or "locked").strip().lower()


def _sidecar_packet_data_in(ns: argparse.Namespace, packet_cdb: list[int]) -> tuple[list[int], dict[str, Any] | None]:
    sidecar_path = getattr(ns, "xdvd_sidecar", None) or getattr(ns, "xdvd_json", None)
    if not sidecar_path or getattr(ns, "xdvd_response", "auto") == "off":
        return [], None
    sc = load_sidecar(sidecar_path)
    if sc is None:
        return [], None
    state = _sidecar_state(ns)
    resp = sc.response_for_cdb(packet_cdb, state=state)
    if resp is None:
        return [], {
            "path": str(sc.path),
            "state": state,
            "matched": False,
            "cdb_hex": " ".join(f"{x & 0xFF:02X}" for x in packet_cdb),
        }
    return [b for b in resp.data], {
        "path": str(sc.path),
        "state": state,
        "matched": True,
        "name": resp.name,
        "source": resp.source,
        "length": len(resp.data),
        "cdb_hex": resp.cdb_hex,
        "first_64_hex": _format_hex_stream([b for b in resp.data], 64),
    }


def build_config(ns: argparse.Namespace, config_path: Path) -> dict[str, Any]:
    bin_path = Path(ns.bin)
    workspace = Path(ns.workspace).resolve() if ns.workspace else Path.cwd().resolve()
    model = ns.model or default_model_from_path(bin_path)
    try:
        custom_bin = bin_path.resolve().relative_to(workspace).as_posix()
    except Exception:
        custom_bin = str(bin_path.resolve())

    preset = PRESETS.get(ns.preset, {"breakpoints": [], "watchpoints": [], "read_overrides": []})
    extra_auth_wps = AUTH_WATCHPOINTS if getattr(ns, "auth_watch", False) else []
    extra_auth_bps = AUTH_BREAKPOINTS if getattr(ns, "auth_watch", False) else []
    bp_values = merge_unique(
        preset.get("breakpoints", []),
        extra_auth_bps,
        flatten_cli_values(ns.breakpoints),
        flatten_cli_values(ns.breakpoint),
    )
    wp_values = merge_unique(
        preset.get("watchpoints", []),
        extra_auth_wps,
        flatten_cli_values(ns.watchpoints),
        flatten_cli_values(ns.watchpoint),
    )
    call_skip_values = merge_unique(flatten_cli_values(ns.call_skip))

    read_override_specs = merge_unique(
        preset.get("read_overrides", []),
        flatten_read_override_values(ns.read8),
        flatten_read_override_values(getattr(ns, "read_override", None)),
    )
    read_override_advance_specs = flatten_read_override_values(getattr(ns, "read8_advance", None))
    stop_conditions = [] if getattr(ns, "clear_preset_stops", False) else list(preset.get("stop_conditions", []) or [])
    for raw in flatten_cli_values(ns.stop_pc):
        stop_conditions.append({
            "pc": raw,
            "hit_count": ns.stop_pc_hit_count,
            "min_cycle": ns.stop_pc_min_cycle,
            "name": f"stop_{raw}",
        })
    if getattr(ns, "gdr_hw_model", False):
        # v118: move these legacy GDR timer/status shims into one explicit
        # hardware model.  Keep DF30/DF3x packet frame overrides intact.
        _hw_addrs = {"0x0000DC54", "0x0000DC55", "0x0000D904", "0x0000D905"}
        _hw_pcs = {
            "0x90009509", "0x90009511", "0x90006228", "0x9000622C", "0x9000880F",
            "0x900099F0", "0x90009A12", "0x90009A8A", "0x9002113E", "0x9002232B", "0x900223C0",
        }
        _filtered = []
        for _raw in read_override_specs:
            _txt = str(_raw)
            _addr = _txt.split("@", 1)[0].split("=", 1)[0].strip()
            _pc = None
            if "@" in _txt:
                _pc = _txt.split("@", 1)[1].split("=", 1)[0].strip()
            if _addr in _hw_addrs and (_pc in _hw_pcs):
                continue
            _filtered.append(_raw)
        read_override_specs = _filtered

    read_overrides: list[dict[str, Any]] = []
    for raw in read_override_specs:
        try:
            read_overrides.append(parse_addr_pc_value_spec(raw))
        except ValueError as exc:
            raise SystemExit(f"[-] bad --read8/--read-override: {exc}")
    for raw in read_override_advance_specs:
        try:
            item = parse_addr_pc_value_spec(raw)
            item["advance"] = True
            item["name"] = str(item.get("name", "read_override")) + "_advance"
            read_overrides.append(item)
        except ValueError as exc:
            raise SystemExit(f"[-] bad --read8-advance: {exc}")

    host_cdb = parse_host_cdb_tokens(ns.host_cdb)
    if host_cdb:
        read_overrides.extend(gdr8050l_host_cdb_read_overrides(host_cdb))

    packet_tokens = ns.packet_cdb
    if ns.eject_tray or ns.inject_tray:
        if ns.eject_tray and ns.inject_tray:
            raise SystemExit("[-] choose only one of --eject-tray or --inject-tray")
        if packet_tokens:
            raise SystemExit("[-] --eject-tray/--inject-tray cannot be combined with --packet-cdb")
        packet_tokens = ["eject" if ns.eject_tray else "inject"]
    packet_cdb = parse_packet_cdb_tokens(packet_tokens)
    packet_data_out: list[int] = []
    if packet_cdb:
        read_overrides.extend(gdr8050l_packet_phase_read_overrides(packet_cdb, ns.packet_flags))
        if ns.mode_select_auth_data:
            packet_data_out = parse_hex_bytes(ns.mode_select_auth_data, label="--mode-select-auth-data")
        elif ns.packet_data_out:
            packet_data_out = parse_hex_bytes(ns.packet_data_out, label="--packet-data-out")
        elif ns.packet_data_out_file:
            packet_data_out = list(Path(ns.packet_data_out_file).read_bytes())
        elif packet_cdb and (packet_cdb[0] & 0xFF) == 0x55 and ns.mode_select_auth_stage:
            packet_data_out = default_mode_select_auth_payload(ns.mode_select_auth_stage)
        if packet_data_out:
            read_overrides.extend(gdr8050l_packet_data_out_read_overrides(packet_data_out, name="packet_data_out"))

        packet_data_in: list[int] = []
        packet_data_in_meta: dict[str, Any] | None = None
        if ns.packet_data_in:
            packet_data_in = parse_hex_bytes(ns.packet_data_in, label="--packet-data-in")
            packet_data_in_meta = {"source": "--packet-data-in", "length": len(packet_data_in)}
        elif ns.packet_data_in_file:
            packet_data_in = list(Path(ns.packet_data_in_file).read_bytes())
            packet_data_in_meta = {"source": str(Path(ns.packet_data_in_file)), "length": len(packet_data_in)}
        else:
            packet_data_in, packet_data_in_meta = _sidecar_packet_data_in(ns, packet_cdb)
        if not packet_data_in and (packet_cdb[0] & 0xFF) == 0x25 and ns.read_capacity_data_in:
            if ns.read_capacity_last_lba:
                last_lba = parse_int(ns.read_capacity_last_lba)
            else:
                last_lba = default_read_capacity_last_lba_from_iso(ns.iso or ns.mount_iso)
            block_len = parse_int(ns.read_capacity_block_len)
            packet_data_in = read_capacity10_payload(last_lba, block_len)
            packet_data_in_meta = {"source": "--read-capacity-data-in", "last_lba": last_lba, "block_len": block_len, "length": len(packet_data_in)}
        if packet_data_in:
            _pdi_source_mode = getattr(ns, "packet_data_in_source_mode", "static") or "static"
            _pdi_chunk_size = parse_int(getattr(ns, "packet_data_in_source_chunk_size", "8"), 8)
            _pdi_prelude_len = parse_int(getattr(ns, "packet_data_in_source_prelude_len", "2"), 2)
            if str(_pdi_source_mode).strip().lower() != "stream":
                read_overrides.extend(gdr8050l_packet_data_in_source_read_overrides(
                    packet_data_in,
                    name="packet_data_in",
                    source_mode=_pdi_source_mode,
                    chunk_size=_pdi_chunk_size,
                    prelude_len=_pdi_prelude_len,
                ))

    mem8_specs = merge_unique(flatten_cli_values(ns.mem8), flatten_cli_values(ns.memory_injection))
    memory_injections: list[dict[str, Any]] = []
    for raw in mem8_specs:
        try:
            memory_injections.append(parse_mem8_spec(raw))
        except ValueError as exc:
            raise SystemExit(f"[-] bad --mem8/--memory-injection: {exc}")

    trace: dict[str, Any] = {
        "enabled": bool(ns.trace_csv or ns.trace_console or bp_values or wp_values),
        "console": bool(ns.trace_console),
    }

    iso_arg = ns.iso or ns.mount_iso
    media_cfg: dict[str, Any] = {}
    if iso_arg:
        iso_path = Path(iso_arg)
        if not iso_path.exists():
            raise SystemExit(f"[-] ISO image does not exist: {iso_path}")
        try:
            iso_resolved = iso_path.resolve()
        except Exception:
            iso_resolved = iso_path
        iso_size = iso_resolved.stat().st_size
        try:
            iso_for_cfg = iso_resolved.relative_to(workspace).as_posix()
        except Exception:
            iso_for_cfg = str(iso_resolved)
        media_cfg = {
            "type": "iso9660/raw2048",
            "iso_path": iso_for_cfg,
            "size_bytes": iso_size,
            "sector_size": 2048,
            "sectors_2048": (iso_size + 2047) // 2048,
            "is_2048_aligned": (iso_size % 2048) == 0,
            "present": not ns.no_disc_present,
            "tray_state": ns.tray_state or "closed",
            "source": "--iso",
        }
    elif ns.disc_present or ns.no_disc_present or ns.tray_state:
        media_cfg = {
            "type": "none",
            "present": bool(ns.disc_present) and not ns.no_disc_present,
            "tray_state": ns.tray_state or ("closed" if ns.disc_present else "open"),
            "source": "media-flags",
        }
    sidecar_path = getattr(ns, "xdvd_sidecar", None) or getattr(ns, "xdvd_json", None)
    if sidecar_path:
        sc = load_sidecar(sidecar_path)
        if sc is not None:
            media_cfg.setdefault("xdvd_sidecar", sc.summary())
            media_cfg.setdefault("source", "--xdvd-sidecar")
            media_cfg.setdefault("present", True)
            media_cfg.setdefault("tray_state", ns.tray_state or "closed")
    if media_cfg:
        trace["media"] = media_cfg

    if ns.trace_csv:
        trace["out_csv"] = resolve_trace_csv_path(ns.trace_csv)
    if bp_values:
        trace["breakpoints"] = [{"address": raw, "name": f"bp_{raw}"} for raw in bp_values]
    if wp_values:
        try:
            trace["watchpoints"] = [parse_watchpoint_spec(raw) for raw in wp_values]
        except Exception as exc:
            raise SystemExit(f"[-] bad --watchpoint/--watchpoints: {exc}")
    if call_skip_values:
        trace.setdefault("call_policy", {})["skip_targets"] = call_skip_values
    if read_overrides:
        trace["read_overrides"] = read_overrides
    if stop_conditions:
        trace["stop_conditions"] = stop_conditions
    if getattr(ns, "gdr_hw_model", False):
        trace["gdr_hardware_model"] = {
            "enabled": True,
            "emit_events": bool(getattr(ns, "gdr_hw_model_emit_events", False)),
            "timer": {
                "enabled": True,
                "addr": "0x0000DC54",
                "base": "0xF000",
                "ticks_per_cycle": getattr(ns, "gdr_timer_ticks_per_cycle", "0x2000"),
            },
            "packet_status": {
                "enabled": True,
                "source": "v118 default GDR D904/D905 packet helper model",
            },
            "source": "--gdr-hw-model",
        }

    cfg: dict[str, Any] = {
        "test_name": ns.name or f"Direct firmware smoke: {model}",
        "workspace": str(workspace),
        "target_profile": model,
        "custom_bin": custom_bin,
        "custom_base_addr": ns.base,
        "entry_point": ns.entry,
        "max_cycles": int(ns.max_cycles),
        "header_skip_bytes": ns.header_skip,
        "config_version": "v100",
        "requires_emulator_version": "V7-direct-config-trace-v100-gcc-version-sync",
        "memory_aliases": [],
        "initial_registers": {"SP": ns.sp},
        "trace": trace,
    }
    if media_cfg:
        cfg["media"] = media_cfg
        if media_cfg.get("iso_path"):
            cfg.setdefault("notes", []).append("media: --iso attaches image metadata; v95 can use ISO size to format READ CAPACITY data-in when --read-capacity-data-in is enabled")

    if host_cdb:
        cfg["host_cdb"] = [f"0x{x:02X}" for x in host_cdb]
    if packet_cdb:
        cfg["packet_cdb"] = [f"0x{x:02X}" for x in packet_cdb]
        cfg.setdefault("notes", []).append("packet_cdb: v97 maps FE/flags plus full packet frames; optional --packet-data-out models host-to-drive DF90..DF97 and --packet-data-in/read-capacity/xdvd-sidecar models the source buffer copied to DF70")
    if packet_data_out:
        cfg["packet_data_out"] = {
            "length": len(packet_data_out),
            "first_64_hex": _format_hex_stream(packet_data_out, 64),
            "source": "--packet-data-out/--mode-select-auth-stage",
        }
    try:
        _packet_data_in_meta = []
        _packet_sidecar_meta = None
        if packet_cdb:
            if ns.packet_data_in:
                _packet_data_in_meta = parse_hex_bytes(ns.packet_data_in, label="--packet-data-in")
            elif ns.packet_data_in_file:
                _packet_data_in_meta = list(Path(ns.packet_data_in_file).read_bytes())
            else:
                _packet_data_in_meta, _packet_sidecar_meta = _sidecar_packet_data_in(ns, packet_cdb)
            if not _packet_data_in_meta and (packet_cdb[0] & 0xFF) == 0x25 and ns.read_capacity_data_in:
                _last_lba = parse_int(ns.read_capacity_last_lba) if ns.read_capacity_last_lba else default_read_capacity_last_lba_from_iso(ns.iso or ns.mount_iso)
                _packet_data_in_meta = read_capacity10_payload(_last_lba, parse_int(ns.read_capacity_block_len))
        if _packet_data_in_meta:
            _pdi_source_mode_meta = getattr(ns, "packet_data_in_source_mode", "static") or "static"
            _pdi_chunk_size_meta = parse_int(getattr(ns, "packet_data_in_source_chunk_size", "8"), 8)
            _pdi_prelude_len_meta = parse_int(getattr(ns, "packet_data_in_source_prelude_len", "2"), 2)
            _pdi_prepend_prelude_meta = not bool(getattr(ns, "packet_data_in_source_no_prelude", False))
            cfg["packet_data_in"] = {
                "length": len(_packet_data_in_meta),
                "first_64_hex": _format_hex_stream(_packet_data_in_meta, 64),
                "full_hex": " ".join(f"{b & 0xFF:02X}" for b in _packet_data_in_meta),
                "source": "0x000001C8 source-buffer read overrides at PC 0x900099FF",
                "source_mode": _pdi_source_mode_meta,
                "source_chunk_size": _pdi_chunk_size_meta,
                "source_prelude_len": _pdi_prelude_len_meta,
                "source_prepend_prelude": _pdi_prepend_prelude_meta,
                "xdvd_sidecar": _packet_sidecar_meta,
                "note": "drive-to-host data-in model for formatting sanity; not standalone authentication proof",
            }
            _match_len = None
            try:
                _raw_match_len = getattr(ns, "packet_data_in_model_match_len", None)
                if _raw_match_len:
                    _match_len = max(1, min(parse_int(_raw_match_len), len(_packet_data_in_meta)))
            except Exception:
                _match_len = None
            cfg["gdr_packet_data_in_model"] = {
                "enabled": True,
                "length": len(_packet_data_in_meta),
                "first_64_hex": _format_hex_stream(_packet_data_in_meta, 64),
                "stop_on_submit": bool(getattr(ns, "packet_data_in_model_stop", False)),
                "source": "packet_data_in metadata",
            }
            if _match_len is not None:
                cfg["gdr_packet_data_in_model"]["match_length"] = _match_len
                cfg["packet_data_in"]["model_match_length"] = _match_len
            if not _pdi_prepend_prelude_meta:
                cfg["gdr_packet_data_in_model"]["stream_match"] = True
                cfg["packet_data_in"]["model_stream_match"] = True
            if str(_pdi_source_mode_meta).strip().lower() == "stream":
                cfg["gdr_packet_source_stream_model"] = {
                    "enabled": True,
                    "length": len(_packet_data_in_meta),
                    "full_hex": " ".join(f"{b & 0xFF:02X}" for b in _packet_data_in_meta),
                    "source_addr": "0x000001C8",
                    "window_size": _pdi_chunk_size_meta,
                    "source_prelude_len": _pdi_prelude_len_meta,
                    "source_prepend_prelude": _pdi_prepend_prelude_meta,
                    "pc": "0x900099FF",
                    "pad_value": "0x00",
                    "source": "packet_data_in metadata",
                }
    except Exception as exc:
        cfg.setdefault("notes", []).append(f"packet_data_in metadata unavailable: {exc}")
    if getattr(ns, "clear_preset_stops", False):
        cfg.setdefault("notes", []).append("stop_conditions: preset stops suppressed by --clear-preset-stops/--no-preset-stop-conditions")

    if not ns.no_sram_alias:
        cfg["memory_aliases"].append({
            "name": "SRAM alias 0x80000000->0x00000000",
            "from": "0x80000000",
            "to": "0x00000000",
            "size": ns.sram_size,
        })
    for reg_name, value in (("A0", ns.a0), ("A1", ns.a1), ("D0", ns.d0)):
        if value is not None:
            cfg["initial_registers"][reg_name] = value
    if memory_injections:
        cfg["memory_injections"] = memory_injections

    if ns.config_out:
        out = Path(ns.config_out)
        out.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
        print(f"[OK] wrote direct config: {out}")
    else:
        config_path.write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
    return cfg


def main(argv: list[str]) -> int:
    ap = argparse.ArgumentParser(description="Direct-config firmware smoke runner for mn103s_emu.py")
    ap.add_argument("--bin", required=True, help="firmware/stub binary path")
    ap.add_argument("--base", required=True, help="load/base address, e.g. 0x90000000 or 0x40008000")
    ap.add_argument("--entry", required=True, help="entry PC, e.g. 0x90000020")
    ap.add_argument("--workspace", help="repo root used for relative path resolution")
    ap.add_argument("--model", help="label only; does not load model profiles")
    ap.add_argument("--name", help="test_name field")
    ap.add_argument("--max-cycles", type=int, default=20000)
    ap.add_argument("--header-skip", default="0x0")
    ap.add_argument("--sp", default="0x003FF000")
    ap.add_argument("--a0")
    ap.add_argument("--a1")
    ap.add_argument("--d0")
    ap.add_argument("--sram-size", default="0x400000")
    ap.add_argument("--no-sram-alias", action="store_true")
    ap.add_argument("--preset", default="none", choices=["none", *sorted(PRESETS)], help="optional built-in breakpoint/watchpoint set")
    ap.add_argument("--call-skip", nargs="*", help="comma/space-separated call targets to skip")
    ap.add_argument("--breakpoints", nargs="*", help="comma/space-separated trace breakpoint addresses")
    ap.add_argument("--breakpoint", action="append", help="repeatable single trace breakpoint address")
    ap.add_argument("--watchpoints", nargs="*", help="comma/space-separated addr[:size] trace watchpoints")
    ap.add_argument("--watchpoint", action="append", help="repeatable single addr[:size] trace watchpoint")
    ap.add_argument("--read8", nargs="*", help="comma/space-separated ADDR[@PC]=VAL[,VAL...] read override specs")
    ap.add_argument("--read8-advance", nargs="*", help="like --read8, but advances through VAL[,VAL...] on each matching read before repeating the last value")
    ap.add_argument("--host-cdb", nargs="*", help="byte list to present on GDR DF30..DF37 and force service dispatch, e.g. --host-cdb 12 00 00 00 24 00")
    ap.add_argument("--packet-cdb", nargs="*", help="SCSI/ATAPI packet byte list or alias for GDR FE packet-phase tracing; aliases include eject, inject, inquiry, mode-sense-auth-xbox, mode-select-auth-xbox, read-dvd-structure-xbox")
    ap.add_argument("--packet-flags", default="0x20", help="second byte of the GDR FE packet-phase frame; default 0x20 reaches 0x9001B6D3")
    ap.add_argument("--packet-data-out", nargs="*", help="host-to-drive packet data-out byte stream for DF90..DF97 reads; accepts hex bytes or @file")
    ap.add_argument("--packet-data-out-file", help="binary file containing host-to-drive packet data-out bytes")
    ap.add_argument("--packet-data-in", nargs="*", help="drive-to-host packet data-in bytes to feed through the 0x01C8->DF70 source-buffer copy; accepts hex bytes or @file")
    ap.add_argument("--packet-data-in-file", help="binary file containing drive-to-host packet data-in bytes")
    ap.add_argument("--packet-data-in-model-stop", action="store_true", help="enable the GDR packet data-in model stop-on-DF2A-submit marker for bounded captures")
    ap.add_argument("--packet-data-in-model-match-len", help="for large data-in responses, match only this many leading DF70 bytes before treating the packet as ready")
    ap.add_argument("--packet-data-in-source-mode", choices=["static", "chunked", "stream"], default="static", help="source-buffer model for packet data-in bytes; stream is a global sequential 0x01C8 source stream for arbitrary burst sizes")
    ap.add_argument("--packet-data-in-source-chunk-size", default="8", help="chunked source-buffer lane count; default 8")
    ap.add_argument("--packet-data-in-source-prelude-len", default="2", help="chunked source-buffer prelude length duplicated before the first full burst; default 2")
    ap.add_argument("--packet-data-in-source-no-prelude", action="store_true", help="do not prepend the prelude bytes to the source stream; use with stream-match data-in model for exact 2048-byte PFI/DMI payloads")
    ap.add_argument("--gdr-hw-model", action="store_true", help="enable v118 GDR hardware MMIO model for DC54/DC55 timer and D904/D905 packet status; replaces the matching legacy read overrides")
    ap.add_argument("--gdr-hw-model-emit-events", action="store_true", help="emit gdr_hw_* semantic trace events for model reads; verbose")
    ap.add_argument("--gdr-timer-ticks-per-cycle", default="0x2000", help="synthetic DC54/DC55 timer decrement per emulated instruction when --gdr-hw-model is enabled")
    ap.add_argument("--read-capacity-data-in", action="store_true", help="for READ CAPACITY(10), feed a standards-shaped 8-byte big-endian response through the 0x01C8 source-buffer copy")
    ap.add_argument("--read-capacity-last-lba", help="last LBA for --read-capacity-data-in; defaults to ISO size/2048-1 when --iso is supplied")
    ap.add_argument("--read-capacity-block-len", default="0x800", help="block length for --read-capacity-data-in; default 0x800")
    ap.add_argument("--xdvd-sidecar", "--xdvd-json", dest="xdvd_sidecar", help="Xbox raw-disc sidecar JSON with captured locked/unlocked responses and external READ DVD STRUCTURE payload files")
    ap.add_argument("--xdvd-state", choices=["locked", "unlocked"], default="locked", help="which captured drive state to use for sidecar state responses; default locked")
    ap.add_argument("--xdvd-response", choices=["auto", "off"], default="auto", help="auto-feeds matching .xdvd sidecar responses for packet data-in when explicit --packet-data-in is not supplied")
    ap.add_argument("--mode-select-auth-data", nargs="*", help="explicit 28-byte MODE SELECT page-0x3E payload; overrides --mode-select-auth-stage")
    ap.add_argument("--mode-select-auth-stage", choices=["challenge1", "challenge2", "partition1"], help="inject a 28-byte Xbox MODE SELECT page-0x3E scaffold for the selected auth stage")
    ap.add_argument("--eject-tray", action="store_true", help="shortcut for --packet-cdb eject (START STOP UNIT LOEJ=1 START=0)")
    ap.add_argument("--inject-tray", action="store_true", help="shortcut for --packet-cdb inject/load (START STOP UNIT LOEJ=1 START=1)")
    ap.add_argument("--iso", help="attach an ISO image as virtual-disc metadata for upcoming media/data-in modeling")
    ap.add_argument("--mount-iso", dest="mount_iso", help="alias for --iso")
    ap.add_argument("--tray-state", choices=["open", "closed"], help="initial virtual tray state metadata")
    ap.add_argument("--disc-present", action="store_true", help="mark virtual media present when no --iso is supplied")
    ap.add_argument("--no-disc-present", action="store_true", help="force virtual media absent/open even if media flags are present")
    ap.add_argument("--read-override", action="append", help="repeatable ADDR[@PC]=VAL[,VAL...] read override spec")
    ap.add_argument("--mem8", nargs="*", help="comma/space-separated ADDR=VAL[,VAL...] initial byte injections")
    ap.add_argument("--memory-injection", action="append", help="repeatable ADDR=VAL[,VAL...] initial byte injection")
    ap.add_argument("--trace-csv", help="write trace recorder CSV")
    ap.add_argument("--trace-console", action="store_true", help="print trace recorder events to console")
    ap.add_argument("--trace-summary", action="store_true", help="print a compact trace CSV summary after the run")
    ap.add_argument("--auth-watch", action="store_true", help="append expanded GDR auth/status candidate watchpoints and breakpoints")
    ap.add_argument("--clear-preset-stops", "--no-preset-stop-conditions", action="store_true", help="do not copy stop_conditions from the selected preset; useful for continuing past packet timer landmarks")
    ap.add_argument("--stop-pc", action="append", help="repeatable PC that stops emulation when hit")
    ap.add_argument("--stop-pc-hit-count", type=int, default=1, help="required hit count for --stop-pc before stopping")
    ap.add_argument("--stop-pc-min-cycle", type=int, default=0, help="minimum cycle before --stop-pc may stop")
    ap.add_argument("--config-out", help="write config JSON and still run it unless --no-run is used")
    ap.add_argument("--no-run", action="store_true", help="only write/print the generated config")
    ap.add_argument("--python", default=sys.executable)
    ns = ap.parse_args(argv[1:])

    bin_path = Path(ns.bin)
    if not bin_path.exists():
        print(f"[-] firmware binary does not exist: {bin_path}", file=sys.stderr)
        return 2

    script_dir = Path(__file__).resolve().parent
    emu = script_dir / "mn103s_emu.py"
    if not emu.exists():
        print(f"[-] missing emulator next to runner: {emu}", file=sys.stderr)
        return 2

    with tempfile.TemporaryDirectory(prefix="mn103s_direct_") as td:
        cfg_path = Path(ns.config_out) if ns.config_out else Path(td) / "direct_firmware_config.json"
        cfg = build_config(ns, cfg_path)
        media = cfg.get("media") or {}
        if media:
            desc = media.get("iso_path", media.get("type", "media"))
            print(f"[MEDIA] type={media.get('type')} present={media.get('present')} tray={media.get('tray_state')} image={desc} sectors={media.get('sectors_2048', 'n/a')}")
        if cfg.get("host_cdb"):
            vals = [int(str(x), 0) for x in cfg.get("host_cdb", [])]
            print(f"[HOST-CDB] {describe_cdb(vals)}")
        if cfg.get("packet_cdb"):
            vals = [int(str(x), 0) for x in cfg.get("packet_cdb", [])]
            print(f"[PACKET-CDB] {describe_cdb(vals)}")
        if cfg.get("packet_data_out"):
            pd = cfg.get("packet_data_out") or {}
            print(f"[DATA-OUT] len={pd.get('length')} first={pd.get('first_64_hex')}")
        if ns.no_run:
            return 0
        cmd = [ns.python, str(emu), str(cfg_path)]
        print("[RUN] " + " ".join(cmd), flush=True)
        rc = subprocess.run(cmd).returncode
        trace_csv = ((cfg.get("trace") or {}).get("out_csv"))
        if trace_csv:
            trace_path = Path(trace_csv)
            if trace_path.exists():
                print(f"[OK] trace CSV written: {trace_path}")
                if ns.trace_summary:
                    summarize_trace_csv(trace_path)
            else:
                print(f"[-] trace CSV was requested but not generated: {trace_path}", file=sys.stderr)
                if rc == 0:
                    return 3
        return rc


if __name__ == "__main__":
    raise SystemExit(main(sys.argv))
