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:
parent
c721830e71
commit
9d3df5f627
5 changed files with 245 additions and 1 deletions
|
|
@ -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.
|
- 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.
|
- 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
|
## #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
|
**"Yes works now."** — the user at the Rithwic cliff, on the first launch that
|
||||||
|
|
|
||||||
|
|
@ -996,6 +996,9 @@ public sealed class GameWindow :
|
||||||
_retailChatVm = retained.Chat;
|
_retailChatVm = retained.Chat;
|
||||||
_characterSheetProvider = retained.CharacterSheet;
|
_characterSheetProvider = retained.CharacterSheet;
|
||||||
_frameScreenshots = retained.Screenshots;
|
_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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
147
src/AcDream.App/Rendering/GlfwCursorCache.cs
Normal file
147
src/AcDream.App/Rendering/GlfwCursorCache.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -22,6 +22,7 @@ public sealed class RetailCursorManager
|
||||||
private UiCursorMedia _lastWidgetCursor;
|
private UiCursorMedia _lastWidgetCursor;
|
||||||
private UiCursorMedia _lastAppliedCursor;
|
private UiCursorMedia _lastAppliedCursor;
|
||||||
private StandardCursor? _lastStandardCursor;
|
private StandardCursor? _lastStandardCursor;
|
||||||
|
private GlfwCursorCache? _nativeCursors;
|
||||||
|
|
||||||
public RetailCursorManager(IDatReaderWriter dats, object datLock)
|
public RetailCursorManager(IDatReaderWriter dats, object datLock)
|
||||||
{
|
{
|
||||||
|
|
@ -30,6 +31,24 @@ public sealed class RetailCursorManager
|
||||||
_globalCursors = new RetailCursorResolver(dats, datLock);
|
_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)
|
public void Apply(IEnumerable<IMouse> mice, CursorFeedback feedback)
|
||||||
{
|
{
|
||||||
foreach (RetailCursorLayer layer in PlanApplication(
|
foreach (RetailCursorLayer layer in PlanApplication(
|
||||||
|
|
@ -79,6 +98,13 @@ public sealed class RetailCursorManager
|
||||||
if (_lastStandardCursor is null && _lastAppliedCursor.Equals(cursorMedia))
|
if (_lastStandardCursor is null && _lastAppliedCursor.Equals(cursorMedia))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_nativeCursors is not null && _nativeCursors.TrySetCustom(cursorMedia, image))
|
||||||
|
{
|
||||||
|
_lastAppliedCursor = cursorMedia;
|
||||||
|
_lastStandardCursor = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var mouse in mice)
|
foreach (var mouse in mice)
|
||||||
{
|
{
|
||||||
var cursor = mouse.Cursor;
|
var cursor = mouse.Cursor;
|
||||||
|
|
@ -98,6 +124,13 @@ public sealed class RetailCursorManager
|
||||||
if (_lastStandardCursor == desired)
|
if (_lastStandardCursor == desired)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
if (_nativeCursors is not null && _nativeCursors.TrySetStandard(desired))
|
||||||
|
{
|
||||||
|
_lastAppliedCursor = default;
|
||||||
|
_lastStandardCursor = desired;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var mouse in mice)
|
foreach (var mouse in mice)
|
||||||
{
|
{
|
||||||
var cursor = mouse.Cursor;
|
var cursor = mouse.Cursor;
|
||||||
|
|
|
||||||
|
|
@ -461,6 +461,16 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Cursor.Manager.Apply(mice, feedback);
|
_bindings.Cursor.Manager.Apply(mice, feedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #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.
|
||||||
|
/// </summary>
|
||||||
|
public void AttachNativeCursorWindow(nint glfwWindowHandle)
|
||||||
|
{
|
||||||
|
_bindings.Cursor.Manager.AttachNativeWindow(glfwWindowHandle);
|
||||||
|
}
|
||||||
|
|
||||||
public void RestoreLayout() => _persistence?.RestoreAll();
|
public void RestoreLayout() => _persistence?.RestoreAll();
|
||||||
|
|
||||||
public void SaveLayout() => _persistence?.SaveAll();
|
public void SaveLayout() => _persistence?.SaveAll();
|
||||||
|
|
@ -1919,6 +1929,7 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private void MountVendor()
|
private void MountVendor()
|
||||||
{
|
{
|
||||||
ImportedLayout? layout;
|
ImportedLayout? layout;
|
||||||
|
uint emptySlotSprite;
|
||||||
lock (_bindings.Assets.DatLock)
|
lock (_bindings.Assets.DatLock)
|
||||||
{
|
{
|
||||||
layout = LayoutImporter.Import(
|
layout = LayoutImporter.Import(
|
||||||
|
|
@ -1928,6 +1939,13 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.DefaultFont,
|
_bindings.Assets.DefaultFont,
|
||||||
_bindings.Assets.ResolveFont);
|
_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)
|
if (layout is null)
|
||||||
{
|
{
|
||||||
|
|
@ -1963,7 +1981,23 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
});
|
});
|
||||||
|
|
||||||
VendorRuntimeBindings b = _bindings.Vendor;
|
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)
|
if (VendorController is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("[M4] vendor: required authored controls are missing.");
|
Console.WriteLine("[M4] vendor: required authored controls are missing.");
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue