#!/usr/bin/env python3
"""GDR-8050L READ(10) phase-4 descriptor consumer probe.

v257 corrected an over-claim: the immediate front-dispatch/scheduler branch at
0x9001C710 returns for READ(10) class 0x48 and does not itself enter the
D2==0x30 descriptor branch.  However, the main firmware service loop at
0x9001B285 consumes the phase/state handoff left by the READ(10) handler:

  READ(10) handler handoff from v257:
    0x05EC = assembled host LBA
    0x05B6 bit 0x04 set
    0x05F0 = 0x11
    0x06F1 = 0x04

  Phase consumer:
    0x9001B285 reads 0x06F1
    phase-dispatch table 0x9003B9A0[4] -> 0x9001B4AE
    0x9001B4AE/0x9001B54E consumes state 0x05F0=0x11
    0x9001B5EF copies 0x05EC -> 0x06E4 when 0x05B6 bit 0x04 is set
    0x9001B5F8 arms 0x06E0 = 0xF0

This closes the READ(10) parser/descriptor proof across the firmware state
handoff, not as one uninterrupted call stack.  It still does not derive/promote
any unlocked media-window delta.
"""
from __future__ import annotations
import argparse, csv, json, 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:
    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='v258'
ROM_BASE=0x90000000
SERVICE_ENTRY=0x9001B285
PHASE_READ_PC=0x9001B2CF
PHASE_CALL_PC=0x9001B2DB
PHASE_DISPATCH_TABLE=0x9003B9A0
DESCRIPTOR_FULL_ENTRY=0x9001B4AE
DESCRIPTOR_SETUP_ENTRY=0x9001B54E
DESCRIPTOR_GATE_TEST=0x9001B5E8
DESCRIPTOR_COPY=0x9001B5EF
DESCRIPTOR_WRITE=0x9001B5F2
TRANSFER_FLAG=0x9001B5F8
AFTER_DESCRIPTOR_COPY=0x9001B5FB
PHASE_CLEAR_PC=0x9001B615
DESCRIPTOR_RETURN=0x9001B66E
RESET_DESCRIPTOR_PC=0x9001BDE4
STATE_TABLE=0x9003B9E8
READ10_STATE=0x11
READ10_PHASE=0x04
SRAM_05B0=0x05B0
SRAM_05B1=0x05B1
SRAM_05B6=0x05B6
SRAM_05BE=0x05BE
SRAM_05EC=0x05EC
SRAM_05F0=0x05F0
SRAM_06E0=0x06E0
SRAM_06E1=0x06E1
SRAM_06E4=0x06E4
SRAM_06E8=0x06E8
SRAM_06EC=0x06EC
SRAM_06ED=0x06ED
SRAM_06F1=0x06F1
MMIO_LOCK_PC=0x90006464
MMIO_UNLOCK_PC=0x90006470
HELPER_E03D=0x9001E03D
HELPER_EC17=0x9001EC17
HELPER_BE88=0x9001BE88

CASES=[
 {'name':'read10_phase4_consumer_video_lba','lba':0x00000010,'flags':0x04,'phase':0x04,'expect_copy':True},
 {'name':'read10_phase4_consumer_redump_boundary_lba','lba':0x0004ABBF,'flags':0x04,'phase':0x04,'expect_copy':True},
 {'name':'read10_phase4_consumer_dispatch_boundary_lba','lba':0x00202000,'flags':0x04,'phase':0x04,'expect_copy':True},
 {'name':'read10_phase4_consumer_unlocked_max_lba','lba':0x00345B5F,'flags':0x04,'phase':0x04,'expect_copy':True},
 {'name':'read10_phase4_gate_off_negative','lba':0x0004ABBF,'flags':0x00,'phase':0x04,'expect_copy':False},
]

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 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 u32le(fw:bytes, addr:int)->int:
    off=addr-ROM_BASE
    return int.from_bytes(fw[off:off+4],'little')
def static_audit(firmware:str)->dict[str,Any]:
    out={'ok':False,'phase_table_addr':hex32(PHASE_DISPATCH_TABLE),'phase4_handler':None,'state11_entry':None}
    if not firmware or not Path(firmware).exists():
        out['reason']='firmware_missing'; return out
    fw=Path(firmware).read_bytes()
    phase_handlers=[]
    for i in range(16):
        phase_handlers.append({'phase':i,'phase_hex':hex8(i),'handler':hex32(u32le(fw,PHASE_DISPATCH_TABLE+i*4))})
    state_off=STATE_TABLE-ROM_BASE+READ10_STATE*3
    state_bytes=list(fw[state_off:state_off+3])
    out.update({
        'phase_handlers':phase_handlers,
        'phase4_handler':hex32(u32le(fw,PHASE_DISPATCH_TABLE+READ10_PHASE*4)),
        'phase4_handler_expected':hex32(DESCRIPTOR_FULL_ENTRY),
        'state11_entry':{'state_hex':hex8(READ10_STATE),'entry_addr':hex32(STATE_TABLE+READ10_STATE*3),'entry_bytes':[hex8(x) for x in state_bytes], 'descriptor_flag_hex':hex8(state_bytes[0]), 'byte_06ec_hex':hex8(state_bytes[1]), 'byte_06ed_hex':hex8(state_bytes[2])},
    })
    out['ok']=out['phase4_handler']==hex32(DESCRIPTOR_FULL_ENTRY) and len(state_bytes)==3
    return out

def case_config(base_config:dict[str,Any], firmware:str, case:dict[str,Any], case_dir:Path)->dict[str,Any]:
    lba=int(case['lba']); flags=int(case['flags']); phase=int(case['phase'])
    c={
      'test_name':f'GDR-8050L {VERSION} READ(10) phase-4 descriptor consumer: {case["name"]}',
      'workspace':base_config.get('workspace', str(Path.cwd())),
      'target_profile':'GDR-8050L','custom_bin':firmware,'custom_base_addr':'0x90000000',
      'entry_point':hex32(SERVICE_ENTRY),'max_cycles':1400,'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_05B0),'data':[0],'description':'clear high-priority alternate service flag'},
        {'address':hex32(SRAM_05B1),'data':[0],'description':'clear optional service flags'},
        {'address':hex32(SRAM_05BE),'data':[0],'description':'clear optional transfer flag'},
        {'address':hex32(SRAM_05B6),'data':[flags&255],'description':f'post-READ(10) gate flags {hex8(flags)}'},
        {'address':hex32(SRAM_05EC),'data':le32(lba),'description':f'post-READ(10) assembled host LBA {hex32(lba)}'},
        {'address':hex32(SRAM_05F0),'data':[READ10_STATE],'description':'post-READ(10) state 0x11'},
        {'address':hex32(SRAM_06F1),'data':[phase&255],'description':'post-READ(10) phase 0x04'},
        {'address':hex32(SRAM_06E0),'data':[0],'description':'clear descriptor transfer flag'},
        {'address':hex32(SRAM_06E1),'data':[0],'description':'clear descriptor metadata'},
        {'address':hex32(SRAM_06E4),'data':[0]*8,'description':'clear descriptor LBA/aux'},
        {'address':hex32(SRAM_06EC),'data':[0,0],'description':'clear descriptor metadata bytes'},
      ],
      'trace':{
        'enabled':True,'live_instruction_log':False,'out_csv':str((case_dir/f'{case["name"]}.trace.csv').resolve()),
        'call_policy':{'skip_targets':[hex32(x) for x in [MMIO_LOCK_PC,MMIO_UNLOCK_PC,HELPER_E03D,HELPER_EC17,HELPER_BE88]], 'return_values':{}},
        'watchpoints':[
          {'name':'watch_05ec_lba','start':hex32(SRAM_05EC),'size':'0x04','kind':'access'},
          {'name':'watch_05b6_gate','start':hex32(SRAM_05B6),'size':'0x01','kind':'access'},
          {'name':'watch_05f0_state','start':hex32(SRAM_05F0),'size':'0x01','kind':'access'},
          {'name':'watch_06f1_phase','start':hex32(SRAM_06F1),'size':'0x01','kind':'access'},
          {'name':'watch_06e0_transfer_flag','start':hex32(SRAM_06E0),'size':'0x01','kind':'access'},
          {'name':'watch_06e1_descriptor_flag','start':hex32(SRAM_06E1),'size':'0x01','kind':'access'},
          {'name':'watch_06e4_descriptor_lba','start':hex32(SRAM_06E4),'size':'0x08','kind':'access'},
          {'name':'watch_06ec_descriptor_byte1','start':hex32(SRAM_06EC),'size':'0x02','kind':'access'},
        ],
        'breakpoints':[{'address':hex32(a),'name':n} for a,n in [
          (SERVICE_ENTRY,'service_loop_entry_9001B285'),(PHASE_READ_PC,'service_phase_read_9001B2CF'),(PHASE_CALL_PC,'service_phase_call_9001B2DB'),
          (DESCRIPTOR_FULL_ENTRY,'phase4_descriptor_full_entry_9001B4AE'),(DESCRIPTOR_SETUP_ENTRY,'descriptor_setup_state_entry_9001B54E'),
          (DESCRIPTOR_GATE_TEST,'descriptor_gate_test_9001B5E8'),(DESCRIPTOR_COPY,'descriptor_copy_05ec_9001B5EF'),(DESCRIPTOR_WRITE,'descriptor_write_06e4_9001B5F2'),
          (TRANSFER_FLAG,'descriptor_transfer_flag_9001B5F8'),(AFTER_DESCRIPTOR_COPY,'descriptor_after_copy_9001B5FB'),(PHASE_CLEAR_PC,'descriptor_clears_phase_9001B615'),(DESCRIPTOR_RETURN,'descriptor_return_9001B66E'),(RESET_DESCRIPTOR_PC,'descriptor_reset_9001BDE4')]]
      }
    }
    return c

def hit(rows:list[dict[str,str]], pc:int, event:str|None='break')->bool:
    for r in rows:
        if event and r.get('event')!=event: continue
        if norm_pc(r.get('pc'))==hex32(pc): return True
    return False

def collect_written_bytes(rows:list[dict[str,str]], base:int, pc:int|None, n:int)->list[int|None]:
    vals=[None]*n
    for r in rows:
        if r.get('event')!='watch_write': continue
        a=parse_int(r.get('addr'),-1)
        if base <= a < base+n:
            if pc is not None and norm_pc(r.get('pc'))!=hex32(pc): continue
            vals[a-base]=parse_int(r.get('value'),-1)&255
    return vals

def latest_write(rows:list[dict[str,str]], addr:int, pc:int|None=None)->dict[str,str]|None:
    got=[]
    for r in rows:
        if r.get('event')!='watch_write': continue
        if parse_int(r.get('addr'),-1)!=addr: continue
        if pc is not None and norm_pc(r.get('pc'))!=hex32(pc): continue
        got.append(r)
    return got[-1] if got else None

def d0_at_break(rows:list[dict[str,str]], pc:int)->str|None:
    for r in rows:
        if r.get('event')=='break' and norm_pc(r.get('pc'))==hex32(pc): return r.get('d0')
    return None

def analyze_case(trace:Path, case:dict[str,Any], rc:int, log:Path, static:dict[str,Any])->dict[str,Any]:
    rows=read_csv(trace); lba=int(case['lba']); expect=le32(lba); expect_copy=bool(case['expect_copy'])
    desc_bytes=collect_written_bytes(rows,SRAM_06E4,DESCRIPTOR_WRITE,4)
    desc_match=(desc_bytes==expect)
    flagw=latest_write(rows,SRAM_06E0,TRANSFER_FLAG)
    flag_f0=bool(flagw and (parse_int(flagw.get('value'),-1)&255)==0xF0)
    phase_clear=bool((w:=latest_write(rows,SRAM_06F1,PHASE_CLEAR_PC)) and (parse_int(w.get('value'),-1)&255)==0)
    e1w=latest_write(rows,SRAM_06E1)
    ecw=latest_write(rows,SRAM_06EC)
    edw=latest_write(rows,SRAM_06ED)
    st=static.get('state11_entry') or {}
    exp_e1=parse_int(str(st.get('descriptor_flag_hex','0')),0)
    exp_ec=parse_int(str(st.get('byte_06ec_hex','0')),0)
    exp_ed=parse_int(str(st.get('byte_06ed_hex','0')),0)
    metadata_match=bool(e1w and ecw and edw and (parse_int(e1w.get('value'),-1)&255)==exp_e1 and (parse_int(ecw.get('value'),-1)&255)==exp_ec and (parse_int(edw.get('value'),-1)&255)==exp_ed)
    common=bool(rows) and rc==0 and hit(rows,SERVICE_ENTRY) and hit(rows,PHASE_READ_PC) and hit(rows,PHASE_CALL_PC) and hit(rows,DESCRIPTOR_FULL_ENTRY) and hit(rows,DESCRIPTOR_SETUP_ENTRY) and hit(rows,DESCRIPTOR_GATE_TEST) and metadata_match
    if expect_copy:
        ok=common and hit(rows,DESCRIPTOR_COPY) and hit(rows,DESCRIPTOR_WRITE) and desc_match and flag_f0 and phase_clear
    else:
        ok=common and not hit(rows,DESCRIPTOR_COPY) and not hit(rows,DESCRIPTOR_WRITE) and not flag_f0 and phase_clear
    return {
        'case':case['name'],'input_lba':lba,'input_lba_hex':hex32(lba),'flags_05b6_hex':hex8(int(case['flags'])),'phase_06f1_hex':hex8(int(case['phase'])),'expect_copy':expect_copy,
        'trace_csv':str(trace),'stdout_stderr_log':str(log),'run_returncode':rc,'row_count':len(rows),
        'service_entry_hit':hit(rows,SERVICE_ENTRY),'phase_read_hit':hit(rows,PHASE_READ_PC),'phase_call_hit':hit(rows,PHASE_CALL_PC),'phase_call_d0_table_offset':d0_at_break(rows,PHASE_CALL_PC),
        'phase4_descriptor_full_entry_hit':hit(rows,DESCRIPTOR_FULL_ENTRY),'descriptor_setup_hit':hit(rows,DESCRIPTOR_SETUP_ENTRY),'descriptor_gate_hit':hit(rows,DESCRIPTOR_GATE_TEST),
        'descriptor_copy_hit':hit(rows,DESCRIPTOR_COPY),'descriptor_write_hit':hit(rows,DESCRIPTOR_WRITE),'descriptor_bytes_written':''.join('??' if b is None else f'{b:02X}' for b in desc_bytes),'descriptor_lba_match':desc_match,
        'transfer_flag_f0':flag_f0,'phase_cleared_to_zero':phase_clear,'metadata_match':metadata_match,'ok':ok,
    }

def run_case(mn103s_emu:Path, cfg:dict[str,Any], cfg_path:Path, log_path:Path, timeout:float)->int:
    write_json(cfg_path,cfg)
    with log_path.open('w',encoding='utf-8',errors='replace') as f:
        try:
            p=subprocess.run([sys.executable,str(mn103s_emu),str(cfg_path)],cwd=str(mn103s_emu.parent),stdout=f,stderr=subprocess.STDOUT,timeout=timeout)
            return int(p.returncode)
        except subprocess.TimeoutExpired as exc:
            f.write(f'\n[TIMEOUT] {exc}\n'); return 124

def write_md(path:Path, report:dict[str,Any])->None:
    lines=[]
    lines.append(f'# GDR-8050L {VERSION} READ(10) phase-4 descriptor consumer probe\n')
    for k in ['ok','firmware_derived_host_lba','read10_phase4_descriptor_consumer','parser_descriptor_proof_complete_read10','single_call_stack_proof','firmware_derived_delta']:
        lines.append(f'- {k}: `{str(report.get(k)).lower()}`')
    lines.append('\n## Static phase-dispatch audit\n')
    lines.append(f"- phase table: `{report['static_audit'].get('phase_table_addr')}`")
    lines.append(f"- phase `0x04` handler: `{report['static_audit'].get('phase4_handler')}`")
    lines.append(f"- expected phase `0x04` descriptor handler: `{report['static_audit'].get('phase4_handler_expected')}`")
    st=report['static_audit'].get('state11_entry') or {}
    lines.append(f"- state `0x11` table entry at `{st.get('entry_addr')}`: `{st.get('entry_bytes')}`")
    lines.append('\n## CPU phase-consumer cases\n')
    lines.append('| case | LBA | flags | D0 table offset | phase4 entry | setup | copy | 0x06E4 LBA | 0x06E0=F0 | 0x06F1 cleared | ok |')
    lines.append('|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|')
    for c in report['cpu_cases']:
        lines.append(f"| `{c['case']}` | `{c['input_lba_hex']}` | `{c['flags_05b6_hex']}` | `{c['phase_call_d0_table_offset']}` | `{c['phase4_descriptor_full_entry_hit']}` | `{c['descriptor_setup_hit']}` | `{c['descriptor_copy_hit']}` | `{c['descriptor_lba_match']}` | `{c['transfer_flag_f0']}` | `{c['phase_cleared_to_zero']}` | `{c['ok']}` |")
    lines.append('\n## Interpretation\n')
    lines.append('This probe closes the READ(10) parser/descriptor proof across the firmware state handoff. v257 proves READ(10) naturally writes `0x05EC`, sets `0x05B6&0x04`, writes `0x05F0=0x11`, and writes `0x06F1=0x04`. This v258 probe proves the main service-loop phase consumer at `0x9001B285` dispatches phase `0x04` through table `0x9003B9A0[4]` to `0x9001B4AE`, which reaches `0x9001B5EF` and copies `0x05EC -> 0x06E4`, then arms `0x06E0=0xF0`.')
    lines.append('\nThis is a state-handoff closure, not one uninterrupted call-stack proof from the SCSI front dispatcher through the main loop. It still does **not** derive the unlocked media-window delta; keep `firmware_derived_delta=false`.')
    path.write_text('\n'.join(lines)+'\n',encoding='utf-8')

def main(argv=None)->int:
    ap=argparse.ArgumentParser()
    ap.add_argument('--config', default='gdr8050l_auth_v258_stateful_relock_keep_loaded_iscsi_config.json')
    ap.add_argument('--firmware')
    ap.add_argument('--mn103s-emu', default='mn103s_emu.py')
    ap.add_argument('--json', default='gdr8050l_auth_v258_firmware_read10_phase4_descriptor_consumer_probe_report.json')
    ap.add_argument('--md', default='gdr8050l_auth_v258_firmware_read10_phase4_descriptor_consumer_probe_report.md')
    ap.add_argument('--case-dir', default='gdr8050l_auth_v258_firmware_read10_phase4_descriptor_consumer_probe_report_cases')
    ap.add_argument('--timeout', type=float, default=30.0)
    args=ap.parse_args(argv)
    cfg_path=Path(args.config)
    cfg=load_json(cfg_path) if cfg_path.exists() else {}
    firmware=resolve_firmware(cfg,cfg_path,args.firmware)
    mn103s=Path(args.mn103s_emu)
    if not mn103s.is_absolute(): mn103s=(Path.cwd()/mn103s)
    case_dir=Path(args.case_dir); case_dir.mkdir(parents=True,exist_ok=True)
    static=static_audit(firmware)
    cases=[]
    for case in CASES:
        cfg_case=case_config(cfg,firmware,case,case_dir)
        cpath=case_dir/f"{case['name']}.config.json"
        log=case_dir/f"{case['name']}.output.txt"
        rc=run_case(mn103s,cfg_case,cpath,log,args.timeout)
        cases.append(analyze_case(Path(cfg_case['trace']['out_csv']),case,rc,log,static))
    positives=[c for c in cases if c['expect_copy']]
    negatives=[c for c in cases if not c['expect_copy']]
    ok=bool(static.get('ok')) and all(c['ok'] for c in cases) and len(positives)>=4 and len(negatives)>=1
    report={
      'version':VERSION,'ok':ok,'firmware':firmware,
      'static_audit':static,'cpu_cases':cases,
      'positive_cases_passed':sum(1 for c in positives if c['ok']),'positive_cases_total':len(positives),
      'negative_cases_passed':sum(1 for c in negatives if c['ok']),'negative_cases_total':len(negatives),
      'firmware_derived_host_lba':ok,
      'read10_phase4_descriptor_consumer':ok,
      'parser_descriptor_proof_complete_read10':ok,
      'single_call_stack_proof':False,
      'firmware_derived_delta':False,
      'next_target':'Prove the media-window/delta decision source consumed after descriptor setup, not just READ(10) host-LBA descriptor handoff.',
    }
    write_json(Path(args.json),report); write_md(Path(args.md),report)
    print(f"[FIRMWARE-READ10-PHASE4-DESCRIPTOR] cases={sum(1 for c in cases if c['ok'])}/{len(cases)} phase4_consumer={str(report['read10_phase4_descriptor_consumer']).lower()} parser_descriptor_read10={str(report['parser_descriptor_proof_complete_read10']).lower()} firmware_derived_delta=false ok={str(ok).lower()}")
    return 0 if ok else 2
if __name__=='__main__': raise SystemExit(main())
