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

v255 proved the READ(10) scheduler stage starting at 0x9001C710 when D2 is
already the READ scheduler class (0x30): scheduler -> READ(10) wrapper ->
READ(10) handler -> descriptor setup -> 0x05EC copied to 0x06E4.

This v256 probe proves the earlier firmware front-dispatch table stage for
READ opcodes.  Starting at 0x9001C5F4 with only the CDB opcode/LBA seeded, the
firmware chooses the opcode-class table, writes the selected handler pointer to
0x06F4, invokes the READ wrapper when the selected class is a data-handler class,
and the wrapper/handler writes host LBA -> 0x05EC plus 0x05F0=0x11 and
0x06F1=0x04.

The front-dispatch stage and v255 scheduler-descriptor stage together close the
parser/dispatch/scheduler/descriptor proof for READ(10) host-LBA plumbing.  This
still does not derive or promote the Redump/unlocked 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
FRONT_ENTRY=0x9001C5F4
TABLE_SELECT_CALL=0x9001BD1D
NORMAL_CLASS_TABLE=0x9003BDB4
ALT_CLASS_TABLE=0x9003BCB4
HANDLER_PTR_TABLE=0x9003BEB4
FRONT_CLASS_LOAD_PC=0x9001C612
FRONT_PTR_LOAD_PC=0x9001C61D
FRONT_PTR_STORE_PC=0x9001C622
FRONT_INITIAL_HANDLER_CALL_PC=0x9001C66A
FRONT_AFTER_HANDLER_PC=0x9001C670
FRONT_READ_SCHEDULER_ENTRY_PC=0x9001C710
FRONT_RETURN_NON_SCHEDULER_PC=0x9001C714
READ10_WRAPPER=0x9002EA7C
READ12_ALT_WRAPPER=0x9002EA89
READ10_HANDLER=0x9001EED0
READ_GATE_HELPER=0x9001BAB9
READ_STORE_05EC=0x9001EEE9
READ_STORE_05F0=0x9001EEEE
READ_STORE_06F1=0x9001EEF3
CLASS_PREP_HELPER=0x9001AB92
SRAM_05D0=0x05D0
SRAM_05B6=0x05B6
SRAM_05EC=0x05EC
SRAM_05F0=0x05F0
SRAM_06F1=0x06F1
SRAM_06F4=0x06F4
SRAM_DE=0x00DE

CASES=[
 {'name':'front_read10_normal_table_video_lba','opcode':0x28,'table_return':0,'lba':0x00000010,'expect_class':0x48,'expect_ptr':READ10_WRAPPER,'expect_handler':True},
 {'name':'front_read10_alt_table_unlocked_max_lba','opcode':0x28,'table_return':1,'lba':0x00345B5F,'expect_class':0x48,'expect_ptr':READ10_WRAPPER,'expect_handler':True},
 {'name':'front_read12_normal_table_metadata_handler_only','opcode':0xA8,'table_return':0,'lba':0x0004ABBF,'expect_class':0x14,'expect_ptr':0x9001C8C8,'expect_handler':False},
 {'name':'front_read12_alt_table_read_wrapper_boundary_lba','opcode':0xA8,'table_return':1,'lba':0x00202000,'expect_class':0x49,'expect_ptr':READ12_ALT_WRAPPER,'expect_handler':True},
]

def hex32(v:int)->str: return f'0x{v & 0xffffffff:08X}'
def hex8(v:int)->str: return f'0x{v & 0xff:02X}'
def le32(v:int)->list[int]: return [(v>>(8*i))&255 for i in range(4)]
def be32(v:int)->list[int]: return [(v>>24)&255,(v>>16)&255,(v>>8)&255,v&255]
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 u8(fw:bytes, addr:int)->int: return fw[addr-ROM_BASE]
def u32le(fw:bytes, addr:int)->int: return int.from_bytes(fw[addr-ROM_BASE:addr-ROM_BASE+4], 'little')

def static_tables(firmware:str)->dict[str,Any]:
    try: fw=Path(firmware).read_bytes()
    except Exception: fw=b''
    rows=[]
    ok=True
    if fw:
        for name,tbl in [('normal',NORMAL_CLASS_TABLE),('alternate',ALT_CLASS_TABLE)]:
            for opc,label in [(0x28,'READ(10)'),(0xA8,'READ(12)')]:
                cls=u8(fw,tbl+opc); ptr=u32le(fw,HANDLER_PTR_TABLE+cls*4)
                rows.append({'table':name,'table_addr':hex32(tbl),'opcode_hex':hex8(opc),'name':label,'class_hex':hex8(cls),'class':cls,'handler_ptr':hex32(ptr)})
        expected={('normal',0x28):(0x48,READ10_WRAPPER),('alternate',0x28):(0x48,READ10_WRAPPER),('normal',0xA8):(0x14,0x9001C8C8),('alternate',0xA8):(0x49,READ12_ALT_WRAPPER)}
        for r in rows:
            cls,ptr=expected[(r['table'], int(r['opcode_hex'],16))]
            ok = ok and r['class']==cls and r['handler_ptr']==hex32(ptr)
    else:
        ok=False
    return {'normal_class_table':hex32(NORMAL_CLASS_TABLE),'alternate_class_table':hex32(ALT_CLASS_TABLE),'handler_pointer_table':hex32(HANDLER_PTR_TABLE),'rows':rows,'ok':ok}

def case_config(base_config:dict[str,Any], firmware:str, case:dict[str,Any], case_dir:Path)->dict[str,Any]:
    lba=int(case['lba']); opc=int(case['opcode'])
    cdb=[opc,0]+be32(lba)+[0,0,0,1,0,0]
    return {
      'test_name':f'GDR-8050L {VERSION} READ front dispatch closure 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(FRONT_ENTRY), 'max_cycles':1000, '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':base_config.get('memory_aliases',[{'name':'SRAM alias 0x80000000->0x00000000','from':'0x80000000','to':'0x00000000','size':'0x400000'}]),
      'memory_injections':[
        {'address':hex32(SRAM_05D0),'data':cdb,'description':'READ CDB shadow; front dispatcher reads opcode at 0x05D0 and handler reads LBA at 0x05D2..0x05D5'},
        {'address':hex32(SRAM_DE),'data':[0],'description':'force non-DE==2 front-dispatch route after initial handler'},
        {'address':hex32(SRAM_05B6),'data':[0],'description':'clear packet gate flags; READ handler may set gate'},
        {'address':hex32(SRAM_05EC),'data':[0,0,0,0],'description':'clear assembled LBA'},
        {'address':hex32(SRAM_05F0),'data':[0],'description':'clear packet state'},
        {'address':hex32(SRAM_06F1),'data':[0],'description':'clear packet phase'},
        {'address':hex32(SRAM_06F4),'data':[0,0,0,0],'description':'clear front-dispatch function pointer'},
      ],
      'trace':{
        'enabled':True,'live_instruction_log':False,'out_csv':str(case_dir/f'{case["name"]}.trace.csv'),
        'call_policy':{
          'skip_targets':[hex32(TABLE_SELECT_CALL),hex32(CLASS_PREP_HELPER),hex32(READ_GATE_HELPER),hex32(0x90006464),hex32(0x90006470)],
          'return_values':{hex32(TABLE_SELECT_CALL):{'D0':hex32(int(case['table_return']))},hex32(READ_GATE_HELPER):{'D0':'0x1'}},
        },
        'watchpoints':[
          {'name':'watch_05d0_cdb_shadow','start':hex32(SRAM_05D0),'size':'0x10','kind':'access'},
          {'name':'watch_05b6_gate_flags','start':hex32(SRAM_05B6),'size':'0x01','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'},
          {'name':'watch_06f4_front_handler_ptr','start':hex32(SRAM_06F4),'size':'0x04','kind':'access'},
        ],
        'breakpoints':[{'address':hex32(a),'name':n} for a,n in [
          (FRONT_ENTRY,'front_dispatch_entry_9001C5F4'),(FRONT_CLASS_LOAD_PC,'front_class_load_9001C612'),(FRONT_PTR_LOAD_PC,'front_ptr_load_9001C61D'),(FRONT_PTR_STORE_PC,'front_ptr_store_9001C622'),(FRONT_INITIAL_HANDLER_CALL_PC,'front_initial_handler_call_9001C66A'),(READ10_WRAPPER,'read10_wrapper_9002EA7C'),(READ12_ALT_WRAPPER,'read12_alt_wrapper_9002EA89'),(READ10_HANDLER,'read_handler_9001EED0'),(READ_STORE_05EC,'read_store_05ec_9001EEE9'),(READ_STORE_05F0,'read_store_05f0_9001EEEE'),(READ_STORE_06F1,'read_store_06f1_9001EEF3'),(FRONT_AFTER_HANDLER_PC,'front_after_handler_9001C670'),(FRONT_READ_SCHEDULER_ENTRY_PC,'front_read_scheduler_entry_9001C710'),(FRONT_RETURN_NON_SCHEDULER_PC,'front_return_non_scheduler_9001C714')]]
      }
    }

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=le32(lba)
    ptr=[None]*4; lba_bytes=[None]*4
    for r in rows:
      if r.get('event')=='watch_write':
        a=parse_int(r.get('addr'),-1); val=parse_int(r.get('value'),-1)&255
        if SRAM_06F4 <= a < SRAM_06F4+4: ptr[a-SRAM_06F4]=val
        if SRAM_05EC <= a < SRAM_05EC+4 and norm_pc(r.get('pc'))==hex32(READ_STORE_05EC): lba_bytes[a-SRAM_05EC]=val
    ptr_val=None if any(v is None for v in ptr) else sum((ptr[i]<<(8*i)) for i in range(4))
    lba_match=(lba_bytes==expected) if case.get('expect_handler') else True
    hit=lambda pc: any(norm_pc(r.get('pc'))==hex32(pc) for r in rows)
    table_call=any(r.get('event')=='call_skip' and parse_int(r.get('addr'),0)==TABLE_SELECT_CALL for r in rows)
    class_seen=any(norm_pc(r.get('pc'))==hex32(FRONT_PTR_LOAD_PC) and parse_int(r.get('d2'),-1)==int(case['expect_class']) for r in rows)
    ptr_ok=(ptr_val==int(case['expect_ptr']))
    handler_hit=hit(READ10_HANDLER)
    wrapper_hit=hit(int(case['expect_ptr'])) if int(case['expect_ptr']) in (READ10_WRAPPER,READ12_ALT_WRAPPER) else False
    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)
    expected_handler=bool(case.get('expect_handler'))
    ok = (rc==0 and table_call and class_seen and ptr_ok and ((handler_hit and wrapper_hit and lba_match and state11 and phase04) if expected_handler else (not handler_hit)))
    return {'case':case['name'],'opcode_hex':hex8(int(case['opcode'])),'table_return':int(case['table_return']),'input_lba_hex':hex32(lba),'expected_class_hex':hex8(int(case['expect_class'])),'expected_handler_ptr':hex32(int(case['expect_ptr'])),'trace_csv':str(trace),'stdout_stderr_log':str(out_path),'run_returncode':rc,'row_count':len(rows),'table_select_call_seen':table_call,'class_seen_at_9001c61d':class_seen,'handler_ptr_06f4_written':hex32(ptr_val or 0) if ptr_val is not None else None,'handler_ptr_match':ptr_ok,'wrapper_hit':wrapper_hit,'read_common_handler_hit':handler_hit,'read_lba_05ec_match':lba_match,'state_05f0_0x11':state11,'phase_06f1_0x04':phase04,'expected_handler_invoked':expected_handler,'ok':ok}

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,'front_dispatch_table_to_handler_ok':all(r.get('ok') for r in results),'ok':bool(results) and all(r.get('ok') for r in results)}

def load_optional(path_s:str|None, base:Path)->tuple[str|None,Any]:
    if not path_s: return None,None
    p=Path(path_s); p=p if p.is_absolute() else base/p
    if not p.exists():
      q=base/p.name
      if q.exists(): p=q
    if p.exists():
      try: return str(p),load_json(p)
      except Exception: return str(p),None
    return None,None

def write_md(path:Path, report:dict[str,Any])->None:
    st=report.get('static_tables') or {}; cpu=report.get('front_dispatch_cpu_probe') or {}; sched=report.get('read10_scheduler_descriptor') or {}
    lines=[f'# GDR-8050L {VERSION} READ front-dispatch closure probe','',f"- ok: `{str(report.get('ok')).lower()}`",f"- firmware_derived_host_lba: `{str(report.get('firmware_derived_host_lba')).lower()}`",f"- parser_descriptor_proof_complete_read10: `{str(report.get('parser_descriptor_proof_complete_read10')).lower()}`",f"- firmware_derived_delta: `{str(report.get('firmware_derived_delta')).lower()}`",'', '## Static front-dispatch tables','', '| table | opcode | command | class | handler pointer |','|---|---:|---|---:|---:|']
    for r in st.get('rows',[]) or []: lines.append(f"| `{r.get('table')}` | `{r.get('opcode_hex')}` | `{r.get('name')}` | `{r.get('class_hex')}` | `{r.get('handler_ptr')}` |")
    lines += ['', '## CPU front-dispatch cases', '', '| case | opcode | table return | expected class | 0x06F4 pointer | wrapper/handler | LBA -> 0x05EC | 0x05F0=0x11 | 0x06F1=0x04 | ok |','|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|']
    for c in cpu.get('cases',[]) or []: lines.append(f"| `{c.get('case')}` | `{c.get('opcode_hex')}` | `{c.get('table_return')}` | `{c.get('expected_class_hex')}` | `{c.get('handler_ptr_06f4_written')}` | `{c.get('read_common_handler_hit')}` | `{c.get('read_lba_05ec_match')}` | `{c.get('state_05f0_0x11')}` | `{c.get('phase_06f1_0x04')}` | `{c.get('ok')}` |")
    lines += ['', '## Closure status','', f"- front_dispatch_table_to_handler_ok: `{cpu.get('front_dispatch_table_to_handler_ok')}`", f"- read10_scheduler_descriptor_ok: `{sched.get('ok') if isinstance(sched,dict) else None}`", f"- parser_descriptor_proof_complete_read10: `{report.get('parser_descriptor_proof_complete_read10')}`", '', 'This closes the READ(10) host-LBA parser/dispatch/scheduler/descriptor proof at the firmware-control-flow level currently modeled: front dispatch selects the READ wrapper and handler from the opcode tables, the handler writes host LBA to `0x05EC`, and the proven v255 scheduler stage copies that LBA into descriptor SRAM `0x06E4` and arms transfer flag `0x06E0=0xF0`.', '', 'It still does **not** prove the Redump/unlocked media-window delta. Keep `firmware_derived_delta=false` until a firmware window/selector path is proven to consume or compute that offset.', '']
    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 front-dispatch table to handler closure')
    ap.add_argument('--config', default='gdr8050l_auth_v256_stateful_relock_keep_loaded_iscsi_config.json')
    ap.add_argument('--firmware')
    ap.add_argument('--mn103s-emu', default='mn103s_emu.py')
    ap.add_argument('--read10-scheduler-json')
    ap.add_argument('--json', default='gdr8050l_auth_v256_firmware_read_front_dispatch_closure_probe_report.json')
    ap.add_argument('--md', default='gdr8050l_auth_v256_firmware_read_front_dispatch_closure_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=resolve_firmware(config,cfg_path,args.firmware)
    case_dir=Path(args.case_dir) if args.case_dir else Path(str(args.json).replace('.json','_cases'))
    if not case_dir.is_absolute(): case_dir=base/case_dir
    st=static_tables(firmware)
    cpu=run_cases(config,cfg_path,args.firmware,args.mn103s_emu,case_dir,args.timeout)
    sched_path,sched=load_optional(args.read10_scheduler_json, base)
    parser_descriptor_complete=bool(cpu.get('ok') and isinstance(sched,dict) and sched.get('ok') and (sched.get('scheduler_cpu_probe') or {}).get('descriptor_copy_from_scheduler_proven'))
    report={'version':VERSION,'probe':'read_front_dispatch_closure','config':str(cfg_path),'firmware':firmware,'static_tables':st,'front_dispatch_cpu_probe':cpu,'read10_scheduler_descriptor_json':sched_path,'read10_scheduler_descriptor':sched,'parser_descriptor_proof_complete_read10':parser_descriptor_complete,'firmware_derived_host_lba':bool(cpu.get('ok')),'firmware_derived_delta':False,'ok':bool(st.get('ok') and cpu.get('ok') and parser_descriptor_complete)}
    write_json(Path(args.json),report); write_md(Path(args.md),report)
    print(f"[FIRMWARE-READ-FRONT-DISPATCH-CLOSURE] cases={cpu.get('passed_count')}/{cpu.get('case_count')} parser_descriptor_read10={str(parser_descriptor_complete).lower()} firmware_derived_delta=false ok={str(report['ok']).lower()}")
    return 0 if report['ok'] else 2
if __name__=='__main__': raise SystemExit(main())
