refactor(app): extract LiveSessionController for network-side session lifecycle (Step 2)
Step 2 of the extraction sequence in docs/architecture/code-structure.md
§4. Lifts DNS resolution + WorldSession instantiation + wireEvents
callback + per-frame Tick + Dispose out of GameWindow.TryStartLiveSession
into a dedicated AcDream.App.Net.LiveSessionController.
What moves:
- DNS resolution (IPAddress.TryParse + Dns.GetHostAddresses fallback,
IPv4 preferred) → LiveSessionController.ResolveEndpoint
- WorldSession instantiation → LiveSessionController.CreateAndWire
- "live: connecting to ..." console line → CreateAndWire
- Try/catch around the setup phase → CreateAndWire (separate from the
Connect/EnterWorld try block that stays in GameWindow)
- Per-frame _liveSession?.Tick() → _liveSessionController?.Tick()
- OnClosing _liveSession?.Dispose() → _liveSessionController?.Dispose()
What stays in GameWindow:
- The 25+ event subscriptions (extracted into a new private
WireLiveSessionEvents method that the controller invokes via callback)
- The Connect → CharacterList → EnterWorld → post-EnterWorld setup dance
(touches Chat, _playerServerGuid, _vitalsVm, _worldState, _settingsStore,
_settingsVm, _playerModeAutoEntry; moving these would balloon Step 2's
scope and risk surface)
- All 60+ outbound _liveSession.Send* call sites (touch the field by
name; LiveSessionController.Session is the controller-side mirror)
The _liveSession field remains as a convenience handle synced with
_liveSessionController.Session; it tracks the same WorldSession instance.
Behavior preservation:
- Same DNS-resolution sequence, same "live: connecting to ..." line,
same wiring-vs-Connect ordering as pre-refactor.
- Same 25+ event subscriptions in the same order, byte-for-byte.
- Same Connect/EnterWorld error handling (the catch block stays in
GameWindow because it disposes _combatChatTranslator which is also
a GameWindow field).
Closes #76.
One subtle nullable-flow fix the compiler required: the chat-bus
lambda's `var liveSession = _liveSession;` capture became
`var liveSession = session;` (the non-null parameter) so the compiler
can prove non-null inside the lambda body. Both pointed to the same
WorldSession instance; only the static analysis changed.
Verification:
- dotnet build green
- dotnet test: AcDream.App.Tests 10/10, Core.Net.Tests 294/294,
UI.Abstractions.Tests 419/419 — all green. Core.Tests 1073/1081
(same 8 pre-existing physics failures as baseline; unrelated).
- Live ACE session against +Acdream verified end-to-end:
* Connection + handshake + EnterWorld
* Door double-click → OnLiveMotionUpdated round-trip
(cmd=0x000B open / cmd=0x000C close)
* NPC double-click → outbound Use
* Ground item F-key pickup × 4 successful (Amaranth, Comfrey,
Damiana, Dragonsblood)
* Spawn stream + chat channels + remote-entity motion all healthy
* Clean OnClosing → controller.Dispose
Walking-range auto-walk + pickup-overshoot bugs observed during
verification are pre-existing (filed as #77); they live in
PlayerMovementController.DriveServerAutoWalk / threshold logic
which this refactor did not touch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4f3b8a6824
commit
0b25df53df
2 changed files with 194 additions and 79 deletions
114
src/AcDream.App/Net/LiveSessionController.cs
Normal file
114
src/AcDream.App/Net/LiveSessionController.cs
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
using System;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using AcDream.Core.Net;
|
||||||
|
|
||||||
|
namespace AcDream.App.Net;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Owns the network-side lifecycle of a live <see cref="WorldSession"/> —
|
||||||
|
/// DNS resolution, endpoint construction, session instantiation, per-frame
|
||||||
|
/// <c>Tick</c>, and disposal. The post-construction work (event wiring,
|
||||||
|
/// <c>Connect</c>, character validation, <c>EnterWorld</c>, post-login UI
|
||||||
|
/// state setup) stays in <c>GameWindow</c> for now because it touches
|
||||||
|
/// renderer / chat / player-controller state that hasn't been extracted yet.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Step 2 of the extraction sequence described in
|
||||||
|
/// <c>docs/architecture/code-structure.md</c> §4. Future expansions can
|
||||||
|
/// fold more of <c>TryStartLiveSession</c> into this controller as the
|
||||||
|
/// surrounding state (event handlers, command bus, settings VM) gets
|
||||||
|
/// extracted in later steps.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <strong>Behavior preservation contract:</strong> this class produces
|
||||||
|
/// byte-identical console output and event-wireup sequencing to the
|
||||||
|
/// pre-refactor inline code path. The DNS-resolution lines, the
|
||||||
|
/// "live: connecting to ..." line, and the wiring-vs-Connect ordering
|
||||||
|
/// all match the previous flow.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class LiveSessionController : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Active session, or <see langword="null"/> when offline / before
|
||||||
|
/// <see cref="CreateAndWire"/> succeeded / after <see cref="Dispose"/>.
|
||||||
|
/// </summary>
|
||||||
|
public WorldSession? Session { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the endpoint, instantiates the <see cref="WorldSession"/>,
|
||||||
|
/// hands it to <paramref name="wireEvents"/> for caller-side event
|
||||||
|
/// subscriptions, and returns the live session. The caller is
|
||||||
|
/// responsible for the subsequent <c>Connect</c> /
|
||||||
|
/// <c>EnterWorld</c> dance.
|
||||||
|
/// </summary>
|
||||||
|
public WorldSession? CreateAndWire(RuntimeOptions options, Action<WorldSession> wireEvents)
|
||||||
|
{
|
||||||
|
if (options is null) throw new ArgumentNullException(nameof(options));
|
||||||
|
if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
|
||||||
|
|
||||||
|
if (!options.LiveMode) return null;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
|
||||||
|
{
|
||||||
|
Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort);
|
||||||
|
Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
|
||||||
|
Session = new WorldSession(endpoint);
|
||||||
|
wireEvents(Session);
|
||||||
|
return Session;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"live: session setup failed: {ex.Message}");
|
||||||
|
Session?.Dispose();
|
||||||
|
Session = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Drains the inbound network queue. Proxies to
|
||||||
|
/// <see cref="WorldSession.Tick"/>; no-op when <see cref="Session"/>
|
||||||
|
/// is <see langword="null"/>.
|
||||||
|
/// </summary>
|
||||||
|
public void Tick() => Session?.Tick();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tears down the live session. Safe to call multiple times.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Session?.Dispose();
|
||||||
|
Session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a host string (literal IP or DNS name) to an
|
||||||
|
/// <see cref="IPEndPoint"/>. Pre-refactor logic preserved exactly:
|
||||||
|
/// try <see cref="IPAddress.TryParse"/> first, fall back to
|
||||||
|
/// <see cref="Dns.GetHostAddresses"/>, prefer IPv4 (ACE + retail use
|
||||||
|
/// IPv4 UDP exclusively), throw on empty resolution.
|
||||||
|
/// </summary>
|
||||||
|
private static IPEndPoint ResolveEndpoint(string host, int port)
|
||||||
|
{
|
||||||
|
IPAddress ip;
|
||||||
|
if (!IPAddress.TryParse(host, out ip!))
|
||||||
|
{
|
||||||
|
var addrs = Dns.GetHostAddresses(host);
|
||||||
|
ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
|
||||||
|
?? (addrs.Length > 0
|
||||||
|
? addrs[0]
|
||||||
|
: throw new Exception($"DNS resolved no addresses for '{host}'"));
|
||||||
|
Console.WriteLine($"live: resolved {host} → {ip}");
|
||||||
|
}
|
||||||
|
return new IPEndPoint(ip, port);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -738,6 +738,11 @@ public sealed class GameWindow : IDisposable
|
||||||
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
||||||
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
||||||
// the offline rendering pipeline.
|
// the offline rendering pipeline.
|
||||||
|
// Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle
|
||||||
|
// (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in
|
||||||
|
// LiveSessionController. _liveSession remains as a convenience handle
|
||||||
|
// for the ~60 outbound SendXxx call sites; it tracks Controller.Session.
|
||||||
|
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||||||
private AcDream.Core.Net.WorldSession? _liveSession;
|
private AcDream.Core.Net.WorldSession? _liveSession;
|
||||||
private int _liveCenterX;
|
private int _liveCenterX;
|
||||||
private int _liveCenterY;
|
private int _liveCenterY;
|
||||||
|
|
@ -1819,38 +1824,80 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private void TryStartLiveSession()
|
private void TryStartLiveSession()
|
||||||
{
|
{
|
||||||
if (!_options.LiveMode) return;
|
// Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
|
||||||
|
// The controller owns DNS resolution + WorldSession instantiation + the
|
||||||
var host = _options.LiveHost;
|
// wireEvents callback; this method keeps the Connect → CharacterList →
|
||||||
var port = _options.LivePort;
|
// EnterWorld → post-setup dance because those touch GameWindow state.
|
||||||
var user = _options.LiveUser;
|
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
||||||
var pass = _options.LivePass;
|
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||||||
if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
|
if (_liveSession is null)
|
||||||
{
|
{
|
||||||
Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
|
_combatChatTranslator?.Dispose();
|
||||||
|
_combatChatTranslator = null;
|
||||||
|
_liveSessionController = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var user = _options.LiveUser!;
|
||||||
|
var pass = _options.LivePass!;
|
||||||
|
var host = _options.LiveHost;
|
||||||
|
var port = _options.LivePort;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Resolve DNS names (e.g. play.coldeve.ac) as well as literal
|
Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
|
||||||
// IP addresses. `IPAddress.Parse` throws on hostnames; fall
|
_liveSession.Connect(user, pass);
|
||||||
// back to `Dns.GetHostAddresses` and prefer IPv4 (ACE + retail
|
Chat.OnSystemMessage("connected — character list received", chatType: 1);
|
||||||
// use IPv4 UDP exclusively).
|
|
||||||
System.Net.IPAddress ip;
|
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
|
||||||
if (!System.Net.IPAddress.TryParse(host, out ip!))
|
|
||||||
{
|
{
|
||||||
var addrs = System.Net.Dns.GetHostAddresses(host);
|
Console.WriteLine("live: no characters on account; disconnecting");
|
||||||
ip = System.Array.Find(addrs,
|
_liveSessionController.Dispose();
|
||||||
a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
_liveSessionController = null;
|
||||||
?? (addrs.Length > 0 ? addrs[0] : throw new System.Exception(
|
_liveSession = null;
|
||||||
$"DNS resolved no addresses for '{host}'"));
|
return;
|
||||||
Console.WriteLine($"live: resolved {host} → {ip}");
|
|
||||||
}
|
}
|
||||||
var endpoint = new System.Net.IPEndPoint(ip, port);
|
|
||||||
Console.WriteLine($"live: connecting to {endpoint} as {user}");
|
var chosen = _liveSession.Characters.Characters[0];
|
||||||
_liveSession = new AcDream.Core.Net.WorldSession(endpoint);
|
_playerServerGuid = chosen.Id;
|
||||||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
|
||||||
|
Chat.SetLocalPlayerGuid(chosen.Id);
|
||||||
|
_worldState.MarkPersistent(chosen.Id);
|
||||||
|
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||||||
|
_liveSession.EnterWorld(user, characterIndex: 0);
|
||||||
|
|
||||||
|
_activeToonKey = chosen.Name;
|
||||||
|
if (_settingsStore is not null && _settingsVm is not null)
|
||||||
|
{
|
||||||
|
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
||||||
|
_settingsVm.LoadCharacterContext(toonBag);
|
||||||
|
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
|
||||||
|
}
|
||||||
|
_playerModeAutoEntry?.Arm();
|
||||||
|
Console.WriteLine($"live: in world — CreateObject stream active " +
|
||||||
|
$"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"live: session failed: {ex.Message}");
|
||||||
|
_combatChatTranslator?.Dispose();
|
||||||
|
_combatChatTranslator = null;
|
||||||
|
_liveSessionController?.Dispose();
|
||||||
|
_liveSessionController = null;
|
||||||
|
_liveSession = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||||||
|
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||||||
|
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
|
||||||
|
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
|
||||||
|
/// is constructed and BEFORE any network I/O.
|
||||||
|
/// </summary>
|
||||||
|
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||||||
|
{
|
||||||
|
_liveSession = session;
|
||||||
|
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||||||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||||||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||||||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||||||
|
|
@ -2011,7 +2058,11 @@ public sealed class GameWindow : IDisposable
|
||||||
// plus a local echo into ChatLog so the player sees their own
|
// plus a local echo into ChatLog so the player sees their own
|
||||||
// message immediately. Closes over _liveSession + Chat so this
|
// message immediately. Closes over _liveSession + Chat so this
|
||||||
// wiring only exists for the lifetime of the live session.
|
// wiring only exists for the lifetime of the live session.
|
||||||
var liveSession = _liveSession;
|
// Step 2: capture the non-null `session` parameter rather than
|
||||||
|
// the nullable _liveSession field. Semantically identical (the
|
||||||
|
// field WAS set to `session` at the top of WireLiveSessionEvents)
|
||||||
|
// but the compiler can prove non-null for the lambda body.
|
||||||
|
var liveSession = session;
|
||||||
var chat = Chat;
|
var chat = Chat;
|
||||||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||||||
var turbineChat = TurbineChat;
|
var turbineChat = TurbineChat;
|
||||||
|
|
@ -2119,58 +2170,6 @@ public sealed class GameWindow : IDisposable
|
||||||
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
|
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
|
||||||
_liveSession.VitalCurrentUpdated += v =>
|
_liveSession.VitalCurrentUpdated += v =>
|
||||||
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
|
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
|
||||||
|
|
||||||
Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
|
|
||||||
_liveSession.Connect(user, pass);
|
|
||||||
Chat.OnSystemMessage("connected — character list received", chatType: 1);
|
|
||||||
|
|
||||||
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
|
|
||||||
{
|
|
||||||
Console.WriteLine("live: no characters on account; disconnecting");
|
|
||||||
_liveSession.Dispose();
|
|
||||||
_liveSession = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var chosen = _liveSession.Characters.Characters[0];
|
|
||||||
_playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
|
|
||||||
_vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid
|
|
||||||
Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast
|
|
||||||
_worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
|
|
||||||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
|
||||||
_liveSession.EnterWorld(user, characterIndex: 0);
|
|
||||||
|
|
||||||
// L.0 Character tab: swap the SettingsVM's character bag
|
|
||||||
// from the "default" pre-login bag to the actual chosen
|
|
||||||
// toon's bag. Every Save from now on writes under the
|
|
||||||
// chosen toon's name. LoadCharacterContext rebinds BOTH
|
|
||||||
// persisted + draft so HasUnsavedChanges doesn't flag the
|
|
||||||
// swap as a pending edit.
|
|
||||||
_activeToonKey = chosen.Name;
|
|
||||||
if (_settingsStore is not null && _settingsVm is not null)
|
|
||||||
{
|
|
||||||
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
|
|
||||||
_settingsVm.LoadCharacterContext(toonBag);
|
|
||||||
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
|
|
||||||
}
|
|
||||||
// Phase K.2: arm auto-entry. The guard's predicates won't
|
|
||||||
// pass yet — the entity stream hasn't started — but the
|
|
||||||
// OnUpdate tick re-checks every frame and fires once
|
|
||||||
// everything converges (typically 100-300 ms after EnterWorld
|
|
||||||
// returns). User can pre-empt via DebugPanel "Toggle
|
|
||||||
// Free-Fly Mode" or Tab; both call Cancel() first.
|
|
||||||
_playerModeAutoEntry?.Arm();
|
|
||||||
Console.WriteLine($"live: in world — CreateObject stream active " +
|
|
||||||
$"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Console.WriteLine($"live: session failed: {ex.Message}");
|
|
||||||
_combatChatTranslator?.Dispose();
|
|
||||||
_combatChatTranslator = null;
|
|
||||||
_liveSession?.Dispose();
|
|
||||||
_liveSession = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -6248,7 +6247,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// CreateObject events find their landblock already loaded in
|
// CreateObject events find their landblock already loaded in
|
||||||
// GpuWorldState. Non-blocking — returns immediately if no datagrams
|
// GpuWorldState. Non-blocking — returns immediately if no datagrams
|
||||||
// are in the kernel buffer. Fires EntitySpawned events synchronously.
|
// are in the kernel buffer. Fires EntitySpawned events synchronously.
|
||||||
_liveSession?.Tick();
|
// Step 2: routed through the controller; functionally identical.
|
||||||
|
_liveSessionController?.Tick();
|
||||||
|
|
||||||
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
// Phase K.1a — tick the input dispatcher so Hold-type bindings
|
||||||
// re-fire while their chord is held. K.1b adds the subscribers
|
// re-fire while their chord is held. K.1b adds the subscribers
|
||||||
|
|
@ -10194,7 +10194,8 @@ public sealed class GameWindow : IDisposable
|
||||||
// Phase I.7: unsubscribe combat → chat translator before the
|
// Phase I.7: unsubscribe combat → chat translator before the
|
||||||
// session it depends on goes away.
|
// session it depends on goes away.
|
||||||
_combatChatTranslator?.Dispose();
|
_combatChatTranslator?.Dispose();
|
||||||
_liveSession?.Dispose();
|
_liveSessionController?.Dispose();
|
||||||
|
_liveSession = null;
|
||||||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||||||
_wbDrawDispatcher?.Dispose();
|
_wbDrawDispatcher?.Dispose();
|
||||||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue