package sdkserver import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "net/http" "os" "path/filepath" "sync" "time" ) const clientUnstuckRequestFile = "scripts/client_unstuck_request.lua" type clientUnstuckStatus struct { RequestID string `json:"request_id"` State string `json:"state"` Error string `json:"error,omitempty"` QueuedAt int64 `json:"queued_at"` CompletedAt int64 `json:"completed_at,omitempty"` } var clientUnstuckState struct { sync.Mutex status clientUnstuckStatus } func queueClientUnstuck() (clientUnstuckStatus, error) { clientUnstuckState.Lock() defer clientUnstuckState.Unlock() var nonce [16]byte if _, err := rand.Read(nonce[:]); err != nil { return clientUnstuckStatus{}, err } id := hex.EncodeToString(nonce[:]) // Only a generated hexadecimal nonce is interpolated. No user-supplied // command, path, endpoint or Lua source can enter this heartbeat script. script := fmt.Sprintf(clientUnstuckLua, id) if err := os.MkdirAll(filepath.Dir(clientUnstuckRequestFile), 0700); err != nil { return clientUnstuckStatus{}, err } file, err := os.CreateTemp(filepath.Dir(clientUnstuckRequestFile), ".client-unstuck-*.tmp") if err != nil { return clientUnstuckStatus{}, err } defer os.Remove(file.Name()) if _, err = file.WriteString(script); err != nil { file.Close() return clientUnstuckStatus{}, err } if err = file.Close(); err != nil { return clientUnstuckStatus{}, err } if err = os.Rename(file.Name(), clientUnstuckRequestFile); err != nil { return clientUnstuckStatus{}, err } status := clientUnstuckStatus{RequestID: id, State: "queued", QueuedAt: time.Now().UnixMilli()} clientUnstuckState.status = status Log("[UNSTUCK] UI recovery queued request=" + id) return status, nil } func apiClientUnstuck(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: clientUnstuckState.Lock() status := clientUnstuckState.status clientUnstuckState.Unlock() writeJSON(w, status) case http.MethodPost: status, err := queueClientUnstuck() if err != nil { http.Error(w, "cannot queue UI recovery", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusAccepted) writeJSON(w, status) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func apiClientUnstuckResult(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var result struct { RequestID string `json:"request_id"` Success bool `json:"success"` Error string `json:"error"` } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)) decoder.DisallowUnknownFields() if err := decoder.Decode(&result); err != nil { http.Error(w, "invalid recovery result", http.StatusBadRequest) return } clientUnstuckState.Lock() defer clientUnstuckState.Unlock() if result.RequestID == "" || result.RequestID != clientUnstuckState.status.RequestID { http.Error(w, "recovery request is no longer current", http.StatusConflict) return } clientUnstuckState.status.State = "failed" if result.Success { clientUnstuckState.status.State = "completed" } clientUnstuckState.status.Error = result.Error clientUnstuckState.status.CompletedAt = time.Now().UnixMilli() // Queueing and acknowledgement share the lock, so removing this owned // request cannot delete a newer recovery. A leftover script still checks // the live nonce before touching UI, including after an SDK restart. if err := os.Remove(clientUnstuckRequestFile); err != nil && !os.IsNotExist(err) { Log("[UNSTUCK] cannot remove completed request: " + err.Error()) } Log("[UNSTUCK] UI recovery " + clientUnstuckState.status.State + " request=" + result.RequestID) writeJSON(w, clientUnstuckState.status) } // Named UIManager API observed on the current Client. Clear only network UI // waits: never close a Battle, change a scene, fabricate a successful gameplay // response, erase a run, or reset the Client's loading/phase flags. const clientUnstuckLua = `do local id = %q local function tick() local url=_G.__GAC_SRTOOL_LIVE_URL if type(url)~='string' or url=='' then return end local s=_G.__GAC_UNSTUCK_WORK if not s or s.id~=id then if s and s.request then pcall(function()s.request:Dispose()end) end s={id=id,phase='check',next=0};_G.__GAC_UNSTUCK_WORK=s end if s.phase=='done' or s.phase=='stale' then return end local now=CS.UnityEngine.Time.unscaledTime if s.request then if not s.request.isDone then return end local request=s.request;s.request=nil local ok,err=pcall(function() local code=request.responseCode if code<200 or code>=300 then error('recovery HTTP '..tostring(code)) end if s.phase=='check' then local status=CS.Newtonsoft.Json.Linq.JObject.Parse(request.downloadHandler.text) local request_id=status:get_Item('request_id') local phase=status:get_Item('state') if not request_id or tostring(request_id)~=id or not phase or tostring(phase)~='queued' then s.phase='stale';return end -- Only the current queued SDK request authorizes a UI recovery. Old -- generated files are inert after reconnect or SDK process restart. local success,message=pcall(function() local ui=CS.RPG.Client.GlobalVars.s_UIManager if not ui then error('Client UIManager is unavailable') end ui:ClearWaitNetPacketFullScreenBlock() end) s.body='{"request_id":"'..id..'","success":'..tostring(success)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(message or ''))..'}' s.phase='ack' CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(success)..' '..tostring(message or '')) else s.phase='done';_G.__GAC_UNSTUCK_LAST=id end end) pcall(function()request:Dispose()end) if not ok then s.next=now+5 if s.error~=tostring(err) then CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] '..tostring(err));s.error=tostring(err) end return end end if s.phase=='done' or s.phase=='stale' or now