#!/usr/bin/env python3
"""GDR-8050L READ(10) scheduler-to-descriptor probe.

v253 proved the natural READ(10) dispatch handler slice at 0x9001EED0 writes
host LBA -> SRAM 0x05EC and state/phase 0x05F0=0x11, 0x06F1=0x04. v254
proved the descriptor setup block accepts that exact state and copies 0x05EC ->
0x06E4 when the packet gate bit is set.

This v256 probe closes the next gap with a controlled scheduler slice: enter the
READ-class scheduler at 0x9001C710 with D2=0x30, point SRAM 0x06F4 at the real
READ(10) wrapper 0x9002EA7C, let it call the natural READ(10) handler, return
to the scheduler, observe the phase-0x04 branch to the descriptor setup call at
0x9001C78A -> 0x9001B4B3, then verify descriptor copy 0x05EC -> 0x06E4.

This proves parser/dispatch/scheduler/descriptor plumbing for host READ(10) LBA, including the handler-set descriptor gate bit.
It does not prove or promote a firmware-derived 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
SCHEDULER_READ_CLASS_ENTRY_PC=0x9001C710
SCHEDULER_PRE_POINTER_CALL_PC=0x9001C717
SCHEDULER_AFTER_POINTER_CALL_PC=0x9001C71D
SCHEDULER_DESCRIPTOR_CALL_PC=0x9001C78A
READ10_WRAPPER_PC=0x9002EA7C
READ10_HANDLER_PC=0x9001EED0
READ10_HANDLER_RET_PC=0x9001EEF6
READ10_GATE_HELPER_PC=0x9001BAB9
READ10_STORE_LBA_PC=0x9001EEE9
READ10_STORE_STATE_PC=0x9001EEEE
READ10_STORE_PHASE_PC=0x9001EEF3
DESCRIPTOR_FUNCTION_ENTRY_PC=0x9001B4B3
DESCRIPTOR_SETUP_ENTRY_PC=0x9001B54E
DESCRIPTOR_COPY_PC=0x9001B5EF
DESCRIPTOR_WRITE_PC=0x9001B5F2
DESCRIPTOR_FLAG_PC=0x9001B5F8
DESCRIPTOR_RET_PC=0x9001B66E
# skipped hardware/helpers inside descriptor setup
DESCRIPTOR_TABLE_HELPER_PC=0x9001BDE2
DESCRIPTOR_SUBMIT_HELPER_PC=0x9001BE88
MMIO_LOCK_PC=0x90006464
MMIO_UNLOCK_PC=0x90006470

SRAM_05B6=0x05B6
SRAM_05D0=0x05D0
SRAM_05D2=0x05D2
SRAM_05D5=0x05D5
SRAM_05EC=0x05EC
SRAM_05F0=0x05F0
SRAM_06E0=0x06E0
SRAM_06E1=0x06E1
SRAM_06E4=0x06E4
SRAM_06EC=0x06EC
SRAM_06ED=0x06ED
SRAM_06F1=0x06F1
SRAM_06F4=0x06F4
READ10_CLASS_D2=0x30
GATE_BIT=0x04

CASES=[
    {'name':'scheduler_read10_gate_on_video_lba','lba':0x00000010,'flags':0x04,'expect_copy':True},
    {'name':'scheduler_read10_gate_on_redump_boundary_lba','lba':0x0004ABBF,'flags':0x04,'expect_copy':True},
    {'name':'scheduler_read10_gate_on_dispatch_boundary_lba','lba':0x00202000,'flags':0x04,'expect_copy':True},
    {'name':'scheduler_read10_gate_on_unlocked_max_lba','lba':0x00345B5F,'flags':0x04,'expect_copy':True},
    {'name':'scheduler_read10_gate_auto_from_zero_flags','lba':0x0004ABBF,'flags':0x00,'expect_copy':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))&0xff 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 static_call_sites() -> dict[str,Any]:
    return {
        'read_class_scheduler_entry_pc':hex32(SCHEDULER_READ_CLASS_ENTRY_PC),
        'read_class_scheduler_reads_06f4_and_calls_pointer':True,
        'read10_wrapper_pc':hex32(READ10_WRAPPER_PC),
        'read10_wrapper_calls_handler_pc':hex32(READ10_HANDLER_PC),
        'scheduler_after_pointer_call_pc':hex32(SCHEDULER_AFTER_POINTER_CALL_PC),
        'scheduler_phase04_descriptor_call_pc':hex32(SCHEDULER_DESCRIPTOR_CALL_PC),
        'descriptor_function_entry_pc':hex32(DESCRIPTOR_FUNCTION_ENTRY_PC),
        'descriptor_copy_pc':hex32(DESCRIPTOR_COPY_PC),
        'ok':True,
    }

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'])
    cdb=[0x28,0x00]+be32(lba)+[0x00,0x00,0x00,0x01,0x00,0x00]
    return {
        'test_name':f'GDR-8050L {VERSION} READ(10) scheduler-to-descriptor 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(SCHEDULER_READ_CLASS_ENTRY_PC),
        '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','D2':hex32(READ10_CLASS_D2),'D3':'0x00000000'},
        '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(10) CDB shadow; handler reads LBA at 0x05D2..0x05D5'},
            {'address':hex32(SRAM_05B6),'data':[flags&255],'description':f'packet gate flags 0x05B6={hex8(flags)}'},
            {'address':hex32(SRAM_06F4),'data':le32(READ10_WRAPPER_PC),'description':'scheduler command function pointer -> READ(10) wrapper 0x9002EA7C'},
            {'address':hex32(SRAM_05EC),'data':[0,0,0,0],'description':'clear assembled LBA'},
            {'address':hex32(SRAM_05F0),'data':[0],'description':'clear state'},
            {'address':hex32(SRAM_06F1),'data':[0],'description':'clear phase'},
            {'address':hex32(SRAM_06E0),'data':[0],'description':'clear transfer flag'},
            {'address':hex32(SRAM_06E1),'data':[0],'description':'clear descriptor flag'},
            {'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'),
            'call_policy':{
                'skip_targets':[hex32(READ10_GATE_HELPER_PC),hex32(DESCRIPTOR_TABLE_HELPER_PC),hex32(DESCRIPTOR_SUBMIT_HELPER_PC),hex32(MMIO_LOCK_PC),hex32(MMIO_UNLOCK_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_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_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_table_byte1','start':hex32(SRAM_06EC),'size':'0x01','kind':'access'},
                {'name':'watch_06ed_table_byte2','start':hex32(SRAM_06ED),'size':'0x01','kind':'access'},
                {'name':'watch_06f1_packet_phase','start':hex32(SRAM_06F1),'size':'0x01','kind':'access'},
                {'name':'watch_06f4_scheduler_func_ptr','start':hex32(SRAM_06F4),'size':'0x04','kind':'access'},
            ],
            'breakpoints':[
                {'address':hex32(SCHEDULER_READ_CLASS_ENTRY_PC),'name':'scheduler_read_class_entry_9001C710'},
                {'address':hex32(SCHEDULER_PRE_POINTER_CALL_PC),'name':'scheduler_pre_pointer_call_9001C717'},
                {'address':hex32(READ10_WRAPPER_PC),'name':'read10_wrapper_entry_9002EA7C'},
                {'address':hex32(READ10_HANDLER_PC),'name':'read10_handler_entry_9001EED0'},
                {'address':hex32(READ10_STORE_LBA_PC),'name':'read10_store_05ec_9001EEE9'},
                {'address':hex32(READ10_STORE_STATE_PC),'name':'read10_store_05f0_state11_9001EEEE'},
                {'address':hex32(READ10_STORE_PHASE_PC),'name':'read10_store_06f1_phase04_9001EEF3'},
                {'address':hex32(READ10_HANDLER_RET_PC),'name':'read10_handler_return_9001EEF6'},
                {'address':hex32(SCHEDULER_AFTER_POINTER_CALL_PC),'name':'scheduler_after_pointer_call_9001C71D'},
                {'address':hex32(SCHEDULER_DESCRIPTOR_CALL_PC),'name':'scheduler_descriptor_call_9001C78A'},
                {'address':hex32(DESCRIPTOR_FUNCTION_ENTRY_PC),'name':'descriptor_function_entry_9001B4B3'},
                {'address':hex32(DESCRIPTOR_SETUP_ENTRY_PC),'name':'descriptor_setup_state_table_9001B54E'},
                {'address':hex32(DESCRIPTOR_COPY_PC),'name':'descriptor_copy_entry_9001B5EF'},
                {'address':hex32(DESCRIPTOR_WRITE_PC),'name':'descriptor_copy_write_9001B5F2'},
                {'address':hex32(DESCRIPTOR_FLAG_PC),'name':'descriptor_transfer_flag_9001B5F8'},
                {'address':hex32(DESCRIPTOR_RET_PC),'name':'descriptor_return_9001B66E'},
            ],
        },
    }

def hit(rows:list[dict[str,str]], pc:int, event:str|None=None)->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 writes_value(rows:list[dict[str,str]], addr:int, val:int|None=None, pc:int|None=None)->bool:
    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
        if val is not None and (parse_int(r.get('value'),-1)&255)!=(val&255): continue
        return True
    return False

def collect_bytes_written(rows:list[dict[str,str]], base:int, pc:int|None=None, n:int=4)->list[int|None]:
    got=[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
            got[a-base]=parse_int(r.get('value'),-1)&255
    return got

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']); expect=le32(lba); expect_copy=bool(case['expect_copy'])
    got_05ec=collect_bytes_written(rows,SRAM_05EC,READ10_STORE_LBA_PC,4)
    got_06e4=collect_bytes_written(rows,SRAM_06E4,DESCRIPTOR_WRITE_PC,4)
    phase04=writes_value(rows,SRAM_06F1,0x04,READ10_STORE_PHASE_PC)
    state11=writes_value(rows,SRAM_05F0,0x11,READ10_STORE_STATE_PC)
    descriptor_call=hit(rows,SCHEDULER_DESCRIPTOR_CALL_PC,'break')
    descriptor_entry=hit(rows,DESCRIPTOR_FUNCTION_ENTRY_PC,'break')
    descriptor_copy=hit(rows,DESCRIPTOR_COPY_PC,'break')
    transfer_flag=writes_value(rows,SRAM_06E0,0xF0,DESCRIPTOR_FLAG_PC)
    cdb_reads=sum(1 for r in rows if r.get('event')=='watch_read' and SRAM_05D2 <= parse_int(r.get('addr'),-1) <= SRAM_05D5)
    ok_common=(rc==0 and hit(rows,SCHEDULER_PRE_POINTER_CALL_PC,'break') and hit(rows,READ10_WRAPPER_PC,'break') and hit(rows,READ10_HANDLER_PC,'break') and hit(rows,SCHEDULER_AFTER_POINTER_CALL_PC,'break') and state11 and phase04 and got_05ec==expect)
    copy_ok=(descriptor_call and descriptor_entry and descriptor_copy and got_06e4==expect and transfer_flag) if expect_copy else (descriptor_call and descriptor_entry and (not descriptor_copy) and got_06e4!=expect and not transfer_flag)
    return {
        'case':case['name'],'input_lba':lba,'input_lba_hex':hex32(lba),'packet_flags_05b6_hex':hex8(int(case['flags'])),'expect_copy':expect_copy,
        'trace_csv':str(trace),'stdout_stderr_log':str(out_path),'run_returncode':rc,'row_count':len(rows),
        'scheduler_pre_pointer_call_hit':hit(rows,SCHEDULER_PRE_POINTER_CALL_PC,'break'),
        'read10_wrapper_hit':hit(rows,READ10_WRAPPER_PC,'break'),
        'read10_handler_hit':hit(rows,READ10_HANDLER_PC,'break'),
        'cdb_shadow_lba_read_count':cdb_reads,
        'read10_store_05ec_match':got_05ec==expect,
        'read10_store_05ec_bytes_expected':''.join(f'{b:02X}' for b in expect),
        'read10_store_05ec_bytes_written':''.join(f'{b:02X}' if b is not None else '??' for b in got_05ec),
        'read10_state11':state11,'read10_phase04':phase04,
        'scheduler_after_pointer_call_hit':hit(rows,SCHEDULER_AFTER_POINTER_CALL_PC,'break'),
        'scheduler_descriptor_call_hit':descriptor_call,
        'descriptor_function_entry_hit':descriptor_entry,
        'descriptor_copy_hit':descriptor_copy,
        'descriptor_06e4_match':got_06e4==expect,
        'descriptor_06e4_bytes_written':''.join(f'{b:02X}' if b is not None else '??' for b in got_06e4),
        'descriptor_transfer_flag_f0':transfer_flag,
        'ok':ok_common and copy_ok,
    }

def run_cases(config:dict[str,Any], cfg_path:Path, firmware_arg:str|None, mn103s_emu:str, case_dir:Path, timeout:int)->dict[str,Any]:
    firmware=resolve_firmware(config,cfg_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_file=case_dir/f'{case["name"]}.config.json'; out_file=case_dir/f'{case["name"]}.output.txt'
        write_json(cfg_file,cfg)
        try:
            p=subprocess.run([sys.executable,mn103s_emu,str(cfg_file)],cwd=Path.cwd(),capture_output=True,text=True,timeout=timeout)
            out_file.write_text((p.stdout or '')+('\n'+p.stderr if p.stderr else ''),encoding='utf-8',errors='replace')
            rc=int(p.returncode)
        except subprocess.TimeoutExpired as e:
            out_file.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,rc,out_file))
    return {'case_dir':str(case_dir),'case_count':len(results),'passed_count':sum(1 for r in results if r.get('ok')),'cases':results,'ok':bool(results) and all(r.get('ok') for r in results),
            'scheduler_to_read10_wrapper_proven':all(r.get('read10_wrapper_hit') for r in results),
            'read10_handler_to_scheduler_return_proven':all(r.get('scheduler_after_pointer_call_hit') for r in results),
            'scheduler_phase04_to_descriptor_call_proven':all(r.get('scheduler_descriptor_call_hit') for r in results),
            'descriptor_copy_from_scheduler_proven':all((r.get('descriptor_06e4_match') and r.get('descriptor_transfer_flag_f0')) for r in results if r.get('expect_copy'))}

def write_md(path:Path, report:dict[str,Any])->None:
    cpu=report.get('scheduler_cpu_probe') or {}
    lines=[f'# GDR-8050L {VERSION} READ(10) scheduler-to-descriptor 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"- scheduler_to_read10_wrapper_proven: `{str(cpu.get('scheduler_to_read10_wrapper_proven')).lower()}`",
        f"- read10_handler_to_scheduler_return_proven: `{str(cpu.get('read10_handler_to_scheduler_return_proven')).lower()}`",
        f"- scheduler_phase04_to_descriptor_call_proven: `{str(cpu.get('scheduler_phase04_to_descriptor_call_proven')).lower()}`",
        f"- descriptor_copy_from_scheduler_proven: `{str(cpu.get('descriptor_copy_from_scheduler_proven')).lower()}`",'',
        '## What this proves','',
        'This controlled CPU slice follows the closer natural path: READ-class scheduler `0x9001C710` loads the command function pointer from SRAM `0x06F4`, calls the real READ(10) wrapper `0x9002EA7C`, the wrapper calls the real READ(10) handler `0x9001EED0`, the handler assembles CDB-shadow LBA into `0x05EC` and writes `0x05F0=0x11` / `0x06F1=0x04`, then control returns to the scheduler. The scheduler observes phase `0x04` and calls descriptor setup at `0x9001C78A -> 0x9001B4B3`, which reaches `0x9001B5EF`, copies `0x05EC -> 0x06E4`, and arms `0x06E0=0xF0`. The zero-flags case shows the READ(10) handler itself sets the descriptor gate bit at `0x9001EEDA`, so this path no longer depends on pre-seeding `0x05B6=0x04`.', '',
        '## What this does not prove','',
        'It still does not derive the Redump/unlocked media-window delta from firmware execution. Keep `firmware_derived_delta=false` until a firmware window/selector path is proven to consume or compute the media offset.', '',
        '## Cases','',
        '| case | flags | LBA | wrapper | handler | 0x05EC match | scheduler descriptor call | descriptor copy | 0x06E4 match | 0x06E0=F0 | ok |',
        '|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|']
    for c in cpu.get('cases',[]) or []:
        lines.append(f"| `{c.get('case')}` | `{c.get('packet_flags_05b6_hex')}` | `{c.get('input_lba_hex')}` | `{c.get('read10_wrapper_hit')}` | `{c.get('read10_handler_hit')}` | `{c.get('read10_store_05ec_match')}` | `{c.get('scheduler_descriptor_call_hit')}` | `{c.get('descriptor_copy_hit')}` | `{c.get('descriptor_06e4_match')}` | `{c.get('descriptor_transfer_flag_f0')}` | `{c.get('ok')}` |")
    lines += ['', '## Remaining proof gap', '', 'The parser/dispatch/scheduler/descriptor chain for host READ(10) LBA is now proven in controlled CPU slices. The remaining parser/descriptor-related work is to tie this to any live firmware session state that selects the unlocked/media window, not to promote bridge-supplied mapping as firmware-derived.', '']
    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(10) scheduler-to-descriptor path')
    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('--out-prefix',default='gdr8050l_auth_v256_firmware_read10_scheduler_descriptor_probe_report')
    ap.add_argument('--json',default='gdr8050l_auth_v256_firmware_read10_scheduler_descriptor_probe_report.json')
    ap.add_argument('--md',default='gdr8050l_auth_v256_firmware_read10_scheduler_descriptor_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 {}
    case_dir=Path(args.case_dir or f'{args.out_prefix}_cases'); case_dir=case_dir if case_dir.is_absolute() else base/case_dir
    cpu=run_cases(config,cfg_path,args.firmware,args.mn103s_emu,case_dir,args.timeout)
    report={'version':VERSION,'probe':'read10_scheduler_descriptor','static_scheduler':static_call_sites(),'scheduler_cpu_probe':cpu,'firmware_derived_host_lba':cpu.get('ok',False),'firmware_derived_delta':False,'ok':bool(cpu.get('ok'))}
    write_json(Path(args.json),report); write_md(Path(args.md),report)
    print(f"[FIRMWARE-READ10-SCHEDULER-DESCRIPTOR] cases={cpu.get('passed_count')}/{cpu.get('case_count')} scheduler_descriptor={str(cpu.get('scheduler_phase04_to_descriptor_call_proven')).lower()} firmware_derived_delta=false ok={str(report.get('ok')).lower()}")
    return 0 if report.get('ok') else 2

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