fix(app): #348 — cursor switches ride a process-lifetime native cache; the per-flip Win32 handle leak is closed

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>
This commit is contained in:
Erik 2026-08-07 18:26:17 +02:00
parent c721830e71
commit 9d3df5f627
5 changed files with 245 additions and 1 deletions

View file

@ -22,6 +22,7 @@ public sealed class RetailCursorManager
private UiCursorMedia _lastWidgetCursor;
private UiCursorMedia _lastAppliedCursor;
private StandardCursor? _lastStandardCursor;
private GlfwCursorCache? _nativeCursors;
public RetailCursorManager(IDatReaderWriter dats, object datLock)
{
@ -30,6 +31,24 @@ public sealed class RetailCursorManager
_globalCursors = new RetailCursorResolver(dats, datLock);
}
/// <summary>
/// #348: switches cursor application to a process-lifetime native
/// GLFW cursor cache. Without it, Silk recreates the native cursor
/// on every alternation and a per-frame flip (pick cursor over an
/// animated NPC) exhausts Win32 USER handles within minutes. No-op
/// when the handle is zero (tests, no native window) — the Silk
/// path below remains the fallback.
/// </summary>
public void AttachNativeWindow(nint glfwWindowHandle)
{
if (_nativeCursors is null)
{
_nativeCursors = GlfwCursorCache.TryCreate(glfwWindowHandle);
if (_nativeCursors is not null)
Console.WriteLine("[D.2b] cursor native cache attached (#348).");
}
}
public void Apply(IEnumerable<IMouse> mice, CursorFeedback feedback)
{
foreach (RetailCursorLayer layer in PlanApplication(
@ -79,6 +98,13 @@ public sealed class RetailCursorManager
if (_lastStandardCursor is null && _lastAppliedCursor.Equals(cursorMedia))
return;
if (_nativeCursors is not null && _nativeCursors.TrySetCustom(cursorMedia, image))
{
_lastAppliedCursor = cursorMedia;
_lastStandardCursor = null;
return;
}
foreach (var mouse in mice)
{
var cursor = mouse.Cursor;
@ -98,6 +124,13 @@ public sealed class RetailCursorManager
if (_lastStandardCursor == desired)
return;
if (_nativeCursors is not null && _nativeCursors.TrySetStandard(desired))
{
_lastAppliedCursor = default;
_lastStandardCursor = desired;
return;
}
foreach (var mouse in mice)
{
var cursor = mouse.Cursor;