"""Current MMML recipe scroll receiver; no process is opened by this module.

Current raw hits, native ancestors and exact penetration handles supply the
scroll route. Enabled overlap delegates to another axis and cannot move this list.
"""
from mini_masukja.native_type_reference import type_definition_index
from copy import deepcopy
from datetime import datetime, timezone
import hashlib
import json
import math
import struct

from .paths import root
from .quest_ui_actions import QuestUIActionSession, NativeUnityUI, _probe_receipt
from .vendor.map_memory import MapReadPending

SOURCE_REF='data/arbeit-recipe-scroll/source-pins.json'
_RAW=(root()/SOURCE_REF).read_bytes()
SOURCE_HASH='a41a44e95b956fcf4e5fc32e00dbeb433200d23275e073fc156411eaa314082f'
if hashlib.sha256(_RAW).hexdigest()!=SOURCE_HASH:raise ValueError('recipe scroll bundled source changed')
SOURCE=json.loads(_RAW)


def _digest(value):
    return hashlib.sha256(json.dumps(value,sort_keys=True,separators=(',',':')).encode()).hexdigest()


def _pointer(value):
    return int(value,16) if isinstance(value,str) else value


def _intersection(a,b):
    x=max(a[0],b[0]);y=max(a[1],b[1]);r=min(a[0]+a[2],b[0]+b[2]);d=min(a[1]+a[3],b[1]+b[3])
    return [x,y,r-x,d-y] if r>x and d>y else None


class RecipeScrollSession(QuestUIActionSession):
    def source_shape(self,name,namespace=None):
        rows=[s for s in SOURCE['classes'] if s['name']==name and (namespace is None or s['namespace']==namespace)]
        if rows:
            if len(rows)!=1 or any(self.plan.get(k)!=v for k,v in SOURCE['source_version'].items()):
                raise ValueError('current recipe scroll declaration/version changed')
            # The original metadata declaration count is separate from selected fields.
            return dict(rows[0])
        return super().source_shape(name,namespace)

    def verify_scroll_source(self):
        for method in SOURCE['methods']:
            raw=bytes.fromhex(method['raw_hex'])
            if hashlib.sha256(raw).hexdigest()!=method['sha256'] or self.reader.read(self.base+method['rva'],len(raw))!=raw:
                raise ValueError('current recipe scroll method changed')

    def scroll_registry(self):
        events=self.field(self.ui_system_root(),'UISystem','<EventSystem>k__BackingField')
        picker=self.field(events,'UnityUIEventSystem','eventSpotPicker')
        group=self.field(picker,'UIEventSpotPicker','eventSpotTables')
        tables=self.field(group,'UIEventSpotTableGroup','tables');found=[]
        for _,table in self.reference_dictionary(tables,limit=64):
            fields=self.closed_fields(table,'UIEventSpotTable`1');klass=self.reader.u64(table)
            # spotTable is source FieldInfo index zero. Inspect its actual closed
            # Dictionary<GameObject, IScrollUIEventSpot>, not the open offset.
            tp=self.reader.u64(self.reader.u64(klass+0x80)+8)
            if (self.reader.u32(tp+8)>>16)&255!=21:raise ValueError('current scroll table is not generic')
            inst=self.reader.u64(self.reader.u64(tp)+8)
            if self.reader.u64(inst)!=2:raise ValueError('current scroll table arity changed')
            argv=self.reader.u64(inst+8);args=[]
            for i in range(2):
                arg=self.reader.u64(argv+8*i);args.append((type_definition_index(self, arg),(self.reader.u32(arg+8)>>16)&255))
            if args!=[(73587,18),(54987,18)]:continue
            mapping=struct.unpack('<Q',self.watch(table+fields['spotTable']['offset'],8))[0]
            found.append((table,self.reference_dictionary(mapping,limit=512)))
        if len(found)!=1:raise MapReadPending('current unique IScrollUIEventSpot table unavailable')
        return events,found[0]

    def scroll_binding(self,candidate):
        self.verify_scroll_source()
        element=candidate['control_pointer'];self.typed_fields(element,'MMMLScrollElement')
        control=self.field(element,'MMMLScrollElement','scrollControl')
        slot=self.field(element,'MMMLScrollElement','uiSlot')
        root_object=self.field(control,'ScrollControl','rootObject')
        scroll_slot=self.field(slot,'MMMLScrollSlot','<ScrollSlot>k__BackingField')
        if root_object!=scroll_slot:raise MapReadPending('current MMML/ScrollControl root differs')
        klass=self.reader.u64(root_object)
        if self.reader.class_name(klass)!=('UISlot`1','MM.Client.Framework.UI.Object'):
            raise MapReadPending('current scroll root lifecycle implementation changed')
        vf=self.reader.layout(klass,['virtualObject'])['fields']
        if len(vf)!=1 or vf[0]['type_code']!=18 or not 16<=vf[0]['offset']<=128:
            raise ValueError('current scroll root lifecycle field changed')
        virtual=struct.unpack('<Q',self.watch(root_object+vf[0]['offset'],8))[0]
        from .material_escape import EscapeView
        if EscapeView(self).value(virtual,'VirtualUIObjectNode','<IsDisposed>k__BackingField'):
            raise MapReadPending('current scroll root disposed')
        if self.field(control,'ScrollControl','scrollDirectionConstraint')!=1:
            raise MapReadPending('current recipe list is not source vertical-only scroll')
        if not self.field(control,'ScrollControl','enableMouseScroll') or self.field(control,'ScrollControl','shouldUpdateLayout'):
            raise MapReadPending('current recipe scroll disabled or layout pending')
        module=self.field(control,'ScrollControl','scrollControlModule')
        handler=self.field(module,'ScrollControlModule','mouseScrollInputHandlerControl')
        compound=self.field(module,'ScrollControlModule','verticalAxisCompound')
        if (self.field(handler,'ScrollControlMouseScrollInputHandlerControl','targetCompound')!=compound
                or self.field(handler,'ScrollControlMouseScrollInputHandlerControl','scrollDirection')!=0
                or self.field(handler,'ScrollControlMouseScrollInputHandlerControl','<RootObject>k__BackingField')!=root_object):
            raise MapReadPending('current recipe scroll axis/handler/root changed')
        overlap=self.field(handler,'ScrollControlMouseScrollInputHandlerControl','overlappedEventRouter')
        if self.field(overlap,'OverlappedScrollControlEventRouter','enable'):
            route=self.overlap_route(overlap,root_object)
            raise MapReadPending('current overlapped recipe wheel delegates to different parent axis: '+json.dumps(route,sort_keys=True))
        position=self.field(compound,'ScrollControlModuleAxisCompound','positionControl')
        receiver=self.field(compound,'ScrollControlModuleAxisCompound','<MouseScrollHandler>k__BackingField')
        if (not self.field(position,'ScrollContentAxisPositionControl','dragEnable')
                or self.field(receiver,'ScrollContentAxisScrollHandler','positionControl')!=position
                or not self.field(receiver,'ScrollContentAxisScrollHandler','<EnableMouseScroll>k__BackingField')):
            raise MapReadPending('current recipe scroll motion receiver disabled')
        speed=self.field(receiver,'ScrollContentAxisScrollHandler','<ScrollSpeed>k__BackingField')
        if not math.isfinite(speed) or speed<=0:raise MapReadPending('current recipe scroll speed invalid')
        native=getattr(self,'_native_ui',None)
        if native is None:native=self._native_ui=NativeUnityUI(self)
        target=native.component_game_object(self.slot_raw(root_object))
        if not native.native_active_cached(target):raise MapReadPending('current scroll target inactive')
        events,(table,spots)=self.scroll_registry();registrations=[];owned=[]
        for managed,spot in spots:
            go=native.native_pointer(managed,{'GameObject'});registrations.append(go)
            if go!=target:continue
            if self.field(spot,'ScrollUIEventSpot','target')!=root_object:raise MapReadPending('current scroll registration root changed')
            router=self.field(spot,'ScrollUIEventSpot','handler')
            if self.field(router,'ScrollEventRouter','handler')!=handler:raise MapReadPending('current scroll registration handler changed')
            dispatcher=self.field(router,'ScrollEventRouter','routerTarget')
            if self.field(dispatcher,'ScrollEventDispatcher','eventSystem')!=events:raise MapReadPending('current scroll event system changed')
            owned.append((spot,router,dispatcher))
        if len(owned)!=1:raise MapReadPending('current exact scroll registration unavailable')
        viewport=self.field(control,'ScrollControl','viewportTransform')
        rect=self.field(viewport,'UIChildTransform','<Transform>k__BackingField')
        return dict(element=element,control=control,root_object=root_object,handler=handler,target=target,
                    viewport_rect=rect,registered_targets=registrations,table=table,registration=owned[0])

    def overlap_route(self,router,root_object):
        if self.field(router,'OverlappedScrollControlEventRouter','<RootObject>k__BackingField')!=root_object:
            raise MapReadPending('current overlapped scroll root differs')
        if not self.field(router,'OverlappedScrollControlEventRouter','isInitialized'):
            raise MapReadPending('current enabled overlapped scroll parent not initialized')
        child=self.field(router,'OverlappedScrollControlEventRouter','childScrollSlot')
        parent=self.field(router,'OverlappedScrollControlEventRouter','parentScrollSlot')
        if child!=self.slot_raw(root_object) or not parent:
            raise MapReadPending('current overlapped scroll child/parent join unavailable')
        axis=self.field(child,'ScrollSlot','ScrollDirectionConstraint')
        parent_axis=self.field(parent,'ScrollSlot','ScrollDirectionConstraint')
        if axis!=1 or parent_axis!=2:
            raise MapReadPending('current overlapped scroll axis route differs from source initialized branch')
        return dict(child_axis=axis,parent_axis=parent_axis,parent_scroll_slot=parent,
            virtual_scroll_handle=self.field(router,'OverlappedScrollControlEventRouter','virtualScrollHandler'),
            source='OverlappedScrollControlEventRouter.Initialize/OnScroll',vertical_recipe_input_ready=False)

    def native_chain(self,game_object):
        from .recipe_scroll_native import ScrollNativeHierarchy
        return ScrollNativeHierarchy(self._native_ui,SOURCE['native_transform']).chain(game_object)

    def penetration(self,managed_game_object):
        events=self.field(self.ui_system_root(),'UISystem','<EventSystem>k__BackingField')
        tracker=self.field(events,'UnityUIEventSystem','raycastPenetrationTracker')
        table=self.field(tracker,'UnityRaycastPenetrationTracker','penetrationHandleTable')
        matches=[handle for go,handle in self.reference_dictionary(table,limit=2048) if go==managed_game_object]
        if len(matches)>1:raise ValueError('Current duplicate penetration handle key')
        if not matches:return None
        handle=matches[0]
        if self.field(handle,'Handle','gameObject')!=managed_game_object:
            raise ValueError('Current penetration handle target differs')
        return dict(handle_pointer=handle,managed_game_object=managed_game_object,
                    is_penetrable=self.field(handle,'Handle','<IsPenetrable>k__BackingField'),
                    source='UnityRaycastPenetrationTracker.TryGetRaycastPenetrationChecker exact GameObject key')

    def ancestor(self,child_rect,parent_rect):
        native=self._native_ui
        # rect_world validates the named getter bodies and clean complete chain.
        native.rect_world(child_rect);native.rect_world(parent_rect)
        child=native.native_pointer(child_rect,{'RectTransform'});parent=native.native_pointer(parent_rect,{'RectTransform'})
        ch,ci=struct.unpack('<Qi',self.watch(child+0x38,12));ph,pi=struct.unpack('<Qi',self.watch(parent+0x38,12))
        if ch!=ph:return False
        parents=struct.unpack('<Q',self.watch(ch+0x20,8))[0];seen=set()
        while ci>=0 and len(seen)<128:
            if ci==pi:return True
            if ci in seen or ci>100000:raise ValueError('current scroll ancestry exceeds bound')
            seen.add(ci);ci=struct.unpack('<i',self.watch(parents+4*ci,4))[0]
        return False

    def scroll_geometry(self,binding,candidates,frame):
        children=[];native=self._native_ui
        for row in candidates:
            button=row.get('button_pointer')
            if not button:continue
            try:
                slot=self.field(button,'ButtonControl','<RaycastTarget>k__BackingField');graphic=self.slot_raw(slot)
                fields=self.base_fields(graphic,'Graphic','UnityEngine.UI')
                rect=struct.unpack('<Q',self.watch(graphic+fields['m_RectTransform'],8))[0]
                if not rect or not self.ancestor(rect,binding['viewport_rect']):continue
                canvas=struct.unpack('<Q',self.watch(graphic+fields['m_Canvas'],8))[0]
                if not canvas:continue
                children.append(dict(game_object=native.component_game_object(graphic),rect=rect,canvas=canvas))
            except MapReadPending:continue
        canvases={r['canvas'] for r in children}
        if len(canvases)!=1:raise MapReadPending('current scroll viewport/child native canvas join unavailable')
        viewport=frame['physical_client_rect'];size=(viewport[2]-viewport[0],viewport[3]-viewport[1])
        geometry=native.overlay_rect(binding['viewport_rect'],next(iter(canvases)),size)
        now=datetime.now(timezone.utc).isoformat()
        proof=dict(confirmed=True,transform_verified=True,active_hierarchy_verified=True,source_revision_verified=True,
                   frame_id=frame['frame_id'],window_signature=frame['window_signature'],source=geometry['source'],
                   source_hashes=geometry['source_hashes']+[SOURCE_HASH],observed_at=now)
        return dict(rect_client=geometry['rect_client'],native_geometry_proof=proof,children=children)

    def receiver_proof(self,binding,geometry,state,delta,frame):
        receipt=_probe_receipt(frame)
        if not receipt:return None
        context=(receipt.get('command') or {}).get('probe_context') or receipt.get('target_binding') or {}
        point=context.get('point_screen');caches=state.get('caches',[])
        if len(caches)!=1 or caches[0]['position']!=point:return None
        events=self.field(self.ui_system_root(),'UISystem','<EventSystem>k__BackingField')
        raycast=self.current_pointer_raycast(self.field(events,'UnityUIEventSystem','raycaster'))
        hits=raycast['results']
        if raycast['position']!=point or not hits or any(h['screen_position']!=point for h in hits):return None
        native_hits=[dict(h,native_game_object=self._native_ui.native_pointer(h['game_object'],{'GameObject'})) for h in hits]
        # Both source loops use List.Add for each raw GameObject. Neither adds
        # ancestors nor deduplicates. Parent routing is independently read below.
        if caches[0].get('targets')!=[h['native_game_object'] for h in native_hits]:return None
        from .recipe_scroll_native import select_scroll_route
        selected=select_scroll_route(native_hits,binding['registered_targets'],binding['target'],chain=self.native_chain,
            active=self._native_ui.native_active_cached,penetrable=self.penetration)
        if not selected:return None
        first=selected['first_target'];prefix=selected['ancestor_prefix']
        viewport=self._native_ui.component_game_object(binding['viewport_rect'])
        if first!=binding['target'] and viewport not in prefix:return None
        return dict(confirmed=True,registered=True,route_verified=True,enabled=True,raycast_verified=True,
            source='current raw raycast/cache exact copy -> source orthogonal/penetration rules -> native parents -> first registered IScrollUIEventSpot',
            source_hashes=list(SOURCE['source_version'].values())+[SOURCE_HASH],observed_at=datetime.now(timezone.utc).isoformat(),
            frame_id=frame['frame_id'],window_signature=frame['window_signature'],target_native_game_object=binding['target'],
            handler_pointer=binding['handler'],delta=delta,point_screen=point,first_target=first,ancestor_prefix=prefix,
            registry_table=binding['table'],registration=list(binding['registration']),prior_hits=selected['prior_hits'],
            route_trace=selected['route_trace'])


def scroll_session(session):
    proxy=RecipeScrollSession.__new__(RecipeScrollSession);proxy.__dict__=session.__dict__;return proxy


def recipe_model_identity(panel,family,source_function):
    """Only source recipe descriptors; unrelated production timers may advance."""
    from .craft_observer import Model
    model=Model(panel['model']);data=model.ref(model.root,'uiData')
    data=model.root if data is None else data
    if family=='native_alter':items=model.list(data,'itemRecipeList')
    elif source_function==933:
        quick=model.ref(data,'quickCraftingTabData');items=model.list(quick,'items') if quick is not None else []
    else:
        items=[]
        for table in model.list(data,'tables'):
            for group in model.list(table,'craftingGroups'):items.extend(model.list(group,'items'))
    if not items:raise MapReadPending('current recipe descriptor list unavailable')
    out=[]
    for ident in items:
        icon=model.ref(ident,'itemData')
        out.append(dict(element_id=ident,values=deepcopy(model.values(ident)),
                        item_data=deepcopy(model.values(icon)) if icon is not None else None))
    return out


def observe_recipe_rows(scene,panel,frame,*,target_index,row_tags,source_function):
    """Current named native rows; available even when target is newly visible."""
    layout=panel['layout'];scale=layout['scale'];rows=layout['candidates'];safe=scene.get('safe_rect')
    if safe is None:
        a=frame['current_context']['safe_rect'];safe=[a[0],a[1],a[2]-a[0],a[3]-a[1]]
    if type(target_index) is not int or not scale or not safe:raise MapReadPending('current source indexed recipe/safe rect required')
    possible=[]
    for parent in rows:
        if parent.get('tag')!='vscroll' or not parent.get('control_pointer'):continue
        clip=_intersection([v*scale for v in parent['rect_ui']],safe)
        if not clip:continue
        if source_function==943:
            # Current source MMML 911 maskPadding=-20; do not apply to craft.
            clip=[clip[0]+20*scale,clip[1]+20*scale,clip[2]-40*scale,clip[3]-40*scale]
            if min(clip[2:])<=0:continue
        visible=[]
        for row in rows:
            if row.get('tag') not in row_tags or not str(row.get('name','')).isdigit() or not row.get('control_pointer'):continue
            area=_intersection([v*scale for v in row['rect_ui']],clip)
            if area and min(area[2:])>=4:visible.append(dict(index=int(row['name']),rect_ui=row['rect_ui'],element_id=row.get('element_id'),intersection=area))
        if visible:possible.append((parent,visible,clip))
    if len(possible)!=1:raise MapReadPending('current unique native recipe vscroll/visible row source join unavailable')
    parent,visible,clip=possible[0];indices=sorted({r['index'] for r in visible})
    observation=dict(window_id=layout['window_id'],digest=_digest(visible),visible_indices=indices,rows=visible,
        source='current source indexed rows and same native vscroll viewport',source_function=source_function,
        observed_at=frame['captured_at'],session_id=frame['session_id'],character_id=frame['character_id'])
    # Preserve the changed-row effect even when the requested row has become
    # visible and the next action is its actual button rather than another wheel.
    scene['scroll_observation']=observation
    return parent,observation,clip


def recipe_scroll_view(session,scene,panel,frame,*,family,target_index,row_tags,source_function):
    """Bind an offscreen source row to its actual current native scroll receiver."""
    allowed={('native_alter',911):('AlteringFacilityPopupUIInstance',['__placeholder']),
             ('native_craft',927):('CraftingFacilityPopupUIInstance',['__placeholder']),
             ('native_craft',933):('CraftingHubPopupUIInstance',['t-QuickCraftingItem'])}
    if allowed.get((family,source_function))!=(panel.get('ui_type'),row_tags):
        raise ValueError('current recipe scroll source/panel type differs')
    descriptors=recipe_model_identity(panel,family,source_function)
    if type(target_index) is not int or not 0<=target_index<len(descriptors):
        raise ValueError('current recipe scroll target index outside its actual model')
    parent,observation,clip=observe_recipe_rows(scene,panel,frame,target_index=target_index,row_tags=row_tags,source_function=source_function)
    layout=panel['layout'];rows=layout['candidates'];visible=observation['rows'];indices=observation['visible_indices']
    if target_index<min(indices):delta=120
    elif target_index>max(indices):delta=-120
    else:raise MapReadPending('current requested recipe is inside visible index range; scroll direction unproven')
    proxy=scroll_session(session);binding=proxy.scroll_binding(parent)
    geometry=proxy.scroll_geometry(binding,rows,frame);area=_intersection(geometry['rect_client'],clip)
    if not area or min(area[2:])<4:raise MapReadPending('current native recipe viewport clipped')
    ui=_pointer(panel['ui_pointer']);candidate=dict(parent,rect_client=area,rect_source='current-native-output',
        native_geometry_proof=geometry['native_geometry_proof'],root_object_pointer=binding['root_object'],
        handler_pointer=binding['handler'],target_native_game_object=binding['target'],ui_pointer=ui,
        window_id=layout['window_id'],window_kind=family,event_kind='registered_scroll_handler',model_event_id=0)
    state=proxy.current_pointer_state()
    proof=proxy.receiver_proof(binding,geometry,state,delta,frame)
    if proof:candidate['scroll_receiver_proof']=proof
    semantics=dict(action_kind='pointer_scroll',delta=delta,source_family=family,recipe_id=scene['recipe_id'],
        source_function=source_function,target_index=target_index,visible_rows=visible,source_revision=SOURCE_HASH,
        model_revision=_digest(descriptors),handler=binding['handler'],registration=binding['registration'])
    view=dict(layout,ui_pointer=ui,ui_type=panel.get('ui_type'),target_id=family+'.recipe.scroll',
        source_hash=SOURCE_HASH,source_revision=SOURCE_HASH,pointer_semantics=semantics,pointer_state=state,
        captured_at=frame['captured_at'])
    proxy.assert_stable()
    return candidate,view,observation


def processing_scroll_view(session,scene,panel,frame):
    return recipe_scroll_view(session,scene,panel,frame,family='native_alter',target_index=scene['selected_array_index'],
                              row_tags=['__placeholder'],source_function=943)


def craft_scroll_view(session,scene,panel,frame):
    listing=scene['recipe_list']
    return recipe_scroll_view(session,scene,panel,frame,family='native_craft',target_index=listing['target_index'],
                              row_tags=listing['row_tags'],source_function=listing['source_function'])


def attach_craft_scroll(session,scene,current,node,snapshot,frame):
    from .quest_ui_actions import prepare_pointer_proposal,pointer_effect_contract
    panels=[p for p in current['panels'].values() if p.get('layout',{}).get('window_id')==scene['recipe_list']['layout']['window_id']]
    if len(panels)!=1:raise MapReadPending('current unique craft recipe panel required')
    actual=dict(frame,input_owner=snapshot['input_owner'],pending_input=deepcopy(snapshot.get('pending_input')))
    if snapshot.get('pointer_probe_receipt'):actual['pointer_probe_receipt']=deepcopy(snapshot['pointer_probe_receipt'])
    candidate,view,observation=craft_scroll_view(session,scene,panels[0],actual)
    pointer=prepare_pointer_proposal(candidate,view,actual);context=pointer['probe_context']
    if pointer['input_kind']=='pointer_probe':
        context.update(source_guard_kind='recipe-scroll',craft_scroll_request={'recipe_id':scene['recipe_id']})
    x,y,w,h=candidate['rect_client'];safe=actual['current_context']['safe_rect']
    action=dict(pointer,kind=pointer['input_kind'],role='craft.recipe.scroll',point=pointer['point_client'],
        source_rect=[x,y,x+w,y+h],safe_rect=[safe[0],safe[1],safe[2]-safe[0],safe[3]-safe[1]],
        input_epoch=actual['input_owner']['epoch'],source_guard_kind='recipe-scroll')
    effect=pointer_effect_contract(pointer,actual.get('current_quest') or snapshot.get('current_quest') or {},node,actual,
                                   view['pointer_state']['observed_at'])
    scene['actions']['scroll']=action;scene['scroll_observation']=observation;scene['scroll_pointer_effect']=effect
    scene.setdefault('source_refs',[]).append(SOURCE_REF)
    return scene


def recipe_scroll_scene(session,binding,frame):
    """Rebuild the owned current list before dispatch, without OCR or input."""
    if binding.get('source_guard_kind')!='recipe-scroll':raise ValueError('recipe scroll guard identity required')
    physical=frame['physical_client_rect'];viewport=[physical[2]-physical[0],physical[3]-physical[1]]
    if binding.get('processing_request'):
        from .processing_observer import resolve_processing_current_scene
        request=binding['processing_request'];owner=frame['input_owner'];identity=owner['owner']
        a=frame['current_context']['safe_rect'];safe=[a[0],a[1],a[2]-a[0],a[3]-a[1]]
        snapshot=dict(now=frame['captured_at'],session_id=frame['session_id'],character_id=frame['character_id'],
            window_preflight={'selected':{'pid':identity['pid'],'process_started_at':identity['process_started_at']}},
            input_owner=owner,layout={'viewport':viewport,'safe_rect':safe},processing_route_id=request['recipe_id'],
            pending_input=request.get('before_probe_pending'),current_quest=frame.get('current_quest'))
        collected=[]
        def collect(scene,panel,*args):
            collected.append(processing_scroll_view(session,scene,panel,frame));return scene
        result=resolve_processing_current_scene(session.current_processing(),node=request['node'],snapshot=snapshot,frame=frame,
            geometry_reader=lambda *args: (_ for _ in ()).throw(MapReadPending('current_processing_native_button_clipped')),scroll_reader=collect)
        if result.get('input_missing') or len(collected)!=1:raise MapReadPending('current processing scroll target changed')
        return collected[0][:2]
    request=binding.get('craft_scroll_request') or {}
    from .craft_observer import craft_sources,scene_from_crafting
    recipes=[r for r in craft_sources()['recipes']['recipes'] if r['id']==request.get('recipe_id')]
    if len(recipes)!=1:raise ValueError('current craft scroll source recipe missing')
    current=session.current_crafting();scene=scene_from_crafting(current,recipes[0],viewport)
    panels=[p for p in current['panels'].values() if _pointer(p.get('ui_pointer'))==binding['target_ui_pointer']]
    if len(panels)!=1 or not scene.get('recipe_list'):raise MapReadPending('current craft scroll root/list changed')
    scene['recipe_id']=recipes[0]['id']
    return craft_scroll_view(session,scene,panels[0],frame)[:2]
