diff --git a/Source/RIMAPI/RIMAPI_Mod.cs b/Source/RIMAPI/RIMAPI_Mod.cs index 62de0d4..8c59186 100644 --- a/Source/RIMAPI/RIMAPI_Mod.cs +++ b/Source/RIMAPI/RIMAPI_Mod.cs @@ -29,6 +29,10 @@ public class RIMAPI_Mod : Mod /// public RIMAPI_Mod(ModContentPack content) : base(content) { + GameObject dispatcherObject = new GameObject("RIMAPI_ThreadDispatcher"); + Object.DontDestroyOnLoad(dispatcherObject); + dispatcherObject.AddComponent(); + Settings = GetSettings(); InitializeHarmony(); } diff --git a/Source/RIMAPI/RimworldRestApi/BaseControllers/CameraController.cs b/Source/RIMAPI/RimworldRestApi/BaseControllers/CameraController.cs index 4650bd9..ade49d4 100644 --- a/Source/RIMAPI/RimworldRestApi/BaseControllers/CameraController.cs +++ b/Source/RIMAPI/RimworldRestApi/BaseControllers/CameraController.cs @@ -6,6 +6,7 @@ using RIMAPI.Core; using RIMAPI.Http; using RIMAPI.Models; +using RIMAPI.Models.Camera; using RIMAPI.Services; using Verse; @@ -43,7 +44,25 @@ public async Task MoveToPosition(HttpListenerContext context) await context.SendJsonResponse(result); } - [Post("/api/v1/stream/start")] + [Post("/api/v1/camera/screenshot")] + [EndpointMetadata("Capture a screenshot of the game view. Supports resizing and format changes.")] + public async Task MakeScreenshot(HttpListenerContext context) + { + var request = await context.Request.ReadBodyAsync(); + var result = await _cameraService.MakeScreenshotAsync(request); + await context.SendJsonResponse(result); + } + + [Post("/api/v1/camera/screenshot/native")] + [EndpointMetadata("Moves the camera and saves a high-quality screenshot directly to the host machine's hard drive.")] + public async Task TriggerNativeScreenshot(HttpListenerContext context) + { + var request = await context.Request.ReadBodyAsync(); + var result = _cameraService.TakeNativeScreenshot(request); + await context.SendJsonResponse(result); + } + + [Post("/api/v1/camera/stream/start")] [EndpointMetadata("Start game camera stream")] public async Task PostStreamStart(HttpListenerContext context) { @@ -51,7 +70,7 @@ public async Task PostStreamStart(HttpListenerContext context) await context.SendJsonResponse(result); } - [Post("/api/v1/stream/stop")] + [Post("/api/v1/camera/stream/stop")] [EndpointMetadata("Stop game camera stream")] public async Task PostStreamStop(HttpListenerContext context) { @@ -59,7 +78,7 @@ public async Task PostStreamStop(HttpListenerContext context) await context.SendJsonResponse(result); } - [Post("/api/v1/stream/setup")] + [Post("/api/v1/camera/stream/setup")] [EndpointMetadata("Set game camera stream configuration")] public async Task PostStreamSetup(HttpListenerContext context) { @@ -68,7 +87,7 @@ public async Task PostStreamSetup(HttpListenerContext context) await context.SendJsonResponse(result); } - [Get("/api/v1/stream/status")] + [Get("/api/v1/camera/stream/status")] [EndpointMetadata("Get game camera stream status")] public async Task GetStreamStatus(HttpListenerContext context) { diff --git a/Source/RIMAPI/RimworldRestApi/Core/GameThreadUtility.cs b/Source/RIMAPI/RimworldRestApi/Core/GameThreadUtility.cs new file mode 100644 index 0000000..8a87ead --- /dev/null +++ b/Source/RIMAPI/RimworldRestApi/Core/GameThreadUtility.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using UnityEngine; +using Verse; + +namespace RIMAPI.Core +{ + // 1. The hidden Unity object that runs on the main game thread + public class GameThreadDispatcher : MonoBehaviour + { + // Thread-safe queue to hold actions coming from the HTTP server + private static readonly ConcurrentQueue _executionQueue = new ConcurrentQueue(); + + public void Update() + { + // Drain the queue every frame. + // We limit to 50 per frame so a massive API spam doesn't drop the game's FPS to zero. + int processed = 0; + while (processed < 50 && _executionQueue.TryDequeue(out var action)) + { + try + { + action(); + } + catch (Exception ex) + { + Log.Error($"[RIMAPI] Error executing API action on main thread: {ex}"); + } + processed++; + } + } + + public static void Enqueue(Action action) + { + _executionQueue.Enqueue(action); + } + } + + // 2. The utility wrapper that gives us beautiful async/await syntax in our Controllers + public static class GameThreadUtility + { + // For methods that return a value (e.g., getting a screenshot or pawn data) + public static Task InvokeAsync(Func func) + { + var tcs = new TaskCompletionSource(); + + GameThreadDispatcher.Enqueue(() => + { + try + { + T result = func(); + tcs.TrySetResult(result); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + return tcs.Task; + } + + // For methods that just execute an action (e.g., forcing a job, dropping an item) + public static Task InvokeAsync(Action action) + { + var tcs = new TaskCompletionSource(); + + GameThreadDispatcher.Enqueue(() => + { + try + { + action(); + tcs.TrySetResult(true); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + return tcs.Task; + } + } +} \ No newline at end of file diff --git a/Source/RIMAPI/RimworldRestApi/Models/Camera/CameraDto.cs b/Source/RIMAPI/RimworldRestApi/Models/Camera/CameraDto.cs new file mode 100644 index 0000000..1f52d81 --- /dev/null +++ b/Source/RIMAPI/RimworldRestApi/Models/Camera/CameraDto.cs @@ -0,0 +1,57 @@ +using Newtonsoft.Json; + +namespace RIMAPI.Models.Camera +{ + public class CameraScreenshotRequestDto + { + [JsonProperty("format")] + public string Format { get; set; } = "jpeg"; + + [JsonProperty("quality")] + public int Quality { get; set; } = 75; + + [JsonProperty("width")] + public int? Width { get; set; } + + [JsonProperty("height")] + public int? Height { get; set; } + + [JsonProperty("hide_ui")] + public bool HideUI { get; set; } = true; + } + + + public class NativeScreenshotRequestDto + { + public string FileName { get; set; } // Optional: Game auto-generates if empty + public float? CenterX { get; set; } + public float? CenterZ { get; set; } + public float? ZoomLevel { get; set; } // Typically 10 to 60 + public bool HideUI { get; set; } = true; + } + + public class CameraScreenshotResponseDto + { + public ImageData Image { get; set; } + public ImageMetadata Metadata { get; set; } + public GameContext GameContext { get; set; } + } + + public class ImageData + { + public string DataUri { get; set; } + } + + public class ImageMetadata + { + public string Format { get; set; } + public int Width { get; set; } + public int Height { get; set; } + public int SizeBytes { get; set; } + } + + public class GameContext + { + public int CurrentTick { get; set; } + } +} diff --git a/Source/RIMAPI/RimworldRestApi/Models/StreamCamera/CameraDto.cs b/Source/RIMAPI/RimworldRestApi/Models/Camera/CameraStreamDto.cs similarity index 100% rename from Source/RIMAPI/RimworldRestApi/Models/StreamCamera/CameraDto.cs rename to Source/RIMAPI/RimworldRestApi/Models/Camera/CameraStreamDto.cs diff --git a/Source/RIMAPI/RimworldRestApi/Services/CameraService.cs b/Source/RIMAPI/RimworldRestApi/Services/CameraService.cs index d236ec6..8f7c42b 100644 --- a/Source/RIMAPI/RimworldRestApi/Services/CameraService.cs +++ b/Source/RIMAPI/RimworldRestApi/Services/CameraService.cs @@ -1,7 +1,11 @@ using System; +using System.Collections; +using System.Threading.Tasks; using RIMAPI.CameraStreamer; using RIMAPI.Core; using RIMAPI.Models; +using RIMAPI.Models.Camera; +using UnityEngine; using Verse; namespace RIMAPI.Services @@ -26,6 +30,55 @@ public ApiResult ChangeZoom(int zoom) return ApiResult.Ok(); } + public ApiResult TakeNativeScreenshot(NativeScreenshotRequestDto request) + { + if (Find.CurrentMap == null) + { + return ApiResult.Fail("Cannot take a screenshot. No map is currently loaded."); + } + + // Start a coroutine to handle the asynchronous screenshot delay + Find.CameraDriver.StartCoroutine(NativeScreenshotRoutine(request)); + + string savedName = string.IsNullOrEmpty(request.FileName) ? "Auto-generated" : request.FileName; + return ApiResult.Ok($"Native screenshot '{savedName}' queued successfully."); + } + + private IEnumerator NativeScreenshotRoutine(NativeScreenshotRequestDto request) + { + bool originalUIState = false; + + // 1. Move the camera instantly + if (request.CenterX.HasValue && request.CenterZ.HasValue && request.ZoomLevel.HasValue) + { + Vector3 targetPos = new Vector3(request.CenterX.Value, 0, request.CenterZ.Value); + Find.CameraDriver.SetRootPosAndSize(targetPos, request.ZoomLevel.Value); + + // Yield for one frame to let the game engine render the new camera position + yield return new WaitForEndOfFrame(); + } + + // 2. Hide UI + if (request.HideUI && Find.UIRoot != null) + { + originalUIState = Find.UIRoot.screenshotMode.Active; + Find.UIRoot.screenshotMode.Active = true; + } + + // 3. Trigger the native screenshot tool + string fileName = string.IsNullOrEmpty(request.FileName) ? $"RIMAPI_{DateTime.Now.Ticks}" : request.FileName; + ScreenshotTaker.TakeNonSteamShot(fileName); + + // 4. WAIT for the frame to finish rendering so the screenshot actually captures! + yield return new WaitForEndOfFrame(); + + // 5. Restore UI + if (request.HideUI && Find.UIRoot != null) + { + Find.UIRoot.screenshotMode.Active = originalUIState; + } + } + public ApiResult MoveToPosition(int x, int y) { try @@ -39,6 +92,129 @@ public ApiResult MoveToPosition(int x, int y) } return ApiResult.Ok(); } + private static CameraScreenshotResponseDto CaptureScreenshotAsDto(CameraScreenshotRequestDto request) + { + request = request ?? new CameraScreenshotRequestDto(); + + string format = (request.Format ?? "jpeg").ToLower(); + int quality = Mathf.Clamp(request.Quality, 1, 100); + + int targetWidth = request.Width ?? Screen.width; + int targetHeight = request.Height ?? Screen.height; + + targetWidth = Mathf.Clamp(targetWidth, 16, 7680); + targetHeight = Mathf.Clamp(targetHeight, 16, 4320); + + RenderTexture tempRT = RenderTexture.GetTemporary(targetWidth, targetHeight, 24, RenderTextureFormat.ARGB32); + RenderTexture flippedRT = null; + Texture2D texture = null; + + try + { + bool needsFlip = false; + + // 1. SYNCHRONOUS UI HIDE (Direct Camera Render) + if (request.HideUI && Find.Camera != null) + { + // Hijack the main world camera. + // This natively renders right-side-up, so we DO NOT need to flip it later. + RenderTexture oldTarget = Find.Camera.targetTexture; + Find.Camera.targetTexture = tempRT; + Find.Camera.Render(); + Find.Camera.targetTexture = oldTarget; + + needsFlip = false; + } + else + { + // Capture the screen buffer (includes UI). + // This grabs the raw display memory, which usually needs flipping. + ScreenCapture.CaptureScreenshotIntoRenderTexture(tempRT); + + needsFlip = true; + } + + // 2. GPU Blit (Conditionally flip based on the render method) + flippedRT = RenderTexture.GetTemporary(targetWidth, targetHeight, 24, RenderTextureFormat.ARGB32); + + if (needsFlip) + { + // Invert the Y-axis for ScreenCapture + Graphics.Blit(tempRT, flippedRT, new Vector2(1, -1), new Vector2(0, 1)); + } + else + { + // Copy exactly as-is for the Camera Render + Graphics.Blit(tempRT, flippedRT); + } + + // 3. Read the pixels from the processed texture + texture = new Texture2D(flippedRT.width, flippedRT.height, TextureFormat.RGB24, false); + RenderTexture.active = flippedRT; + texture.ReadPixels(new Rect(0, 0, flippedRT.width, flippedRT.height), 0, 0); + texture.Apply(); + RenderTexture.active = null; + + // 4. Encode based on requested format + byte[] imageBytes; + string actualFormat; + + if (format == "png") + { + imageBytes = texture.EncodeToPNG(); + actualFormat = "png"; + } + else + { + imageBytes = texture.EncodeToJPG(quality); + actualFormat = "jpeg"; + } + + string base64String = Convert.ToBase64String(imageBytes); + + var dto = new CameraScreenshotResponseDto + { + Image = new ImageData + { + DataUri = $"data:image/{actualFormat};base64,{base64String}" + }, + Metadata = new ImageMetadata + { + Format = actualFormat, + Width = flippedRT.width, + Height = flippedRT.height, + SizeBytes = imageBytes.Length + }, + GameContext = new GameContext + { + CurrentTick = Find.TickManager != null ? Find.TickManager.TicksGame : 0 + } + }; + + return dto; + } + finally + { + // 5. Cleanup memory + if (tempRT != null) RenderTexture.ReleaseTemporary(tempRT); + if (flippedRT != null) RenderTexture.ReleaseTemporary(flippedRT); + if (texture != null) UnityEngine.Object.Destroy(texture); + } + } + + public ApiResult MakeScreenshot(CameraScreenshotRequestDto request) + { + try + { + var dto = CaptureScreenshotAsDto(request); + return ApiResult.Ok(dto); + } + catch (Exception ex) + { + Log.Error($"[RIMAPI] Failed to capture screenshot: {ex}"); + return ApiResult.Fail(ex.Message); + } + } public ApiResult GetStreamStatus(ICameraStream stream) { @@ -84,5 +260,144 @@ public ApiResult StopStream(ICameraStream stream) } return ApiResult.Ok(); } + + public Task> MakeScreenshotAsync(CameraScreenshotRequestDto request) + { + var tcs = new TaskCompletionSource>(); + + // Ensure the coroutine is launched from the main Unity thread + GameThreadUtility.InvokeAsync(() => + { + if (Find.CurrentMap == null || Find.CameraDriver == null) + { + tcs.TrySetResult(ApiResult.Fail("Cannot take a screenshot. No map is currently loaded.")); + return; + } + + // Start the Coroutine, passing the request and the TaskCompletionSource + Find.CameraDriver.StartCoroutine(CaptureScreenshotCoroutine(request, tcs)); + }); + + // The HTTP Controller will await this task, pausing until TrySetResult is called below! + return tcs.Task; + } + + private IEnumerator CaptureScreenshotCoroutine(CameraScreenshotRequestDto request, TaskCompletionSource> tcs) + { + request = request ?? new CameraScreenshotRequestDto(); + + string format = (request.Format ?? "jpeg").ToLower(); + int quality = Mathf.Clamp(request.Quality, 1, 100); + + int targetWidth = request.Width ?? Screen.width; + int targetHeight = request.Height ?? Screen.height; + + targetWidth = Mathf.Clamp(targetWidth, 16, 7680); + targetHeight = Mathf.Clamp(targetHeight, 16, 4320); + + bool originalUIState = false; + + // 1. Hide the UI BEFORE the try-catch block + if (request.HideUI && Find.UIRoot != null) + { + originalUIState = Find.UIRoot.screenshotMode.Active; + Find.UIRoot.screenshotMode.Active = true; + } + + // 2. THE MAGIC SAUCE: Yield to let Unity render a brand new frame WITHOUT the UI! + // We must do this outside the try-catch to satisfy C# compiler rules (CS1626). + yield return new WaitForEndOfFrame(); + + // 3. Now enter the try-catch for the risky GPU memory and encoding operations + try + { + // 1. Read the raw screen buffer directly! + // Because we waited for the end of the frame, the IMGUI (RimWorld UI) is guaranteed to be here. + RenderTexture.active = null; // Ensure we are reading from the screen, not an RT + Texture2D screenTex = new Texture2D(Screen.width, Screen.height, TextureFormat.RGB24, false); + screenTex.ReadPixels(new Rect(0, 0, Screen.width, Screen.height), 0, 0); + screenTex.Apply(); + + Texture2D finalTexture = screenTex; + RenderTexture resizedRT = null; + + // 2. Handle Resizing (If the API client requested a smaller/larger width than the game window) + bool needsResize = (targetWidth != Screen.width || targetHeight != Screen.height); + if (needsResize) + { + resizedRT = RenderTexture.GetTemporary(targetWidth, targetHeight, 24, RenderTextureFormat.ARGB32); + + // Copy the full screen into the resized target + Graphics.Blit(screenTex, resizedRT); + + // Read the resized pixels into a new texture + finalTexture = new Texture2D(targetWidth, targetHeight, TextureFormat.RGB24, false); + RenderTexture.active = resizedRT; + finalTexture.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0); + finalTexture.Apply(); + RenderTexture.active = null; + } + + // 3. Encode to Image Format + byte[] imageBytes; + string actualFormat; + + if (format == "png") + { + imageBytes = finalTexture.EncodeToPNG(); + actualFormat = "png"; + } + else + { + imageBytes = finalTexture.EncodeToJPG(quality); + actualFormat = "jpeg"; + } + + string base64String = Convert.ToBase64String(imageBytes); + + var dto = new CameraScreenshotResponseDto + { + Image = new ImageData + { + DataUri = $"data:image/{actualFormat};base64,{base64String}" + }, + Metadata = new ImageMetadata + { + Format = actualFormat, + Width = targetWidth, + Height = targetHeight, + SizeBytes = imageBytes.Length + }, + GameContext = new GameContext + { + CurrentTick = Find.TickManager != null ? Find.TickManager.TicksGame : 0 + } + }; + + // 4. Cleanup memory to prevent massive memory leaks + UnityEngine.Object.Destroy(screenTex); + if (needsResize) + { + UnityEngine.Object.Destroy(finalTexture); + RenderTexture.ReleaseTemporary(resizedRT); + } + + // Complete the Task! + tcs.TrySetResult(ApiResult.Ok(dto)); + } + catch (Exception ex) + { + Log.Error($"[RIMAPI] Failed to capture DTO screenshot: {ex}"); + tcs.TrySetResult(ApiResult.Fail(ex.Message)); + } + finally + { + // 4. ALWAYS Restore the UI state, even if encoding failed + if (request.HideUI && Find.UIRoot != null) + { + Find.UIRoot.screenshotMode.Active = originalUIState; + } + } + } } } diff --git a/Source/RIMAPI/RimworldRestApi/Services/Interfaces/ICameraService.cs b/Source/RIMAPI/RimworldRestApi/Services/Interfaces/ICameraService.cs index 3bcf35c..bc3ba6b 100644 --- a/Source/RIMAPI/RimworldRestApi/Services/Interfaces/ICameraService.cs +++ b/Source/RIMAPI/RimworldRestApi/Services/Interfaces/ICameraService.cs @@ -1,7 +1,9 @@ +using System.Threading.Tasks; using RIMAPI.CameraStreamer; using RIMAPI.Core; using RIMAPI.Models; +using RIMAPI.Models.Camera; namespace RIMAPI.Services { @@ -9,6 +11,9 @@ public interface ICameraService { ApiResult ChangeZoom(int zoom); ApiResult MoveToPosition(int x, int y); + Task> MakeScreenshotAsync(CameraScreenshotRequestDto request); + ApiResult MakeScreenshot(CameraScreenshotRequestDto request); + ApiResult TakeNativeScreenshot(NativeScreenshotRequestDto request); ApiResult StartStream(ICameraStream stream); ApiResult StopStream(ICameraStream stream); ApiResult SetupStream(ICameraStream stream, StreamConfigDto config); diff --git a/tests/bruno_api_collection/Camera/Screenshot Native.yml b/tests/bruno_api_collection/Camera/Screenshot Native.yml new file mode 100644 index 0000000..854117b --- /dev/null +++ b/tests/bruno_api_collection/Camera/Screenshot Native.yml @@ -0,0 +1,21 @@ +info: + name: Screenshot Native + type: http + seq: 8 + +http: + method: POST + url: "{{baseURL}}/api/v1/camera/screenshot/native" + body: + type: json + data: |- + { + + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5 diff --git a/tests/bruno_api_collection/Camera/Screenshot.yml b/tests/bruno_api_collection/Camera/Screenshot.yml new file mode 100644 index 0000000..120152f --- /dev/null +++ b/tests/bruno_api_collection/Camera/Screenshot.yml @@ -0,0 +1,22 @@ +info: + name: Screenshot + type: http + seq: 7 + +http: + method: POST + url: "{{baseURL}}/api/v1/camera/screenshot" + body: + type: json + data: |- + { + "format": "png", + "hide_ui": false + } + auth: inherit + +settings: + encodeUrl: true + timeout: 0 + followRedirects: true + maxRedirects: 5