From 9d3df5f627eb5a1afef5dc58ae28e6841a0030de Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 7 Aug 2026 18:26:17 +0200 Subject: [PATCH] =?UTF-8?q?fix(app):=20#348=20=E2=80=94=20cursor=20switche?= =?UTF-8?q?s=20ride=20a=20process-lifetime=20native=20cache;=20the=20per-f?= =?UTF-8?q?lip=20Win32=20handle=20leak=20is=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/ISSUES.md | 27 ++++ src/AcDream.App/Rendering/GameWindow.cs | 3 + src/AcDream.App/Rendering/GlfwCursorCache.cs | 147 ++++++++++++++++++ .../Rendering/RetailCursorManager.cs | 33 ++++ src/AcDream.App/UI/RetailUiRuntime.cs | 36 ++++- 5 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 src/AcDream.App/Rendering/GlfwCursorCache.cs 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.");