"""Current map Arbeit card reader. The progress action is native autoplay.

The separately declared AcceptClickMessage has no established UI producer.
This module does not expose it as an input and never invokes client functions.
"""
from mini_masukja.native_type_reference import type_definition_index
from copy import deepcopy
from datetime import datetime, timezone
from functools import lru_cache
import hashlib
import json
import math
import struct

from .paths import root
from .quest_ui_actions import QuestUIActionSession, NativeUnityUI, prepare_pointer_proposal
from .recipe_scroll import RecipeScrollSession, _intersection, _digest
from .material_dimmer import DimmerView
from .vendor.map_memory import MapReadPending
from .vendor.map_character_context import active_context
from .vendor.map_character_identity import CharacterMemoryReader

SOURCE_REF = 'data/r6-map-card/source.json'
SOURCE = 'pinned-current-map-quest-card'


@lru_cache(maxsize=1)
def source_definition():
    return json.loads((root()/SOURCE_REF).read_text(encoding='utf-8'))


def source_hash():
    return hashlib.sha256((root()/SOURCE_REF).read_bytes()).hexdigest()


class MapQuestCardSession(QuestUIActionSession):
    def shape(self, index):
        return next(s for s in source_definition()['classes'] if s['type_index']==index)

    def fields(self, klass, schema):
        # Other observers validate smaller subsets of the same generated class.
        cached=self._validated.get((klass,schema['type_index']))
        if cached is not None and not {f['name'] for f in schema['fields']}<=cached.keys():
            self._validated.pop((klass,schema['type_index']))
        return super().fields(klass,schema)

    def source_shape(self, name, namespace=None):
        rows=[s for s in source_definition()['classes'] if s['name']==name and
              (namespace is None or s['namespace']==namespace)]
        if len(rows)==1:return rows[0]
        return super().source_shape(name,namespace)

    def declared(self, pointer, index):
        shape=self.shape(index);klass=self.reader.u64(pointer);seen=set()
        while klass and klass not in seen and len(seen)<16:
            seen.add(klass)
            if self.reader.class_name(klass)==(shape['name'],shape['namespace']):
                return klass,self.fields(klass,shape)
            klass=self.reader.u64(klass+0x58)
        raise ValueError('map card current declaration differs: '+shape['name'])

    def value(self, pointer, index, name):
        klass,fields=self.declared(pointer,index)
        declaration=next(f for f in self.shape(index)['fields'] if f['name']==name)
        kind=declaration['type']['type_code'];at=pointer+fields[name]
        if kind==21 and declaration['type'].get('name')=='U`1':
            return self.updated_value(klass,at,declaration)
        if kind==17:
            expected=declaration['type']['definition_index']
            type_pointer=self.reader.u64(self.reader.u64(klass+0x80)+32*declaration['index']+8)
            if type_definition_index(self, type_pointer)!=expected:raise ValueError('current inline map ID/enum changed')
            shape=self.shape(expected)
            scalar=next(f for f in shape['fields'] if f['name'] in ('value__','int64Value'))
            if scalar['offset']!=16:raise ValueError('map ID/enum boxed layout changed')
            kind=scalar['type']['type_code']
        fmt={2:'B',8:'i',9:'I',10:'q',11:'Q',14:'Q',18:'Q',21:'Q',28:'Q',29:'Q'}.get(kind)
        if fmt is None:raise ValueError('unsupported map card field '+name)
        value=struct.unpack('<'+fmt,self.watch(at,struct.calcsize(fmt)))[0]
        if kind==2:
            if value not in (0,1):raise ValueError('invalid current map Boolean')
            return bool(value)
        if kind==14:return self.reader.string(value) if value else None
        return value

    def updated_value(self, klass, at, field):
        """Actual closed U<T>.value/version offsets, never open-generic zeros."""
        r=self.reader;tp=r.u64(r.u64(klass+0x80)+32*field['index']+8)
        if ((r.u32(tp+8)>>16)&255)!=21:raise ValueError('map U field generic type changed')
        generic=r.u64(tp);closed=r.u64(generic+24)
        if not closed:raise MapReadPending('current closed U class unavailable')
        if r.class_name(closed)!=('U`1','Silvervine.ManualMemory.Linx'):
            raise ValueError('current map U class changed')
        if r.class_name(r.u64(closed+0x58))!=('ValueType','System'):
            raise ValueError('map U has no verified boxed value layout')
        fields={f['name']:f for f in r.layout(closed)['fields']}
        arg=field['type']['arguments'][0];kind=arg['type_code']
        if kind==17:
            enum=self.shape(arg['definition_index'])
            if next(f for f in enum['fields'] if f['name']=='value__')['type']['type_code']!=8:
                raise ValueError('map U enum storage changed')
            value_type=r.u64(r.u64(closed+0x80)+8)
            if type_definition_index(self, value_type)!=arg['definition_index'] or ((r.u32(value_type+8)>>16)&255)!=17:
                raise ValueError('current closed U enum domain changed')
        if (set(fields)!={'value','version'} or fields['value']['type_code']!=kind or
                fields['version']['type_code']!=8):raise ValueError('current U named fields changed')
        value_at,version_at=(at+fields[k]['offset']-16 for k in ('value','version'))
        width=1 if kind==2 else 4 if kind in (8,17) else 0
        if not width or not 0<=value_at-at<=16 or not width<=version_at-at<=20:
            raise ValueError('current U value/version extent unsupported')
        self.watch(version_at,4);raw=self.watch(value_at,width)
        if kind==2:
            if raw not in (b'\0',b'\1'):raise ValueError('current U Boolean invalid')
            return raw==b'\1'
        return struct.unpack('<i',raw)[0]

    def verify_source(self):
        definition=source_definition()
        if any(self.plan.get(k)!=definition[k] for k in ('game_assembly_sha256','metadata_sha256')):
            raise ValueError('map quest card source version differs')
        for method in definition['methods']:
            raw=bytes.fromhex(method['raw_hex'])
            if hashlib.sha256(raw).hexdigest()!=method['sha256'] or self.watch(self.base+method['rva'],len(raw))!=raw:
                raise ValueError('current map quest method changed: '+method['name'])
        guard=self.character_plan['context']['patch_guard'];r=self.reader
        klass=r.u64(self.base+guard['wrapper_slot_rva'])
        if r.class_name(klass)!=('ILFixDynamicMethodWrapper','IFix'):raise ValueError('map quest patch table unavailable')
        statics=r.u64(klass+0xb8);table=struct.unpack('<Q',self.watch(statics,8))[0]
        count=struct.unpack('<Q',self.watch(table+24,8))[0]
        if count>100000:raise ValueError('map quest patch table bound changed')
        for index in definition['patch_slots']:
            if index<count and struct.unpack('<Q',self.watch(table+32+8*index,8))[0]:
                raise ValueError('current map quest method dynamically patched')

    def rooted_components(self, entity):
        flags=self.value(entity,55903,'ComponentUpdateStates')
        length,_=self.array(flags,stride=1,limit=10195)
        rows={}
        for name,index in source_definition()['component_presence'].items():
            if index>=length:raise ValueError('map quest component presence array truncated')
            present=self.watch(flags+32+index,1)[0]
            if present==0:raise MapReadPending('current map quest component unavailable: '+name)
            rows[name]=self.value(entity,30244,name)
        return rows

    def model_data(self, model):
        r=self.reader;klass=r.u64(model);shape=self.shape(51051)
        if r.class_name(klass)!=(shape['name'],shape['namespace']):
            raise ValueError('current map collection model implementation changed')
        fields={f['name']:f for f in r.layout(klass)['fields']}
        row=fields.get('<CurrentData>k__BackingField') or {}
        if row.get('type_code') not in (18,19) or not 16<=row.get('offset',0)<=128:
            raise ValueError('current closed map model data field unavailable')
        if fields.get('isDirty',{}).get('type_code')!=2:raise ValueError('current map model dirty field changed')
        if self.watch(model+fields['isDirty']['offset'],1)!=b'\0':raise MapReadPending('current map model dirty')
        return struct.unpack('<Q',self.watch(model+row['offset'],8))[0]

    def rendered_cards(self, list_control):
        collection=self.value(list_control,25123,'collectionControl')
        alignment=self.value(collection,51009,'alignmentModule')
        if any(self.value(alignment,51121,n) for n in ('needDataUpdate','needLayoutUpdate')):
            raise MapReadPending('current map card collection layout updating')
        table=self.value(alignment,51121,'collectionEntries');out=[]
        for collection,entry in self.reference_dictionary(table,limit=32):
            if self.value(entry,51119,'<ElementCollection>k__BackingField')!=collection:
                raise ValueError('map collection entry owner changed')
            model_table=self.value(self.value(entry,51119,'modelToControlTable'),51125,'table')
            for model,entry in self.reference_dictionary(model_table,limit=512):
                wrapper=self.value(entry,51124,'ControlWrapper')
                control=self.value(wrapper,51134,'control')
                if self.reader.class_name(self.reader.u64(control))!=(self.shape(25108)['name'],self.shape(25108)['namespace']):continue
                data=self.model_data(model)
                if self.value(control,25108,'data')!=data:raise MapReadPending('map rendered control data changed')
                out.append(dict(data_pointer=data,control_pointer=control,model_pointer=model,
                                wrapper_pointer=wrapper,root_transform=self.value(wrapper,51134,'rootTransform'),
                                element_collection=collection))
        if len({r['data_pointer'] for r in out})!=len(out):raise MapReadPending('duplicate current map card controls')
        return out

    def collection_order(self,element_collection):
        """Walk actual named HashLinkedList<ICollectionModelElement> links."""
        model=self.value(element_collection,51104,'<Model>k__BackingField')
        if self.value(model,51050,'isDirty'):raise MapReadPending('current collection model dirty')
        linked=self.value(model,51050,'elements');r=self.reader;klass=r.u64(linked)
        shape=self.shape(51038)
        if r.class_name(klass)!=(shape['name'],shape['namespace']):raise ValueError('current model order implementation changed')
        fields={f['name']:f for f in r.layout(klass)['fields']}
        if set(fields)!={'nodeTable','head','tail'}:raise ValueError('current model order fields changed')
        values={}
        for name,row in fields.items():
            if row['type_code'] not in (18,19,21) or not 16<=row['offset']<=128:raise ValueError('current model order reference changed')
            values[name]=struct.unpack('<Q',self.watch(linked+row['offset'],8))[0]
        table=values['nodeTable'];info=r.dictionary(table)
        if info.get('kind')=='manual_dense' or not 0<info['count']<=info['used']<=512:raise MapReadPending('current linked collection table unavailable or contains removed entries')
        self.watch(table+info['count_offset'],4);self.watch(table+info['version_offset'],4)
        entries=int(info['entries'],16);entry_class=r.u64(r.u64(entries)+0x40)
        ef={f['name'].lower():f for f in info['entry_layout']['fields']}
        if ef['key']['type_code'] not in (18,19) or ef['value']['type_code']!=21:raise ValueError('current linked Node entry type changed')
        value_index=next(i for i,f in enumerate(info['entry_layout']['fields']) if f['name'].lower()=='value')
        tp=r.u64(r.u64(entry_class+0x80)+32*value_index+8)
        if (r.u32(tp+8)>>16)&255!=21:raise ValueError('current linked Node generic type required')
        generic=r.u64(tp)
        if type_definition_index(self, r.u64(generic))!=51037 or (r.u32(r.u64(generic)+8)>>16)&255!=17:raise ValueError('current linked Node domain differs')
        closed=r.u64(generic+24)
        if not closed or r.class_name(closed)!=('Node','') or r.class_name(r.u64(closed+0x58))!=('ValueType','System'):raise MapReadPending('current closed linked Node layout unavailable')
        nf={f['name']:f for f in r.layout(closed)['fields']}
        if set(nf)!={'Previous','Next'} or any(f['type_code'] not in (18,19) for f in nf.values()):raise ValueError('current linked Node references differ')
        links={}
        for i in range(info['used']):
            raw=self.watch(entries+32+info['stride']*i,info['stride'])
            if struct.unpack_from('<i',raw,ef['hashcode']['offset']-16)[0]<0:continue
            key=struct.unpack_from('<Q',raw,ef['key']['offset']-16)[0]
            vals={}
            for name,row in nf.items():
                off=ef['value']['offset']+row['offset']-32
                if not 0<=off<=info['stride']-8:raise ValueError('current inline linked Node extent changed')
                vals[name]=struct.unpack_from('<Q',raw,off)[0]
            if key in links:raise ValueError('duplicate current collection links')
            links[key]=vals
        order=[];current=values['head'];previous=0
        while current:
            if current in order or current not in links or links[current]['Previous']!=previous:raise MapReadPending('current collection links inconsistent')
            order.append(current);previous=current;current=links[current]['Next']
        if len(order)!=len(links) or len(links)!=info['count'] or previous!=values['tail']:raise MapReadPending('current collection head/tail disagrees')
        return [dict(model_pointer=m,data_pointer=self.model_data(m)) for m in order]

    def card_geometry(self,card,list_control,ui,frame):
        """Current wrapper RectTransform clipped to the actual scroll viewport."""
        root_object=self.value(card['control_pointer'],25108,'uiSlot')
        raw=self.value(root_object,54163,'<Raw>k__BackingField')
        virtual=self.value(root_object,54163,'virtualObject')
        from .material_escape import EscapeView
        if EscapeView(self).value(virtual,'VirtualUIObjectNode','<IsDisposed>k__BackingField'):raise MapReadPending('current card root disposed')
        native=getattr(self,'_native_ui',None)
        if native is None:native=self._native_ui=NativeUnityUI(self)
        rect=card['root_transform'];target=native.component_game_object(raw)
        if target!=native.component_game_object(rect) or not native.native_active_cached(target):raise MapReadPending('current card wrapper/root differs or inactive')
        graphic=self.value(root_object,9053,'<BackgroundSelected>k__BackingField')
        gf=self.base_fields(graphic,'Graphic','UnityEngine.UI')
        canvas=struct.unpack('<Q',self.watch(graphic+gf['m_Canvas'],8))[0]
        if not canvas:raise MapReadPending('current card canvas unavailable')
        proxy=map_scroll_session(self);collection=self.value(list_control,25123,'collectionControl')
        scroll=proxy.field(collection,'CollectionControl','scrollControl')
        viewport=proxy.field(scroll,'ScrollControl','viewportTransform')
        viewport_rect=proxy.field(viewport,'UIChildTransform','<Transform>k__BackingField')
        if not proxy.ancestor(rect,viewport_rect):raise MapReadPending('current card outside owning viewport hierarchy')
        physical=frame['physical_client_rect'];size=(physical[2]-physical[0],physical[3]-physical[1])
        geometry=native.overlay_rect(rect,canvas,size);clip=native.overlay_rect(viewport_rect,canvas,size)
        safe=(frame.get('current_context') or {}).get('safe_rect',[0,0,*size])
        area=_intersection(geometry['rect_client'],clip['rect_client'])
        if area:area=_intersection(area,[safe[0],safe[1],safe[2]-safe[0],safe[3]-safe[1]])
        if not area or min(area[2:])<4:raise MapReadPending('current map card clipped outside viewport')
        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_hashes=geometry['source_hashes']+[source_hash()],
            observed_at=datetime.now(timezone.utc).isoformat(),clipped_to_current_scroll_viewport=True)
        return dict(rect_client=area,full_rect=geometry['rect_client'],viewport_rect=clip['rect_client'],canvas=canvas,
            native_geometry_proof=proof,root_object_pointer=root_object,target_native_game_object=target,
            control_pointer=card['control_pointer'],ui_pointer=ui,window_id=ui,window_kind='MapQuestPanelUI',
            index=card['index'],rect_source='current-native-output')

    def unfold_candidate(self,card,list_control,ui,frame):
        if self.value(card['data_pointer'],25161,'NeedChangeFoldState'):raise MapReadPending('current fold transition already pending')
        geometry=self.card_geometry(card,list_control,ui,frame)
        view=MapCardClickView(self);view.verify_methods();registration=view.current_registration(geometry['root_object_pointer'])
        callback=registration['callback']
        if callback['target']!=card['control_pointer'] or callback['method']!=self.base+0x986ca30:raise MapReadPending('current background callback owns another card')
        if self._native_ui.native_pointer(registration['game_object_managed'],{'GameObject'})!=geometry['target_native_game_object']:raise MapReadPending('current background registration/root GameObject differs')
        return dict(geometry,target_id='map_quest_card.unfold',handler_pointer=registration['handler_pointer'],
            model_event_id=callback['pointer'],event_kind='registered-background-fold',callback=callback,registration=registration)

    def scroll_candidates(self,cards,list_control,ui,frame):
        rendered=[c for c in cards if c.get('rendered')]
        collections={c['element_collection'] for c in rendered}
        if len(collections)!=1:raise MapReadPending('current unique card element collection required')
        order=self.collection_order(next(iter(collections)));indices={r['data_pointer']:i for i,r in enumerate(order)}
        if len(indices)!=len(order):raise MapReadPending('current collection repeats card data')
        visible=[]
        for card in rendered:
            if card['data_pointer'] not in indices:raise MapReadPending('current rendered card absent from model order')
            try:geometry=self.card_geometry(card,list_control,ui,frame)
            except MapReadPending:continue
            visible.append(dict(index=indices[card['data_pointer']],instance_id=card['instance_id'],rect_client=geometry['rect_client']))
        if not visible:raise MapReadPending('current card scroll needs a visible owned layout anchor')
        visible.sort(key=lambda r:r['index'])
        proxy=map_scroll_session(self);collection=self.value(list_control,25123,'collectionControl')
        binding=proxy.collection_binding(collection)
        layout=proxy.current_layout(collection,next(iter(collections)))
        if layout['model_revision']!=_digest(order):raise MapReadPending('current card layout/model changed')
        if layout['viewport_transform']!=binding['viewport_rect']:raise ValueError('current layout and scroll viewport owner differs')
        from .collection_scroll_axis import scroll_direction
        axis=proxy.layout_scroll_axis(layout,binding)
        centers=[row['center'][axis['axis_index']]*axis['layout_to_screen_sign'] for row in layout['rows']]
        orientation=1 if all(a<=b for a,b in zip(centers,centers[1:])) else -1 if all(a>=b for a,b in zip(centers,centers[1:])) else 0
        for row in visible:
            row['layout_center_axis']=centers[row['index']]
            if axis['axis_index']==1:row['layout_center_y']=centers[row['index']]
        signature=_digest(dict(collection=collection,element_collection=layout['element_collection_pointer'],
            fitter=layout['layout_fitter'],rule=layout['layout_rule'],pivot=layout['pivot'],axis=axis,
            rows=[{k:r[k] for k in ('model_pointer','data_pointer','position','size','center')} for r in layout['rows']]))
        sample=self.card_geometry(rendered[0],list_control,ui,frame)
        physical=frame['physical_client_rect'];size=(physical[2]-physical[0],physical[3]-physical[1])
        geo=self._native_ui.overlay_rect(binding['viewport_rect'],sample['canvas'],size)
        safe=(frame.get('current_context') or {}).get('safe_rect',[0,0,*size]);area=_intersection(geo['rect_client'],[safe[0],safe[1],safe[2]-safe[0],safe[3]-safe[1]])
        if not area or min(area[2:])<4:raise MapReadPending('current map scroll viewport clipped')
        observation=dict(model_revision=_digest(order),visible_rows=visible,digest=_digest(visible),orientation=orientation,viewport_rect=deepcopy(area),
            layout_signature=signature,layout_source=layout['source'],layout_rule=layout['layout_rule'],layout_centers=centers,
            layout_source_revision=source_hash(),scroll_axis=axis,
            screen_y_per_layout_y_sign=axis['layout_to_screen_sign'] if axis['axis_index']==1 else None)
        state=self.current_pointer_state();out=[]
        for card in cards:
            index=indices.get(card['data_pointer'])
            if index is None:raise MapReadPending('current quest card absent from actual collection model')
            target=centers[index];seen=[r['layout_center_axis'] for r in visible]
            delta=scroll_direction(axis,target,seen)
            if delta is None:continue # No target displacement on the actual receiver axis.
            candidate=dict(rect_client=area,native_geometry_proof=sample['native_geometry_proof'],control_pointer=binding['control'],
                root_object_pointer=binding['root_object'],handler_pointer=binding['handler'],target_native_game_object=binding['target'],
                ui_pointer=ui,window_id=ui,window_kind='MapQuestPanelUI',event_kind='registered-scroll-handler',model_event_id=0,
                target_id='map_quest_card.scroll',index=card['index'],delta=delta,scroll_observation=observation,
                model_index=index,registration=binding['registration'])
            proof=proxy.receiver_proof(binding,geo,state,delta,frame)
            if proof:candidate['scroll_receiver_proof']=proof
            out.append(candidate)
        return out,observation

    def callback(self, pointer):
        # Shared source-pinned System.Delegate declarations, no function call.
        from .material_dimmer import DimmerView
        return DimmerView(self).delegate(pointer)

    def progress_candidate(self, card, ui, frame, list_control=None):
        control=card['control_pointer'];button=self.value(control,25108,'progressButton')
        if not self.field(button,'ButtonControl','interactable') or not self.field(button,'ButtonControl','enabled'):
            raise MapReadPending('current map progress button disabled')
        selection=self.field(button,'ButtonControl','<SelectionControl>k__BackingField')
        action=self.value(selection,51195,'ClickedWithTouchInputMethod')
        if any(self.value(selection,51195,n) for n in ('ClickedWithTouchDeviceType','ClickedWithMouseButtonType','<OnClick>k__BackingField')):
            raise MapReadPending('current map progress selection has additional handlers')
        callback=self.callback(action)
        if callback['target']!=control or callback['method']!=self.base+0x986d530:
            raise MapReadPending('current map progress callback does not own card')
        geometry=self.read_control_geometry(button,ui,frame)
        if list_control is not None:
            owned=self.card_geometry(card,list_control,ui,frame)
            clipped=_intersection(geometry['rect_client'],owned['rect_client'])
            if not clipped or min(clipped[2:])<4:raise MapReadPending('current card progress button clipped')
            geometry=dict(geometry,rect_client=clipped)
        return dict(geometry,index=card['index'],button_pointer=button,control_pointer=button,
                    ui_pointer=ui,window_id=ui,window_kind='MapQuestPanelUI',target_id='map_quest_card.native_autoplay',
                    model_event_id=action,event_kind='native-selection-action',callback=callback)

    def category_flag(self, table, key):
        r=self.reader;info=r.dictionary(table)
        if info.get('kind')=='manual_dense' or not 0<=info['count']<=info['used']<=16:
            raise ValueError('map category state dictionary changed')
        fields={f['name'].lower():f for f in info['entry_layout']['fields']}
        if fields['key']['type_code']!=17 or fields['value']['type_code']!=2:
            raise ValueError('map category state is not enum to Boolean')
        self.watch(table+info['count_offset'],4);self.watch(table+info['version_offset'],4)
        offsets={k:fields[k]['offset']-16 for k in ('hashcode','key','value')};found=[]
        for index in range(info['used']):
            address=int(info['entries'],16)+32+index*info['stride'];raw=self.watch(address,info['stride'])
            if struct.unpack_from('<i',raw,offsets['hashcode'])[0]<0:continue
            if struct.unpack_from('<i',raw,offsets['key'])[0]==key:
                value=raw[offsets['value']]
                if value not in (0,1):raise ValueError('invalid current category flag')
                found.append(value==1)
        if len(found)!=1:raise MapReadPending('current Arbeit category state is not unique')
        return found[0]

    def category_candidate(self, ui, frame):
        control=self.value(ui,25125,'mapQuestPanelCategoryControl');data=self.value(control,25119,'data')
        if not self.value(data,7273,'IsMainCategoryActive') or any(not self.category_flag(self.value(data,7273,name),2)
                for name in ('MainCategoryUnlockData','MainCategoryVisible')):
            raise MapReadPending('current Arbeit category is hidden or locked')
        slot=self.value(control,25119,'uiSlot');slots=self.value(slot,9063,'<MainCategorySlots>k__BackingField')
        matches=[s for s in self.refs(slots,element='MapQuestPanelMainCategorySlot',limit=16)
                 if self.value(s,9064,'<Type>k__BackingField')==2]
        if len(matches)!=1:raise MapReadPending('current Arbeit category source slot is not unique')
        button_slot=self.value(matches[0],9064,'<ButtonSlot>k__BackingField')
        group=self.value(control,25119,'mainCategoryRadioButtonGroup')
        action=self.value(group,51221,'onSelectionChanged');callback=self.callback(action)
        if callback['target']!=control or callback['method']!=self.base+0x9876310:
            raise MapReadPending('current map category selection callback owner changed')
        choices=[]
        for selection,button in self.reference_dictionary(self.value(group,51221,'selectionControlMap'),limit=16):
            if self.field(button,'ButtonControl','<RootObject>k__BackingField')!=button_slot:continue
            if self.field(button,'ButtonControl','<SelectionControl>k__BackingField')!=selection:
                raise MapReadPending('current category radio/control owner differs')
            if not self.field(button,'ButtonControl','enabled') or not self.field(button,'ButtonControl','interactable'):
                raise MapReadPending('current Arbeit category button disabled')
            choices.append(button)
        if len(choices)!=1:raise MapReadPending('current Arbeit category button is not unique')
        button=choices[0];geometry=self.read_control_geometry(button,ui,frame)
        return dict(geometry,index=2,button_pointer=button,control_pointer=button,ui_pointer=ui,window_id=ui,
                    window_kind='MapQuestPanelUI',target_id='map_quest_card.category.arbeit',model_event_id=action,
                    event_kind='native-radio-selection',callback=callback)

    def observe_current(self, frame):
        self.verify_source();r=self.reader
        context=active_context(r,self.base,self.character_plan)
        identity=CharacterMemoryReader._value(r,context['identity_pointer'],self.identity_class,self.identity_shape)
        components=self.rooted_components(context['chain'][3]);panel=components['MapQuestPanelUIComponent']
        result=dict(confirmed=True,source=SOURCE,source_hash=source_hash(),source_revision=source_hash(),
                    source_version={k:source_definition()[k] for k in ('game_assembly_sha256','metadata_sha256')},
                    observed_at=datetime.now(timezone.utc).isoformat(),pid=r.pid,
                    process_started_at=r.started_at.isoformat(),character_id=str(identity['character_id']),
                    direct_accept_supported=False,cards=[],missing=[],candidates=[],unfold_candidates=[],scroll_candidates=[])
        result['map_panel_visible']=self.value(panel,27854,'showUI')
        if result['map_panel_visible']:
            ui=self.value(panel,27854,'ui');component=components['MapQuestPanelListUIComponent']
            data=self.value(component,27852,'data');control=self.value(component,27852,'ui')
            if self.value(ui,25125,'mapQuestPanelListControl')!=control:raise MapReadPending('map panel/list root differs')
            result.update(ui_pointer=ui,current_category=self.value(data,25160,'MainCategory'),
                          list_active=self.value(component,27852,'isActive'))
            if result['current_category']!=2:
                try:result['category_candidate']=self.category_candidate(ui,frame)
                except (ValueError,RuntimeError,KeyError) as exc:result['category_missing']=str(exc)
            if result['current_category']==2 and result['list_active']:
                collection=self.value(control,25123,'collectionControl')
                alignment=self.value(collection,51009,'alignmentModule')
                pending_flags={name:bool(self.value(alignment,51121,name)) for name in ('needDataUpdate','needLayoutUpdate')}
                updating=any(pending_flags.values())
                if updating:
                    result['transient']=dict(kind='collection_layout_updating',collection_pointer=collection,
                        alignment_pointer=alignment,flags=pending_flags,source='current_collection_update_flags')
                rendered={} if updating else {c['data_pointer']:c for c in self.rendered_cards(control)}
                pointers=self.refs(self.value(data,25160,'QuestCardDatas'),element='MapQuestCardDataBase',limit=512)
                for index,pointer in enumerate(pointers):
                    if r.class_name(r.u64(pointer))!=(self.shape(25158)['name'],self.shape(25158)['namespace']):continue
                    card=dict(index=index,data_pointer=pointer,instance_id=self.value(pointer,25158,'QuestInstanceId'),
                        arbeit_type=self.value(pointer,25158,'ArbeitType'),accepted=self.value(pointer,25161,'IsAccepted'),
                        folded=self.value(pointer,25161,'IsFold'),auto_playing=self.value(pointer,25161,'IsAutoPlaying'),
                        time_over=self.value(pointer,25158,'IsTimeOver'),cleared=self.value(pointer,25158,'IsCleared'),
                        rendered=pointer in rendered,input_ready=False)
                    if pointer in rendered:
                        card.update(rendered[pointer]);card['instantiated']=True
                        try:self.card_geometry(card,control,ui,frame)
                        except (ValueError,RuntimeError,KeyError) as exc:
                            card.update(rendered=False,geometry_missing=str(exc))
                    if card['rendered'] and card['folded'] and not any(card[k] for k in ('accepted','auto_playing','time_over','cleared')) and card['arbeit_type']!=1:
                        try:result['unfold_candidates'].append(self.unfold_candidate(card,control,ui,frame))
                        except (ValueError,RuntimeError,KeyError) as exc:card['unfold_missing']=str(exc)
                    if card['rendered'] and not any(card[k] for k in ('accepted','folded','auto_playing','time_over','cleared')) and card['arbeit_type']!=1:
                        try:
                            candidate=self.progress_candidate(card,ui,frame,control)
                            result['candidates'].append(candidate);card['input_ready']=True
                        except (ValueError,RuntimeError,KeyError) as exc:card['input_missing']=str(exc)
                    result['cards'].append(card)
                if len({c['instance_id'] for c in result['cards']})!=len(result['cards']):raise MapReadPending('duplicate current map quest instances')
                if not updating and any(not c['rendered'] for c in result['cards']):
                    try:result['scroll_candidates'],result['scroll_observation']=self.scroll_candidates(result['cards'],control,ui,frame)
                    except (ValueError,RuntimeError,KeyError) as exc:result['scroll_missing']=str(exc)
        if result['candidates'] or result.get('category_candidate') or result['unfold_candidates'] or result['scroll_candidates']:result['pointer_state']=self.current_pointer_state()
        if context!=active_context(r,self.base,self.character_plan):raise MapReadPending('current map quest root changed')
        if identity!=CharacterMemoryReader._value(r,context['identity_pointer'],self.identity_class,self.identity_shape):
            raise MapReadPending('current map quest character changed')
        self.assert_stable()
        return result


class MapQuestAcceptObservationProvider:
    """Explicit enable only; construction/read(False) cannot open a process."""
    def __init__(self,pid,*,session_id,session_factory=None):
        self.pid=pid;self.session_id=session_id;self.session_factory=session_factory or MapQuestCardSession

    def read(self,*,node,snapshot,frame,allow_process_read=False):
        result=dict(ok=False,read_attempted=False,game_inputs=0,memory_writes=0,client_functions_called=0,missing=[])
        if allow_process_read is not True:return dict(result,reason='process_read_not_explicitly_enabled')
        result['read_attempted']=True
        try:
            with self.session_factory(self.pid) as session:
                current=session.observe_current(frame)
            for key,expected in (('pid',self.pid),('session_id',self.session_id),('character_id',snapshot.get('character_id')),
                                 ('process_started_at',snapshot.get('process_started_at'))):
                if key=='session_id':current[key]=expected
                if expected is None or str(current.get(key))!=str(expected):raise ValueError('map card current owner differs: '+key)
            current.update(frame_id=frame['frame_id'],window_signature=frame['window_signature'],input_epoch=(frame.get('input_owner') or {}).get('epoch'))
            current['pointer_progress']=pending_probe_observations(current,frame)
            return dict(result,ok=True,current_map_accept=current)
        except (OSError,ValueError,RuntimeError,KeyError,TypeError) as exc:
            return dict(result,reason='current_map_quest_card_unavailable:'+str(exc),missing=[str(exc)])


def pending_probe_observations(current,frame):
    """Typed, owned observations only; no clearance or input permission."""
    from .quest_ui_actions import _probe_receipt, _pointer_scene, _fresh, _stamp, _producer_revision
    from .map_quest_accept import pointer_semantics
    receipt=_probe_receipt(frame)
    if not receipt:return []
    context=(receipt.get('command') or {}).get('probe_context') or receipt.get('target_binding')
    if not isinstance(context,dict):return []
    owner=frame.get('input_owner') or {};state=current.get('pointer_state') or {}
    scope=context.get('map_quest_card_scope') or {}
    if (receipt.get('status')!='sent_unconfirmed' or not receipt.get('operation_id') or not receipt.get('dispatch_finished_at')
            or owner.get('continuous') is not True or not owner.get('epoch') or not _fresh(owner,frame)
            or context.get('input_epoch')!=owner['epoch'] or context.get('window_signature')!=frame.get('window_signature')
            or context.get('source_revision')!=_producer_revision(current) or not state.get('source') or not _fresh(state,frame)
            or any(str(scope.get(k))!=str(current.get(k)) or str(frame.get(k))!=str(current.get(k))
                   for k in ('session_id','character_id','process_started_at'))):return []
    if _stamp(state['observed_at'])<=_stamp(receipt['dispatch_finished_at']):return []
    caches=state.get('caches') or []
    if len(caches)!=1:return []
    cache=caches[0]
    if cache.get('handle')!=context.get('pointer_handle') or cache.get('cache_pointer')!=context.get('pointer_cache'):return []
    if cache.get('position')==context.get('point_screen') and cache.get('position')!=context.get('before_position') and cache.get('update_time',0)>context.get('before_update_time',0) and _digest(cache)!=context.get('before_results_hash'):return []
    out=[]
    candidates=list(current.get('candidates',[]))+list(current.get('unfold_candidates',[]))+list(current.get('scroll_candidates',[]))
    if current.get('category_candidate'):candidates.append(current['category_candidate'])
    for candidate in candidates:
        stage={'map_quest_card.category.arbeit':'category','map_quest_card.unfold':'unfold','map_quest_card.scroll':'scroll','map_quest_card.native_autoplay':'autoplay'}.get(candidate.get('target_id'))
        if stage is None:continue
        if stage!='category' and not any(c.get('instance_id')==scope.get('instance_id') and c.get('index')==candidate.get('index') for c in current.get('cards',[])):continue
        geometry=candidate.get('native_geometry_proof') or {}
        if (any(geometry.get(k) is not True for k in ('confirmed','transform_verified','active_hierarchy_verified','source_revision_verified'))
                or geometry.get('frame_id')!=frame.get('frame_id') or geometry.get('window_signature')!=frame.get('window_signature')):continue
        typed=dict(current,target_id=candidate['target_id'],ui_type='MapQuestPanelUI',pointer_semantics=pointer_semantics(candidate,scope,stage))
        if _pointer_scene(candidate,typed)!=context.get('scene_signature'):continue
        out.append(dict(kind='pointer_probe_cache_pending',stage=stage,operation_id=receipt['operation_id'],
            scene_signature=context['scene_signature'],input_epoch=owner['epoch'],offer_scope=scope,
            pointer_handle=cache['handle'],pointer_cache=cache['cache_pointer'],observed_at=state['observed_at']))
    return out


def map_quest_card_scene_reader(session,binding,frame):
    """Last-moment same-root re-read for the shared native pointer guard."""
    current=session.observe_current(frame)
    current.update(target_id=binding.get('target_id'),ui_type='MapQuestPanelUI')
    scope=binding.get('map_quest_card_scope') or {}
    for key in ('character_id','process_started_at'):
        if str(current.get(key))!=str(scope.get(key)):raise ValueError('map card guard occurrence changed')
    if binding.get('target_id')=='map_quest_card.category.arbeit':
        candidate=current.get('category_candidate')
        if not candidate:raise MapReadPending('current owned Arbeit tab no longer available')
        current['pointer_semantics']={'action_kind':'pointer_click','map_quest_card_scope':scope,'stage':'category'}
        return candidate,current
    cards=[c for c in current['cards'] if c['instance_id']==scope.get('instance_id')]
    if len(cards)!=1:raise MapReadPending('owned current map card no longer unique')
    role=binding.get('target_id');stage={'map_quest_card.unfold':'unfold','map_quest_card.scroll':'scroll','map_quest_card.native_autoplay':'autoplay'}.get(role)
    if stage is None:raise ValueError('unsupported current map card input role')
    key={'unfold':'unfold_candidates','scroll':'scroll_candidates','autoplay':'candidates'}[stage]
    candidates=[c for c in current.get(key,[]) if c['index']==cards[0]['index']]
    if len(candidates)!=1:raise MapReadPending('owned current map card control unavailable')
    from .map_quest_accept import pointer_semantics
    current['pointer_semantics']=pointer_semantics(candidates[0],scope,stage)
    return candidates[0],current


class MapCardClickView(DimmerView):
    def registered_click(self,group,root_object,dispatcher):
        tables=self.value(group,'UIEventSpotTableGroup','tables');matches=[]
        for _,table in self.s.reference_dictionary(tables,limit=64):
            klass=self.r.u64(table);shape=self.shape('UIEventSpotTable`1')
            if self.r.class_name(klass)!=(shape['name'],shape['namespace']):
                raise ValueError('current event spot table implementation changed')
            rows={f['name']:f for f in self.r.layout(klass,[f['name'] for f in shape['fields']])['fields']}
            for declared in shape['fields']:
                actual=rows[declared['name']]
                if actual['type_code']!=declared['type']['type_code'] or not 16<=actual['offset']<=2048:
                    raise ValueError('current closed event table field changed')
            # Verify the instantiated spotTable Dictionary<GameObject,IClickUIEventSpot>
            # through its actual FieldInfo type. Open generic offsets are never used.
            field_table=self.r.u64(klass+0x80)
            type_pointer=self.r.u64(field_table+8) # source spotTable field index 0
            if ((self.r.u32(type_pointer+8)>>16)&255)!=21:raise ValueError('current spot table field is not generic')
            generic=self.r.u64(type_pointer);inst=self.r.u64(generic+8)
            count=self.r.u64(inst);arguments=self.r.u64(inst+8)
            if count!=2:raise ValueError('current spot dictionary generic argument count changed')
            types=[self.r.u64(arguments+8*i) for i in range(2)]
            args=[(type_definition_index(self.s, p),(self.r.u32(p+8)>>16)&255) for p in types]
            if args!=[(73587,18),(54976,18)]:continue
            pointers={key:struct.unpack('<Q',self.s.watch(table+rows[key]['offset'],8))[0]
                      for key in ('spotTable','lifeCycleTable','slotTable')}
            spots=self.s.reference_dictionary(pointers['spotTable'],limit=512)
            lifecycle=self.s.reference_dictionary(pointers['lifeCycleTable'],limit=512)
            slots=self.s.reference_dictionary(pointers['slotTable'],limit=512)
            selected=[(obj,spot) for obj,spot in spots if self.value(spot,'ClickUIEventSpot','target')==root_object]
            if len(selected)>1:raise ValueError('current map background has duplicate click spots')
            if not selected:continue
            managed_go,spot=selected[0]
            if self.value(spot,'ClickUIEventSpot','target')!=root_object:
                raise MapReadPending('current click spot target differs from map background root')
            handler=self.value(spot,'ClickUIEventSpot','handler')
            action=self.delegate(self.value(handler,'DelegateClickEventHandler','action'))
            if action['method']!=self.s.base+0x743cbf0:raise MapReadPending('current map background dispatcher wrapper changed')
            closure=action['target']
            if self.value(closure,'<>c__DisplayClass3_0','<>4__this')!=dispatcher:
                raise MapReadPending('current map background wrapper owns another dispatcher')
            wrapped=self.delegate(self.value(closure,'<>c__DisplayClass3_0','action'))
            if wrapped['method']!=self.s.base+0x16a8d60:raise MapReadPending('current SetOnClick action wrapper changed')
            callback=self.delegate(self.value(wrapped['target'],'<>c__DisplayClass8_0','action'))
            matches.append(dict(table_pointer=table,spot_pointer=spot,handler_pointer=handler,
                root_object_pointer=root_object,game_object_managed=managed_go,dispatcher_pointer=dispatcher,
                dispatcher_action=action,set_onclick_action=wrapped,callback=callback,
                lifecycle_rows=[(o,g) for o,g in lifecycle if o==root_object],
                slot_rows=[(g,o) for g,o in slots if g==managed_go]))
        if len(matches)!=1:raise MapReadPending('current exact map background click registration not unique')
        return matches[0]


class MapCardScrollSession(RecipeScrollSession):
    def source_shape(self,name,namespace=None):
        from .collection_scroll_axis import axis_source
        extra=[s for s in axis_source()['classes'] if s['name']==name and (namespace is None or s['namespace']==namespace)]
        if len(extra)==1:
            if any(self.plan.get(k)!=v for k,v in axis_source()['source_version'].items()):
                raise ValueError('current collection axis source version changed')
            return extra[0]
        rows=[s for s in source_definition()['classes'] if s['name']==name and (namespace is None or s['namespace']==namespace)]
        if len(rows)==1:return rows[0]
        return super().source_shape(name,namespace)

    def current_layout(self,collection,element_collection):
        """Actual output cache, not a reconstruction from list indices/pixels.

        Generic reference models (including inventory) are preserved verbatim.
        Coordinates are entry-relative Unity layout centers, never screen pixels.
        """
        view=MapQuestCardSession.__new__(MapQuestCardSession);view.__dict__=self.__dict__
        view.verify_source();value=view.value
        alignment=value(collection,51009,'alignmentModule')
        if any(value(alignment,51121,k) for k in ('needDataUpdate','needLayoutUpdate')):
            raise MapReadPending('current collection layout updating')
        table=value(alignment,51121,'collectionEntries')
        matches=[entry for owner,entry in self.reference_dictionary(table,limit=32) if owner==element_collection]
        if len(matches)!=1:raise MapReadPending('current collection layout entry owner not unique')
        entry=matches[0]
        if value(entry,51119,'<ElementCollection>k__BackingField')!=element_collection:
            raise ValueError('current collection layout entry owner changed')
        fitter=value(entry,51119,'<LayoutFitter>k__BackingField')
        if not fitter:raise MapReadPending('current collection layout fitter unavailable')
        if value(fitter,51056,'<NeedLayoutUpdate>k__BackingField'):
            raise MapReadPending('current collection fitter updating')
        accessor=value(fitter,51056,'elementAccessor');model=value(element_collection,51104,'<Model>k__BackingField')
        if value(accessor,51109,'model')!=model:raise ValueError('current layout accessor model owner changed')
        if value(accessor,51109,'<ElementCollectionChangeDetected>k__BackingField'):
            raise MapReadPending('current collection layout model change pending')
        rule=value(fitter,51056,'layoutRule');local=value(fitter,51056,'localArea')
        if (value(element_collection,51104,'<LayoutRule>k__BackingField')!=rule or
                value(element_collection,51104,'<LayoutArea>k__BackingField')!=local):
            raise ValueError('current collection layout rule/area owner changed')
        converter=value(entry,51119,'positionConverter');layout=value(converter,51101,'layoutPositionConverter')
        native=value(converter,51101,'nativePositionConverter');view.declared(native,51043)
        area=value(collection,51009,'layoutArea')
        if (value(layout,51103,'layoutRule')!=rule or value(layout,51103,'localArea')!=local or
                value(layout,51103,'globalArea')!=area or value(alignment,51121,'layoutArea')!=area or
                value(alignment,51121,'nativePositionConverter')!=native or value(local,51116,'baseArea')!=area):
            raise ValueError('current collection coordinate converter owner changed')
        if value(area,51041,'<ContentSizeChanged>k__BackingField') or value(local,51116,'<ContentSizeChanged>k__BackingField'):
            raise MapReadPending('current collection content size updating')
        r=self.reader;rules=source_definition()['r6_layout']['rules'];rule_name=r.class_name(r.u64(rule))
        candidates=[int(i) for i in rules if rule_name==(view.shape(int(i))['name'],view.shape(int(i))['namespace'])]
        if len(candidates)!=1:raise MapReadPending('current collection layout rule implementation unsupported')
        rule_index=candidates[0];policy=rules[str(rule_index)];view.declared(rule,rule_index)
        def inline(pointer,index,name,fmt):
            _,fields=view.declared(pointer,index)
            return struct.unpack('<'+fmt,self.watch(pointer+fields[name],struct.calcsize('<'+fmt)))
        if policy['kind']=='field':pivot=inline(rule,rule_index,policy['field'],'ii')
        else:
            klass=r.u64(self.base+policy['class_slot_rva']);shape=view.shape(51057)
            if r.class_name(klass)!=(shape['name'],shape['namespace']):raise ValueError('current collection pivot static class changed')
            fields=view.fields(klass,shape);statics=r.u64(klass+0xb8)
            pivot=struct.unpack('<ii',self.watch(statics+fields[policy['field']],8))
        if any(v not in (0,1,2) for v in pivot):raise ValueError('current collection pivot enum invalid')
        # Pinned ToPivotVectorBipolar: horizontal left/middle/right=-.5/0/.5;
        # vertical top/middle/bottom=.5/0/-.5. Constants are read, not guessed.
        for c in source_definition()['r6_layout']['bipolar_constants'].values():
            if self.watch(self.base+c['rva'],4)!=bytes.fromhex(c['raw_hex']):raise ValueError('current collection pivot constant changed')
        bipolar=(pivot[0]*.5-.5,.5-pivot[1]*.5)
        entries=value(accessor,51109,'entries');klass=r.u64(entries)
        if r.class_name(klass)!=('List`1','System.Collections.Generic'):raise ValueError('current layout entries implementation changed')
        fields={f['name']:f for f in r.layout(klass,['_items','_size','_version'])['fields']}
        if set(fields)!={'_items','_size','_version'} or fields['_items']['type_code'] not in (18,29) or any(fields[k]['type_code']!=8 for k in ('_size','_version')):
            raise ValueError('current layout entry list fields changed')
        if any(not 16<=f['offset']<=128 for f in fields.values()):raise ValueError('current layout entry list offset invalid')
        pointers=struct.unpack('<Q',self.watch(entries+fields['_items']['offset'],8))[0]
        count=struct.unpack('<i',self.watch(entries+fields['_size']['offset'],4))[0];self.watch(entries+fields['_version']['offset'],4)
        capacity=struct.unpack('<Q',self.watch(pointers+24,8))[0]
        if not 0<count<=capacity<=512:raise MapReadPending('current layout entry list bounds unavailable')
        order=view.collection_order(element_collection)
        if count!=len(order):raise MapReadPending('current layout entries do not cover current model')
        rows=[]
        for i,owned in enumerate(order):
            row=struct.unpack('<Q',self.watch(pointers+32+8*i,8))[0]
            element=value(row,51108,'LayoutElement')
            if value(element,51106,'modelElement')!=owned['model_pointer']:raise MapReadPending('current layout entry/model order differs')
            if value(element,51106,'hasChanges') or not value(element,51106,'<HasSizeSetAtLeastOnce>k__BackingField'):
                raise MapReadPending('current layout element position/size updating')
            position=inline(element,51106,'position','ff');size=inline(element,51106,'size','ff')
            if any(not math.isfinite(v) for v in position+size) or min(size)<=0:raise MapReadPending('current layout element extent invalid')
            center=[position[j]-bipolar[j]*size[j] for j in range(2)]
            rows.append(dict(owned,index=i,layout_element=element,position=list(position),size=list(size),center=center,
                active=bool(value(element,51106,'active'))))
        return dict(confirmed=True,source='current_collection_layout_output_cache',source_ref=SOURCE_REF,source_revision=source_hash(),
            collection_pointer=collection,element_collection_pointer=element_collection,layout_rule=rule_name[0],pivot=list(pivot),
            layout_fitter=fitter,rows=rows,model_revision=_digest(order),layout_revision=_digest(rows),
            content_transform=value(area,51041,'contentTransform'),viewport_transform=value(area,51041,'viewportTransform'))

    def layout_screen_axis(self,current):
        """Only axis-aligned current native overlay geometry is supported."""
        native=getattr(self,'_native_ui',None)
        if native is None:native=self._native_ui=NativeUnityUI(self)
        points=native.rect_world(current['content_transform'])['corners_world']
        x=[points[1][i]-points[0][i] for i in range(3)]
        y=[points[3][i]-points[0][i] for i in range(3)]
        if any(not math.isfinite(v) for v in x+y) or abs(x[0])<1e-6 or abs(y[1])<1e-6 or max(abs(x[1]),abs(x[2]),abs(y[0]),abs(y[2]))>1e-5:
            raise MapReadPending('current collection native scroll axes rotated or degenerate')
        # Existing overlay_rect verifies renderMode and world-to-client conversion.
        return -1 if y[1]>0 else 1

    def layout_scroll_axis(self,current,binding):
        from .collection_scroll_axis import projected_axis
        native=getattr(self,'_native_ui',None)
        if native is None:native=self._native_ui=NativeUnityUI(self)
        from .collection_scroll_axis import parent_screen_sign,source_axis
        rule=source_axis(binding['scroll_constraint'],binding['scroll_direction'])
        if current['content_transform']!=binding['content_rect']:
            raise ValueError('current scroll layout/content adapter owner differs')
        points=native.rect_world(current['content_transform'])['corners_world']
        # Validate local basis before interpreting the parent translation basis.
        projected_axis(points,dict(binding,wheel_parent_screen_sign=1))
        sign=parent_screen_sign(self,native,current['content_transform'],rule['axis_index'])
        return projected_axis(points,dict(binding,wheel_parent_screen_sign=sign))

    def collection_binding(self,collection):
        self.verify_scroll_source()
        element=collection
        control=self.field(collection,'CollectionControl','scrollControl')
        root_object=self.field(control,'ScrollControl','rootObject')
        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')
        from .collection_scroll_axis import source_axis
        constraint=self.field(control,'ScrollControl','scrollDirectionConstraint')
        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')
        direction=self.field(handler,'ScrollControlMouseScrollInputHandlerControl','scrollDirection')
        axis=source_axis(constraint,direction)
        compound=self.field(module,'ScrollControlModule',axis['compound'])
        if (self.field(handler,'ScrollControlMouseScrollInputHandlerControl','targetCompound')!=compound
                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')
        from .collection_scroll_axis import axis_source
        content_rect=None;adapter_type=None;adapter_policy=None
        for key in ('viewportTransform','contentTransform'):
            adapter=self.field(position,'ScrollContentAxisPositionControl',key)
            name,namespace=self.reader.class_name(self.reader.u64(adapter))
            policy=axis_source()['adapters'].get(name)
            if not policy or policy['axis_index']!=axis['axis_index']:
                raise MapReadPending('current scroll axis adapter implementation differs')
            actual=self.field(adapter,name,'rectTransform')
            root=self.field(control,'ScrollControl',key)
            expected=self.field(root,'UIChildTransform','<Transform>k__BackingField')
            if actual!=expected or not actual:raise ValueError('current scroll axis transform owner changed')
            if adapter_type is not None and adapter_type!=name:raise ValueError('current viewport/content axis adapters differ')
            adapter_type=name;adapter_policy=policy
            if key=='contentTransform':content_rect=actual
        for method in axis_source()['adapter_methods']:
            if method['owner']!=adapter_type:continue
            raw=bytes.fromhex(method['raw_hex'])
            if self.reader.read(self.base+method['rva'],len(raw))!=raw:
                raise ValueError('current scroll axis adapter method changed')
        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],
                    scroll_constraint=constraint,scroll_direction=direction,compound=compound,content_rect=content_rect,adapter_type=adapter_type,adapter_position_sign=adapter_policy['position_sign'])


def map_scroll_session(session):
    proxy=MapCardScrollSession.__new__(MapCardScrollSession);proxy.__dict__=session.__dict__;return proxy
