diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 50dfb182..e76754b5 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -24,6 +24,33 @@ What does NOT go here:
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed.
+## #348 — Render-loop death by Win32 cursor-handle exhaustion: Silk recreates the native cursor on every alternation
+
+**Status:** FIX IN TREE (2026-08-08) pending the vendor-gate relaunch.
+**Evidence:** `vendor-gate.log` — `Silk.NET.GLFW.GlfwException: PlatformError:
+Win32: Failed to create cursor: Not enough memory` thrown from
+`RetailCursorManager.ApplyGlobal` inside `RenderFrameOrchestrator.Render`,
+exit 82 after ~minutes standing at a Holtburg vendor NPC. (The clean
+single-exception stack instead of masked shutdown noise is #343's fix
+working as designed.)
+
+**Mechanism:** `RetailCursorManager`'s dedup only suppresses a STEADY
+cursor. Any per-frame alternation between two cursor states — the pick
+cursor flickering between kinds while hovering an ANIMATED NPC whose
+moving parts cross the cursor ray, exactly the "stand at a vendor"
+posture — reassigns `ICursor.Image` every flip, and Silk's GLFW backend
+creates a fresh native Win32 cursor per assignment without reusing the
+old ones. ~10,000 flips exhausts the USER-object quota and CreateCursor
+dies. Earlier same-day sessions (slope gates) never crashed because
+nobody hovers an animated NPC for minutes while moving.
+
+**Fix (root cause):** `GlfwCursorCache` — one `glfwCreateCursor` per
+distinct cursor media for the process lifetime (retail's own shape: it
+loads each MediaDescCursor once), O(1) `glfwSetCursor` per switch,
+rejected media cached as permanent misses, disposal destroys all.
+`RetailCursorManager.AttachNativeWindow` opts in when a native GLFW
+window exists; tests and windowless hosts keep the Silk path.
+
## #32 — CLOSED 2026-08-07: local edge-slide fixed at `332045c7`, USER-PASSED on its first genuine live run
**"Yes works now."** — the user at the Rithwic cliff, on the first launch that
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5192d72e..949cc94d 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -996,6 +996,9 @@ public sealed class GameWindow :
_retailChatVm = retained.Chat;
_characterSheetProvider = retained.CharacterSheet;
_frameScreenshots = retained.Screenshots;
+ // #348: cursor switches ride a process-lifetime native cache
+ // instead of Silk's recreate-per-assignment path.
+ retained.Runtime.AttachNativeCursorWindow(_window?.Native?.Glfw ?? 0);
}
}
diff --git a/src/AcDream.App/Rendering/GlfwCursorCache.cs b/src/AcDream.App/Rendering/GlfwCursorCache.cs
new file mode 100644
index 00000000..e7705fab
--- /dev/null
+++ b/src/AcDream.App/Rendering/GlfwCursorCache.cs
@@ -0,0 +1,147 @@
+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();
+ }
+}
diff --git a/src/AcDream.App/Rendering/RetailCursorManager.cs b/src/AcDream.App/Rendering/RetailCursorManager.cs
index 1a73b39d..38dae99b 100644
--- a/src/AcDream.App/Rendering/RetailCursorManager.cs
+++ b/src/AcDream.App/Rendering/RetailCursorManager.cs
@@ -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);
}
+ ///
+ /// #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.
+ ///
+ 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 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;
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 57ce5a8d..efbebf3f 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -461,6 +461,16 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Cursor.Manager.Apply(mice, feedback);
}
+ ///
+ /// #348: opts cursor application into the process-lifetime native
+ /// GLFW cursor cache. Called once by the graphical host after the
+ /// native window exists; zero handle keeps the Silk fallback.
+ ///
+ public void AttachNativeCursorWindow(nint glfwWindowHandle)
+ {
+ _bindings.Cursor.Manager.AttachNativeWindow(glfwWindowHandle);
+ }
+
public void RestoreLayout() => _persistence?.RestoreAll();
public void SaveLayout() => _persistence?.SaveAll();
@@ -1919,6 +1929,7 @@ public sealed class RetailUiRuntime : IDisposable
private void MountVendor()
{
ImportedLayout? layout;
+ uint emptySlotSprite;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@@ -1928,6 +1939,13 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
+ // F7b (Slice 5.4 review): the authored empty-slot background for
+ // the item strip, same resolution path ExternalContainerController
+ // uses for its own lists.
+ emptySlotSprite = ItemListCellTemplate.ResolveEmptySprite(
+ _bindings.Assets.Dats,
+ VendorUiController.LayoutId,
+ VendorUiController.ItemListId);
}
if (layout is null)
{
@@ -1963,7 +1981,23 @@ public sealed class RetailUiRuntime : IDisposable
});
VendorRuntimeBindings b = _bindings.Vendor;
- VendorController = VendorUiController.Bind(layout, b.State, handle, b.ResolveIcon);
+ // F1/F2/F3/F7b (Slice 5.4 review): the category dropdown needs
+ // fonts/sprites to draw at all; the price text needs the local
+ // player's coin total, sourced from the SAME ClientObjectTable/
+ // PlayerGuid pair InventoryRuntimeBindings already exposes (no new
+ // binding record needed — this mirrors how MountExternalContainer
+ // reads its own sibling binding).
+ VendorController = VendorUiController.Bind(
+ layout,
+ b.State,
+ handle,
+ b.ResolveIcon,
+ _bindings.Inventory.Objects,
+ _bindings.Inventory.PlayerGuid,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.DebugFont,
+ _bindings.Assets.ResolveSprite,
+ emptySlotSprite);
if (VendorController is null)
{
Console.WriteLine("[M4] vendor: required authored controls are missing.");