using System; using System.Collections.Generic; using AcDream.App.UI; using Silk.NET.Core; using Silk.NET.GLFW; using Silk.NET.Input; namespace AcDream.App.Rendering; /// /// Process-lifetime cache of native GLFW cursors, keyed by cursor media. /// /// /// #348: Silk's per-mouse ICursor recreates the native Win32 cursor /// on every Image assignment. A per-frame cursor alternation — the /// pick cursor flickering between two kinds while hovering an animated /// NPC — therefore allocates a fresh USER-object handle on every flip /// until Win32 CreateCursor fails with "Not enough memory" and the /// render loop dies (exit 82, vendor-gate.log 2026-08-08). Retail loads /// each MediaDescCursor once and switches between loaded cursors; this /// cache restores that shape: one glfwCreateCursor per distinct /// media for the process lifetime, and an O(1), zero-allocation /// glfwSetCursor per switch. /// /// internal sealed unsafe class GlfwCursorCache : IDisposable { private readonly Glfw _glfw; private readonly WindowHandle* _window; private readonly Dictionary _customCursors = new(); private readonly Dictionary _standardCursors = new(); private bool _disposed; private GlfwCursorCache(Glfw glfw, WindowHandle* window) { _glfw = glfw; _window = window; } /// /// Returns null when no native GLFW window is available (tests, /// non-GLFW platforms) — callers then stay on the Silk path. /// public static GlfwCursorCache? TryCreate(nint glfwWindowHandle) => glfwWindowHandle == 0 ? null : new GlfwCursorCache(Glfw.GetApi(), (WindowHandle*)glfwWindowHandle); /// /// Sets the window cursor, creating the native cursor only on the /// first use of this media. A media GLFW rejects is cached as a /// permanent miss so the failure cannot re-trigger per frame. /// public bool TrySetCustom(UiCursorMedia media, RawImage image) { if (_disposed) return false; if (!_customCursors.TryGetValue(media, out nint cursor)) { cursor = CreateCustomCursor(media, image); _customCursors[media] = cursor; } if (cursor == 0) return false; _glfw.SetCursor(_window, (Cursor*)cursor); return true; } /// /// Sets a standard OS cursor through the same cached-native route. /// Arrow uses GLFW's default-cursor restore; the other shapes cover /// the AP-72 missing-art fallback set. Unknown shapes report false so /// the caller can keep its Silk fallback. /// public bool TrySetStandard(StandardCursor desired) { if (_disposed) return false; if (desired == StandardCursor.Arrow) { _glfw.SetCursor(_window, null); return true; } CursorShape shape; switch (desired) { case StandardCursor.Hand: shape = CursorShape.Hand; break; case StandardCursor.Crosshair: shape = CursorShape.Crosshair; break; case StandardCursor.IBeam: shape = CursorShape.IBeam; break; default: return false; } if (!_standardCursors.TryGetValue(desired, out nint cursor)) { cursor = (nint)_glfw.CreateStandardCursor(shape); _standardCursors[desired] = cursor; } if (cursor == 0) return false; _glfw.SetCursor(_window, (Cursor*)cursor); return true; } private nint CreateCustomCursor(UiCursorMedia media, RawImage image) { // GLFW copies the pixel data before CreateCursor returns, so the // pin only needs to span the call. fixed (byte* pixels = image.Pixels.Span) { var glfwImage = new Image { Width = image.Width, Height = image.Height, Pixels = pixels, }; return (nint)_glfw.CreateCursor(&glfwImage, media.HotspotX, media.HotspotY); } } public void Dispose() { if (_disposed) return; _disposed = true; _glfw.SetCursor(_window, null); foreach (nint cursor in _customCursors.Values) { if (cursor != 0) _glfw.DestroyCursor((Cursor*)cursor); } foreach (nint cursor in _standardCursors.Values) { if (cursor != 0) _glfw.DestroyCursor((Cursor*)cursor); } _customCursors.Clear(); _standardCursors.Clear(); } }