using Ananta.SDK.Network; using Ananta.SDK.Serialization; using Ananta.Server.Protocol.Client4229938; namespace Ananta.Server.Handlers.Game; internal sealed partial class GameRouter { // Runs only after a validated EscortToWard request, BEFORE reaction 30001. // Never call DoBindUnit, ForceReset, teleport, or rebuild the director graph: // those would discard the working paired movement and native end callback. private static async Task PreparePolicePrisonAnimation4229938(TcpSession session, ulong owner, ulong npc, string token) { if (!session.Items.TryGetValue(PoliceGateSessionKey4229938, out var item) || item is not TcpSession gate || gate.ClosedToken.IsCancellationRequested) return; var endBytes = new UxWriter().U8(255).Int7(1).U8(4).U32(PoliceRootNid4229938) .UxString("PolicePrisonTimelineEnd").U8(42).UxString(token).I64(0).Bool(false).ToArray(); var script = PolicePrisonAnimationScript4229938 .Replace("__END_BYTES__", string.Join(',', endBytes)) .Replace("__OWNER_LOW__", ((uint)owner).ToString(System.Globalization.CultureInfo.InvariantCulture)) .Replace("__OWNER_HIGH__", (owner >> 32).ToString(System.Globalization.CultureInfo.InvariantCulture)) .Replace("__NPC_LOW__", ((uint)npc).ToString(System.Globalization.CultureInfo.InvariantCulture)) .Replace("__NPC_HIGH__", (npc >> 32).ToString(System.Globalization.CultureInfo.InvariantCulture)); try { await gate.NotifyAsync(MethodId.SyncDoToLuaMaster, new UxWriter().UxString(script).ToArray(), session.ClosedToken); session.Log.Info($"[LIXI-PRISON-ANIM] scoped actor binding recovery sent owner={owner} npc={npc}"); } catch (OperationCanceledException) when (session.ClosedToken.IsCancellationRequested) { throw; } catch (Exception ex) { session.Log.Warn($"[LIXI-PRISON-ANIM] install unavailable: {ex.GetType().Name}"); } } internal static string PolicePrisonAnimationScript4229938 => """ local ok, err = pcall(function() local prefix='[LIXI-PRISON-ANIM] ' local get,field,call,set=ReflectionGetProperty,ReflectionGetField,ReflectionCallMethod,ReflectionSetProperty if not get or not field or not call or not set or not Timer or not Timer.New then error('reflection/timer unavailable') end if _G.__drmkWardActors then _G.__drmkWardActors.stop('replaced') end local ownerId=ulong.new(__OWNER_LOW__,__OWNER_HIGH__) local npcId=ulong.new(__NPC_LOW__,__NPC_HIGH__) local owner=gCS.SceneDataMgr.GetUnit(ownerId) local npc=gCS.SceneDataMgr.GetUnit(npcId) if not owner or not npc then error('escort actors unavailable') end local mgr=L50.L50App.L50Game.CutsceneManager local names={'play_police_arrest_lf','play_police_arrest_lm','play_police_arrest_mf', 'play_police_arrest_mm','play_police_arrest_sf'} local T='LX6.TimelineScript.Timeline' local A='LX6.TimelineScript.IActor' local D='UnityEngine.Playables.PlayableDirector' local Tr='UnityEngine.Timeline.TrackAsset' local R='LX6.TimelineScript.TimelineDefine+TimelineTrackRef' -- Exists in C#, but is NOT exported by CutsceneManager's Lua wrapper. local function playing(name) return call('LX6.TimelineScript.CutsceneManager','GetPlayingTimelineByName',mgr,name) end local function count(list) return get('System.Collections.ICollection','Count',list) end local function item(list,index) return call('System.Collections.IList','get_Item',list,index) end local s={started=UnityEngine.Time.realtimeSinceStartup,stopped=false,undo={},seen={},reported={},skip={}} s.stop=function(reason) if s.stopped then return end s.stopped=true if s.timer then pcall(function() s.timer:Stop() end) end -- Restore only values this request actually changed, including shared track mute. for i=#s.undo,1,-1 do pcall(s.undo[i]) end if _G.__drmkWardActors==s then _G.__drmkWardActors=nil end print_error_without_stack(prefix..'cleanup '..tostring(reason)) end for _,name in ipairs(names) do local existing=playing(name) if existing then s.skip[existing]=true end end local function repair(tl) local actors=field(T,'iActors',tl) local director=field(T,'director',tl) local asset=field(T,'timelineAsset',tl) if not actors or not director or not asset then return end for i=0,count(actors)-1 do local actor=item(actors,i) local name=tostring(field(A,'actorName',actor) or '') local real=field(A,'realUnit',actor) local officer=real==owner or (not real and name:find('main_A104003_gm_actor',1,true)==1) local prisoner=real==npc or (not real and name:find('fwnpc_A401058_p021_gm_actor',1,true)==1) if officer or prisoner then -- Never repair an actor explicitly attached to someone else. if not real or real==(officer and owner or npc) then local animator=field(A,'animAnimator',actor) local refs=field(A,'refTracks',actor) local state=get(A,'loadState_Anim',actor) local report=tostring(state)..':'..tostring(animator~=nil) if s.reported[actor]~=report then s.reported[actor]=report print_error_without_stack(prefix..'actor='..name..' animLoad='..tostring(state) ..' animator='..tostring(animator~=nil)..' streaming='..tostring(field(A,'streamingAnim',actor))) end -- Do not re-enter LoadAnim: its native Loaded branch increments a -- completion counter. Repeated loading would corrupt the lifecycle. if animator and refs and (state==2 or tostring(state)=='Loaded') then for j=0,count(refs)-1 do local ref=item(refs,j) local kind=field(R,'TrackType',ref) local index=field(R,'TrackIndex',ref) -- Skeleton only. RootAnimation, RootMotion, camera and door -- tracks are intentionally excluded, as are rejected body variants. if (kind==1 or tostring(kind)=='Animation') and field(R,'canUse',ref)==true then local track=call('UnityEngine.Timeline.TimelineAsset','GetOutputTrack',asset,index) if track and not s.seen[track] then local ty=call('System.Object','GetType',track) if get('System.Type','FullName',ty)=='UnityEngine.Timeline.AnimationTrack' then local binding=call(D,'GetGenericBinding',director,track) local muted=get(Tr,'muted',track) -- Loaded is a lifecycle state, not proof every clip was -- resolved. Log payloads without restarting native loading. local inspected,details=pcall(function() local clips=field(Tr,'m_Clips',track) local total,ready,split=0,0,0 if clips then total=count(clips) for k=0,total-1 do local clipAsset=get('UnityEngine.Timeline.TimelineClip','asset',item(clips,k)) if clipAsset then local clipType=call('System.Object','GetType',clipAsset) if get('System.Type','FullName',clipType)=='UnityEngine.Timeline.AnimationPlayableAsset' then if field('UnityEngine.Timeline.AnimationPlayableAsset','m_Clip',clipAsset) then ready=ready+1 end if field('UnityEngine.Timeline.AnimationPlayableAsset','m_Split',clipAsset) then split=split+1 end end end end end return ' clips='..total..' directClips='..ready..' splitClips='..split end) print_error_without_stack(prefix..'track actor='..name..' index='..tostring(index) ..' binding='..tostring(binding~=nil)..' muted='..tostring(muted) ..(inspected and details or ' clips=unknown')) if binding==nil then call(D,'SetGenericBinding',director,track,animator) table.insert(s.undo,function() if call(D,'GetGenericBinding',director,track)==animator then call(D,'ClearGenericBinding',director,track) end end) end if muted then -- TrackUtils.SetMute also changes child tracks. Only -- this body's eligible track may be changed here. set(Tr,'muted',track,false) table.insert(s.undo,function() if get(Tr,'muted',track)==false then set(Tr,'muted',track,true) end end) end if binding==nil or muted then -- Keeps timeline time and root movement; unlike RebuildGraph. call(D,'RebindPlayableGraphOutputs',director) print_error_without_stack(prefix..'recovered actor='..name..' track='..tostring(index) ..' missingBinding='..tostring(binding==nil)..' muted='..tostring(muted)) end s.seen[track]=true end end end end end end end end end s.tick=function() if s.stopped then return end local good,why=pcall(function() if UnityEngine.Time.realtimeSinceStartup-s.started>60 or gCS.SceneDataMgr.GetUnit(ownerId)~=owner or gCS.SceneDataMgr.GetUnit(npcId)~=npc then s.stop('timeout/actors changed');return end local active=nil for _,name in ipairs(names) do local tl=playing(name) if tl and not s.skip[tl] then active=tl;break end end if s.timeline and active~=s.timeline then -- InteractEnd is sent at the beginning of the cutscene in this -- client. Wait for the actual timeline to disappear and allow -- its native unit restore/OnTimelineEnd to finish first. s.endedAt=s.endedAt or UnityEngine.Time.realtimeSinceStartup if UnityEngine.Time.realtimeSinceStartup-s.endedAt<0.3 then return end s.stop('timeline ended') local bytes={__END_BYTES__} gClientToGameSceneDelegate:Notify(67130745,function(writer) for i=1,#bytes do writer:WriteByte(bytes[i]) end end) return end if active then s.endedAt=nil;s.timeline=active -- A skeleton diagnosis failure must not lose the independent -- timeline-end handshake and strand custody indefinitely. if not s.repairFailed then local repaired,reason=pcall(repair,active) if not repaired then s.repairFailed=true print_error(prefix..'skeletal repair failed: '..tostring(reason)) end end end end) if not good then s.stop('error');print_error(prefix..tostring(why)) end end _G.__drmkWardActors=s s.timer=Timer.New(s.tick,0.1,-1,true) s.timer:Start() print_error_without_stack(prefix..'armed for native paired ward timeline') end) if not ok then if _G.__drmkWardActors then _G.__drmkWardActors.stop('install failed') end print_error('[LIXI-PRISON-ANIM] install failed: '..tostring(err)) end """; }