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() 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 if _G.__GAC_UNSTUCK_HTTP and _G.__GAC_UNSTUCK_HTTP.isDone then pcall(function() _G.__GAC_UNSTUCK_HTTP:Dispose() end); _G.__GAC_UNSTUCK_HTTP=nil end if _G.__GAC_UNSTUCK_LAST ~= id then _G.__GAC_UNSTUCK_LAST=id local ok,err=pcall(function() local ui=CS.RPG.Client.GlobalVars.s_UIManager if not ui then error('Client UIManager is unavailable') end ui:ClearWaitNetPacketFullScreenBlock() end) CS.UnityEngine.Debug.Log('[GAC_UNSTUCK] request='..id..' success='..tostring(ok)..' '..tostring(err or '')) pcall(function() local url=_G.__GAC_SRTOOL_LIVE_URL if type(url)~='string' or url=='' then return end local body='{"request_id":"'..id..'","success":'..tostring(ok)..',"error":'..CS.Newtonsoft.Json.JsonConvert.SerializeObject(tostring(err or ''))..'}' local req=CS.UnityEngine.Networking.UnityWebRequest(url..'/api/client/unstuck/result','POST') req.uploadHandler=CS.UnityEngine.Networking.UploadHandlerRaw(CS.System.Text.Encoding.UTF8:GetBytes(body)) req.downloadHandler=CS.UnityEngine.Networking.DownloadHandlerBuffer() req:SetRequestHeader('Content-Type','application/json');req.timeout=10 req:SendWebRequest();_G.__GAC_UNSTUCK_HTTP=req end) end end `