-- Include from a GearSwap job file: include('xilogsgearswap.lua') -- The bridge hands CombatLog GearSwap's resolved action and engaged-state -- equipment intent while a parse is recording. -- It never loads user files or changes equipment. Its only network request is a -- delayed, bounded, fail-safe version check against xilogs. local json = require('combatlog_json') local existing_bridge=_G and _G.__xilogs_gearswap_bridge or nil if existing_bridge and equip==existing_bridge.equip_wrapper then return existing_bridge end if existing_bridge and existing_bridge.callback_id and windower and type(windower.unregister_event)=='function' then pcall(windower.unregister_event,existing_bridge.callback_id) end local bridge = {version='2.3.7'} if _G then _G.__xilogs_gearswap_bridge=bridge end local VERSION_URL = 'https://www.xilogs.com/api/addon/version' local VERSION_TIMEOUT = 2 local VERSION_RESPONSE_LIMIT = 16384 local MAX_PENDING_WS_FILES = 4 local MAX_PENDING_STATE_FILES = 4 local SLOT_ORDER = { 'main','sub','range','ammo','head','neck','left_ear','right_ear', 'body','hands','left_ring','right_ring','back','waist','legs','feet', } local item_names local augment_cache={} local augment_cache_run_id=nil local ws_counter = 0 local pending_gear={} local pending_state={} local dirty_action=nil local dirty_state=nil local recent_ws_paths = {} local recent_state_paths = {} local unpack_values = table.unpack or unpack local session_cache={valid=false,value=nil} local last_handoff_warning={ws=0,state=0} local function pack_values(...) return {n=select('#',...),...} end local function bridge_chat(message) if windower and type(windower.add_to_chat)=='function' then pcall(windower.add_to_chat,207,'[xilogs GearSwap] '..tostring(message)) end end local function version_parts(value) local major,minor,patch=tostring(value or ''):match('^v?(%d+)%.(%d+)%.(%d+)$') if not major then return nil end return {tonumber(major),tonumber(minor),tonumber(patch)} end local function compare_versions(left,right) local a,b=version_parts(left),version_parts(right) if not a or not b then return nil end for index=1,3 do if a[index]~=b[index] then return a[index]0,download_url=download_url} end bridge._evaluate_version_metadata=evaluate_version_metadata local function fetch_version_metadata() -- GearSwap replaces require() in user files with its include loader. Use the -- addon's native loader for LuaSec while keeping normal bridge includes in -- the user environment. local native_require=gearswap and gearswap.require if type(native_require)~='function' then native_require=require end local loaded,https=pcall(native_require,'ssl.https') if not loaded or type(https)~='table' or type(https.request)~='function' then return nil,'HTTPS unavailable' end local chunks,size={},0 local function bounded_sink(chunk) if chunk==nil then return 1 end if type(chunk)~='string' or size+#chunk>VERSION_RESPONSE_LIMIT then return nil,'response too large' end size=size+#chunk; chunks[#chunks+1]=chunk return 1 end local previous_timeout=https.TIMEOUT pcall(function() https.TIMEOUT=VERSION_TIMEOUT end) local request_ok,result,code=pcall(https.request,{ url=VERSION_URL,sink=bounded_sink,redirect=false, headers={['Accept']='application/json',['User-Agent']='XILogsGearSwap/'..bridge.version}, }) pcall(function() https.TIMEOUT=previous_timeout end) if request_ok and tonumber(code)==200 then return table.concat(chunks) end if not request_ok then return nil,'request failed' end return nil,code and ('HTTP '..tostring(code)) or tostring(result or 'request failed') end local function check_version() local payload,reason=fetch_version_metadata() if not payload then bridge_chat('Bridge v'..bridge.version..' loaded; version check unavailable ('..tostring(reason)..')') return end local decoded_ok,metadata=pcall(json.decode,payload) local update=decoded_ok and evaluate_version_metadata(metadata) or nil if not update then bridge_chat('Bridge v'..bridge.version..' loaded; version service returned an invalid response') elseif update.update_available then bridge_chat('Update available: v'..bridge.version..' -> v'..update.latest_version) bridge_chat('Download: '..tostring(update.download_url or 'https://www.xilogs.com')) elseif update.newer_than_latest then bridge_chat('Bridge v'..bridge.version..' is newer than published v'..update.latest_version) else bridge_chat('Bridge v'..bridge.version..' is up to date') end end local function safe_check_version() local ok=pcall(check_version) if not ok then bridge_chat('Bridge v'..bridge.version..' loaded; version check failed') end return ok end bridge._safe_check_version=safe_check_version local function safe_component(value) value=tostring(value or '') return value:match('^[A-Za-z0-9_-]+$') and value or nil end local function sync_augment_cache_session(session) local run_id=type(session)=='table' and session.run_id or nil if run_id~=augment_cache_run_id then augment_cache={} augment_cache_run_id=run_id end return session end local function active_session() if bridge._session_override then return sync_augment_cache_session(bridge._session_override) end if session_cache.valid then return sync_augment_cache_session(session_cache.value) end local function finish(value) session_cache={valid=true,value=value} return sync_augment_cache_session(value) end local current=windower and windower.ffxi and windower.ffxi.get_player and windower.ffxi.get_player() or nil local character=current and safe_component(current.name); if not character then return finish(nil) end local path=windower.addon_path..'../CombatLog/data/gear-sync/'..character..'.json' local opened,f=pcall(io.open,path,'rb'); if not opened or not f then return finish(nil) end local read_ok,encoded=pcall(f.read,f,4097); pcall(f.close,f) if not read_ok or not encoded or #encoded>4096 then return finish(nil) end local ok,marker=pcall(json.decode,encoded) if not ok or type(marker)~='table' or marker.character~=character or type(marker.run_id)~='string' or #marker.run_id~=26 then return finish(nil) end return finish(marker) end bridge._active_session=active_session local function ensure_handoff_dir(kind, character) kind=safe_component(kind); character=safe_component(character) if not kind or not character or not windower or type(windower.create_dir)~='function' then return nil end local combatlog=windower.addon_path..'../CombatLog/' local data=combatlog..'data/' local parent=data..kind..'/' local character_dir=parent..character..'/' for _,path in ipairs({combatlog,data,parent,character_dir}) do if not pcall(windower.create_dir,path) then return nil end end return character_dir end local function ws_handoff_dir(character) return ensure_handoff_dir('ws-gear',character) end local function state_handoff_dir(character) return ensure_handoff_dir('gear-state',character) end bridge._state_handoff_dir=state_handoff_dir bridge._ws_handoff_dir=ws_handoff_dir local function ws_handoff_path(character) character=safe_component(character) if not character or not windower or type(windower.addon_path)~='string' then return nil end return windower.addon_path..'../CombatLog/data/ws-gear/'..character..'/' end local function state_handoff_path(character) character=safe_component(character) if not character or not windower or type(windower.addon_path)~='string' then return nil end return windower.addon_path..'../CombatLog/data/gear-state/'..character..'/' end local function owned_ws_filename(name) return type(name)=='string' and (name:match('^WS[A-F0-9]+%.json$')~=nil or name:match('^WS[A-F0-9]+%.tmp$')~=nil) end local function owned_state_filename(name) return type(name)=='string' and (name:match('^GS[A-F0-9]+%.json$')~=nil or name:match('^GS[A-F0-9]+%.tmp$')~=nil) end local remove_or_missing local function sweep_stale_ws_handoffs() if not windower or type(windower.get_dir)~='function' or not windower.ffxi or type(windower.ffxi.get_player)~='function' then return 0 end local player_ok,current_player=pcall(windower.ffxi.get_player) local base=player_ok and current_player and ws_handoff_path(current_player.name) or nil if not base then return 0 end local list_ok,entries=pcall(windower.get_dir,base) if not list_ok or type(entries)~='table' then return 0 end local removed=0 for _,name in ipairs(entries) do if owned_ws_filename(name) then if remove_or_missing(base..name) then removed=removed+1 else recent_ws_paths[#recent_ws_paths+1]=base..name end end end return removed end bridge._sweep_stale_ws_handoffs=sweep_stale_ws_handoffs local function sweep_stale_state_handoffs() if not windower or type(windower.get_dir)~='function' or not windower.ffxi or type(windower.ffxi.get_player)~='function' then return 0 end local player_ok,current_player=pcall(windower.ffxi.get_player) local base=player_ok and current_player and state_handoff_path(current_player.name) or nil if not base then return 0 end local list_ok,entries=pcall(windower.get_dir,base) if not list_ok or type(entries)~='table' then return 0 end local removed=0 for _,name in ipairs(entries) do if owned_state_filename(name) then if remove_or_missing(base..name) then removed=removed+1 else recent_state_paths[#recent_state_paths+1]=base..name end end end return removed end bridge._sweep_stale_state_handoffs=sweep_stale_state_handoffs remove_or_missing=function(path) local ok,removed,err,errno=pcall(os.remove,path) if ok and removed then return true end if ok and not removed and tonumber(errno)==2 then return true end local message=tostring(err or ''):lower() if ok and not removed and (message:find('no such file',1,true) or message:find('cannot find the file',1,true) or message:find('enoent',1,true)) then return true end return false end local function prepare_path(paths,maximum,kind) while #paths>=maximum do if not remove_or_missing(paths[1]) then local now=os.time() if now-(last_handoff_warning[kind] or 0)>=30 then bridge_chat((kind=='ws' and 'Weapon-skill' or 'Engaged-state')..' gear handoff skipped: retained file cleanup failed') last_handoff_warning[kind]=now end return false end table.remove(paths,1) end return true end local function prepare_ws_path() return prepare_path(recent_ws_paths,MAX_PENDING_WS_FILES,'ws') end local function prepare_state_path() return prepare_path(recent_state_paths,MAX_PENDING_STATE_FILES,'state') end local function remember_ws_path(path) recent_ws_paths[#recent_ws_paths+1]=path end local function remember_state_path(path) recent_state_paths[#recent_state_paths+1]=path end local function clean_string(value, limit) if type(value)~='string' or value=='' or #value>limit or value:find('[%z\1-\31\127]') then return nil end return value end local function resources_table() return (gearswap and gearswap.res) or res end local function build_item_names() if item_names then return item_names end item_names={} for id,item in pairs((resources_table() or {}).items or {}) do local name=item.en or item.english if type(id)=='number' and clean_string(name,100) then item_names[name:lower()]=id end end return item_names end local function copy_augments(values) if type(values)~='table' or #values>10 then return nil end local result={} for index=1,#values do local augment=clean_string(rawget(values,index),160) if not augment then return nil end result[#result+1]=augment end return result end local function contains_all_augments(values, expected) local present={} for _,value in ipairs(values) do present[value]=(present[value] or 0)+1 end for _,value in ipairs(expected) do if not present[value] or present[value]==0 then return false end present[value]=present[value]-1 end return true end local function decode_augments(item) local decoder=gearswap and gearswap.extdata if not decoder or type(decoder.decode)~='function' then return nil end local decoded_ok,result=pcall(decoder.decode,item) if not decoded_ok or type(result)~='table' or type(result.augments)~='table' then return nil end local augments={} local has_rank=false for _,augment in ipairs(result.augments) do if augment~='none' then augment=clean_string(augment,160) if not augment or #augments>=10 then return nil end augments[#augments+1]=augment if augment:match('^Rank:%s*%d+$') then has_rank=true end end end local rank=tonumber(result.rank) if not has_rank and rank and rank>=0 and rank<=255 and rank==math.floor(rank) then if #augments>=10 then return nil end augments[#augments+1]='Rank: '..tostring(rank) end return augments end local function same_augments(left,right) if #left~=#right then return false end for index=1,#left do if left[index]~=right[index] then return false end end return true end local function inventory_augment_identities(item_id) local cached=augment_cache[item_id] if cached~=nil then return cached end if not windower.ffxi or type(windower.ffxi.get_items)~='function' then return nil end local identities={} local function remember(item) if type(item)~='table' or tonumber(item.id)~=item_id then return end local augments=decode_augments(item) if not augments then return end for _,known in ipairs(identities) do if same_augments(known,augments) then return end end identities[#identities+1]=augments end -- Include equipped and stored copies, then resolve only when every -- compatible copy has the same full augment identity. local equipment_ok,equipment=pcall(windower.ffxi.get_items,'equipment') if not equipment_ok or type(equipment)~='table' then equipment=nil end for _,slot in ipairs(SLOT_ORDER) do local index=tonumber(equipment and equipment[slot]) local bag=tonumber(equipment and equipment[slot..'_bag']) if index and index>0 and bag then local ok,item=pcall(windower.ffxi.get_items,bag,index) if ok then remember(item) end end end for bag=0,16 do local ok,items=pcall(windower.ffxi.get_items,bag) if ok and type(items)=='table' then local limit=tonumber(items.max_count) or #items for index=1,limit do remember(items[index]) end end end augment_cache[item_id]=identities return identities end local function find_inventory_augments(item_id,expected) local identities=inventory_augment_identities(item_id) if not identities then return nil end local matched for _,augments in ipairs(identities) do if not expected or contains_all_augments(augments,expected) then if matched then return nil end matched=augments end end return matched end local function normalize_spec(slot, value, equipped) if value==nil or value=='empty' or value=='displaced' then return false end local name,augments,identity_complete if type(value)=='string' then name=value; augments={}; identity_complete=false elseif type(value)=='table' then name=rawget(value,'name') if name=='empty' or name=='displaced' then return false end local raw_augments=rawget(value,'augments') if raw_augments==nil then augments={}; identity_complete=false else augments=copy_augments(raw_augments); identity_complete=augments~=nil end else return nil end name=clean_string(name,100) local known local equipped_id=type(equipped)=='table' and tonumber(equipped.item_id) or nil local equipped_resource=equipped_id and ((resources_table() or {}).items or {})[equipped_id] or nil local equipped_name=equipped_resource and (equipped_resource.en or equipped_resource.english) if name and type(equipped_name)=='string' and equipped_name:lower()==name:lower() then -- Several upgrade-stage items share one resource name. A name-only -- GearSwap intent cannot distinguish them, but the item now equipped in -- this slot can, so retain its exact resource ID. known=equipped_id else known=name and build_item_names()[name:lower()] or nil end if not known or not augments then return nil end local equipped_augments=known==equipped_id and equipped.augments_decoded and type(equipped.augments)=='table' and equipped.augments or nil local compatible_equipped=equipped_augments and (not identity_complete or (#augments>0 and contains_all_augments(equipped_augments,augments))) if compatible_equipped then -- GearSwap set declarations commonly include only the augment path. The -- equipped instance contains the complete extdata (including RP rank), so -- prefer that authoritative evidence over a partial intent declaration. augments=equipped_augments identity_complete=true elseif not identity_complete or #augments>0 then -- GearSwap may run this bridge before the equipment packet reflects the -- equip() call. Complete an explicit partial declaration from the matching -- inventory instance instead of recording path-only and path-plus-rank as -- separate loadouts. local inventory_augments=find_inventory_augments(known,#augments>0 and augments or nil) if inventory_augments then augments=inventory_augments; identity_complete=true end end local resource_items=(resources_table() or {}).items or {} local canonical=resource_items[known] return {slot=slot,item_id=known,name=canonical and (canonical.en or canonical.english) or name,augments=augments, augments_decoded=identity_complete} end local function decode_equipped(slot, equipment) local index=tonumber(equipment and equipment[slot]) local bag=tonumber(equipment and equipment[slot..'_bag']) if index==0 then return false end if not index or not bag or not windower.ffxi or type(windower.ffxi.get_items)~='function' then return nil end local ok,item=pcall(windower.ffxi.get_items,bag,index) local item_id=ok and type(item)=='table' and tonumber(item.id) or nil local known=item_id and (resources_table() or {}).items and (resources_table() or {}).items[item_id] or nil if not item_id or item_id<=0 or not known then return nil end local augments=decode_augments(item) local decoded=augments~=nil augments=augments or {} return {slot=slot,item_id=item_id,name=clean_string(known.en or known.english,100), augments=augments,augments_decoded=decoded} end local function read_equipment() if not windower.ffxi or type(windower.ffxi.get_items)~='function' then return nil end local ok,equipment=pcall(windower.ffxi.get_items,'equipment') if ok and type(equipment)=='table' then return equipment end return nil end local function fallback_equipped(slot) local value=player and player.equipment and player.equipment[slot] return normalize_spec(slot,value) end local function append_warning(warnings,value) if #warnings<16 then warnings[#warnings+1]=value end end local function is_weaponskill(spell) if type(spell)~='table' then return false end local spell_type=tostring(spell.type or ''):lower() local prefix=tostring(spell.prefix or ''):lower() return spell_type=='weaponskill' or prefix=='/ws' or prefix=='/weaponskill' end local function build_action_payload(spell,event_name,frozen_intent) if not is_weaponskill(spell) then return nil end local action_id=tonumber(spell.id) local target_id=tonumber(spell.target and spell.target.id) local current_player=windower.ffxi and windower.ffxi.get_player and windower.ffxi.get_player() or nil local resources=resources_table() local job_id=tonumber(current_player and current_player.main_job_id) local job=job_id and resources and resources.jobs and resources.jobs[job_id] or nil local job_code=clean_string(job and (job.ens or job.en_short),3) local job_name=clean_string(job and (job.en or job.english),40) if not action_id or not target_id or not current_player or not safe_component(current_player.name) or not job_id or not job_code or not job_name then return nil end local equipment=read_equipment() local equip_list=frozen_intent or (gearswap and gearswap.equip_list) or {} local baseline_by_slot={} for _,slot in ipairs(SLOT_ORDER) do local item=decode_equipped(slot,equipment) if item==nil then item=fallback_equipped(slot) end baseline_by_slot[slot]=item end local result={schema_version=1,character_name=current_player.name,action_id=action_id,target_id=target_id, snapshot={schema_version=1,gear_role='weaponskill',job={id=job_id,code=job_code,name=job_name},complete=true,slots={},empty_slots={},unresolved_slots={},intent_slots={},warnings={}}} for _,slot in ipairs(SLOT_ORDER) do local intended=equip_list and rawget(equip_list,slot) local item if intended~=nil then result.snapshot.intent_slots[#result.snapshot.intent_slots+1]=slot item=normalize_spec(slot,intended,baseline_by_slot[slot]) else item=baseline_by_slot[slot] end if item==false then result.snapshot.empty_slots[#result.snapshot.empty_slots+1]=slot elseif item then result.snapshot.slots[#result.snapshot.slots+1]=item else result.snapshot.complete=false result.snapshot.unresolved_slots[#result.snapshot.unresolved_slots+1]=slot append_warning(result.snapshot.warnings,'equipment_item_unresolved:'..slot) end end return result end bridge._build_ws_payload=build_action_payload local function build_state_payload(capture_timing,frozen_intent) local current_player=windower.ffxi and windower.ffxi.get_player and windower.ffxi.get_player() or nil local status=clean_string(player and player.status,20) if not current_player or not safe_component(current_player.name) or tostring(status or ''):lower()~='engaged' then return nil end local resources=resources_table() local job_id=tonumber(current_player.main_job_id) local job=job_id and resources and resources.jobs and resources.jobs[job_id] or nil local job_code=clean_string(job and (job.ens or job.en_short),3) local job_name=clean_string(job and (job.en or job.english),40) if not job_id or not job_code or not job_name then return nil end local equipment=read_equipment() local equip_list=frozen_intent or (gearswap and gearswap.equip_list) or {} local baseline_by_slot={} for _,slot in ipairs(SLOT_ORDER) do local item=decode_equipped(slot,equipment) if item==nil then item=fallback_equipped(slot) end baseline_by_slot[slot]=item end local snapshot={schema_version=1,gear_role='engaged',capture_timing=capture_timing, player_status='Engaged',job={id=job_id,code=job_code,name=job_name},complete=true, slots={},empty_slots={},unresolved_slots={},intent_slots={},warnings={}} for _,slot in ipairs(SLOT_ORDER) do local intended=equip_list and rawget(equip_list,slot) local item if intended~=nil then snapshot.intent_slots[#snapshot.intent_slots+1]=slot item=normalize_spec(slot,intended,baseline_by_slot[slot]) else item=baseline_by_slot[slot] end if item==false then snapshot.empty_slots[#snapshot.empty_slots+1]=slot elseif item then snapshot.slots[#snapshot.slots+1]=item else snapshot.complete=false snapshot.unresolved_slots[#snapshot.unresolved_slots+1]=slot append_warning(snapshot.warnings,'equipment_item_unresolved:'..slot) end end return {schema_version=1,character_name=current_player.name,snapshot=snapshot} end bridge._build_state_payload=build_state_payload local function next_ws_token() ws_counter=(ws_counter%65535)+1 return string.format('WS%08X%04X',os.time()%4294967296,ws_counter) end local function next_state_token() ws_counter=(ws_counter%65535)+1 return string.format('GS%08X%04X',os.time()%4294967296,ws_counter) end local function write_handoff(payload,prefix,base,command,prepare,remember) local token=prefix=='WS' and next_ws_token() or next_state_token() payload.token=token local tmp,final=base..token..'.tmp',base..token..'.json' local encode_ok,encoded=pcall(json.encode,payload) if not encode_ok or #encoded>65536 then return false end if not prepare() then return false end local handle=io.open(tmp,'wb'); if not handle then return false end local wok=handle:write(encoded); local fok=wok and handle:flush(); local cok=handle:close() if not wok or not fok or not cok then pcall(os.remove,tmp); return false end if not os.rename(tmp,final) then pcall(os.remove,tmp); return false end remember(final) windower.send_command(command..token) return true end local function write_ws_handoff(payload) if type(payload)~='table' or not safe_component(payload.character_name) then return false end local base=ws_handoff_dir(payload.character_name); if not base then return false end return write_handoff(payload,'WS',base,'lua invoke CombatLog xilogs_ws_gear_handoff ',prepare_ws_path,remember_ws_path) end bridge._write_ws_handoff=write_ws_handoff local function write_state_handoff(payload) if type(payload)~='table' or not safe_component(payload.character_name) then return false end local base=state_handoff_dir(payload.character_name); if not base then return false end return write_handoff(payload,'GS',base,'lua invoke CombatLog xilogs_gear_state_handoff ',prepare_state_path,remember_state_path) end bridge._write_state_handoff=write_state_handoff function bridge._handoff_status() return {ws_retained=#recent_ws_paths,state_retained=#recent_state_paths} end local function queue_gear_handoff(payload) if type(payload)~='table' or type(payload.snapshot)~='table' then return end local role=payload.snapshot.gear_role for index=#pending_gear,1,-1 do local previous=pending_gear[index] if previous.action_id==payload.action_id and previous.target_id==payload.target_id and previous.snapshot and previous.snapshot.gear_role==role then table.remove(pending_gear,index) end end pending_gear[#pending_gear+1]=payload while #pending_gear>4 do table.remove(pending_gear,1) end end local function queue_state_handoff(payload) if type(payload)~='table' or type(payload.snapshot)~='table' or payload.snapshot.gear_role~='engaged' then return end for index=#pending_state,1,-1 do if pending_state[index].snapshot and pending_state[index].snapshot.capture_timing==payload.snapshot.capture_timing then table.remove(pending_state,index) end end pending_state[#pending_state+1]=payload while #pending_state>2 do table.remove(pending_state,1) end end local original_equip=equip local function copy_equip_intent() local result={} for _,slot in ipairs(SLOT_ORDER) do local value=gearswap and gearswap.equip_list and rawget(gearswap.equip_list,slot) if type(value)=='table' then local copied={}; for key,item in pairs(value) do if key=='augments' and type(item)=='table' then local a={}; for i,v in ipairs(item) do a[i]=v end; copied[key]=a else copied[key]=item end end result[slot]=copied elseif value~=nil then result[slot]=value end end return result end if type(original_equip)=='function' then equip=function(...) local results=pack_values(original_equip(...)) if _global and _global.current_event=='precast' and is_weaponskill(_global.spell) then local session=active_session() if session then dirty_action={spell=_global.spell,event_name='precast',session=session,intent=copy_equip_intent()} end elseif _global and (_global.current_event=='status_change' or _global.current_event=='aftercast') then local session=active_session() if session then dirty_state={timing=_global.current_event=='status_change' and 'gearswap_status_change' or 'gearswap_aftercast',session=session,intent=copy_equip_intent()} end end return unpack_values(results,1,results.n) end bridge.equip_wrapper=equip end if windower and type(windower.raw_register_event)=='function' then bridge.callback_id=windower.raw_register_event('prerender',function() session_cache={valid=false,value=nil} local current=(dirty_action or dirty_state) and active_session() or nil local dirty=dirty_action; dirty_action=nil if dirty and current and current.run_id==dirty.session.run_id then local ok,payload=pcall(build_action_payload,dirty.spell,dirty.event_name,dirty.intent) if ok and payload then payload.run_id=current.run_id; queue_gear_handoff(payload) end end if dirty_state then local dirty=dirty_state; dirty_state=nil if current and current.run_id==dirty.session.run_id then local ok,payload=pcall(build_state_payload,dirty.timing,dirty.intent) if ok and payload then payload.run_id=current.run_id; queue_state_handoff(payload) end end end session_cache={valid=false,value=nil} if #pending_gear==0 and #pending_state==0 then return end local payloads=pending_gear; pending_gear={} local state_payloads=pending_state; pending_state={} if not (_global and _global.cancel_spell) then for _,payload in ipairs(payloads) do pcall(write_ws_handoff,payload) end end for _,payload in ipairs(state_payloads) do pcall(write_state_handoff,payload) end end) end function bridge.cleanup() if bridge.equip_wrapper and equip~=bridge.equip_wrapper then return false end if bridge.callback_id and windower and type(windower.unregister_event)=='function' then pcall(windower.unregister_event,bridge.callback_id) end equip=original_equip if _G and _G.__xilogs_gearswap_bridge==bridge then _G.__xilogs_gearswap_bridge=nil end return true end -- A crashed/missing CombatLog consumer can leave handoffs behind. Cleanup is -- path-bounded and failure-contained so loading a GearSwap job never depends -- on directory or filesystem behavior. pcall(sweep_stale_ws_handoffs) pcall(sweep_stale_state_handoffs) if coroutine and type(coroutine.schedule)=='function' then -- Loading and equipment capture never depend on the network check. Even a -- scheduler, TLS, JSON, timeout, or chat failure remains contained here. local now=os.time() local key='__xilogs_gearswap_version_check_at' if not _G or now-(tonumber(_G[key]) or 0)>=86400 then if _G then _G[key]=now end local ok,scheduled=pcall(coroutine.schedule,safe_check_version,3) if (not ok or scheduled==false) and _G then _G[key]=nil end end end return bridge