Silk's per-mouse ICursor recreates the native Win32 cursor on every Image assignment; a per-frame cursor alternation (the pick cursor flickering between kinds while hovering an ANIMATED NPC — exactly the stand-at-a-vendor posture) allocated a fresh USER handle each flip until CreateCursor died with "Not enough memory" and took the render loop with it (vendor-gate.log, exit 82 — surfaced as one clean stack by #343's fix, as designed). GlfwCursorCache restores retail's own shape: each distinct MediaDescCursor is created ONCE for the process lifetime (glfwCreateCursor, rejected media cached as permanent misses) and switching is an O(1) zero-allocation glfwSetCursor. The AP-72 missing-art standard-cursor fallback rides the same cache (Arrow/Hand/Crosshair/IBeam; anything else keeps the Silk path). Graphical hosts attach after the native window exists; tests and windowless hosts keep the Silk path untouched. RetailCursorManager's dedup and PlanApplication logic are unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
147 lines
4.7 KiB
C#
147 lines
4.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Process-lifetime cache of native GLFW cursors, keyed by cursor media.
|
|
///
|
|
/// <para>
|
|
/// #348: Silk's per-mouse <c>ICursor</c> recreates the native Win32 cursor
|
|
/// on every <c>Image</c> 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 <c>CreateCursor</c> 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 <c>glfwCreateCursor</c> per distinct
|
|
/// media for the process lifetime, and an O(1), zero-allocation
|
|
/// <c>glfwSetCursor</c> per switch.
|
|
/// </para>
|
|
/// </summary>
|
|
internal sealed unsafe class GlfwCursorCache : IDisposable
|
|
{
|
|
private readonly Glfw _glfw;
|
|
private readonly WindowHandle* _window;
|
|
private readonly Dictionary<UiCursorMedia, nint> _customCursors = new();
|
|
private readonly Dictionary<StandardCursor, nint> _standardCursors = new();
|
|
private bool _disposed;
|
|
|
|
private GlfwCursorCache(Glfw glfw, WindowHandle* window)
|
|
{
|
|
_glfw = glfw;
|
|
_window = window;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns null when no native GLFW window is available (tests,
|
|
/// non-GLFW platforms) — callers then stay on the Silk path.
|
|
/// </summary>
|
|
public static GlfwCursorCache? TryCreate(nint glfwWindowHandle)
|
|
=> glfwWindowHandle == 0
|
|
? null
|
|
: new GlfwCursorCache(Glfw.GetApi(), (WindowHandle*)glfwWindowHandle);
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|