#!/usr/bin/env python3
"""GDR-8050L firmware READ dispatch/handler probe.

v252 proved a parser-side path into SRAM 0x05EC and a descriptor-state gate.
v253 adds a separate, more natural firmware dispatch-table check for READ(10):

* the opcode dispatch table maps READ(10) opcode 0x28 to class 0x48 and handler
  0x9002EA7C;
* the 0x9002EA7C wrapper calls 0x9001EED0;
* the 0x9001EED0 READ(10) handler uses CDB shadow bytes at 0x05D2..0x05D5,
  calls the firmware byte-assembler 0x90030687, writes the assembled host LBA
  to SRAM 0x05EC, then writes state 0x05F0=0x11 and phase 0x06F1=0x04.

This is still host-LBA proof only.  It does not derive or promote the media
window delta.
"""
from __future__ import annotations

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

try:
    from hlds_gdr_firmware_lba_cpu_smoke import resolve_firmware, load_json, write_json, parse_int
except Exception:  # pragma: no cover
    def load_json(path: Path) -> dict[str, Any]:
        with path.open('r', encoding='utf-8') as f: return json.load(f)
    def write_json(path: Path, obj: Any) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open('w', encoding='utf-8') as f:
            json.dump(obj, f, indent=2); f.write('\n')
    def parse_int(v: Any, default: int = 0) -> int:
        if v is None or v == '': return default
        if isinstance(v, int): return v
        return int(str(v), 0)
    def resolve_firmware(config: dict[str, Any], config_path: Path, firmware_arg: str | None) -> str:
        return str(firmware_arg or config.get('custom_bin') or config.get('proof_firmware_image') or '')

VERSION = 'v256'
ROM_BASE = 0x90000000
OPCODE_CLASS_TABLE = 0x9003BDB4
HANDLER_PTR_TABLE = 0x9003BEB4
READ10_OPCODE = 0x28
READ12_OPCODE = 0xA8
READ10_WRAPPER_PC = 0x9002EA7C
READ10_HANDLER_PC = 0x9001EED0
READ10_HANDLER_ASSEMBLER_CALL_PC = 0x9001EEE2
READ10_HANDLER_STORE_LBA_PC = 0x9001EEE9
READ10_HANDLER_STORE_STATE_PC = 0x9001EEEE
READ10_HANDLER_STORE_PHASE_PC = 0x9001EEF3
READ10_HANDLER_RET_PC = 0x9001EEF6
READ10_GATE_CHECK_PC = 0x9001EED5
READ10_GATE_HELPER_PC = 0x9001BAB9
BYTE_ASSEMBLER_PC = 0x90030687
SRAM_05D0 = 0x05D0
SRAM_05D2 = 0x05D2
SRAM_05EC = 0x05EC
SRAM_05F0 = 0x05F0
SRAM_06F1 = 0x06F1

CASES = [
    {'name':'read10_dispatch_video_low_lba', 'lba':0x00000010},
    {'name':'read10_dispatch_redump_boundary_lba', 'lba':0x0004ABBF},
    {'name':'read10_dispatch_dispatcher_boundary_lba', 'lba':0x00202000},
    {'name':'read10_dispatch_unlocked_max_lba', 'lba':0x00345B5F},
]

def hex32(v:int)->str: return f'0x{v & 0xffffffff:08X}'
def hex8(v:int)->str: return f'0x{v & 0xff:02X}'
def lba_be_bytes(lba:int)->list[int]: return [(lba>>24)&255,(lba>>16)&255,(lba>>8)&255,lba&255]
def lba_le_bytes(lba:int)->list[int]: return [(lba>>(8*i))&255 for i in range(4)]

def norm_pc(v:Any)->str:
    try: return hex32(parse_int(v,0))
    except Exception: return str(v or '')

def read_csv(path:Path)->list[dict[str,str]]:
    if not path.exists(): return []
    with path.open('r', encoding='utf-8', newline='') as f: return list(csv.DictReader(f))

def load_firmware_bytes(path:Path)->bytes:
    with path.open('rb') as f: return f.read()

def u32le(buf:bytes, addr:int)->int:
    off = addr - ROM_BASE
    if off < 0 or off + 4 > len(buf): return -1
    return int.from_bytes(buf[off:off+4], 'little')

def u8(buf:bytes, addr:int)->int:
    off = addr - ROM_BASE
    if off < 0 or off >= len(buf): return -1
    return buf[off]

def find_call_refs(asm_path:Path|None, target:int)->list[dict[str,Any]]:
    if not asm_path or not asm_path.exists(): return []
    t = hex32(target).lower().replace('0x','0x')
    out=[]
    with asm_path.open('r', encoding='utf-8', errors='replace') as f:
        for line in f:
            s=line.strip()
            if f'call\t{t}' in s.lower() or f'call {t}' in s.lower() or f'call\t0x{target:08x}' in s.lower():
                try: pc=int(s.split(':',1)[0],16)
                except Exception: pc=None
                out.append({'pc':hex32(pc) if pc is not None else None, 'line':s})
    return out

def static_dispatch(fw:bytes, asm:Path|None)->dict[str,Any]:
    op_results=[]
    for opc,name in [(READ10_OPCODE,'READ(10)'),(READ12_OPCODE,'READ(12)')]:
        cls = u8(fw, OPCODE_CLASS_TABLE + opc)
        ptr = u32le(fw, HANDLER_PTR_TABLE + (cls*4)) if cls >= 0 else -1
        op_results.append({'opcode_hex':hex8(opc),'name':name,'dispatch_class_hex':hex8(cls),'dispatch_class':cls,'handler_pc':hex32(ptr) if ptr >= 0 else None})
    refs_eed0 = find_call_refs(asm, READ10_HANDLER_PC)
    refs_b4b3 = find_call_refs(asm, 0x9001B4B3)
    read10 = next((x for x in op_results if x['opcode_hex']=='0x28'),{})
    read12 = next((x for x in op_results if x['opcode_hex']=='0xA8'),{})
    return {
        'opcode_class_table': hex32(OPCODE_CLASS_TABLE),
        'handler_pointer_table': hex32(HANDLER_PTR_TABLE),
        'opcodes': op_results,
        'read10_dispatch_class_is_0x48': read10.get('dispatch_class') == 0x48,
        'read10_handler_is_wrapper_9002ea7c': read10.get('handler_pc') == hex32(READ10_WRAPPER_PC),
        'read12_dispatch_class_is_0x14': read12.get('dispatch_class') == 0x14,
        'read12_handler_pc': read12.get('handler_pc'),
        'read10_handler_call_refs': refs_eed0,
        'descriptor_setup_call_refs': refs_b4b3,
        'read10_wrapper_calls_handler': any(r.get('pc') == hex32(READ10_WRAPPER_PC+3) or r.get('pc') == hex32(READ10_WRAPPER_PC) for r in refs_eed0) or any(r.get('pc') == '0x9002EA7F' for r in refs_eed0),
        'ok': read10.get('dispatch_class') == 0x48 and read10.get('handler_pc') == hex32(READ10_WRAPPER_PC),
    }

def case_config(base_config:dict[str,Any], firmware:str, case:dict[str,Any], case_dir:Path)->dict[str,Any]:
    lba=int(case['lba'])
    cdb = [0x28,0x00] + lba_be_bytes(lba) + [0x00,0x00,0x00,0x01,0x00,0x00]
    return {
        'test_name': f'GDR-8050L {VERSION} READ(10) dispatch handler probe: {case["name"]}',
        'workspace': base_config.get('workspace', str(Path.cwd())),
        'target_profile': 'GDR-8050L',
        'custom_bin': firmware,
        'custom_base_addr': '0x90000000',
        'entry_point': hex32(READ10_HANDLER_PC),
        'max_cycles': 500,
        'header_skip_bytes': '0x0',
        'config_version': VERSION,
        'requires_emulator_version': base_config.get('requires_emulator_version','V7-direct-config-trace-v120-gcc-version-sync-gdr163-v227-media-preflight'),
        'initial_registers': {'SP':'0x003FF000'},
        'memory_aliases': [{'name':'SRAM alias 0x80000000->0x00000000','from':'0x80000000','to':'0x00000000','size':'0x400000'}],
        'memory_injections': [{'address':hex32(SRAM_05D0),'data':cdb,'description':'READ(10) CDB shadow at 0x05D0; handler reads LBA at 0x05D2'}],
        'trace': {
            'enabled': True,
            'live_instruction_log': False,
            'out_csv': str(case_dir / f'{case["name"]}.trace.csv'),
            'call_policy': {
                'skip_targets': [hex32(READ10_GATE_HELPER_PC)],
                'return_values': {hex32(READ10_GATE_HELPER_PC): {'D0':'0x1'}},
            },
            'watchpoints': [
                {'name':'watch_05d0_cdb_shadow','start':hex32(SRAM_05D0),'size':'0x10','kind':'access'},
                {'name':'watch_05ec_assembled_lba','start':hex32(SRAM_05EC),'size':'0x04','kind':'access'},
                {'name':'watch_05f0_packet_state','start':hex32(SRAM_05F0),'size':'0x01','kind':'access'},
                {'name':'watch_06f1_packet_phase','start':hex32(SRAM_06F1),'size':'0x01','kind':'access'},
            ],
            'breakpoints': [
                {'address':hex32(READ10_HANDLER_PC),'name':'read10_handler_entry_9001EED0'},
                {'address':hex32(READ10_GATE_CHECK_PC),'name':'read10_gate_post_bab9_9001EED5'},
                {'address':hex32(READ10_HANDLER_ASSEMBLER_CALL_PC),'name':'read10_byte_assembler_call_9001EEE2'},
                {'address':hex32(BYTE_ASSEMBLER_PC),'name':'byte_assembler_entry_90030687'},
                {'address':hex32(READ10_HANDLER_STORE_LBA_PC),'name':'read10_store_05ec_9001EEE9'},
                {'address':hex32(READ10_HANDLER_STORE_STATE_PC),'name':'read10_store_05f0_state11_9001EEEE'},
                {'address':hex32(READ10_HANDLER_STORE_PHASE_PC),'name':'read10_store_06f1_phase04_9001EEF3'},
                {'address':hex32(READ10_HANDLER_RET_PC),'name':'read10_handler_return_9001EEF6'},
            ],
        },
    }

def analyze_case(trace:Path, case:dict[str,Any], rc:int, out_path:Path)->dict[str,Any]:
    rows=read_csv(trace)
    lba=int(case['lba'])
    expected=lba_le_bytes(lba)
    got=[None]*4
    for r in rows:
        if r.get('event')=='watch_write' and parse_int(r.get('addr'),-1) in range(SRAM_05EC,SRAM_05EC+4):
            got[parse_int(r.get('addr'),-1)-SRAM_05EC]=parse_int(r.get('value'),-1)&255
    read_shadow=[r for r in rows if r.get('event')=='watch_read' and parse_int(r.get('addr'),-1) in range(SRAM_05D2,SRAM_05D2+4)]
    state11=any(r.get('event')=='watch_write' and parse_int(r.get('addr'),-1)==SRAM_05F0 and parse_int(r.get('value'),-1)==0x11 for r in rows)
    phase04=any(r.get('event')=='watch_write' and parse_int(r.get('addr'),-1)==SRAM_06F1 and parse_int(r.get('value'),-1)==0x04 for r in rows)
    store_hit=any(norm_pc(r.get('pc'))==hex32(READ10_HANDLER_STORE_LBA_PC) for r in rows)
    assembler_hit=any(norm_pc(r.get('pc'))==hex32(BYTE_ASSEMBLER_PC) for r in rows)
    call_skip=any(r.get('event')=='call_skip' and parse_int(r.get('addr'),0)==READ10_GATE_HELPER_PC for r in rows)
    match=(got==expected)
    return {
        'case':case['name'], 'input_lba':lba, 'input_lba_hex':hex32(lba),
        'trace_csv':str(trace), 'stdout_stderr_log':str(out_path), 'run_returncode':rc, 'row_count':len(rows),
        'gate_helper_skipped_return_true': call_skip,
        'byte_assembler_entry_hit': assembler_hit,
        'cdb_shadow_lba_read_count': len(read_shadow),
        'descriptor_05ec_bytes_expected': ''.join(f'{b:02X}' for b in expected),
        'descriptor_05ec_bytes_written': ''.join(f'{b:02X}' if b is not None else '??' for b in got),
        'assembled_05ec_lba_match': match,
        'store_05ec_pc_hit': store_hit,
        'state_05f0_0x11': state11,
        'phase_06f1_0x04': phase04,
        'ok': rc==0 and call_skip and assembler_hit and store_hit and match and state11 and phase04,
    }

def run_cases(config:dict[str,Any], config_path:Path, firmware_arg:str|None, mn103s_emu:str, case_dir:Path, timeout:int)->dict[str,Any]:
    firmware=resolve_firmware(config, config_path, firmware_arg)
    case_dir.mkdir(parents=True, exist_ok=True)
    results=[]
    for case in CASES:
        cfg=case_config(config, firmware, case, case_dir)
        cfg_path=case_dir / f'{case["name"]}.config.json'
        out_path=case_dir / f'{case["name"]}.output.txt'
        write_json(cfg_path, cfg)
        try:
            p=subprocess.run([sys.executable, mn103s_emu, str(cfg_path)], cwd=Path.cwd(), capture_output=True, text=True, timeout=timeout)
            out_path.write_text((p.stdout or '') + ('\n'+p.stderr if p.stderr else ''), encoding='utf-8', errors='replace')
            rc=p.returncode
        except subprocess.TimeoutExpired as e:
            out_path.write_text((e.stdout or '')+'\n[TIMEOUT]\n'+(e.stderr or ''), encoding='utf-8', errors='replace')
            rc=124
        results.append(analyze_case(Path(cfg['trace']['out_csv']), case, int(rc), out_path))
    return {'case_dir':str(case_dir),'case_count':len(results),'passed_count':sum(1 for r in results if r.get('ok')),'cases':results,'read10_handler_host_lba_to_05ec_proven': bool(results) and all(r.get('assembled_05ec_lba_match') for r in results),'read10_handler_state11_phase04_proven': bool(results) and all(r.get('state_05f0_0x11') and r.get('phase_06f1_0x04') for r in results),'ok': bool(results) and all(r.get('ok') for r in results)}

def write_md(path:Path, report:dict[str,Any])->None:
    st=report.get('static_dispatch') or {}; cpu=report.get('read10_handler_cpu_probe') or {}
    lines=[
        f'# GDR-8050L {VERSION} READ dispatch/handler probe','',
        f"- ok: `{str(report.get('ok')).lower()}`",
        f"- firmware_derived_host_lba: `{str(report.get('firmware_derived_host_lba')).lower()}`",
        f"- firmware_derived_delta: `{str(report.get('firmware_derived_delta')).lower()}`",
        f"- static_dispatch_ok: `{str(st.get('ok')).lower()}`",
        f"- read10_handler_cpu_ok: `{str(cpu.get('ok')).lower()}`",'',
        '## What this proves','',
        'This probe follows the firmware opcode dispatch metadata for READ commands and then executes the READ(10) natural handler slice.  The dispatch table maps READ(10) opcode `0x28` to class `0x48` and wrapper `0x9002EA7C`; that wrapper calls `0x9001EED0`.  The `0x9001EED0` handler reads the CDB-shadow LBA from `0x05D2..0x05D5`, uses firmware helper `0x90030687`, writes the assembled host LBA to `0x05EC`, then writes `0x05F0=0x11` and `0x06F1=0x04`.', '',
        'This is a second host-LBA proof path and is closer to the natural READ(10) dispatch path than the v252 parser-state probe.  It still does not derive the Redump media-window delta.', '',
        '## Dispatch table', '',
        '| opcode | command | dispatch class | handler PC |', '|---:|---|---:|---:|',
    ]
    for o in st.get('opcodes',[]) or []:
        lines.append(f"| `{o.get('opcode_hex')}` | `{o.get('name')}` | `{o.get('dispatch_class_hex')}` | `{o.get('handler_pc')}` |")
    lines += ['', '## READ(10) handler CPU cases', '', '| case | LBA | CDB-shadow reads | 0x05EC match | 0x05F0=0x11 | 0x06F1=0x04 | ok |', '|---|---:|---:|---:|---:|---:|---:|']
    for c in cpu.get('cases',[]) or []:
        lines.append(f"| `{c.get('case')}` | `{c.get('input_lba_hex')}` | `{c.get('cdb_shadow_lba_read_count')}` | `{c.get('assembled_05ec_lba_match')}` | `{c.get('state_05f0_0x11')}` | `{c.get('phase_06f1_0x04')}` | `{c.get('ok')}` |")
    lines += ['', '## Remaining proof gap', '', 'The READ(10) host-LBA path is now proven through the natural dispatch handler slice, but the live bridge still supplies the media-window mapping.  Keep `firmware_derived_delta=false` until an executed firmware window/descriptor selector is proven to consume or derive the unlocked media offset.', '']
    path.parent.mkdir(parents=True, exist_ok=True); path.write_text('\n'.join(lines), encoding='utf-8')

def main(argv:list[str]|None=None)->int:
    ap=argparse.ArgumentParser(description='Probe GDR-8050L READ dispatch table and READ(10) handler LBA path')
    ap.add_argument('--config', default='gdr8050l_auth_v256_stateful_relock_keep_loaded_iscsi_config.json')
    ap.add_argument('--firmware')
    ap.add_argument('--asm')
    ap.add_argument('--mn103s-emu', default='mn103s_emu.py')
    ap.add_argument('--out-prefix', default='gdr8050l_auth_v256_firmware_read_dispatch_probe_report')
    ap.add_argument('--json', default='gdr8050l_auth_v256_firmware_read_dispatch_probe_report.json')
    ap.add_argument('--md', default='gdr8050l_auth_v256_firmware_read_dispatch_probe_report.md')
    ap.add_argument('--case-dir')
    ap.add_argument('--timeout', type=int, default=45)
    args=ap.parse_args(argv)
    base=Path.cwd(); cfg_path=Path(args.config); cfg_path = cfg_path if cfg_path.is_absolute() else base/cfg_path
    config=load_json(cfg_path) if cfg_path.exists() else {}
    firmware=Path(resolve_firmware(config, cfg_path, args.firmware))
    if not firmware.is_absolute(): firmware = base / firmware
    fw=load_firmware_bytes(firmware)
    asm=Path(args.asm) if args.asm else None
    if asm and not asm.is_absolute(): asm=base/asm
    st=static_dispatch(fw, asm)
    case_dir=Path(args.case_dir) if args.case_dir else base / f'{args.out_prefix}_cases'
    if not case_dir.is_absolute(): case_dir=base/case_dir
    cpu=run_cases(config, cfg_path, str(firmware), args.mn103s_emu, case_dir, args.timeout)
    report={'version':VERSION,'config':str(cfg_path),'firmware':str(firmware),'asm':str(asm) if asm else None,'static_dispatch':st,'read10_handler_cpu_probe':cpu,'firmware_derived_host_lba': bool(st.get('ok') and cpu.get('read10_handler_host_lba_to_05ec_proven')),'firmware_derived_delta':False,'ok': bool(st.get('ok') and cpu.get('ok'))}
    jp=Path(args.json); mp=Path(args.md); jp=jp if jp.is_absolute() else base/jp; mp=mp if mp.is_absolute() else base/mp
    write_json(jp, report); write_md(mp, report)
    print(f"[FIRMWARE-READ-DISPATCH] static_ok={str(st.get('ok')).lower()} read10_cases={cpu.get('passed_count')}/{cpu.get('case_count')} firmware_derived_host_lba={str(report['firmware_derived_host_lba']).lower()} firmware_derived_delta=false ok={str(report['ok']).lower()}")
    return 0 if report['ok'] else 1

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