#!/usr/bin/env python3
"""v138 persistent stateful host bridge.

Boots GDR-8050L firmware once and feeds a persistent command queue. The queue
starts as raw SCSI/MMC CDB script entries, is resolved by gdr_scsi_command_parser.py
for firmware-exact validation, and is also replayed through the stateful JSONL
host bridge used as a command-server/iSCSI prototype.
"""
from __future__ import annotations

import argparse, json, os, subprocess, sys
from pathlib import Path
from typing import Any

from gdr_scsi_command_parser import GDRScsiParser, hx

WORKSPACE = os.environ.get('XDVD_WORKSPACE', r'D:\vscode-workspace\xbox-dvd-tools-v2')
FWBIN = os.environ.get('GDR8050L_FWBIN', r'D:\vscode-workspace\xbox-dvd-tools-v2\firmware\GDR-8050L\8050L_fw_plaintext_256k.bin')


def run_checked(cmd: list[str], cwd: Path, stdout_file: Path | None = None) -> None:
    if stdout_file:
        with stdout_file.open('w', encoding='utf-8', errors='replace') as fh:
            res = subprocess.run(cmd, cwd=str(cwd), stdout=fh, stderr=subprocess.STDOUT)
    else:
        res = subprocess.run(cmd, cwd=str(cwd))
    if res.returncode != 0:
        raise SystemExit(res.returncode)


def minimal_watchpoints() -> list[dict[str, str]]:
    return [
        {'start': '0x0000DF70', 'size': '0x40', 'kind': 'write', 'name': 'watch_df70'},
        {'start': '0x0000DF90', 'size': '0x08', 'kind': 'read',  'name': 'watch_df90'},
        {'start': '0x0000DF2A', 'size': '0x01', 'kind': 'write', 'name': 'watch_df2a'},
        {'start': '0x0000D108', 'size': '0x02', 'kind': 'access','name': 'watch_D108_data_phase'},
        {'start': '0x000005CD', 'size': '0x02', 'kind': 'access','name': 'watch_05CD_auth_candidate'},
    ]


def debug_watchpoints() -> list[dict[str, str]]:
    return minimal_watchpoints() + [
        {'start': '0x000005B1', 'size': '0x01', 'kind': 'access', 'name': 'watch_05B1'},
        {'start': '0x00000143', 'size': '0x01', 'kind': 'access', 'name': 'watch_0143'},
        {'start': '0x0000014A', 'size': '0x01', 'kind': 'access', 'name': 'watch_014A'},
        {'start': '0x000000DE', 'size': '0x01', 'kind': 'access', 'name': 'watch_00DE_host_gate'},
        {'start': '0x0000016D', 'size': '0x01', 'kind': 'access', 'name': 'watch_016D_host_gate'},
        {'start': '0x000008A8', 'size': '0x08', 'kind': 'access', 'name': 'watch_08A8_service_state'},
        {'start': '0x000007AC', 'size': '0x04', 'kind': 'access', 'name': 'watch_07AC_packet_state'},
        {'start': '0x00000778', 'size': '0x01', 'kind': 'access', 'name': 'watch_0778_dispatch'},
        {'start': '0x000007E8', 'size': '0x08', 'kind': 'access', 'name': 'watch_07E8_cdb_mirror'},
        {'start': '0x0000D950', 'size': '0x08', 'kind': 'access', 'name': 'watch_D950_packet_status'},
        {'start': '0x0000DA18', 'size': '0x01', 'kind': 'access', 'name': 'watch_DA18_packet_branch'},
        {'start': '0x00000278', 'size': '0x01', 'kind': 'access', 'name': 'watch_0278_media_branch'},
        {'start': '0x000005B6', 'size': '0x02', 'kind': 'access', 'name': 'watch_05B6_scheduler'},
        {'start': '0x000006F1', 'size': '0x03', 'kind': 'access', 'name': 'watch_06F1_dispatch'},
        {'start': '0x00000132', 'size': '0x04', 'kind': 'access', 'name': 'watch_0132_host_flags'},
        {'start': '0x0000002C', 'size': '0x02', 'kind': 'read',   'name': 'watch_002C_service_irq'},
        {'start': '0x0000D904', 'size': '0x02', 'kind': 'access', 'name': 'watch_D904_packet_status'},
    ]


def minimal_breakpoints() -> list[dict[str, str]]:
    return [
        {'address': '0x90006608', 'name': 'main_loop_90006608'},
        {'address': '0x90008824', 'name': 'packet_timer_done_90008824'},
        {'address': '0x9001CD4D', 'name': 'data_phase_helper_9001CD4D'},
    ]


def debug_breakpoints() -> list[dict[str, str]]:
    return minimal_breakpoints() + [
        {'address': '0x9001BBAC', 'name': 'host_gate_9001BBAC'},
        {'address': '0x900240EC', 'name': 'packet_service_gate_900240EC'},
        {'address': '0x900210C4', 'name': 'packet_service_900210C4'},
        {'address': '0x90024283', 'name': 'packet_fe_frame_copy_90024283'},
        {'address': '0x90021169', 'name': 'packet_tail_copy_90021169'},
        {'address': '0x900222C9', 'name': 'data_phase_dispatch_900222C9'},
        {'address': '0x900222FF', 'name': 'data_phase_ready_900222FF'},
        {'address': '0x90022301', 'name': 'data_phase_call_9001CD4D_90022301'},
        {'address': '0x900099EA', 'name': 'gdr_data_transfer_helper_900099EA'},
        {'address': '0x900099FF', 'name': 'gdr_data_in_source_read_900099FF'},
        {'address': '0x90009A02', 'name': 'gdr_df70_write_90009A02'},
        {'address': '0x90009A0E', 'name': 'gdr_df2a_submit_90009A0E'},
        {'address': '0x90017775', 'name': 'irq_status_handler_90017775'},
        {'address': '0x9001B285', 'name': 'main_scheduler_9001B285'},
        {'address': '0x9001B71B', 'name': 'packet_scheduler_9001B71B'},
    ]


def build_scsi_script(args: argparse.Namespace, parser: GDRScsiParser) -> list[dict[str, str]]:
    if args.scsi_script:
        raw=json.loads(Path(args.scsi_script).read_text(encoding='utf-8'))
        items=raw.get('commands', raw) if isinstance(raw, dict) else raw
        if not isinstance(items, list):
            raise SystemExit('[-] --scsi-script must contain a list or an object with commands')
        return [dict(x) for x in items]
    if args.command_json:
        # Compatibility path: old already-resolved persistent command JSON.
        raw=json.loads(Path(args.command_json).read_text(encoding='utf-8'))
        items=raw.get('commands', raw) if isinstance(raw, dict) else raw
        if not isinstance(items, list):
            raise SystemExit('[-] --command-json must contain a list or an object with commands')
        # Convert resolved commands back to a raw CDB script where possible.
        out=[]
        for x in items:
            ent={'tag': x.get('tag') or x.get('title'), 'cdb_hex': x.get('cdb_hex') or x.get('cdb')}
            if x.get('data_out_hex') or x.get('data_out'):
                ent['data_out_hex']=x.get('data_out_hex') or x.get('data_out')
            out.append(ent)
        return out
    items = parser.script_from_unlock_log()
    if not args.no_post_unlock:
        items = parser.append_post_unlock_script(items)
    if not args.no_post_read10:
        items = parser.append_read10_script(items, args.read10)
    if getattr(args, 'windows_probes', False):
        items = parser.append_windows_probe_script(items)
    elif getattr(args, 'standard_probes', False):
        items = parser.append_standard_probe_script(items)
    return items


def build_config(args: argparse.Namespace, commands: list[dict[str, str]], stem: str) -> dict[str, Any]:
    trace_csv = f'{stem}_trace.csv'
    debug = bool(args.debug_trace)
    cfg: dict[str, Any] = {
        'test_name': 'GDR-8050L v138 stateful host bridge persistent session',
        'workspace': WORKSPACE,
        'target_profile': 'GDR-8050L',
        'custom_bin': FWBIN,
        'custom_base_addr': '0x90000000',
        'entry_point': '0x90000020',
        'max_cycles': int(str(args.max_cycles), 0),
        'header_skip_bytes': '0x0',
        'config_version': 'v100',
        'requires_emulator_version': 'V7-direct-config-trace-v100-gcc-version-sync',
        'initial_registers': {'SP': '0x003FF000'},
        'memory_aliases': [{'name': 'SRAM alias 0x80000000->0x00000000', 'from': '0x80000000', 'to': '0x00000000', 'size': '0x400000'}],
        'trace': {
            'enabled': True,
            'live_instruction_log': bool(args.debug_instructions),
            'out_csv': trace_csv,
            'watchpoints': debug_watchpoints() if debug else minimal_watchpoints(),
            'breakpoints': debug_breakpoints() if debug else minimal_breakpoints(),
            'gdr_hardware_model': {
                'enabled': True,
                'emit_events': debug,
                'timer': {'enabled': True, 'addr': '0x0000DC54', 'base': '0xF000', 'ticks_per_cycle': '0x2000'},
                'packet_status': {'enabled': True, 'source': 'v138 persistent session shared GDR D904/D905 model'},
            },
            'gdr_packet_session_model': {
                'enabled': True,
                'emit_events': True,
                'commands': commands,
                'packet_flags': '0x20',
                'main_loop_pc': '0x90006608',
                'timer_done_pc': '0x90008824',
                'stop_on_complete': True,
                'host_event_gate': {
                    'enabled': not bool(args.no_host_gate),
                    'force_016d_active': True,
                    'force_016d_value': '0x01',
                    'force_00de_pollable': True,
                    'force_00de_value': '0x01',
                    'force_08a8': str(args.host_gate_08a8),
                    'force_d950_value': '0x10',
                    'post_frame_d950_value': '0x40',
                    'clear_08a8_after_frame': not bool(args.no_clear_08a8_after_frame),
                    'clear_08a8_after_tail': not bool(args.no_clear_08a8_after_tail),
                    'clear_d950_after_tail': not bool(args.no_clear_d950_after_tail),
                    'd950_zero_after_post_frame_reads': str(args.d950_zero_after_post_frame_reads),
                    'irq_after_d108_data_phase': not bool(args.no_irq_after_d108_data_phase),
                    'complete_data_phase_on_d108': not bool(args.no_d108_hw_data_phase),
                    'seed_mem8': {
                        '0x000005B1': '0x20',
                        '0x000000DE': '0x01',
                        '0x0000016D': '0x01',
                        '0x000008A8': str(args.host_gate_08a8),
                        '0x000008A9': '0x00',
                        '0x0000DA18': '0x00',
                    },
                },
            },
        },
        'notes': [
            'v138: stateful host bridge. Raw SCSI/MMC CDB script is resolved into persistent firmware session commands, a catalog, and JSONL host-bridge responses.',
            'Supported proven CDBs: READ CAPACITY(10), MODE SENSE(10/6) page 3E, MODE SELECT(10/6), READ DVD STRUCTURE sidecar packets, READ(10), and selected host probes including GET CONFIGURATION and READ TOC.',
            'D108 hardware data-phase completion remains baseline behavior.',
        ],
    }
    if args.iso:
        cfg['media'] = {'type': 'iso9660/raw2048', 'iso_path': args.iso, 'sector_size': 2048, 'present': True, 'tray_state': 'closed', 'source': '--iso'}
    return cfg




def payload_bytes_for_command(c: dict[str, str]) -> bytes:
    direction=str(c.get('direction','')).lower()
    payload_hex=c.get('data_in_hex') if direction=='in' else c.get('data_out_hex') if direction=='out' else ''
    payload_bytes=[]
    if payload_hex:
        for tok in str(payload_hex).replace(',', ' ').split():
            try: payload_bytes.append(int(tok, 16) & 0xFF)
            except Exception: pass
    return bytes(payload_bytes)


def write_compact_transcript(path: Path, commands: list[dict[str, str]], *, xdvd_json: str, iso: str | None, source: str, response_dir: Path | None = None) -> None:
    import hashlib
    rows=[]
    if response_dir:
        response_dir.mkdir(parents=True, exist_ok=True)
    for i,c in enumerate(commands):
        direction=str(c.get('direction','')).lower()
        payload=payload_bytes_for_command(c)
        response_file=''
        if response_dir is not None and payload:
            response_file=f'{i:03d}_{str(c.get("tag") or f"cmd{i:02d}").replace("/","_").replace("\\","_")}.bin'
            (response_dir / response_file).write_bytes(payload)
        rows.append({
            'index': i,
            'tag': c.get('tag') or f'cmd{i:02d}',
            'direction': direction,
            'cdb_hex': c.get('cdb_hex',''),
            'payload_len': len(payload),
            'payload_sha256': hashlib.sha256(payload).hexdigest() if payload else '',
            'payload_preview_hex': ' '.join(f'{b:02X}' for b in payload[:32]),
            'payload_file': response_file,
        })
    doc={
        'format': 'gdr8050l_persistent_session_transcript_v2',
        'xdvd_json': xdvd_json,
        'iso': iso,
        'source': source,
        'commands_expected': len(commands),
        'responses_dir': str(response_dir) if response_dir else '',
        'commands': rows,
    }
    path.write_text(json.dumps(doc, indent=2), encoding='utf-8')




def _cdb_bytes_from_hex(text: str) -> bytes:
    out=[]
    for tok in str(text or '').replace(',', ' ').split():
        try:
            out.append(int(tok, 16) & 0xFF)
        except Exception:
            pass
    return bytes(out)


def describe_cdb(cdb_hex: str) -> dict[str, Any]:
    cdb = _cdb_bytes_from_hex(cdb_hex)
    if not cdb:
        return {'opcode_hex': '', 'opcode_name': 'EMPTY', 'cdb_len': 0}
    op = cdb[0]
    names = {
        0x00: 'TEST UNIT READY',
        0x03: 'REQUEST SENSE(6)',
        0x12: 'INQUIRY(6)',
        0x15: 'MODE SELECT(6)',
        0x1A: 'MODE SENSE(6)',
        0x1B: 'START STOP UNIT',
        0x1E: 'PREVENT/ALLOW MEDIUM REMOVAL',
        0x25: 'READ CAPACITY(10)',
        0x28: 'READ(10)',
        0x35: 'SYNCHRONIZE CACHE(10)',
        0x43: 'READ TOC/PMA/ATIP',
        0x46: 'GET CONFIGURATION',
        0x4A: 'GET EVENT STATUS NOTIFICATION',
        0x51: 'READ DISC INFORMATION',
        0x55: 'MODE SELECT(10)',
        0x5A: 'MODE SENSE(10)',
        0xAD: 'READ DVD STRUCTURE',
    }
    d: dict[str, Any] = {'opcode_hex': f'0x{op:02X}', 'opcode_name': names.get(op, f'OP_0x{op:02X}'), 'cdb_len': len(cdb)}
    if op == 0x28 and len(cdb) >= 10:
        d['lba'] = int.from_bytes(cdb[2:6], 'big')
        d['blocks'] = int.from_bytes(cdb[7:9], 'big')
    if op in (0x1A, 0x5A) and len(cdb) >= 3:
        d['mode_page'] = f'0x{(cdb[2] & 0x3F):02X}'
    if op == 0xAD and len(cdb) >= 12:
        d['dvd_structure_format'] = f'0x{cdb[7]:02X}'
        d['allocation_len'] = int.from_bytes(cdb[8:10], 'big')
    if op in (0x03,0x12,0x1A) and len(cdb) >= 5:
        d['allocation_len'] = cdb[4]
    if op in (0x43,0x46,0x4A,0x51,0x5A) and len(cdb) >= 9:
        d['allocation_len'] = int.from_bytes(cdb[7:9], 'big')
    return d


def write_response_catalog(path: Path, commands: list[dict[str, str]], *, xdvd_json: str, iso: str | None, transcript_path: Path, response_dir: Path) -> None:
    """Write an iSCSI/command-server friendly response catalog.

    This is not a live iSCSI target yet.  It is a deterministic map from raw CDBs
    to GOOD-status payloads/no-data completions proven by the persistent firmware
    session run.  DATA OUT commands are included as accepted host payloads so the
    unlock/auth transcript can be replayed exactly.
    """
    import hashlib
    entries=[]
    for i,c in enumerate(commands):
        direction=str(c.get('direction','')).lower()
        payload=payload_bytes_for_command(c)
        tag=str(c.get('tag') or f'cmd{i:02d}')
        safe_tag=tag.replace('/','_').replace('\\','_')
        payload_file=''
        if payload:
            payload_file=f'{i:03d}_{safe_tag}.bin'
        entry={
            'index': i,
            'tag': tag,
            'direction': direction,
            'cdb_hex': c.get('cdb_hex',''),
            'cdb': describe_cdb(c.get('cdb_hex','')),
            'scsi_status': 'GOOD',
            'sense_key': 'NO SENSE',
            'asc': '0x00',
            'ascq': '0x00',
            'payload_len': len(payload),
            'payload_sha256': hashlib.sha256(payload).hexdigest() if payload else '',
            'payload_file': payload_file,
            'payload_preview_hex': ' '.join(f'{b:02X}' for b in payload[:64]),
            'data_role': 'device_to_host' if direction == 'in' else 'host_to_device' if direction == 'out' else 'no_data',
        }
        entries.append(entry)
    doc={
        'format': 'gdr8050l_response_catalog_v1',
        'purpose': 'Seed catalog for a future command-server/iSCSI bridge; generated from an exact persistent MN103S firmware session.',
        'xdvd_json': xdvd_json,
        'iso': iso,
        'transcript': str(transcript_path),
        'responses_dir': str(response_dir),
        'commands_expected': len(commands),
        'entries': entries,
    }
    path.write_text(json.dumps(doc, indent=2), encoding='utf-8')


def write_host_request_jsonl(path: Path, script_items: list[dict[str, str]]) -> None:
    """Write the raw CDB/DATA OUT request stream consumed by gdr_host_jsonl_bridge.py."""
    rows=[]
    for i,item in enumerate(script_items):
        row={
            'index': i,
            'tag': item.get('tag') or item.get('title') or f'cmd{i:03d}',
            'cdb_hex': item.get('cdb_hex') or item.get('cdb') or '',
        }
        if item.get('data_out_hex') or item.get('data_out'):
            row['data_out_hex'] = item.get('data_out_hex') or item.get('data_out')
        rows.append(row)
    path.write_text('\n'.join(json.dumps(r, separators=(',',':')) for r in rows) + ('\n' if rows else ''), encoding='utf-8')


def run_jsonl_host_bridge(script_dir: Path, args: argparse.Namespace, host_requests: Path, host_responses: Path, host_response_dir: Path, host_verify: Path, catalog_path: Path) -> None:
    cmd=[
        sys.executable, 'gdr_host_jsonl_bridge.py',
        args.xdvd_json,
    ]
    if args.iso:
        cmd.append(args.iso)
    if args.log:
        cmd.extend(['--log', args.log])
    cmd.extend([
        '--script', str(host_requests),
        '--out', str(host_responses),
        '--write-response-dir', str(host_response_dir),
        '--catalog', str(catalog_path),
        '--verify-md', str(host_verify),
    ])
    run_checked(cmd, cwd=script_dir)


def main() -> int:
    ap=argparse.ArgumentParser(description='Run v138 persistent GDR-8050L stateful host bridge')
    ap.add_argument('xdvd_json', help='XDVD sidecar path')
    ap.add_argument('iso', nargs='?', help='ISO/raw2048 path stored into media metadata and used for READ(10)')
    ap.add_argument('--log', help='Unlock log path; default is .log next to xdvd_json')
    ap.add_argument('--scsi-script', help='raw SCSI CDB script JSON; parser resolves expected data')
    ap.add_argument('--command-json', help='compatibility input: prior resolved persistent command JSON converted to parser script where possible')
    ap.add_argument('--write-scsi-script', help='write the raw CDB script used by this run')
    ap.add_argument('--no-post-unlock', action='store_true', help='do not append post-unlock sidecar/MMC command script entries')
    ap.add_argument('--no-post-read10', action='store_true', help='do not append media-backed READ(10) script entries')
    ap.add_argument('--standard-probes', action='store_true', help='append standard host probes after READ(10): TEST UNIT READY, INQUIRY, REQUEST SENSE')
    ap.add_argument('--windows-probes', action='store_true', help='append broader Windows/OS optical-drive probe set after READ(10)')
    ap.add_argument('--write-response-dir', default=None, help='write one binary response/payload file per command; default is <stem>_responses')
    ap.add_argument('--write-transcript', default=None, help='write compact command transcript JSON; default is <stem>_transcript.json')
    ap.add_argument('--write-catalog', default=None, help='write command-server/iSCSI seed response catalog JSON; default is <stem>_catalog.json')
    ap.add_argument('--no-write-catalog', action='store_true', help='skip writing the response catalog')
    ap.add_argument('--no-run-jsonl-bridge', action='store_true', help='skip the stateful JSONL host bridge replay/verification')
    ap.add_argument('--write-host-requests', default=None, help='write JSONL host requests used by the stateful bridge; default is <stem>_host_requests.jsonl')
    ap.add_argument('--write-host-jsonl', default=None, help='write JSONL host responses; default is <stem>_host_responses.jsonl')
    ap.add_argument('--write-host-response-dir', default=None, help='write host bridge payload files; default is <stem>_host_responses')
    ap.add_argument('--write-host-verify', default=None, help='write host bridge verification md; default is <stem>_host_bridge_verify.md')
    ap.add_argument('--read10', action='append', default=[], metavar='LBA[:BLOCKS]', help='post-unlock READ(10) range to append; repeatable')
    ap.add_argument('--stem', default='gdr8050l_auth_v138_stateful_host_bridge', help='output stem')
    ap.add_argument('--max-cycles', default='240000')
    ap.add_argument('--no-host-gate', action='store_true')
    ap.add_argument('--host-gate-08a8', default='0x04')
    ap.add_argument('--no-clear-08a8-after-frame', action='store_true')
    ap.add_argument('--no-clear-08a8-after-tail', action='store_true')
    ap.add_argument('--no-clear-d950-after-tail', action='store_true')
    ap.add_argument('--d950-zero-after-post-frame-reads', default='4')
    ap.add_argument('--no-irq-after-d108-data-phase', action='store_true')
    ap.add_argument('--no-d108-hw-data-phase', action='store_true')
    ap.add_argument('--debug-trace', action='store_true')
    ap.add_argument('--debug-instructions', action='store_true')
    ap.add_argument('--verbose-commands', action='store_true')
    args=ap.parse_args()

    script_dir=Path(__file__).resolve().parent
    xdvd=Path(args.xdvd_json)
    log_path=Path(args.log) if args.log else xdvd.with_suffix('.log')
    parser=GDRScsiParser(xdvd, args.iso, log_path if log_path.exists() else None)
    script_items=build_scsi_script(args, parser)
    if args.write_scsi_script:
        Path(args.write_scsi_script).write_text(json.dumps({'commands': script_items}, indent=2), encoding='utf-8')
    commands=parser.commands_from_script_items(script_items)

    print('[V138] Persistent stateful host bridge session.', flush=True)
    print(f'[V138] XDVD sidecar: {args.xdvd_json}', flush=True)
    if args.iso:
        print(f'[V138] ISO metadata: {args.iso}', flush=True)
    if args.scsi_script:
        src=f'SCSI script: {args.scsi_script}'
    elif args.command_json:
        src=f'compat command JSON via parser: {args.command_json}'
    else:
        suffix=' + parser-resolved post-unlock/READ(10) extensions'
        if args.windows_probes:
            suffix += ' + Windows/OS probe extension'
        elif args.standard_probes:
            suffix += ' + standard host probe extension'
        src=f'unlock log CDB script: {log_path}{suffix}'
    print(f'[V138] Command source: {src}', flush=True)
    print(f'[V138] Commands queued: {len(commands)}; d108_hw_data_phase={0 if args.no_d108_hw_data_phase else 1}; debug_trace={1 if args.debug_trace else 0}; windows_probes={1 if args.windows_probes else 0}', flush=True)
    transcript_path = Path(args.write_transcript) if args.write_transcript else (Path(__file__).resolve().parent / f'{args.stem}_transcript.json')
    response_dir = Path(args.write_response_dir) if args.write_response_dir else (Path(__file__).resolve().parent / f'{args.stem}_responses')
    write_compact_transcript(transcript_path, commands, xdvd_json=args.xdvd_json, iso=args.iso, source=src, response_dir=response_dir)
    print(f'[OK] wrote compact transcript: {transcript_path.name}', flush=True)
    print(f'[OK] wrote response payload directory: {response_dir.name}', flush=True)
    catalog_path = Path(args.write_catalog) if args.write_catalog else (Path(__file__).resolve().parent / f'{args.stem}_catalog.json')
    if not args.no_write_catalog:
        write_response_catalog(catalog_path, commands, xdvd_json=args.xdvd_json, iso=args.iso, transcript_path=transcript_path, response_dir=response_dir)
        print(f'[OK] wrote command response catalog: {catalog_path.name}', flush=True)
    if args.verbose_commands:
        for i,c in enumerate(commands,1):
            payload = c.get('data_in_hex') if c.get('direction')=='in' else c.get('data_out_hex','')
            plen = len(str(payload).split()) if payload else 0
            print(f"[V138] {i:02d} {c.get('tag')} {c.get('direction')} cdb={c.get('cdb_hex')} bytes={plen}", flush=True)

    if not args.no_run_jsonl_bridge:
        host_requests = Path(args.write_host_requests) if args.write_host_requests else (Path(__file__).resolve().parent / f'{args.stem}_host_requests.jsonl')
        host_responses = Path(args.write_host_jsonl) if args.write_host_jsonl else (Path(__file__).resolve().parent / f'{args.stem}_host_responses.jsonl')
        host_response_dir = Path(args.write_host_response_dir) if args.write_host_response_dir else (Path(__file__).resolve().parent / f'{args.stem}_host_responses')
        host_verify = Path(args.write_host_verify) if args.write_host_verify else (Path(__file__).resolve().parent / f'{args.stem}_host_bridge_verify.md')
        write_host_request_jsonl(host_requests, script_items)
        print(f'[OK] wrote JSONL host requests: {host_requests.name}', flush=True)
        run_jsonl_host_bridge(script_dir, args, host_requests, host_responses, host_response_dir, host_verify, catalog_path)
        print(f'[OK] wrote JSONL host responses: {host_responses.name}', flush=True)
        print(f'[OK] wrote JSONL host response directory: {host_response_dir.name}', flush=True)
        print(f'[OK] wrote JSONL host bridge verify: {host_verify.name}', flush=True)

    stem=args.stem
    trace_csv=f'{stem}_trace.csv'
    config_json=script_dir / f'{stem}_config.json'
    output_txt=script_dir / f'{stem}.output.txt'
    verify_md=f'{stem}_verify.md'
    cfg=build_config(args, commands, stem)
    config_json.write_text(json.dumps(cfg, indent=2), encoding='utf-8')
    print(f'[OK] wrote persistent config: {config_json.name}', flush=True)
    run_checked([sys.executable, 'mn103s_emu.py', config_json.name], cwd=script_dir, stdout_file=output_txt)
    print(f'[OK] emulator output: {output_txt.name}', flush=True)
    res=subprocess.run([sys.executable, 'analyze_gdr_persistent_session.py', trace_csv, '--config', config_json.name, '--out', verify_md], cwd=str(script_dir))
    if res.returncode == 0:
        print('[OK] v138 stateful-host persistent session completed exactly.', flush=True)
    else:
        print(f'[WARN] v138 verifier returned {res.returncode}; session incomplete or mismatched, but output/trace were written.', flush=True)
    print(f'[NEXT] Send {stem}.output.txt, {trace_csv}, {config_json.name}, {verify_md}, {stem}_catalog.json, {stem}_host_responses.jsonl, and {stem}_host_bridge_verify.md.', flush=True)
    return 0


if __name__ == '__main__':
    raise SystemExit(main())
