-- Push current Client battle-statistic snapshots to the configured GaC SDK. -- Reads one known BattleModule property; no assembly or method enumeration. -- Keep the corrected reader isolated from earlier callbacks that may still be -- registered in the Client until its Lua event handlers are refreshed. -- Older callbacks may survive a script refresh in the same Client session. if _G.__GAC_DAMAGE_ANALYST_LIVE_V4 then _G.__GAC_DAMAGE_ANALYST_LIVE_V4.nextSample = math.huge end if _G.__GAC_DAMAGE_ANALYST_LIVE_V5 then _G.__GAC_DAMAGE_ANALYST_LIVE_V5.nextSample = math.huge end if _G.__GAC_DAMAGE_ANALYST_LIVE_V6 then _G.__GAC_DAMAGE_ANALYST_LIVE_V6.nextSample = math.huge end local state = _G.__GAC_DAMAGE_ANALYST_LIVE_V7 if not state then state = {registered=false, inBattle=false, nextSample=0, pending=nil, queuedBody=nil} _G.__GAC_DAMAGE_ANALYST_LIVE_V7 = state end local Time = CS.UnityEngine.Time local Camera = CS.UnityEngine.Camera if state.sourceRevision ~= 2 then state.battleWorld = nil state.sourceRevision = 2 end if state.detailsRevision ~= 3 then state.lastFinalSignature = nil state.detailsRevision = 3 end local function logOnce(key, message) if state[key] ~= message then state[key] = message CS.UnityEngine.Debug.Log("[GaC Damage Analyst] " .. tostring(message)) end end local function isBattleActive() local ok, active = pcall(function() if not state.phaseField then local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60) state.phaseField = typeof(CS.RPG.Client.GamePhaseManager):GetField("_CurrentPhase", flags) end local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager local phase = manager and state.phaseField:GetValue(manager) return phase ~= nil and typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) end) if not ok then logOnce("phaseError", active) return false end return active end local function readKnownField(object, name) if object == nil then return nil end if type(object) == "table" then return object[name] end if not state.fieldCache then state.fieldCache = {} end local typeName = tostring(object:GetType().FullName) local key = typeName .. ":" .. name local field = state.fieldCache[key] if field == nil then local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60) field = object:GetType():GetField(name, flags) state.fieldCache[key] = field or false end if field == false then return nil end return field:GetValue(object) end local function breakdownJSON(character) local function dictionary(name) local value = readKnownField(character, name) return value and CS.Newtonsoft.Json.JsonConvert.SerializeObject(value) or '{}' end return '{"attack_type_damage":' .. dictionary("FNFFCPLHPGI") .. ',"attack_type_break_damage":' .. dictionary("HNJNDLEFMBE") .. ',"attack_type_max_damage":' .. dictionary("KEOOJBHFLFD") .. '}' end local function activeStatistics() local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager local phase = state.phaseField:GetValue(manager) local instance = phase.BattleInstanceRef if instance == nil then return nil end local world = readKnownField(instance, "_GameWorld") if world ~= state.battleWorld then state.battleWorld = world state.statComponent = nil state.componentArray = nil state.componentIndex = 0 state.entityIndex = 0 state.battleInfoBase64 = nil local info = CS.RPG.Client.GlobalVars.s_ModuleManager.BattleModule:GetBattleInfo() if info ~= nil then state.battleInfoBase64 = CS.System.Convert.ToBase64String(CS.Google.Protobuf.MessageExtensions.ToByteArray(info)) end end if not state.statComponent then if not state.componentArray then local entities = readKnownField(world, "_EntityList") for i=state.entityIndex,math.min(state.entityIndex+15,entities.Count-1) do local entity = entities[i] state.entityIndex = i+1 if entity and (entity.RuntimeID == CS.RPG.GameCore.GameWorld.CommonLevelEntityRuntimeID or entity.RuntimeID == CS.RPG.GameCore.GameWorld.BattleLevelEntityRuntimeID) then state.componentArray = readKnownField(entity, "_ComponentArray") state.componentIndex = 0 break end end if not state.componentArray then return nil end end for i=state.componentIndex,math.min(state.componentIndex+31,state.componentArray.Length-1) do state.componentIndex = i+1 local component = state.componentArray[i] if component and typeof(CS.RPG.GameCore.StatisticsComponent):IsInstanceOfType(component) then state.statComponent = component break end end if not state.statComponent then if state.componentIndex >= state.componentArray.Length then state.componentArray = nil end return nil end end local component = state.statComponent local characters = readKnownField(component, "_LightTeamStt") if characters == nil then return nil end local avatars, breakdowns = {}, {} for i=0,math.min(characters.Length-1,15) do local character = characters[i] if character ~= nil then avatars[#avatars+1] = character.avatarBattleInfo -- Native dictionaries are updated during battle; their protobuf -- lists are populated only by the final snapshot builder. breakdowns[#breakdowns+1] = breakdownJSON(character) end end local servants, servantBreakdowns = {}, {} local servantStats = readKnownField(component, "_ServantStt") if servantStats ~= nil then local iterator = servantStats:GetEnumerator() local scanned = 0 while scanned < 64 and iterator:MoveNext() do scanned = scanned + 1 local character = iterator.Current.Value local info = readKnownField(character, "LJLDPJFOKCE") if info ~= nil then servants[#servants+1] = info servantBreakdowns[#servantBreakdowns+1] = breakdownJSON(character) end end end return { servantBattleInfoList=servants, servantBreakdownJSON='[' .. table.concat(servantBreakdowns, ',') .. ']', lightTeamBattleInfoList=avatars, breakdownJSON='[' .. table.concat(breakdowns, ',') .. ']', totalBattleTurns=readKnownField(component,"_totalBattleTurns"), totalAutoTurns=readKnownField(component,"_totalAutoTurns"), totalBattleRounds=readKnownField(component,"_totalBattleRounds"), costTime=readKnownField(component,"_lastSimulateTime") } end local function jsonNumber(value) local number = tonumber(value) or 0 if number ~= number or number == math.huge or number == -math.huge then return "0" end return string.format("%.6f", number) end local function jsonInteger(value) local number = tonumber(value) or 0 if number ~= number or number == math.huge or number == -math.huge then return "0" end return string.format("%.0f", number) end local function buildSnapshot(statistics) local avatars = readKnownField(statistics, "lightTeamBattleInfoList") local luaAvatars = type(avatars) == "table" local avatarCount = avatars and (luaAvatars and #avatars or avatars.Count) or 0 if avatarCount <= 0 then -- Keep compatibility with protocol BattleStatistics if the Client -- changes the type exposed by LastBattleStatistic in a later build. avatars = statistics.BattleAvatarList avatarCount = avatars and avatars.Count or 0 if avatarCount <= 0 then return nil end local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(statistics) local byteCount = type(bytes) == "string" and #bytes or bytes.Length if byteCount > 2*1024*1024 then error("live statistics exceed the upload limit") end local encoded = CS.System.Convert.ToBase64String(bytes) return { signature=encoded, body='{"active":true,"statistics_base64":"' .. encoded .. '"}' } end local encodedAvatars = {} local signatureParts = {} local totalBytes = 0 for i=0,math.min(avatarCount-1,15) do local avatar = avatars[luaAvatars and i+1 or i] if avatar ~= nil then -- AvatarBattleInfo is a protobuf message even though its containing -- PVEGameStatistics object is a Client-only type and cannot be serialized. local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(avatar) local byteCount = type(bytes) == "string" and #bytes or bytes.Length totalBytes = totalBytes + byteCount if totalBytes > 2*1024*1024 then error("live avatar statistics exceed the upload limit") end local encoded = CS.System.Convert.ToBase64String(bytes) encodedAvatars[#encodedAvatars+1] = '"' .. encoded .. '"' signatureParts[#signatureParts+1] = encoded end end if #encodedAvatars == 0 then return nil end local servants = readKnownField(statistics, "servantBattleInfoList") local luaServants = type(servants) == "table" local servantCount = servants and (luaServants and #servants or servants.Count) or 0 if servantCount > 64 then error("too many native servant statistics") end local encodedServants = {} for i=0,servantCount-1 do local info = servants[luaServants and i+1 or i] if info ~= nil then local bytes = CS.Google.Protobuf.MessageExtensions.ToByteArray(info) totalBytes = totalBytes + (type(bytes) == "string" and #bytes or bytes.Length) if totalBytes > 2*1024*1024 then error("native statistics exceed the upload limit") end local encoded = CS.System.Convert.ToBase64String(bytes) encodedServants[#encodedServants+1] = '"' .. encoded .. '"' signatureParts[#signatureParts+1] = encoded end end local totalBattleTurns = readKnownField(statistics, "totalBattleTurns") or statistics.TotalBattleTurns or 0 local totalAutoTurns = readKnownField(statistics, "totalAutoTurns") or statistics.TotalAutoTurns or 0 local roundCount = readKnownField(statistics, "totalBattleRounds") or statistics.RoundCnt or 0 local costTime = readKnownField(statistics, "costTime") or statistics.CostTime or 0 local breakdown = type(statistics) == "table" and statistics.breakdownJSON or nil local servantBreakdown = type(statistics) == "table" and statistics.servantBreakdownJSON or nil local signature = table.concat(signatureParts, "|") .. (breakdown or "") .. (servantBreakdown or "") .. table.concat({ tostring(totalBattleTurns), tostring(totalAutoTurns), tostring(roundCount), tostring(costTime) }, ":") local body = '{"active":true,"avatar_statistics_base64":[' .. table.concat(encodedAvatars, ",") .. '],"total_battle_turns":' .. jsonInteger(totalBattleTurns) .. ',"total_auto_turns":' .. jsonInteger(totalAutoTurns) .. ',"round_cnt":' .. jsonInteger(roundCount) .. ',"cost_time":' .. jsonNumber(costTime) .. (breakdown and ',"avatar_breakdowns":' .. breakdown or '') .. ',"servant_statistics_base64":[' .. table.concat(encodedServants, ',') .. ']' .. (servantBreakdown and ',"servant_breakdowns":' .. servantBreakdown or '') .. (state.battleInfoBase64 and ',"battle_info_base64":"' .. state.battleInfoBase64 .. '"' or '') .. '}' return {signature=signature, body=body} end local function sendBody(body, finalSignature) if state.pending then state.queuedBody = body state.queuedFinalSignature = finalSignature return end local ok, err = pcall(function() local url = _G.__GAC_SRTOOL_LIVE_URL if not url or url == "" then error("SDK URL is not configured") end -- Some client Lua bindings do not expose UnityWebRequest.Post(string, string). -- Build the request explicitly so the overload resolution is unambiguous. local request = CS.UnityEngine.Networking.UnityWebRequest(url .. "/api/battle_report/live", "POST") -- XLua marshals a Lua string directly to byte[]. This body contains -- ASCII JSON keys/numbers and base64, so it is already UTF-8 bytes. request.uploadHandler = CS.UnityEngine.Networking.UploadHandlerRaw(body) request.downloadHandler = CS.UnityEngine.Networking.DownloadHandlerBuffer() request:SetRequestHeader("Content-Type", "application/json") state.pending = {request=request, operation=request:SendWebRequest(), finalSignature=finalSignature} end) if not ok then logOnce("sendError", err) end end local function finishPendingRequest() if not state.pending then return end local ok, done = pcall(function() return state.pending.operation.isDone end) if not ok or not done then return end local pending = state.pending state.pending = nil pcall(function() local code = pending.request.responseCode if code < 200 or code >= 300 then logOnce("httpError", "SDK live endpoint returned HTTP " .. tostring(code)) else state.httpError = nil if pending.finalSignature then state.lastFinalSignature = pending.finalSignature end end pending.request:Dispose() end) if state.queuedBody then local body = state.queuedBody local finalSignature = state.queuedFinalSignature state.queuedBody = nil state.queuedFinalSignature = nil sendBody(body, finalSignature) end end local function sample() if Time.frameCount == state.lastFrame then return end state.lastFrame = Time.frameCount local now = tonumber(Time.unscaledTime) if not now and os and os.clock then now = os.clock() end if not now then return end local nextSample = tonumber(state.nextSample) or 0 if now < nextSample then return end state.nextSample = now + 0.75 finishPendingRequest() if isBattleActive() then local ok, result = pcall(function() local statistics = activeStatistics() if statistics == nil then return nil end return {statistics=statistics, snapshot=buildSnapshot(statistics)} end) if not ok then logOnce("statisticsError", result) return end if result == nil then return end state.statisticsError = nil if result.snapshot == nil then logOnce("snapshotError", "Client battle statistics have no avatar snapshots") return end state.snapshotError = nil if not state.inBattle then -- Send the first sample immediately to replace any prior report. state.inBattle = true state.lastSignature = nil state.nextHeartbeat = 0 end if result.snapshot.signature == state.lastSignature and now < state.nextHeartbeat then return end state.lastSignature = result.snapshot.signature state.nextHeartbeat = now + 2.5 sendBody(result.snapshot.body) else state.inBattle = false state.lastSignature = nil state.nextHeartbeat = 0 -- The network result intentionally omits rich per-avatar counters. -- Upload the completed native snapshot; SDK matches counters/team -- against the received result before preserving these details. -- Module access can temporarily be unavailable during login/logout. local ready, statistics = pcall(function() local manager = CS.RPG.Client.GlobalVars.s_ModuleManager local module = manager and manager.BattleModule return module and module.LastBattleStatistic end) if not ready then return end if statistics ~= nil then local snapshot = buildSnapshot(statistics) if snapshot and snapshot.signature ~= state.lastFinalSignature then local body = snapshot.body:gsub('"active":true', '"active":false,"final":true', 1) sendBody(body, snapshot.signature) end end end end local function runSampleSafely() local ok, err = pcall(sample) if not ok then logOnce("sampleError", err) end end state.sample = runSampleSafely -- Also sample on heartbeat, so a background window or a render pipeline that -- suppresses a camera event cannot stop delivery altogether. runSampleSafely() local function addHook(target, eventName, callback) local ok = pcall(function() target[eventName]("+", callback) end) if ok then return true end return pcall(function() target[eventName] = target[eventName] + callback end) end if not state.registered then local function tick() if state.sample then state.sample() end end local cameraHooked = addHook(Camera, "onPostRender", tick) local applicationHooked = addHook(CS.UnityEngine.Application, "onBeforeRender", tick) state.registered = cameraHooked or applicationHooked if not state.registered then logOnce("hookError", "Could not register a Unity render callback; will retry") else state.hookError = nil logOnce("hookReady", "Live battle sampling callback registered") end end