acdream/src/AcDream.App/Rendering/GameWindow.cs
Erik 476c2e6de1 test(app): freeze GameWindow lifecycle boundaries
Pin the accepted startup, input, frame, resize, shutdown, and native-window order before Slice 8 moves those edges, while deleting only unread duplicate state and test-only GameWindow facades.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 09:27:55 +02:00

4623 lines
227 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using AcDream.Core.Plugins;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Content;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
private readonly AcDream.App.RuntimeOptions _options;
private readonly AnimationPresentationDiagnostics _animationDiagnostics;
private readonly string _datDir;
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
private readonly AcDream.Core.Selection.SelectionState _selection;
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IDatReaderWriter? _dats;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private float _lastMouseX
{
get => _pointerPosition.X;
set => _pointerPosition.X = value;
}
private float _lastMouseY
{
get => _pointerPosition.Y;
set => _pointerPosition.Y = value;
}
private Shader? _meshShader;
private TextureCache? _textureCache;
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
// wireframes are noisy outdoors and confuse first-time users into
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
// → Diagnostics → "Toggle collision wires" button toggles too.
private readonly AcDream.App.Rendering.WorldSceneDebugState
_worldSceneDebugState = new();
// Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in
// favor of the ImGui-backed DebugPanel (see _debugVm below). The
// TextRenderer + BitmapFont fields stay alive because they're shared
// with UiHost and reserved for the future world-space HUD (D.6 —
// damage floaters, name plates) where ImGui can't reach into the 3D
// scene. They are no longer used for any debug overlay.
private TextRenderer? _textRenderer;
private BitmapFont? _debugFont;
// [FRAME-DIAG]: retain only the render-thread entity upload distribution.
// Landblock publication timing/counts live with the focused publishers and
// are read from their typed snapshots when diagnostics flush.
private readonly bool _frameDiag = string.Equals(
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"),
"1",
System.StringComparison.Ordinal);
// MP0 (2026-07-05): permanent frame profiler — one FrameBoundary call
// per OnRender + three stage scopes. All logic lives in
// AcDream.App.Diagnostics.FrameProfiler (structure rule 1).
private readonly AcDream.App.Diagnostics.FrameProfiler _frameProfiler = new();
private readonly AcDream.App.Rendering.IRenderFrameDiagnosticLog
_renderDiagnosticLog =
new AcDream.App.Rendering.ConsoleRenderFrameDiagnosticLog();
private readonly AcDream.App.Rendering.DebugVmRenderFactsPublisher
_debugVmRenderFacts = new();
private AcDream.App.Rendering.RenderFrameDiagnosticsController?
_renderFrameDiagnostics;
private AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private AcDream.App.Rendering.RenderFrameOrchestrator?
_renderFrameOrchestrator;
private AcDream.App.Rendering.WorldSceneSkyState? _worldSceneSkyState;
private ResourceShutdownTransaction? _shutdown;
private readonly DisplayFramePacingController _displayFramePacing;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.LandblockPresentationPipeline?
_landblockPresentationPipeline;
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
_localPlayerTeleportSink = new();
private AcDream.App.Streaming.LocalPlayerTeleportController?
_localPlayerTeleport;
private readonly AcDream.App.Rendering.WorldRenderRangeState _renderRange =
new(nearRadius: 4, farRadius: 12);
private int _nearRadius
{
get => _renderRange.NearRadius;
set => _renderRange.NearRadius = value;
}
private int _farRadius
{
get => _renderRange.FarRadius;
set => _renderRange.FarRadius = value;
}
// A.5 T22.5: resolved quality settings (preset + env-var overrides).
// Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on
// ReapplyQualityPreset(). Default matches QualityPreset.High so the field
// is valid before OnLoad fires (no GL calls are made before OnLoad anyway).
private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality =
AcDream.UI.Abstractions.Settings.QualitySettings.From(
AcDream.UI.Abstractions.Settings.QualityPreset.High);
// Phase B.3: physics engine — populated from the streaming pipeline.
private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new();
// Task 4: physics data cache — BSP trees + collision shapes extracted from
// GfxObj/Setup dats during streaming. Populated on the worker thread;
// ConcurrentDictionary inside makes cross-thread access safe.
private readonly AcDream.Core.Physics.PhysicsDataCache _physicsDataCache = new();
// #184 Slice 2a: the per-remote dead-reckoning tick, extracted out of the
// >10k-line TickAnimations (Code Structure Rule 1). Reusable setup/motion
// policy is supplied through the once-bound live motion runtime, while the
// stateless server-velocity cycle remains a focused Physics helper.
private readonly AcDream.App.Physics.RemotePhysicsUpdater _remotePhysicsUpdater;
private readonly AcDream.App.Physics.RemoteInboundMotionDispatcher
_remoteInboundMotion;
private readonly AcDream.App.World.RetailInboundEventDispatcher
_inboundEntityEvents = new();
private LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = null!;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
// each runtime component is stored on the canonical LiveEntityRecord.
private AcDream.App.Physics.ProjectileController? _projectileController;
private AcDream.App.World.LiveEntityProjectionWithdrawalController?
_liveEntityProjectionWithdrawal;
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates;
private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!;
private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
_liveEntityMotionBindings = new();
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
// the render thread (LandblockBuildFactory on the worker; live-spawn
// handlers + animation ticks on the render
// thread) corrupt that state and produce half-populated LandBlock.Height[]
// arrays, rendering as "a giant ball with spikes". All _dats.Get<T> call
// sites that can race with the streaming worker MUST hold this lock.
//
// Worker-thread DAT reads enter through LandblockBuildFactory.Build, which
// holds _datLock for the complete CPU transaction. The terrain-mesh closure
// consumes that completed heightmap and performs no DAT read. Render-thread
// live hydration holds this lock via its outer wrapper; all remaining
// render-thread
// _dats.Get calls run only when no worker dat read can be in flight (during
// initialization or within the same lock scope).
private readonly object _datLock = new();
// Terrain build context shared by the off-thread mesh-build closure across
// all streamed landblocks.
private float[]? _heightTable;
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
// Was: Dictionary<uint, SurfaceInfo>. ConcurrentDictionary so the off-thread
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render
// through this dedicated pipeline now, NOT through WbDrawDispatcher.
// The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity
// records with real GfxObj MeshRefs); only the cell GEOMETRY (walls /
// floors / ceilings) flows through here.
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): portal-view draw owners are retained transitively
// by the frame orchestrator rather than duplicated as GameWindow roots.
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
// UBO). In U.3 a single ClipFrame.NoClip() instance is created lazily (??=) and
// REUSED across frames — its GL buffers persist; only the cheap CPU-side no-clip
// state is re-uploaded each frame before terrain/entities draw, so the whole
// scene renders ungated (identical to pre-U.3). The buffer ids are handed to the
// three renderers so each re-binds binding=2 immediately before its own draw.
// U.4 replaces the NoClip() frame with one built from the portal-visibility result.
private ClipFrame? _clipFrame;
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
/// of these every frame, advances the current frame number, then
/// rebuilds the entity's MeshRefs by re-flattening the Setup against
/// the new <see cref="DatReaderWriter.Types.AnimationFrame"/>.
/// Static decorations and entities with no motion table never
/// appear in this map.
/// </summary>
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
/// paired with <see cref="_animatedEntities"/> — that dictionary is the
/// gating predicate, this cache is the lookup that depends on it.
/// Passed to <see cref="AcDream.App.Rendering.Wb.WbDrawDispatcher"/> at
/// construction time. Tasks 9-10 of the cache plan wire the per-entity
/// miss-populate / hit-fast-path through the dispatcher's loop.
/// </summary>
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
private AcDream.Core.Physics.IAnimationLoader? _animLoader;
private AcDream.App.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder;
// Phase E.1: central fan-out for animation hooks. Audio (E.2),
// particles (E.3), combat (E.4), and renderer state mutators all
// register sinks at startup. The router is always non-null so the
// per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
// failed to init; all three are set together.
private AcDream.App.Audio.OpenAlAudioEngine? _audioEngine;
private AcDream.Core.Audio.DatSoundCache? _soundCache;
private AcDream.App.Audio.DictionaryEntitySoundTable? _entitySoundTables;
private AcDream.App.Audio.AudioHookSink? _audioSink;
// Phase E.3 particles.
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private readonly AcDream.App.Rendering.Vfx.ParticleVisibilityController _particleVisibility = new();
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
// and nested/default-script routing at its StartTime offset.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per
// ACE Player_Tick.cs line 368: the client never sends "released forward"
// MoveToState, so the server never broadcasts an explicit stop. Observer
// must infer it from position deltas.
private readonly AcDream.App.Physics.RemoteMovementObservationTracker
_remoteMovementObservations = new();
/// <summary>
/// Per-remote-entity movement state for smoothing between server
/// UpdatePosition broadcasts. Retail queues each received position and
/// advances the body toward the queue head through
/// <c>InterpolationManager::adjust_offset</c> every object tick.
///
/// <para>
/// Each entry owns the retail interpolation queue, MovementManager,
/// PhysicsBody, latest authoritative position, and the velocity estimate
/// used only to select animation cycles for NPCs that receive no motion
/// messages. Body translation consumes queue catch-up plus the literal
/// root-motion Frame emitted by CSequence; it never guesses movement from
/// a Walk/Run command or from packet cadence.
/// </para>
/// </summary>
/// <summary>
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
/// keyed by server guid. Retail keeps these stamps on CPhysicsObj
/// (update_times); seeded from CreateObject's PhysicsDesc timestamp
/// block during <see cref="LiveEntityHydrationController.OnCreate"/>, consulted at the
/// top of <see cref="LiveEntityNetworkUpdateController.OnMotion"/>, and dropped by the live
/// entity teardown owner.
/// </summary>
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
// Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global
// chat rooms. Empty/disabled until the server fires
// SetTurbineChatChannels (0x0295) shortly after EnterWorld.
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Items.ItemManaState ItemMana = new();
public readonly AcDream.Core.Social.FriendsState Friends = new();
public readonly AcDream.Core.Social.SquelchState Squelch = new();
public IReadOnlyList<(uint Id, uint Amount)> DesiredComponents { get; private set; }
= System.Array.Empty<(uint Id, uint Amount)>();
// Retail resolves learned IDs through portal.dat's CSpellTable. MagicCatalog
// installs that immutable table once DatCollection opens in OnLoad.
public AcDream.Core.Spells.SpellTable SpellTable => SpellBook.Metadata;
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.ClientObjectTable Objects = new();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.App.Rendering.ImGuiDevToolsFrameBackend? _devToolsBackend;
private AcDream.App.Rendering.DevToolsFramePresenter? _devToolsFramePresenter;
private AcDream.App.Rendering.DevToolsCameraMenuOperations? _devToolsCameraMenu;
private AcDream.App.Rendering.DevToolsCommandBusSource? _devToolsCommandBus;
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
private AcDream.UI.Abstractions.Panels.Chat.ChatVM? _retailChatVm;
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.CombatFeedbackSlot
_combatFeedback = new();
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
private readonly AcDream.Core.Items.ExternalContainerState _externalContainers = new();
private AcDream.App.World.ExternalContainerLifecycleController? _externalContainerLifecycle;
private AcDream.App.Spells.MagicRuntime? _magicRuntime;
private AcDream.App.Spells.MagicCatalog? _magicCatalog;
private readonly AcDream.Core.Items.StackSplitQuantityState _stackSplitQuantity = new();
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
private AcDream.App.Rendering.PaperdollFramePresenter? _paperdollFramePresenter;
// Phase D.2b Task 9 — plugin UI registrations buffered before OnLoad; drained in OnLoad.
private readonly AcDream.App.Plugins.BufferedUiRegistry? _uiRegistry;
// Phase I.2: ImGui debug panel ViewModel. The devtools presenter owns
// its panel; the VM remains here because runtime feedback producers bind
// directly to it during composition.
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
// DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools;
// Slice 3: the reset transaction remains cached by the host, while the
// lifecycle controller owns each exact event/command/session generation.
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
public readonly AcDream.Core.World.WeatherSystem Weather = new();
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
private AcDream.App.Rendering.Vfx.LiveEntityLightController? _liveEntityLights;
private AcDream.App.World.LiveEntityPresentationController? _liveEntityPresentation;
// #188 — TransparentPartHook fires from the animation pipeline drive
// a per-(entity,part) translucency ramp; WbDrawDispatcher reads it
// per frame. Wired into the hook router in OnLoad, advanced once per
// frame in the main loop regardless of _animatedEntities membership
// (a fade must keep running even if its entity's one-shot cycle ends).
private readonly AcDream.Core.Rendering.TranslucencyFadeManager _translucencyFades = new();
private AcDream.Core.Rendering.TranslucencyHookSink? _translucencySink;
// Phase G.1 sky renderer + shared UBO. Created once the GL context
// exists in OnLoad; shared across every other renderer via
// binding = 1 so terrain/mesh/instanced/sky all read the same
// sun / ambient / fog / flash data per frame.
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
// Phase 3a — retail-faithful per-Dereth-day weather roll. The active
// DayGroup is re-picked deterministically whenever the server clock
// crosses a DayTicks boundary. <c>long.MinValue</c> sentinel means
// "no day rolled yet" so the first RefreshSkyForCurrentDay call
// unconditionally installs a provider. See r12 §11 for the roller
// semantics.
private long _loadedSkyDayIndex = long.MinValue;
// F7 / F10 debug-cycle steps for time + weather. Initialized out of
// range of the real values so the first press hits index 0 of the
// cycle table cleanly.
private int _timeDebugStep = 0;
private int _weatherDebugStep = 0;
// Phase B.2: player movement mode.
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController
=> _playerControllerSlot.Controller;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
=> _chaseCameraInput.Legacy;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera
=> _chaseCameraInput.Retail;
private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
private bool _playerMode
=> _localPlayerMode.IsPlayerMode;
private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new();
private uint _playerServerGuid
{
get => _localPlayerIdentity.ServerGuid;
set => _localPlayerIdentity.ServerGuid = value;
}
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly AcDream.App.Input.LocalPlayerSkillState _localPlayerSkills = new();
private readonly AcDream.App.Input.ViewportAspectState _viewportAspect = new();
private AcDream.App.Input.PlayerModeController? _playerModeController;
private readonly AcDream.App.Interaction.PlayerApproachCompletionState
_playerApproachCompletions = new();
private AcDream.App.Input.LocalPlayerAnimationController?
_localPlayerAnimation;
private AcDream.App.Physics.LocalPlayerShadowSynchronizer?
_localPlayerShadowSynchronizer;
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
// Mouse sensitivity multipliers — one per camera mode because the visual
// feel is very different. Adjust via F8 / F9 for whichever mode is
// currently active. Chase default is low because the character + camera
// rotating together is overwhelming at fly speeds.
private float _sensChase
{
get => _chaseCameraInput.Sensitivity;
set => _chaseCameraInput.Sensitivity = value;
}
private float _sensFly = 1.0f;
private float _sensOrbit = 1.0f;
// Right-mouse-button held → free-orbit the chase camera around the
// player without turning the character. Release leaves the camera at
// the orbited position (no snap back).
private bool _rmbHeld
{
get => _chaseCameraInput.RmbOrbitHeld;
}
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by ApplyLiveSessionEnteredWorld; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely
// offline (orbit camera stays the foreground). The class internally
// tracks IsArmed; we read it via the guard rather than mirroring
// the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// Phase K.1b — single input path. Every keyboard/mouse-button reaction
// flows through InputDispatcher.Fired (see OnInputAction below) or
// IsActionHeld (per-frame polling for movement). The legacy direct
// kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only
// mouse.MouseMove survives as a direct handler because mouse-delta is
// axis input, not chord input.
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
private AcDream.App.Input.SilkMouseSource? _mouseSource;
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture;
private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture;
private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput;
private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new();
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
// falling back to the retail-faithful defaults if the file is missing
// or corrupt. This is THE single source of truth for the keymap at
// startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
// should land in the GameWindow construction path.
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = LoadStartupKeyBindings();
private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings()
{
var path = AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath();
var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
return bindings;
}
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
// Slice 3: the controller is the sole App owner. Outbound feature
// callbacks resolve this borrowed handle at call time and never cache it.
private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.Core.Net.WorldSession? LiveSession =>
_liveSessionController?.CurrentSession;
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
private int _liveCenterX => _liveWorldOrigin.CenterX;
private int _liveCenterY => _liveWorldOrigin.CenterY;
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
// single-flag reads instead of env-var lookups. True iff
// ACDREAM_LIVE=1 was set when the window came up.
// Backed by RuntimeOptions.LiveMode via the _options field.
/// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. This is the canonical materialized top-level
/// projection, including live objects parked in pending landblocks;
/// visibility must never suppress authoritative mutation.
/// </summary>
private static readonly IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> EmptyLiveEntityMap =
new Dictionary<uint, AcDream.Core.World.WorldEntity>();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> EmptyLiveSpawnMap =
new Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn>();
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid =>
_liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap;
/// <summary>Visible-only view for radar, picking, status, and targets.</summary>
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities?.WorldEntities ?? EmptyLiveEntityMap;
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured before the renderability gate so no-position inventory /
/// parented children retain Setup and parent metadata; hydration rereads
/// this canonical snapshot after every relationship callback.
/// <see cref="LiveEntityHydrationController.OnAppearance"/> reuses the cached
/// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
/// updated visuals.
/// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields.
private readonly AcDream.App.Input.LocalPlayerPhysicsHostSlot
_playerHostSlot = new();
private EntityPhysicsHost? _playerHost
=> _playerHostSlot.Host;
// R5-V2: guid → per-entity IPhysicsObjHost registry (retail's
// CObjectMaint::GetObjectA lookup). Backs every host's GetObjectA seam,
// giving the TargetManager voyeur round-trip its cross-entity delivery
// path. Populated for remotes plus the PlayerModeController local entry
// (player); pruned only by logical LiveEntityRuntime teardown.
// ServerControlledVelocityStaleSeconds moved to RemotePhysicsUpdater (#184
// Slice 2a — the DR tick's stale-velocity anim-stop was its only user).
public GameWindow(
AcDream.App.RuntimeOptions options,
WorldGameState worldGameState,
WorldEvents worldEvents,
AcDream.Core.Selection.SelectionState selection,
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
_retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_inputCapture);
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_displayFramePacing = new DisplayFramePacingController(
options.UncappedRendering,
_frameProfiler);
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
// setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder,
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
(movement, cellId, update) =>
_liveEntityMotionBindings.RouteServerMoveTo(
movement, cellId, update),
(host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
_movementTruthDiagnostics =
new AcDream.App.Input.MovementTruthDiagnosticController(
options.DumpMoveTruth,
_playerControllerSlot,
_localPlayerIdentity);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
_movementTruthDiagnostics);
}
public void Run()
{
// A.5 T22.5: resolve quality preset BEFORE creating the window so
// Samples (MSAA) is baked into WindowOptions correctly. GL context
// sample count cannot change at runtime; all other quality fields are
// applied again in OnLoad after the full settings load.
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
FramePacingPolicy startupPacing =
_displayFramePacing.InitializeStartup(startupDisplay.VSync);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = startupPacing.UseVSync,
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
// explicit rather than platform-implicit.
PreferredStencilBufferBits = 8,
};
_window = Window.Create(options);
_displayFramePacing.BindSurface(
new SilkDisplayFramePacingSurface(_window));
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
// Registered after OnRender so software pacing waits after all frame
// work and before Silk.NET performs its automatic buffer swap.
_window.Render += _displayFramePacing.OnFrameRendered;
_window.Closing += OnClosing;
_window.FocusChanged += OnFocusChanged;
_window.Move += _displayFramePacing.OnWindowMoved;
_window.StateChanged += _displayFramePacing.OnWindowStateChanged;
// L.0 Display tab: keep the GL viewport + camera aspect in sync
// with the window framebuffer. Without this handler, resizing
// the window (or applying a Display-tab Resolution change at
// startup) leaves the viewport pinned to the original size —
// user sees a small render in the corner of a big window.
_window.FramebufferResize += OnFramebufferResize;
_window.Run();
}
private void OnLoad()
{
_worldSceneSkyState ??= new AcDream.App.Rendering.WorldSceneSkyState(
WorldTime);
// Task 7: wire the physics data cache into the engine so Transition can
// run narrow-phase BSP tests during FindObjCollisions.
_physicsEngine.DataCache = _physicsDataCache;
_gl = GL.GetApi(_window!);
_gpuFrameFlights = new AcDream.App.Rendering.GpuFrameFlightController(_gl);
var worldRenderDiagnostics = new AcDream.App.Rendering.WorldRenderDiagnostics(
new AcDream.App.Rendering.SilkRenderGlStateReader(_gl),
_renderDiagnosticLog);
_input = _window!.CreateInput();
// Phase K.1b — every keyboard/mouse handler routes through the
// InputDispatcher. The legacy direct kb.KeyDown / mouse.MouseDown
// switches are gone; subscribers below own all game-side reactions.
// We KEEP a direct mouse.MouseMove handler because mouse-delta is
// axis input, not chord input — but with explicit WantCaptureMouse
// gating and the previous "_playerMouseDeltaX +=" line dropped so
// mouse delta NEVER drives character yaw (regression-prevention
// per K.1b plan §D).
var firstKb = _input.Keyboards.FirstOrDefault();
var firstMouse = _input.Mice.FirstOrDefault();
if (firstKb is not null && firstMouse is not null)
{
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
_mouseSource = new AcDream.App.Input.SilkMouseSource(
firstMouse,
_inputCapture,
_kbSource);
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_movementInput.Bind(_inputDispatcher);
_cameraInput.Bind(_inputDispatcher);
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
}
// Mouse delta handler — kept direct because Silk.NET delivers mouse
// moves as continuous (x, y) positions, not chord events. We gate
// on WantCaptureMouse so panel hover never drives camera. The
// previous "_playerMouseDeltaX += dx * sens" branch is GONE — mouse
// never drives character yaw in K.1b. RMB-held orbit is wired via
// the dispatcher's AcdreamRmbOrbitHold action (subscriber sets
// _rmbHeld below).
foreach (var mouse in _input.Mice)
{
mouse.MouseMove += (m, pos) =>
{
// K.1b §E: explicit WantCaptureMouse defense-in-depth on the
// surviving direct-mouse handler. Suppresses RMB orbit /
// FlyCamera look while ImGui has the mouse focus.
if (_inputCapture.WantCaptureMouse)
{
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
return;
}
if (_cameraController is null) return;
float dx = pos.X - _lastMouseX;
float dy = pos.Y - _lastMouseY;
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
float sens = _sensChase;
if (_gameplayInputFrame?.MouseLookActive == true)
{
// DirectInput may deliver several device records before
// one retail GetInput pass. Accumulate raw motion here;
// the update loop filters the aggregate exactly once.
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
}
else if (_rmbHeld)
{
// Hold-RMB orbit: player stays the central point, camera
// free-orbits around. X rotates around, Y pitches. On release
// the camera STAYS at the new angle (no snap back).
// K.1b: this is the ONLY mouse-delta path that affects
// ANYTHING when in player mode — character yaw is
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
// above takes precedence when active.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
float nowSec = (float)(Environment.TickCount64 / 1000.0);
var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
_retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
}
else
{
_chaseCamera.YawOffset -= dx * 0.004f * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
}
// K-fix1 (2026-04-26): no default-pitch path. With
// neither MMB nor RMB held, mouse moves the cursor
// freely so the user can interact with panels +
// (eventually) click selectables in the world. Pitch
// is gated on a deliberate hold input.
}
else if (_cameraController.IsFlyMode)
{
float sens = _sensFly;
_cameraController.Fly.Look(dx * sens, dy * sens);
}
else
{
// Orbit-camera mode (offline / pre-login): hold LMB to
// drag-rotate. K.1b reads the mouse-button state via
// IMouseSource so all "is button held" queries go
// through the same abstraction the dispatcher uses.
float sens = _sensOrbit;
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
{
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
_cameraController.Orbit.Pitch = Math.Clamp(
_cameraController.Orbit.Pitch + dy * 0.005f * sens,
0.1f, 1.5f);
}
}
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
};
}
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest);
// Phase U.3: the 8 hardware clip planes (GL_CLIP_DISTANCE0..7) are NOT
// enabled here. Only the mesh_modern / terrain_modern vertex shaders write
// gl_ClipDistance[0..7]; the sky / particle / weather / UI / debug-line
// shaders write gl_Position but no gl_ClipDistance. Enabling a clip plane
// for a draw whose vertex shader doesn't write that plane's distance is
// UNDEFINED per the GL/GLSL spec (a driver may read the unwritten value as
// negative and clip the primitive away). So instead of a permanent global
// enable, OnRender brackets glEnable/glDisable(GL_CLIP_DISTANCE0..7) around
// ONLY the clip-writing world-geometry draws (terrain + entities, plus
// U.4's EnvCellRenderer.Render); everything else draws with clipping off.
string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
// Phase N.5b: terrain_modern shader pair — bindless texture handles +
// glMultiDrawElementsIndirect dispatch path. The only terrain shader
// since Task 9 retired the legacy terrain.vert/.frag program.
_terrainModernShader = new Shader(_gl,
Path.Combine(shadersDir, "terrain_modern.vert"),
Path.Combine(shadersDir, "terrain_modern.frag"));
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting`
// reads from this without further intervention.
_sceneLightingUbo = new SceneLightingUboBinding(_gl);
_debugLines = new DebugLineRenderer(_gl, shadersDir);
// Phase I.2: load a system monospace font + TextRenderer for the
// future world-space HUD (D.6). The custom DebugOverlay is gone;
// the ImGui DebugPanel handles all dev surfaces now. These fields
// are reserved for future work — currently unused at the renderer
// level. Skips silently if no font is available.
var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
if (fontBytes is not null)
{
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
_textRenderer = new TextRenderer(_gl, shadersDir);
Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " +
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
$"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)");
}
else
{
Console.WriteLine("world-hud font: no system monospace font found");
}
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
_cameraController = new CameraController(orbit, fly);
_cameraController.ModeChanged += OnCameraModeChanged;
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder(
_physicsDataCache,
new AcDream.App.Physics.LiveEntityDefaultPoseResolver(
id => _dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
_animLoader,
_animationDiagnostics.DumpMotionEnabled));
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
// Phase E.3 particles: always-on, no driver dependency. Registered
// with the hook router so CreateParticle / DestroyParticle /
// StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
_particleSink.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
_hookRouter,
_effectPoses);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
// resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline.
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g.
// the "fading wall" secret-passage doors) routes here.
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
_hookRouter.Register(_translucencySink);
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (!_options.NoAudio)
{
try
{
_soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
_audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
_entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
if (_audioEngine.IsAvailable)
{
_audioSink = new AcDream.App.Audio.AudioHookSink(
_audioEngine, _soundCache, _entitySoundTables);
_hookRouter.Register(_audioSink);
Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
}
else
{
Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
}
}
catch (Exception ex)
{
Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
}
}
// L.0 follow-up — load + apply persisted Display / Audio settings
// BEFORE the DevToolsEnabled block. The settings.json values
// (resolution, vsync, FOV, master volume, etc) are runtime
// settings, not devtools settings — a user running without
// ACDREAM_DEVTOOLS=1 still expects their saved values to take
// effect. The Settings PANEL (editing UI) is gated on devtools;
// the persisted state is not. Caches values into fields so the
// SettingsVM construction in the devtools block reads them
// without re-loading.
LoadAndApplyPersistedSettings();
// A.5 T22.5: resolve quality preset immediately after settings load so
// _resolvedQuality is available for TerrainAtlas.SetAnisotropic,
// WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below.
{
var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality);
_resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase);
if (!_resolvedQuality.Equals(qBase))
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}");
else
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}");
}
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
if (DevToolsEnabled)
{
AcDream.UI.ImGui.ImGuiBootstrapper? imguiBootstrap = null;
try
{
imguiBootstrap =
new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
var panelHost = new AcDream.UI.ImGui.ImGuiPanelHost();
// VitalsVM: GUID=0 at construction; set later at EnterWorld
// (see the _playerServerGuid assignment path). Pre-login the
// HP bar just reads 1.0 (safe default) — harmless. Stam/Mana
// bars surface only after the first PlayerDescription has
// populated LocalPlayer (Issue #5).
_vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
var vitalsPanel =
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm);
panelHost.Register(vitalsPanel);
// ChatPanel: reads the tail of the shared ChatLog. No GUID
// dependency — works pre-login (empty) and post-login (live
// tail of received speech/tells/channels/system msgs).
// FpsProvider + PositionProvider plumb the runtime state
// the client-side /framerate and /loc commands need; the
// panel asks the VM, the VM asks GameWindow via these
// delegates, no panel-vs-renderer-vs-state coupling.
var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat)
{
FpsProvider = () =>
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
PositionProvider = () => GetDebugPlayerPosition(),
};
var chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm);
panelHost.Register(chatPanel);
// Phase I.2: DebugPanel — replaces the deleted custom
// DebugOverlay (six floating panels + hint bar + toast).
// The VM closes over every data source the old snapshot
// record exposed; reads are live (no per-frame snapshot
// build). Action hooks tie the panel's cycle/toggle
// buttons back to the same routines the F2/F7/F10
// keybinds use.
_debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM(
getPlayerPosition: () => GetDebugPlayerPosition(),
getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(),
getPlayerCellId: () => GetDebugPlayerCellId(),
getPlayerOnGround: () => GetDebugPlayerOnGround(),
getInPlayerMode: () => _playerMode,
getInFlyMode: () => _cameraController?.IsFlyMode ?? false,
getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f,
getEntityCount: () => _worldState.Entities.Count,
getAnimatedCount: () => _animatedEntities.Count,
getLandblocksVisible: () =>
_debugVmRenderFacts.DebugVmFacts.VisibleLandblocks,
getLandblocksTotal: () =>
_debugVmRenderFacts.DebugVmFacts.TotalLandblocks,
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
getNearestObjDist: () =>
_debugVmRenderFacts.DebugVmFacts.NearestObjectDistance,
getNearestObjLabel: () =>
_debugVmRenderFacts.DebugVmFacts.NearestObjectLabel,
getColliding: () =>
_debugVmRenderFacts.DebugVmFacts.Colliding,
getDebugWireframes: () =>
_worldSceneDebugState.CollisionWireframesVisible,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () => GetActiveSensitivity(),
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () => _rmbHeld,
getHourName: () => WorldTime.CurrentCalendar.Hour.ToString(),
getDayFraction: () => (float)WorldTime.DayFraction,
getWeather: () => Weather.Kind.ToString(),
getActiveLights: () => Lighting.ActiveCount,
getRegisteredLights: () => Lighting.RegisteredCount,
getParticleCount: () => _particleSystem?.ActiveParticleCount ?? 0,
getFps: () =>
(float)(_renderFrameDiagnostics?.Snapshot.Fps ?? 60.0),
getFrameMs: () =>
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
combat: Combat);
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
_debugVm.CycleWeather = CycleWeather;
_debugVm.ToggleCollisionWires = ToggleCollisionWires;
// Phase K.2: free-fly toggle button — same routine the
// legacy F-key alias hits. Cancels the one-shot
// auto-entry if the user opts out of player mode before
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
_debugVm.ToggleFlyMode = () =>
_playerModeController?.ToggleFlyOrChase();
_combatFeedback.ViewModel = _debugVm;
var debugPanel =
new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
panelHost.Register(debugPanel);
// Phase K.3 — Settings panel. SettingsVM owns a draft
// copy of the active KeyBindings. Save replaces the
// dispatcher's live table + writes JSON; Cancel reverts
// the draft. Construction is null-safe vs. the
// dispatcher because the dispatcher is built earlier in
// the same OnLoad path (see _inputDispatcher field).
AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? settingsPanel = null;
if (_inputDispatcher is not null && _settingsStore is not null)
{
// L.0 — SettingsStore + persisted-settings load + apply
// happened earlier in OnLoad via
// LoadAndApplyPersistedSettings (settings are runtime
// state, not devtools state — they take effect even
// when ACDREAM_DEVTOOLS=0). Here we just construct the
// Settings PANEL on top of the already-loaded values.
var settingsStore = _settingsStore;
_settingsVm = new AcDream.UI.Abstractions.Panels.Settings.SettingsVM(
persisted: _keyBindings,
dispatcher: _inputDispatcher,
onSave: bindings =>
{
_inputDispatcher.SetBindings(bindings);
try
{
bindings.SaveToFile(
AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
Console.WriteLine(
"keybinds: saved to "
+ AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"keybinds: save failed: {ex.Message}");
}
},
persistedDisplay: _persistedDisplay,
onSaveDisplay: display =>
{
try
{
settingsStore.SaveDisplay(display);
Console.WriteLine(
"settings: display saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Apply window-level changes that are too
// jarring to live-preview (resolution +
// fullscreen). VSync / FOV / ShowFps
// already track DisplayDraft via the
// per-frame push.
ApplyDisplayWindowState(display);
// A.5 T22.5: apply quality preset if it changed.
// MSAA changes log a restart-required warning
// inside ReapplyQualityPreset; all other fields
// apply immediately.
_persistedDisplay = display;
ReapplyQualityPreset(display.Quality);
}
catch (Exception ex)
{
Console.WriteLine($"settings: display save failed: {ex.Message}");
}
},
persistedAudio: _persistedAudio,
onSaveAudio: audio =>
{
try
{
settingsStore.SaveAudio(audio);
Console.WriteLine(
"settings: audio saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"settings: audio save failed: {ex.Message}");
}
},
persistedGameplay: _persistedGameplay,
onSaveGameplay: gameplay =>
{
try
{
settingsStore.SaveGameplay(gameplay);
// Runtime consumers (including retained gmRadarUI) read
// the live persisted snapshot, not SettingsVM's draft.
_persistedGameplay = gameplay;
if (_uiHost is not null)
_uiHost.Root.UiLocked = gameplay.LockUI;
Console.WriteLine(
"settings: gameplay saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Local-only this phase. Server-sync packet
// (CharacterOption bitmask) goes in here when
// the protocol round-trip is in place.
}
catch (Exception ex)
{
Console.WriteLine($"settings: gameplay save failed: {ex.Message}");
}
},
persistedChat: _persistedChat,
onSaveChat: chat =>
{
try
{
settingsStore.SaveChat(chat);
Console.WriteLine(
"settings: chat saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
// Channel filters affect client-side display
// only this phase. ChatPanel will read them
// off SettingsVM.ChatDraft when filtering is
// wired into the chat-line render path.
}
catch (Exception ex)
{
Console.WriteLine($"settings: chat save failed: {ex.Message}");
}
},
persistedCharacter: _persistedCharacter,
onSaveCharacter: character =>
{
try
{
// _activeToonKey is updated by
// ApplyLiveSessionEnteredWorld
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
settingsStore.SaveCharacter(_activeToonKey, character);
if (string.Equals(
_activeToonKey,
"default",
StringComparison.OrdinalIgnoreCase))
{
_persistedCharacter = character;
}
Console.WriteLine(
$"settings: character[{_activeToonKey}] saved to "
+ AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
}
catch (Exception ex)
{
Console.WriteLine($"settings: character save failed: {ex.Message}");
}
});
settingsPanel =
new AcDream.UI.Abstractions.Panels.Settings.SettingsPanel(_settingsVm);
panelHost.Register(settingsPanel);
}
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel + SettingsPanel registered)");
// L.0 Display tab: seed sensible default positions for
// every registered panel. cond=FirstUseEver means imgui.ini
// takes precedence on subsequent launches — the user's
// dragged positions persist. Without this, the first-run
// experience stacks every panel at (0,0) which looks
// broken.
_devToolsBackend = new AcDream.App.Rendering.ImGuiDevToolsFrameBackend(
imguiBootstrap,
panelHost);
_devToolsCameraMenu =
new AcDream.App.Rendering.DevToolsCameraMenuOperations(
_cameraController!);
_devToolsCommandBus =
new AcDream.App.Rendering.DevToolsCommandBusSource();
_devToolsFramePresenter =
new AcDream.App.Rendering.DevToolsFramePresenter(
_devToolsBackend,
_devToolsCameraMenu,
_devToolsCommandBus,
_frameProfiler,
new AcDream.App.Rendering.DevToolsPanelSet(
vitalsPanel,
chatPanel,
debugPanel,
_debugVm,
settingsPanel));
_devToolsFramePresenter.ResetLayout(
_window!.Size.X,
_window.Size.Y,
AcDream.App.Rendering.DevToolsPanelLayoutCondition.FirstUseEver);
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
// The focused backend borrows the bootstrap during normal
// operation. Construction failure still owns this local and
// must release it before abandoning the optional frontend.
imguiBootstrap?.Dispose();
_devToolsBackend = null;
_devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null;
_vitalsVm = null;
_combatFeedback.ViewModel = null;
_debugVm = null;
_settingsVm = null;
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
// celestial mesh layers) and swap WorldTime's provider over to the
// dat-backed keyframes. The stub default provider is only used if
// the Region lacks HasSkyInfo.
if (region is not null)
{
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
if (_loadedSkyDesc is not null)
{
// Phase 3d: do NOT assign WorldTime.TickSize from
// SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241
// FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick
// deadline" period — a throttle — NOT a game-time
// advancement rate. ACE's server advances PortalYearTicks at
// 1.0 ticks per real-second (Timers.cs: `PortalYearTicks +=
// worldTickTimer.Elapsed.TotalSeconds`). Our client
// extrapolation between TimeSyncs must match: 1.0.
//
// Previous behavior: WorldTime.TickSize = 0.8 (from the live
// SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4
// ticks behind the server, producing a visible "acdream sky
// is behind retail" time-of-day mismatch (user-verified
// 2026-04-23).
WorldTime.TickSize = 1.0;
// Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
// calendar-extraction offset. Dereth's dat value is 3600
// (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
// comment that "tick 0 = Morntide-and-Half" (3333.75
// offset = +7/16) is WRONG by 266.25 ticks against the
// authoritative dat. The mismatch cascaded into both the
// wrong hour label AND the wrong DayOfYear at boundary
// times (different LCG seed → different DayGroup roll),
// which explained the user's observation of "acdream
// clear night, retail stormy pre-dawn" at the same
// server PortalYearTicks.
if (region.GameTime is not null)
{
AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
region.GameTime.ZeroTimeOfYear);
Console.WriteLine(
$"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
$"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
}
Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
// Initial DayGroup roll using whatever WorldTime currently
// has (either the hardcoded boot seed or a pre-arrived
// server sync). RefreshSkyForCurrentDay will re-roll when
// ServerTimeUpdated delivers the real ConnectRequest tick.
RefreshSkyForCurrentDay();
}
}
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
// the server sends its first TimeSync packet (offline rendering in
// particular never receives one).
//
// "Noon" here means sun at zenith — dayFraction = 0.5. Because
// DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half,
// hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 =
// Midsong / Hour 9, which is what retail considers noon).
//
// Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were
// zero, but with our 3333.75-tick shift it lands on dayFraction
// 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight),
// producing a dim orange sky with the sun below the horizon until
// TimeSync arrives.
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
// building the terrain atlas / renderer — both consume BindlessSupport
// (atlas via Texture2DArray bindless handles, renderer for SSBO uploads).
// The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
// is mandatory as of Phase N.5 — missing extensions throw at startup with
// a clear error so users can file a real bug report rather than silently
// falling back to a half-working renderer.
if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
{
if (bindless!.HasShaderDrawParameters(_gl))
{
_bindlessSupport = bindless;
Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
}
else
{
Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
}
}
else
{
Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
}
if (_bindlessSupport is null)
{
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// Build the terrain atlas once from the Region dat. Phase N.5b: the
// atlas exposes bindless handles for the modern terrain path, so
// BindlessSupport is threaded through.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport);
// A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect.
terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel);
_terrain = new TerrainModernRenderer(
_gl,
_bindlessSupport,
_terrainModernShader!,
terrainAtlas,
_gpuFrameFlights!);
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
// Build blending context from the terrain atlas. The worker-side
// LandblockBuildFactory captures this immutable table for each build.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// (Bindless detection moved above — must precede TerrainAtlas.Build /
// TerrainModernRenderer ctor so they can consume BindlessSupport.)
// Mesh shader always loads (modern path is the only path).
_meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh_modern.vert"),
Path.Combine(shadersDir, "mesh_modern.frag"));
Console.WriteLine("[N.5] mesh_modern shader loaded");
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport, _gpuFrameFlights!);
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
// the sky pass can pick wrap mode per submesh without mutating
// shared per-texture wrap state. See SamplerCache + the
// WorldBuilder reference at
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
// Retail ClientCombatSystem attack-request owner. This exists independently
// of the retained UI so keyboard combat keeps the same press/hold/release
// semantics even when the retail renderer is disabled.
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
Combat,
_combatAttackOperations);
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
Combat,
_selection,
autoTarget: () => _persistedGameplay.AutoTarget,
selectClosestTarget: () => SelectClosestCombatTarget(showToast: false));
_externalContainerLifecycle = new AcDream.App.World.ExternalContainerLifecycleController(
_externalContainers,
Objects,
guid => LiveSession?.SendNoLongerViewingContents(guid));
AcDream.App.Spells.MagicCatalog magicCatalog = _magicCatalog!;
_itemInteractionController = new AcDream.App.UI.ItemInteractionController(
Objects,
playerGuid: () => _playerServerGuid,
// ItemHolder::UseObject is the common policy owner, but world
// activation still has to pass through the application-level
// use adapter. It owns speculative turn/move, the close-range
// deferred send, and ACE's far-range MoveTo callback. Calling
// WorldSession directly here made keyboard R approach a corpse
// without completing the same open transaction as double-click.
sendUse: SendUse,
sendExamine: g => LiveSession?.SendAppraise(g),
sendUseWithTarget: (source, target) => LiveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => LiveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => LiveSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
LiveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
combatState: Combat,
sendChangeCombatMode: mode =>
LiveSession?.SendChangeCombatMode(mode),
isComponentPack: magicCatalog.IsComponentPack,
placeInBackpack: SendPickUp,
backpackContainerId: () =>
_retailUiRuntime?.InventoryPanelController?.CurrentOpenContainerId
?? _playerServerGuid,
// Retail ItemHolder::DetermineUseResult treats children of the
// current ground object as loot. Keep this late-bound because
// ViewContents can replace/close the external container while
// the retained controller remains alive.
groundObjectId: () => _externalContainers.CurrentContainerId,
sendSplitToWorld: (item, amount) =>
LiveSession?.SendStackableSplitTo3D(item, amount),
selectedObjectId: () => _selection.SelectedObjectId ?? 0u,
stackSplitQuantity: _stackSplitQuantity,
systemMessage: text => Chat.OnSystemMessage(text, 0x1Au),
sendPutItemInContainer: (item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
sendSplitToContainer: (item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
requestExternalContainer: guid => _externalContainers.RequestOpen(guid));
AcDream.App.World.DeferredLiveEntityParentAcceptance? parentAcceptance = null;
// Phase D.2b retail-look retained UI (ACDREAM_RETAIL_UI=1).
if (_options.RetailUi)
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
// Retail UpdateCursorState (0x00564630) keys target-mode
// valid/invalid off the SmartBox found object — the world
// entity under the cursor, self included.
worldTargetProvider: () => PickWorldGuidAtCursor(includeSelf: true) ?? 0u,
combatModeProvider: () => Combat.CurrentMode);
var retailCursorManager = new RetailCursorManager(_dats!, _datLock);
_characterSheetProvider = new AcDream.App.UI.Layout.CharacterSheetProvider(
Objects, LocalPlayer,
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionController?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
sendTrainSkill: (statId, credits) => LiveSession?.SendTrainSkill(statId, credits));
_magicRuntime = AcDream.App.Spells.MagicRuntime.Create(
magicCatalog,
SpellBook,
Objects,
selectedObject: () => _selection.SelectedObjectId,
localPlayerId: () => _playerServerGuid,
// SpellFormula::Randomize hashes the canonical account spelling
// delivered by CharacterList.
accountName: () => LiveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: _combatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionController?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
// pointer is over a widget — no double-handling.
foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
return (t, w, h);
}
// Phase D.5.1 — icon composer for the toolbar shortcut slots.
// Constructed once here so the closure below can capture it; needs
// the same cache reference that ResolveChrome uses above.
var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache);
// Phase D.2b — optional retail stylesheet. controls.ini lives under
// the AC install (ACDREAM_AC_DIR); absent → source-verified fallback.
var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
// Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000,
// Latin-1, 16px, outline atlas). Passed into the importer so the meter
// number overlay renders through the dat-font two-pass blit; falls back to
// the debug font only if it fails to load. Under _datLock like other reads.
AcDream.App.UI.UiDatFont? vitalsDatFont;
lock (_datLock)
vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
var datFontCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?>();
if (vitalsDatFont is not null)
datFontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, vitalsDatFont);
AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
=> datFontCache.GetOrAdd(fontDid, id =>
{
lock (_datLock)
return AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!, id);
});
Console.WriteLine(vitalsDatFont is not null
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
_uiHost.Root.Width = _window!.Size.X;
_uiHost.Root.Height = _window.Size.Y;
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
() => _visibleEntitiesByServerGuid,
() => LastSpawns,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI,
playerEntities: () => _entitiesByServerGuid,
spatialQuery: () => _worldState);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
_settingsStore,
CharacterKey: () => _activeToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
if (_options.UiProbeEnabled
&& _options.AutomationArtifactDirectory is { } artifactDirectory)
{
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
_gl!,
System.IO.Path.Combine(artifactDirectory, "screenshots"),
UiProbeLog);
_worldLifecycleAutomation =
new AcDream.App.Diagnostics.WorldLifecycleAutomationController(
() => _worldReveal?.Snapshot ?? default,
() => _worldReveal?.PortalMaterializationCount ?? 0,
CaptureWorldLifecycleResourceSnapshot,
_frameScreenshots,
artifactDirectory,
UiProbeLog);
}
_retailUiRuntime = AcDream.App.UI.RetailUiRuntime.Mount(
new AcDream.App.UI.RetailUiRuntimeBindings(
Host: _uiHost,
Assets: new AcDream.App.UI.RetailUiAssets(
_dats!, _datLock, ResolveChrome, ResolveDatFont,
vitalsDatFont, _debugFont, controls, iconComposer),
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _liveSessionController?.Commands
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
_selection,
SetRetailUiLocked),
Combat: new AcDream.App.UI.CombatRuntimeBindings(
Combat,
_combatAttackController,
() => _persistedGameplay,
SetRetailCombatGameplay),
Magic: new AcDream.App.UI.MagicRuntimeBindings(
SpellBook,
_magicRuntime.Casting,
Objects,
() => _playerServerGuid,
magicCatalog.Components,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
iconComposer.GetSpellIcon,
iconComposer.GetSpellComponentIcon,
_selection,
magicCatalog.GetSpellLevel,
guid => _selection.Select(
guid, AcDream.Core.Selection.SelectionChangeSource.Inventory),
UseItemByGuid,
(tab, position, spellId) =>
LiveSession?.SendAddSpellFavorite(spellId, position, tab),
(tab, spellId) =>
LiveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => LiveSession?.SendSpellbookFilter(filters),
spellId => LiveSession?.SendRemoveSpell(spellId),
(componentId, amount) =>
LiveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
JumpPowerbar: new AcDream.App.UI.JumpPowerbarRuntimeBindings(
() => _playerController?.JumpCharge ?? default),
Fps: new AcDream.App.UI.FpsRuntimeBindings(
() => _renderFrameDiagnostics?.Snapshot.Fps ?? 60.0,
// Retail displays either the user degrade bias or the live
// distance-driven multiplier. acdream does not yet implement
// adaptive distance degradation (tracked by TS-15), so its
// effective multiplier is exactly 1.0.
() => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps
?? _persistedDisplay.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection,
() => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook,
Objects,
() => _playerServerGuid,
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => LiveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow,
() => LiveSession?.RequestLinkStatusPing(),
() => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
ItemMana,
ToggleLiveCombatMode,
_itemInteractionController,
entry => LiveSession?.SendAddShortcut(entry),
index => LiveSession?.SendRemoveShortcut(index),
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
guid => Objects.Get(guid)?.GetAppropriateName(),
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => LiveSession?.SendQueryHealth(guid),
guid => LiveSession?.SendQueryItemMana(guid),
() => _playerServerGuid,
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
() => _playerServerGuid,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
(source, target, amount) =>
LiveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
_externalContainers,
Objects,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) =>
LiveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
Probe: new AcDream.App.UI.RetailUiProbeBindings(
_options.UiProbeEnabled,
_options.UiProbeScript,
_options.UiProbeDump,
UiProbeLog,
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) =>
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
_worldLifecycleAutomation)));
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// always construct.
// Phase O (2026-05-21): WbMeshAdapter now consumes our DatCollection
// directly via DatCollectionAdapter. No second dat reader; index cache
// is shared with the rest of the client.
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
_gl,
_dats,
wbLogger,
_gpuFrameFlights!);
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
}
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
// and rebuild _worldState so it threads the adapter in. _worldState starts
// as an unadorned GpuWorldState (field initializer); here we replace it with
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
// per-instance content under the same flag.
// N.5 mandatory path: spawn adapters + dispatcher always construct.
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are
// all guaranteed non-null here (startup throws above if any are missing).
var liveEntityComponentLifecycle =
new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
AcDream.App.Physics.LiveEntityMotionRuntimeController?
liveEntityMotionRuntime = null;
{
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
// Sequencer factory: look up Setup + MotionTable from dats and build
// an AnimationSequencer. Falls back to a no-op sequencer when the
// entity has no motion table (static props, etc.). Uses _animLoader
// which is initialised earlier in OnLoad; it is non-null here.
var capturedDats = _dats;
var capturedAnimLoader = _animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
if (capturedDats is not null && capturedAnimLoader is not null)
{
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
capturedAnimLoader);
}
}
// Complete fallback: empty setup + empty motion table + null loader.
return new AcDream.Core.Physics.AnimationSequencer(
new DatReaderWriter.DBObjs.Setup(),
new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader());
}
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// transforms from the entity's exact current MeshRefs so
// multi-emitter scripts distribute across mesh parts (closes #56).
// Animated frames replace this snapshot through EntityEffectPoseRegistry.
// _scriptRunner and
// _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter(
_liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects,
_liveWorldOrigin,
_effectPoses);
var staticAnimationResidency = new LiveStaticAnimationResidency(
_liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!,
_animationHookFrames!.Capture,
_effectPoses.Publish,
staticAnimationResidency.IsResident,
(entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body),
staticAnimationResidency.ProjectionVersion);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
{
DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (e.IndexedPartTransforms.Count == 0)
{
var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
setup,
e);
e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
}
IReadOnlyList<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
bool usesStaticAnimationWorkset = e.ServerGuid == 0
|| (_liveEntities?.TryGetRecord(
e.ServerGuid,
out LiveEntityRecord liveRecord) == true
&& (liveRecord.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile,
availability,
setup,
(uint)setup.DefaultAnimation,
usesStaticAnimationWorkset);
}
catch
{
return null;
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!,
_particleSink!,
_effectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
_lightingSink?.UnregisterOwner(ownerId);
_translucencyFades.ClearEntity(ownerId);
},
(entity, info) => _staticAnimationScheduler.Register(entity, info),
ownerId => _staticAnimationScheduler.Unregister(ownerId),
(entity, info) => _staticAnimationScheduler.Rebind(entity, info));
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
new(
entity => wbEntitySpawnAdapter.OnCreate(entity),
entity => wbEntitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
_liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities,
_physicsDataCache,
() => _selectionInteractions,
_selection,
_liveWorldOrigin);
_liveEntityMotionBindings.Bind(liveEntityMotionRuntime);
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
wbEntitySpawnAdapter.SetPresentationResident(entity, visible);
};
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
new EntityRootPosePublisher(_effectPoses),
_liveWorldOrigin)
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
_liveEntityProjectionWithdrawal =
new AcDream.App.World.LiveEntityProjectionWithdrawalController(
_liveEntities,
_projectileController,
_worldGameState,
_worldEvents,
_physicsEngine.ShadowObjects,
_effectPoses,
_localPlayerShadow);
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities,
_effectPoses,
_lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
_liveEntities,
_localPlayerIdentity,
_remotePhysicsUpdater,
ordinaryPhysicsUpdater,
_projectileController,
new EntityRootPosePublisher(_effectPoses),
new AnimationHookCaptureSink(_animationHookFrames!));
_animationPresenter = new LiveEntityAnimationPresenter(
_liveEntities,
_staticAnimationScheduler,
_effectPoses,
new LiveAnimationPresentationContext(
_liveEntities,
_localPlayerIdentity,
_playerControllerSlot),
_animationDiagnostics,
_options.HidePartIndex);
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
_effectPoses,
parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord,
positionVersion,
projectionVersion,
_playerServerGuid));
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
_effectPoses,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
var partArrayLifecycle =
new AcDream.App.Rendering.LiveEntityPartArrayLifecycle(
_animatedEntities);
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities,
_physicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw,
_liveEntityMotionBindings.ClearTargetForHiddenEntity,
_liveWorldOrigin.GetCenter,
handlePartArrayEnterWorld: partArrayLifecycle.HandleEnterWorld);
var remoteShadowPlacement =
new AcDream.App.Physics.RemoteShadowPlacementSynchronizer(
_remotePhysicsUpdater,
_liveWorldOrigin);
var remoteTeleportPresentation =
new AcDream.App.Physics.RemoteTeleportPlacementPresentation(
_liveEntityPresentation);
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
_liveEntityMotionBindings.GetSetupCylinder,
_liveWorldOrigin.CellLocalForSeed,
remoteShadowPlacement.Sync,
remoteTeleportPresentation.Complete,
remoteTeleportPresentation.Begin);
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)),
_retailAlphaQueue);
_worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery(
_liveEntities,
Objects,
_retailSelectionScene,
() => _playerServerGuid,
() =>
{
var camera = GetSelectionCamera();
return new AcDream.App.Interaction.SelectionCameraSnapshot(
camera.View,
camera.Projection,
camera.Viewport);
},
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
() => _playerController is { } player
? new AcDream.App.Interaction.PlayerInteractionPose(
player.CellId,
player.Position)
: null,
_liveEntityMotionBindings.GetSetupCylinder,
setupId =>
{
if (_dats is null)
return null;
lock (_datLock)
{
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)
|| setup.SelectionSphere is not { } sphere)
{
return null;
}
return (sphere.Origin, sphere.Radius);
}
});
if (_itemInteractionController is { } itemInteractions)
{
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
_selection,
_worldSelectionQuery,
itemInteractions,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
() => LiveSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink(
() => _playerController,
_playerApproachCompletions),
text => _debugVm?.AddToast(text),
_playerApproachCompletions);
}
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
// Slice 2: now that the dispatcher exists, build the paperdoll doll RTT renderer and hand it to
// the viewport widget. Reuses the dispatcher + the scene-lighting UBO + _gl. The widget only
// blits the texture; the pre-UI hook drives the 3-D pass (see OnRender).
if (_retailUiRuntime?.PaperdollViewportWidget is { } paperdollViewport
&& _retailUiRuntime.InventoryFrame is { } paperdollInventoryFrame
&& _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!);
paperdollViewport.Renderer = _paperdollViewportRenderer;
_paperdollFramePresenter =
new AcDream.App.Rendering.PaperdollFramePresenter(
_paperdollViewportRenderer,
new AcDream.App.Rendering.RetailPaperdollFrameView(
paperdollViewport,
new AcDream.App.Rendering.PaperdollInventoryVisibility(
paperdollInventoryFrame)),
new AcDream.App.Rendering.RetailPaperdollDollFactory(
new AcDream.App.Rendering.LivePaperdollEntityLookup(
_liveEntities),
_localPlayerIdentity,
new AcDream.App.Rendering.RetailPaperdollPoseApplicator(
_dats!,
_animLoader!,
_datLock)));
}
// Phase A8: EnvCellRenderer init. Shares _meshShader (mesh_modern.{vert,frag})
// with the dispatcher — both consume the same global mesh buffer (VAO/IBO)
// from ObjectMeshManager.GlobalBuffer.
_envCellFrustum = new AcDream.App.Rendering.Wb.WbFrustum();
_envCellRenderer = new AcDream.App.Rendering.Wb.EnvCellRenderer(
_gl, _wbMeshAdapter!.MeshManager!, _envCellFrustum);
_envCellRenderer.Initialize(_meshShader!);
var landblockRenderPublisher = new AcDream.App.Streaming.LandblockRenderPublisher(
publishTerrain: (landblockId, meshData, origin) =>
_terrain!.AddLandblockWithMesh(landblockId, meshData, origin),
removeTerrain: landblockId => _terrain!.RemoveLandblock(landblockId),
cellVisibility: _cellVisibility,
worldState: _worldState,
commitEnvCells: _envCellRenderer.CommitLandblock,
prepareEnvCells: build =>
AcDream.App.Rendering.Wb.EnvCellMeshPreparationScheduler.Schedule(
build,
_wbMeshAdapter.MeshManager!),
removeEnvCells: _envCellRenderer.RemoveLandblock);
var landblockPhysicsPublisher =
new AcDream.App.Streaming.LandblockPhysicsPublisher(
_physicsEngine,
_heightTable!);
var landblockStaticPresentationPublisher =
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
_lightingSink!,
_translucencyFades,
_worldGameState,
_worldEvents);
var landblockRetirementOwner =
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_lightingSink!,
_translucencyFades);
_landblockPresentationPipeline =
new AcDream.App.Streaming.LandblockPresentationPipeline(
landblockRenderPublisher,
landblockPhysicsPublisher,
landblockStaticPresentationPublisher,
_worldState,
landblockRetirementOwner,
onLandblockLoaded: loadedLandblockId =>
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
ensureEnvCellMeshes:
landblockRenderPublisher.PrepareAfterRenderPins);
_clipFrame ??= ClipFrame.NoClip();
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
_hookRouter,
_wbDrawDispatcher,
_sceneLightingUbo!,
_wbMeshAdapter!);
// Publish the presentation before touching the mesh-reference
// backend. Partial acquisition remains reachable by staged
// shutdown instead of becoming constructor-local leaked state.
_portalTunnel.PrepareResources();
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
// with depth writes off + far plane 1e6 so celestial meshes
// never clip. Shares the TextureCache with the static pipeline.
var skyShader = new Shader(_gl,
Path.Combine(shadersDir, "sky.vert"),
Path.Combine(shadersDir, "sky.frag"));
_skyRenderer = new AcDream.App.Rendering.Sky.SkyRenderer(
_gl, _dats, skyShader, _textureCache!, _samplerCache);
// Phase G.1 particle renderer — renders rain / snow / spell auras
// spawned into the shared ParticleSystem. Mode-1/no-degrade GfxObjs
// retain their authored mesh; Always2D degrade modes use billboards.
// Weather uses AttachLocal emitters so its volume follows the player.
_particleRenderer = new ParticleRenderer(
_gl,
shadersDir,
_particleSystem,
_textureCache,
_dats,
_wbMeshAdapter,
_retailAlphaQueue);
AcDream.App.Rendering.IRenderFrameResourceDiagnosticsSource? resourceDiagnostics =
_options.UiProbeDump
? new AcDream.App.Rendering.RuntimeRenderFrameResourceDiagnosticsSource(
_particleSystem,
_particleSink,
_wbDrawDispatcher,
_envCellRenderer,
_particleRenderer,
_uiHost?.TextRenderer,
_portalDepthMask,
_clipFrame,
_terrain,
_sceneLightingUbo,
_wbMeshAdapter,
_textureCache)
: null;
_renderFrameDiagnostics =
new AcDream.App.Rendering.RenderFrameDiagnosticsController(
new AcDream.App.Rendering.RuntimeRenderFrameTitleFactsSource(
_worldState,
_animatedEntities,
WorldTime),
new AcDream.App.Rendering.SilkRenderFrameTitleSink(_window!),
_renderDiagnosticLog,
_options.UiProbeDump,
resourceDiagnostics);
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
// LoadAndApplyPersistedSettings() above, absorbing all env-var overrides.
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
_nearRadius = _resolvedQuality.NearRadius;
_farRadius = _resolvedQuality.FarRadius;
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius. Parsed once into
// RuntimeOptions.LegacyStreamRadius (null when unset or invalid).
if (_options.LegacyStreamRadius is { } sr)
{
_nearRadius = sr;
_farRadius = System.Math.Max(sr, _farRadius);
}
Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2 * _nearRadius + 1}x{2 * _nearRadius + 1})" +
$" farRadius={_farRadius} (window={2 * _farRadius + 1}x{2 * _farRadius + 1})");
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock acquires _datLock (T10) before touching DatCollection.
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// it can call LandblockMesh.Build without a dat read — _heightTable and
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
var landblockBuildFactory = new AcDream.App.Streaming.LandblockBuildFactory(
_dats!,
_datLock,
_heightTable!,
_physicsDataCache,
_options.DumpSceneryZ);
_streamer = AcDream.App.Streaming.LandblockStreamer.CreateForRequests(
loadLandblock: landblockBuildFactory.Build,
buildMeshOrNull: (id, lb) =>
{
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
return null;
uint lbX = (id >> 24) & 0xFFu;
uint lbY = (id >> 16) & 0xFFu;
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
// _heightTable and _blendCtx are read-only after initialization.
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
});
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind, generation) => _streamer.EnqueueLoad(
new AcDream.App.Streaming.LandblockBuildRequest(
id,
kind,
generation,
new AcDream.App.Streaming.LandblockBuildOrigin(
_liveCenterX,
_liveCenterY))),
enqueueUnload: (id, generation) => _streamer.EnqueueUnload(id, generation),
drainCompletions: _streamer.DrainCompletions,
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
clearPendingLoads: _streamer.ClearPendingLoads,
// Retained-object recovery runs after each
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
// objects it still considers known.
presentationPipeline: _landblockPresentationPipeline!);
_streamingOriginRecenter =
new AcDream.App.Streaming.StreamingOriginRecenterCoordinator(
_streamingController,
_liveWorldOrigin);
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
_worldReveal = new AcDream.App.Streaming.WorldRevealCoordinator(
isRenderNeighborhoodReady: _streamingController.IsRenderNeighborhoodResident,
isSpawnCellReady: _physicsEngine.IsSpawnCellReady,
isTerrainNeighborhoodReady: _physicsEngine.IsNeighborhoodTerrainResident,
areCompositeTexturesReady: () => _wbDrawDispatcher?.CompositeTexturesReady == true,
prepareCompositeTextures: (destinationCell, radius) =>
_wbDrawDispatcher?.PrepareCompositeTextures(
_worldState.Entities,
_worldState.FlatViewGeneration,
destinationCell,
radius),
invalidateCompositeTextures: () =>
_wbDrawDispatcher?.InvalidateCompositeWarmupReadiness(),
isSpawnClaimUnhydratable: IsSpawnClaimUnhydratable,
log: message => Console.WriteLine(message));
var networkUpdateBridge =
new AcDream.App.World.DeferredLiveEntityNetworkUpdateSink();
var sealedDungeonCells =
new AcDream.App.Streaming.DatSealedDungeonCellClassifier(
_dats!,
_datLock);
var projectionMaterializer = new DatLiveEntityProjectionMaterializer(
_options,
_dats!,
_liveEntities!,
_physicsDataCache,
_animLoader!,
_wbEntitySpawnAdapter!,
_textureCache!,
_classificationCache,
_effectPoses,
_equippedChildRenderer!,
_worldGameState,
_worldEvents,
_physicsEngine.ShadowObjects,
_liveEntityCollisionBuilder!,
_projectileController!,
_animationPresenter,
_staticAnimationScheduler!,
_liveWorldOrigin,
_updateFrameClock);
var originCoordinator =
new AcDream.App.World.LiveEntityWorldOriginCoordinator(
_liveWorldOrigin,
_streamingController,
_worldState,
_worldReveal,
_localPlayerIdentity,
sealedDungeonCells,
Console.WriteLine);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
var localPhysicsTimestamps =
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
_localPlayerIdentity,
_liveSessionController);
var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController(
_liveEntities,
_liveEntityPresentation!,
_entityEffects!,
_remoteTeleportController!,
_selectionInteractions,
_selection,
_animatedEntities,
_remoteMovementObservations,
_translucencyFades,
_liveEntityProjectionWithdrawal!,
_equippedChildRenderer!,
_physicsEngine.ShadowObjects,
_liveEntityLights!,
_classificationCache,
_localPlayerIdentity);
liveEntityComponentLifecycle.Bind(liveEntityTeardown);
var liveEntityDeletion =
new AcDream.App.World.LiveEntityDeletionController(
_liveEntities,
Objects,
liveEntityTeardown,
_localPlayerIdentity,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
_liveEntities,
Objects,
_datLock,
projectionMaterializer,
new AcDream.App.World.LiveEntityRelationshipProjection(
_equippedChildRenderer),
new AcDream.App.World.LiveEntityReadyPublisher(
_liveEntities,
_entityEffects!,
_liveEntityPresentation!),
originCoordinator,
networkUpdateBridge,
localPhysicsTimestamps,
_localPlayerIdentity,
liveEntityDeletion,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityNetworkUpdates =
new AcDream.App.Physics.LiveEntityNetworkUpdateController(
_liveEntities,
Objects,
_liveEntityHydration,
_entityEffects!,
_liveEntityPresentation!,
_liveEntityLights!,
_equippedChildRenderer!,
_projectileController!,
_remoteTeleportController!,
_animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
_liveEntityRuntimeSlot),
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
liveEntityMotionRuntime!,
_physicsEngine,
_dats!,
_animLoader!,
_combatTargetController,
_liveWorldOrigin,
_localPlayerTeleportSink,
_playerControllerSlot,
_localPlayerOutbound,
_playerHostSlot,
_localPlayerIdentity,
_updateFrameClock,
_liveSessionController,
localPhysicsTimestamps.Publish,
_movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
_localPlayerIdentity,
liveEntityDeletion);
_liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController(
_inboundEntityEvents,
_liveEntityHydration,
_liveEntityNetworkUpdates,
_localPlayerTeleportSink,
_entityEffects!);
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
_equippedChildRenderer.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid =>
{
if (guid == _playerServerGuid)
_paperdollFramePresenter?.MarkDirty();
};
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
// CreateObject messages into _worldGameState as they arrive. Entirely
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_combatAttackOperations.Bind(
new AcDream.App.Combat.LiveCombatAttackOperations(
Combat,
new AcDream.App.Combat.CombatAttackTargetSource(
_selection,
_liveEntities!,
Objects,
_localPlayerIdentity),
_gameplaySettings,
_playerControllerSlot,
_localPlayerOutbound,
_liveSessionController,
_liveSessionController,
_combatFeedback));
AcDream.App.Input.MouseLookController? mouseLookController =
_mouseSource is not null && _mouseLookCursor is not null
? new AcDream.App.Input.MouseLookController(
_mouseSource,
_pointerPosition,
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_chaseCameraInput,
_movementInput,
_localPlayerOutbound,
_liveSessionController,
_mouseLookCursor,
new AcDream.App.Input.EnvironmentInputMonotonicClock())
: null;
_gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController(
_inputDispatcher,
_movementInput,
mouseLookController,
new AcDream.App.Input.CombatAttackInputFrameAdapter(
_combatAttackController!));
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
_options.LiveMode,
_localPlayerMode,
_playerControllerSlot,
_liveSessionController,
_liveWorldOrigin,
_liveEntityNetworkUpdates!,
new AcDream.App.Streaming.FlyCameraStreamingObserverSource(
_cameraController!),
new AcDream.App.Streaming.PhysicsStreamingDungeonCellSource(
_physicsEngine),
_streamingOriginRecenter!,
_streamingController!,
new AcDream.App.Streaming.LiveProjectionRescueRebucketter(
_worldState,
_liveEntities));
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
_translucencyFades,
_animationHookFrames!,
_entityEffects!,
_particleSink!,
_liveEntityLights!,
_particleVisibility,
_particleSystem!,
_scriptRunner!,
_updateFrameClock,
new AcDream.App.Update.SettingsParticleRangeSource(
_settingsVm,
_persistedDisplay.ParticleRange));
var liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
_equippedChildRenderer!,
_particleSink!,
_liveEntityLights!);
_localPlayerAnimation =
new AcDream.App.Input.LocalPlayerAnimationController(
_liveEntities,
_localPlayerIdentity,
_animatedEntities,
_animationPresenter,
_animationHookFrames!);
_localPlayerShadowSynchronizer =
new AcDream.App.Physics.LocalPlayerShadowSynchronizer(
_physicsEngine,
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_localPlayerShadow);
var localPlayerProjection =
new AcDream.App.Input.LocalPlayerProjectionController(
new AcDream.App.Input.LiveLocalPlayerProjectionRuntime(
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_localPlayerShadowSynchronizer));
var localPlayerFrameRuntime =
new AcDream.App.Input.LiveLocalPlayerFrameRuntime(
_cameraController!,
_localPlayerMode,
_playerControllerSlot,
_chaseCameraInput,
_movementInput,
_inputCapture,
_liveEntities,
_localPlayerIdentity,
_playerHostSlot,
localPlayerProjection,
_localPlayerOutbound,
_liveSessionController);
var localPlayerFrame =
new AcDream.App.Input.RetailLocalPlayerFrameController(
localPlayerFrameRuntime,
_movementInput);
var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
_inboundEntityEvents,
localPlayerFrame,
_selectionInteractions,
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveAnimationScheduler,
_staticAnimationScheduler,
_animationPresenter,
_animatedEntities,
_equippedChildRenderer!,
liveEffectFrame);
_viewportAspect.Update(_window!.Size.X, _window.Size.Y);
_playerModeController = new AcDream.App.Input.PlayerModeController(
_localPlayerMode,
_playerControllerSlot,
_playerHostSlot,
_chaseCameraInput,
_cameraController!,
_physicsEngine,
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveEntityMotionBindings,
_dats!,
_datLock,
_physicsDataCache,
_animatedEntities,
_localPlayerAnimation,
_localPlayerShadowSynchronizer,
_playerApproachCompletions,
_gameplayInputFrame,
_liveSessionController,
_movementTruthDiagnostics,
_localPlayerSkills,
_viewportAspect);
_devToolsCameraMenu?.Bind(_playerModeController);
_devToolsCommandBus?.Bind(_liveSessionController);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
_liveSessionController,
_liveEntities,
_localPlayerIdentity,
_worldReveal
?? throw new InvalidOperationException(
"World reveal was not composed before player auto-entry."),
_localPlayerMode,
_playerModeController));
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
var localTeleportPresentation =
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
_portalTunnel
?? throw new InvalidOperationException(
"Portal presentation was not composed before local teleport."));
_localPlayerTeleport =
new AcDream.App.Streaming.LocalPlayerTeleportController(
new AcDream.App.Streaming.LiveLocalPlayerTeleportAuthority(
_liveEntities,
_localPlayerIdentity),
_gameplayInputFrame,
_playerModeController,
new AcDream.App.Streaming.LocalPlayerTeleportStreamingOperations(
_liveWorldOrigin,
_streamingOriginRecenter!,
_streamingController!,
sealedDungeonCells),
_worldReveal,
new AcDream.App.Streaming.LocalPlayerTeleportPlacement(
_physicsEngine,
_liveEntities,
_localPlayerIdentity,
_playerControllerSlot,
_playerHostSlot,
_chaseCameraInput,
_liveWorldOrigin,
liveSpatialReconciler),
new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController),
localTeleportPresentation);
// Ownership transferred to LocalPlayerTeleportController. This field
// remains only as the staged-startup fallback used by shutdown when
// composition fails before the transfer.
_portalTunnel = null;
_localPlayerTeleportSink.Bind(_localPlayerTeleport);
var teleportRenderState =
new AcDream.App.Rendering.LocalPlayerTeleportRenderStateSource(
_localPlayerTeleport);
var renderLoginState = new AcDream.App.Rendering.RenderLoginStateSource(
_options.LiveMode,
_localPlayerMode);
var renderFrameGlState = new AcDream.App.Rendering.RenderFrameGlStateController(
new AcDream.App.Rendering.SilkRenderFrameGlStateApi(_gl!));
var renderFrameLivePreparation =
new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation(
_textureCache,
_wbMeshAdapter,
_worldReveal,
teleportRenderState,
renderLoginState,
new AcDream.App.Rendering.LiveLoginRevealCellSource(
_liveEntities!,
_localPlayerIdentity),
_particleRenderer,
_frameProfiler,
_frameDiag);
var renderFrameResources =
new AcDream.App.Rendering.RenderFrameResourceController(
_gpuFrameFlights!,
new AcDream.App.Rendering.RuntimeRenderFrameBeginResources(
_textureCache,
_wbDrawDispatcher,
_envCellRenderer,
_portalDepthMask,
_textRenderer,
_uiHost?.TextRenderer,
_clipFrame,
_terrain,
_sceneLightingUbo,
_frameProfiler,
_gl!),
new AcDream.App.Rendering.RuntimeRenderFrameClearPhase(
_gl!,
WorldTime,
Weather,
teleportRenderState,
_particleVisibility,
worldRenderDiagnostics,
renderFrameGlState),
renderFrameLivePreparation);
var renderWeatherFrame =
new AcDream.App.Rendering.RenderWeatherFrameController(
WorldTime,
Weather);
var skyPesFrame = new AcDream.App.Rendering.SkyPesFrameController(
_scriptRunner!,
_particleSink!,
_effectPoses,
_entityEffects);
var worldFrameEnvironment =
new AcDream.App.Rendering.RuntimeWorldFrameEnvironmentPreparation(
_options,
WorldTime,
Lighting,
_wbDrawDispatcher,
_envCellRenderer,
_sceneLightingUbo,
_renderRange,
skyPesFrame);
var worldRenderFrameBuilder =
new AcDream.App.Rendering.WorldRenderFrameBuilder(
new AcDream.App.Rendering.RuntimeWorldFrameCameraSource(
_cameraController!,
_localPlayerTeleport),
new AcDream.App.Rendering.RuntimeWorldFrameVisibilityPreparation(
_retailSelectionScene,
_particleVisibility,
_terrain,
_worldReveal,
_envCellFrustum),
new AcDream.App.Rendering.RuntimeWorldFrameSettingsPreview(
_settingsVm,
_audioEngine,
_cameraController!,
_displayFramePacing),
new AcDream.App.Rendering.RuntimeWorldFrameRootSource(
_physicsEngine,
_cellVisibility,
_localPlayerMode,
_chaseCameraInput,
_playerControllerSlot,
_liveWorldOrigin),
worldFrameEnvironment,
new AcDream.App.Rendering.RuntimeWorldFrameAnimatedEntitySource(
_animatedEntities,
_staticAnimationScheduler,
_equippedChildRenderer),
new AcDream.App.Rendering.RuntimeWorldFrameBuildingSource(
_landblockPresentationPipeline!,
_cellVisibility));
var terrainDrawDiagnostics =
new AcDream.App.Rendering.TerrainDrawDiagnosticsController(
_frameDiag,
worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeFramePipelineDiagnosticFactsSource(
_terrain,
_landblockPresentationPipeline,
renderFrameLivePreparation,
_wbDrawDispatcher,
_streamingController,
_liveEntities!,
_worldState),
_renderDiagnosticLog);
var retailPViewCells = new AcDream.App.Rendering.RetailPViewCellSource(
_cellVisibility);
var retailPViewPassExecutor =
new AcDream.App.Rendering.RetailPViewPassExecutor(
_gl!,
renderFrameGlState,
new AcDream.App.Rendering.SilkRetailPViewFramebufferSource(
_window!),
_clipFrame!,
_terrain,
_envCellRenderer!,
_wbDrawDispatcher!,
_skyRenderer,
_particleSystem,
_particleRenderer,
_portalDepthMask,
_retailAlphaQueue,
worldRenderDiagnostics,
terrainDrawDiagnostics);
var worldSceneDiagnostics =
new AcDream.App.Rendering.WorldSceneDiagnosticsController(
worldRenderDiagnostics,
new AcDream.App.Rendering.RuntimeWorldScenePViewDiagnosticSource(
_playerControllerSlot,
_physicsEngine,
_cellVisibility),
_worldSceneDebugState,
_debugLines,
_physicsEngine,
_localPlayerMode,
_playerControllerSlot,
_debugVmRenderFacts,
_debugVm is not null);
var worldScenePasses = new AcDream.App.Rendering.WorldScenePassExecutor(
_gl!,
renderFrameGlState,
_clipFrame!,
_wbDrawDispatcher!,
_envCellRenderer!,
_terrain,
terrainDrawDiagnostics,
_skyRenderer,
_particleSystem,
_particleRenderer);
var worldSceneRenderer = new AcDream.App.Rendering.WorldSceneRenderer(
renderFrameResources,
renderLoginState,
_worldSceneSkyState!,
worldRenderFrameBuilder,
new AcDream.App.Rendering.RuntimeWorldSceneEntitySource(_worldState),
_retailSelectionScene,
_retailAlphaQueue,
_particleVisibility,
new AcDream.App.Rendering.WorldScenePViewRenderer(
new AcDream.App.Rendering.RetailPViewRenderer(),
retailPViewPassExecutor,
retailPViewPassExecutor),
retailPViewCells,
worldScenePasses,
_renderRange,
worldSceneDiagnostics);
AcDream.App.Rendering.IRetainedGameplayUiFrame? retainedGameplayUi =
_options.RetailUi && _retailUiRuntime is not null
? new AcDream.App.Rendering.RetainedGameplayUiFrame(
_retailUiRuntime,
_input)
: null;
AcDream.App.Rendering.IPrivateFrameScreenshot? privateScreenshot =
_frameScreenshots is not null
? new AcDream.App.Rendering.PrivateFrameScreenshot(
_frameScreenshots)
: null;
var privatePresentation =
new AcDream.App.Rendering.PrivatePresentationRenderer(
new AcDream.App.Rendering.LocalPlayerPortalViewport(
_localPlayerTeleport!,
_cameraController!),
renderFrameResources,
_paperdollFramePresenter,
retainedGameplayUi,
_devToolsFramePresenter,
privateScreenshot);
var framePreparation =
new AcDream.App.Rendering.RenderFramePreparationController(
renderFrameResources,
_devToolsFramePresenter,
renderWeatherFrame);
_renderFrameOrchestrator =
new AcDream.App.Rendering.RenderFrameOrchestrator(
_gpuFrameFlights!,
framePreparation,
worldSceneRenderer,
privatePresentation,
_renderFrameDiagnostics!,
(AcDream.App.Rendering.IRenderFrameFailureRecovery?)
_devToolsFramePresenter
?? AcDream.App.Rendering.NullRenderFrameFailureRecovery.Instance);
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
_liveSessionController,
localPlayerFrame,
liveSpatialReconciler);
var cameraFrame = new AcDream.App.Rendering.CameraFrameController(
_cameraController!,
_inputCapture,
_cameraInput,
localPlayerFrameRuntime,
_chaseCameraInput,
localPlayerFrame,
liveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
_gameplaySettings,
Combat,
_selection,
_worldSelectionQuery!));
_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
new AcDream.App.Update.LiveEntityTeardownFramePhase(
_liveEntities),
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
_updateFrameClock,
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
streamingFrame,
_gameplayInputFrame,
liveFrameCoordinator,
new AcDream.App.Update.LiveEntityLivenessFramePhase(
_liveEntityLiveness,
new AcDream.App.Update.StopwatchClientMonotonicTimeSource()),
_localPlayerTeleport,
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
cameraFrame);
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
CreateLiveSessionLifecycleHost());
switch (liveStart.Status)
{
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
Console.WriteLine(
"live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
break;
case AcDream.App.Net.LiveSessionStartStatus.Failed:
Console.WriteLine($"live: session failed: {liveStart.Error}");
break;
}
}
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
Bind: CreateLiveSessionBinding,
Reset: () =>
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
chatType: 1),
Connected: () =>
Chat.OnSystemMessage(
"connected — character list received",
chatType: 1),
Selected: ApplyLiveSessionSelection,
Entered: ApplyLiveSessionEnteredWorld));
private void ApplyLiveSessionSelection(
AcDream.App.Net.LiveSessionCharacterSelection selection)
{
_playerServerGuid = selection.CharacterId;
_vitalsVm?.SetLocalPlayerGuid(selection.CharacterId);
Chat.SetLocalPlayerGuid(selection.CharacterId);
_worldState.MarkPersistent(selection.CharacterId);
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId;
Combat.Clear();
}
private void ApplyLiveSessionEnteredWorld(
AcDream.App.Net.LiveSessionCharacterSelection selection)
{
_activeToonKey = selection.CharacterName;
_retailUiRuntime?.RestoreLayout();
SyncToolbarWindowButtons();
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();
}
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
AcDream.App.Net.LiveSessionResetManifest.Create(new()
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
TeleportTransit = _localPlayerTeleportSink.ResetSession,
SessionDialogs = () => _retailUiRuntime?.ResetSessionDialogs(),
ChatCommandTargets = () => _retailChatVm?.ResetSessionTargets(),
SettingsCharacterContext = () =>
_settingsVm?.LoadCharacterContext(_persistedCharacter),
EquippedChildren = () => _equippedChildRenderer?.Clear(),
ExternalContainer = () => _externalContainers.Reset(),
InteractionAndSelection = ResetSessionInteraction,
SelectionPresentation = () => _retailSelectionScene?.Reset(),
ObjectTable = Objects.Clear,
Spellbook = SpellBook.Clear,
MagicRuntime = () => _magicRuntime?.Reset(),
CombatAttack = () => _combatAttackController?.ResetSession(),
CombatState = Combat.Clear,
ItemMana = ItemMana.Clear,
LocalPlayer = LocalPlayer.Clear,
Friends = Friends.Clear,
Squelch = Squelch.Clear,
TurbineChat = TurbineChat.Reset,
ParticleVisibility = _particleVisibility.Reset,
InboundEventFifo = _inboundEntityEvents.Clear,
LiveLiveness = () => _liveEntityLiveness?.Clear(),
LiveRuntime = ResetSessionLiveRuntime,
SessionIdentity = ResetSessionIdentity,
RemoteTeleport = () => _remoteTeleportController?.Clear(),
NetworkEffects = () => _entityEffects?.ClearNetworkState(),
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
});
private void ResetSessionMouseCapture()
=> _gameplayInputFrame?.ResetSession();
private void ResetSessionPlayerPresentation()
{
_playerModeController?.ResetSession();
_spawnClaimRangeMemo = null;
}
private void ResetSessionIdentity()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.RequireConvergence(_liveEntities);
// PlayerModule::Clear @ 0x005D48A0 clears shortcuts, desired
// components, and character option objects. CPlayerSystem::Begin
// @ 0x0055D410 resets player identity and session fields. The option
// default comes from PlayerModule::PlayerModule @ 0x005D51F0.
_playerServerGuid = 0u;
_vitalsVm?.SetLocalPlayerGuid(0u);
Chat.ResetSessionIdentity();
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = 0u;
_activeToonKey = "default";
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
_localPlayerSkills.ResetSession();
_liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty<AcDream.Core.Items.ShortcutEntry>();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
_paperdollFramePresenter?.MarkDirty();
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic
// landblock while still forcing first-spawn initialization.
_liveWorldOrigin.Reset();
}
private void ResetSessionLiveRuntime()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_liveEntities);
}
private void ResetSessionInteraction()
{
if (_selectionInteractions is { } interactions)
interactions.ResetSession();
else
{
_itemInteractionController?.ResetSession();
_selection.Reset();
}
}
/// <summary>
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
AcDream.Core.Net.WorldSession session)
{
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats,
Console.WriteLine);
}
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
return new AcDream.App.Net.LiveSessionBinding(
session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch
{
commands?.Dispose();
events?.Dispose();
throw;
}
}
private AcDream.App.Net.LiveInventorySessionBindings
CreateLiveInventorySessionBindings() => new(
Objects,
LocalPlayer,
PlayerGuid: () => _playerServerGuid,
OnShortcuts: list => Shortcuts = list,
OnUseDone: error =>
{
_externalContainers.ApplyUseDone(error);
_itemInteractionController?.CompleteUse(error);
},
ItemMana,
OnDesiredComponents: components => DesiredComponents = components,
ExternalContainers: _externalContainers);
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
{
// ACE AttributeFormula.GetFormula
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
// Attribute1Multiplier == 0 means no attribute contribution.
// Otherwise the retail data formula is
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
// Guard a zero divisor because malformed/custom DAT input must not
// take down the live-session dispatcher.
if (skillTable?.Skills is null)
return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
return 0u;
var formula = skillBase.Formula;
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
return 0u;
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
long numerator =
(long)attribute1 * formula.Attribute1Multiplier +
(long)attribute2 * formula.Attribute2Multiplier;
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
if (_localPlayerSkills.IsComplete && _playerController is not null)
{
Console.WriteLine(
$"player: applied server skills run={_localPlayerSkills.RunSkill} " +
$"jump={_localPlayerSkills.JumpSkill}");
}
},
OnConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
Chat,
TurbineChat,
Friends,
Squelch);
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
AcDream.Core.Net.WorldSession session) => new(
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
TeleportToPkArena: session.SendTeleportToPkArena,
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
TeleportToHouse: session.SendTeleportToHouse,
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => Chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
Chat,
TurbineChat,
PlayerGuid: () => _playerServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
/// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc)
/// for now — actual sound playback needs a lookup table from
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
/// have dat-indexed; follow-up will wire the thunder wave ids.
/// </summary>
private void OnEnvironChanged(uint environChangeType)
{
// Fog presets — values match AcDream.Core.World.EnvironOverride
// byte-for-byte (we deliberately mirrored retail's enum).
if (environChangeType <= 0x06u)
{
Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType;
Console.WriteLine(
$"live: AdminEnvirons fog override = " +
$"{(AcDream.Core.World.EnvironOverride)environChangeType}");
return;
}
// Sound cues 0x65..0x7B. Log by retail name for now; audio
// binding is a separate follow-up (needs sound-table indexing
// plus a PlaySound API on OpenAlAudioEngine that takes a
// retail sound enum → wave-id mapping).
string name = environChangeType switch
{
0x65u => "RoarSound",
0x66u => "BellSound",
0x67u => "Chant1Sound",
0x68u => "Chant2Sound",
0x69u => "DarkWhispers1Sound",
0x6Au => "DarkWhispers2Sound",
0x6Bu => "DarkLaughSound",
0x6Cu => "DarkWindSound",
0x6Du => "DarkSpeechSound",
0x6Eu => "DrumsSound",
0x6Fu => "GhostSpeakSound",
0x70u => "BreathingSound",
0x71u => "HowlSound",
0x72u => "LostSoulsSound",
0x75u => "SquealSound",
0x76u => "Thunder1Sound",
0x77u => "Thunder2Sound",
0x78u => "Thunder3Sound",
0x79u => "Thunder4Sound",
0x7Au => "Thunder5Sound",
0x7Bu => "Thunder6Sound",
_ => $"Unknown(0x{environChangeType:X2})",
};
Console.WriteLine(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
private void OnUpdate(double dt)
{
using var _updStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Update);
_updateFrameOrchestrator.Tick(
new AcDream.App.Update.UpdateFrameInput(dt));
}
private void OnCameraModeChanged(bool _modeBool)
{
if (_gameplayInputFrame?.MouseLookActive == true
&& _cameraController?.IsChaseMode != true)
{
_gameplayInputFrame?.EndMouseLook();
}
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// K-fix2 (2026-04-26): the bool passed to ModeChanged is NOT
// reliably "isFlyMode" — CameraController.EnterChaseMode invokes
// it with IsChaseMode (true), CameraController.ToggleFly invokes
// it with IsFlyMode, and CameraController.ExitChaseMode invokes
// it with IsFlyMode. Reading the controller state directly is
// the only correct gate. Cursor visible by default in chase /
// orbit modes; Raw cursor only in fly mode (continuous
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
// held is handled separately by the gameplay input owner.
bool needsRawCursor = _cameraController?.IsFlyMode == true;
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
}
// Performance overlay state — updated every ~0.5s and written to the
// window title so there's zero rendering cost (no font/overlay needed).
private void OnRender(double deltaSeconds)
{
Vector2D<int> size = _window!.Size;
_renderFrameOrchestrator!.Render(
new AcDream.App.Rendering.RenderFrameInput(
deltaSeconds,
size.X,
size.Y));
}
private AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot
CaptureWorldLifecycleResourceSnapshot()
{
var meshManager = _wbMeshAdapter?.MeshManager;
var mesh = meshManager?.Diagnostics ?? default;
AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot render =
_renderFrameDiagnostics?.Snapshot
?? AcDream.App.Rendering.RenderFrameDiagnosticsSnapshot.Initial;
GCMemoryInfo memory = GC.GetGCMemoryInfo();
return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot(
LoadedLandblocks: _worldState.LoadedLandblockIds.Count,
WorldEntities: _worldState.Entities.Count,
AnimatedEntities: _animatedEntities.Count,
VisibleLandblocks: render.VisibleLandblocks,
TotalLandblocks: render.TotalLandblocks,
LiveEntities: _liveEntities?.Count ?? 0,
MaterializedLiveEntities: _liveEntities?.MaterializedCount ?? 0,
PendingLiveTeardowns: _liveEntities?.PendingTeardownCount ?? 0,
PendingLandblockRetirements:
_streamingController?.PendingRetirementCount ?? 0,
ParticleEmitters: _particleSystem?.ActiveEmitterCount ?? 0,
Particles: _particleSystem?.ActiveParticleCount ?? 0,
ParticleBindings: _particleSink?.ActiveBindingCount ?? 0,
ParticleOwners: _particleSink?.TrackedOwnerCount ?? 0,
EffectOwners: _entityEffects?.ReadyOwnerCount ?? 0,
LightOwners: _liveEntityLights?.TrackedOwnerCount ?? 0,
ScriptOwners: _scriptRunner?.ActiveOwnerCount ?? 0,
ActiveScripts: _scriptRunner?.ActiveScriptCount ?? 0,
MeshRenderData: mesh.RenderData,
MeshAtlasArrays: mesh.AtlasArrays,
MeshEstimatedBytes: mesh.EstimatedBytes,
StagedMeshUploads: _wbMeshAdapter?.StagedUploadBacklog ?? 0,
StagedMeshBytes: _wbMeshAdapter?.StagedUploadBytes ?? 0,
TrackedGpuBytes: AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes,
TrackedGpuBuffers: AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount,
TrackedGpuTextures: AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount,
OwnedCompositeTextures: _textureCache?.OwnedBindlessTextureCount ?? 0,
CompositeTextureOwners: _textureCache?.TextureOwnerCount ?? 0,
ActiveParticleTextures: _textureCache?.ActiveParticleTextureCount ?? 0,
ParticleTextureOwners: _textureCache?.ParticleTextureOwnerCount ?? 0,
CompositeWarmupPending: _wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0,
ManagedBytes: GC.GetTotalMemory(forceFullCollection: false),
ManagedCommittedBytes: memory.TotalCommittedBytes,
Fps: render.Fps,
FrameMilliseconds: render.FrameMilliseconds,
LastFrameProfile: _frameProfiler.LastReport);
}
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
// animatedIds build site — every Sequencer entity is now added
// unconditionally, which is the known-good pre-optimization behavior.
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
// PlayerMovementController; airborne-Falling falls out of
// contact_allows_move + apply_current_movement). The #45 sidestep
// 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
/// <summary>
/// Phase 3a — re-roll the active DayGroup whenever the current
/// Dereth-day index differs from what we last installed. Idempotent
/// within the same server-day. Swaps both the
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="WorldSceneSkyState.ActiveDayGroup"/> (for the sky-object
/// render loop).
///
/// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
/// group N regardless of day index. Useful for A/B testing each
/// weather preset against retail. See
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
/// for the roller.
/// </para>
/// </summary>
private void RefreshSkyForCurrentDay()
{
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
// Retail FUN_00501990 seeds the LCG with the triple stored in
// TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear)
//
// The decompile agent labeled +0x10 "SecondsPerDay (int copy)"
// but a live memory probe of retail's acclient.exe (2026-04-23,
// tools/RetailTimeProbe) shows the value is actually **360** —
// semantically DaysPerYear, not seconds. So the LCG seed is
// seed = Year × DaysPerYear + DayOfYear
// which is literally "total days since epoch" (a flat day index),
// confirmed against retail's Year=116, DayOfYear=47, seed=41807.
//
// Previously we passed 7620 (DayTicks), producing seed 883967 —
// a completely different LCG output → wrong DayGroup pick →
// user-observed weather mismatch (acdream clear while retail
// stormy, 2026-04-23). The live probe nailed the fix.
double ticks = WorldTime.NowTicks;
int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks);
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth
* AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360
// Composite day key for change-detection and logging only; the
// LCG seed is computed inside SelectDayGroupIndex from (absYear,
// secondsPerDay, dayOfYear).
long dayIndex = (long)absYear * 360 + dayOfYear;
int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[idx]
: null;
bool dayChanged = dayIndex != _loadedSkyDayIndex;
bool groupChanged = !ReferenceEquals(
grp,
_worldSceneSkyState?.ActiveDayGroup);
if (!dayChanged && !groupChanged) return;
_loadedSkyDayIndex = dayIndex;
(_worldSceneSkyState
??= new AcDream.App.Rendering.WorldSceneSkyState(WorldTime))
.ActiveDayGroup = grp;
if (grp is not null && grp.SkyTimes.Count > 0)
{
WorldTime.SetProvider(
new AcDream.Core.World.SkyStateProvider(
grp.SkyTimes.Select(s => s.Keyframe).ToList()));
// Phase 3e: drive the atmospheric weather (rain/snow emitters,
// fog-override categories, lightning strobe) from the retail
// DayGroup name. Stops the legacy WeatherSystem.RollKind hash
// from spawning rain particles on a "Sunny" day (user-observed
// rain regression 2026-04-23 after the retail picker landed on
// DayGroup[6] "Sunny" but the internal hash picked Rain).
Weather.SetKindFromDayGroupName(grp.Name);
Console.WriteLine(
$"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " +
$"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " +
$"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})");
}
}
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
//
// The ImGui DebugPanel reads through DebugVM closures that ask
// GameWindow for live state on every frame. The helper methods below
// are the *named* targets of those closures (and of the F-key
// shortcuts that share the same actions). Keeping them as methods
// (vs ad-hoc lambdas where the VM is constructed) means both the
// panel button and the keybind run the *same* code, so behavior
// can't drift between the two surfaces.
/// <summary>Player-mode-aware position source for the DebugPanel.</summary>
private System.Numerics.Vector3 GetDebugPlayerPosition()
{
if (_playerMode && _playerController is not null)
return _playerController.Position;
if (_cameraController?.Active is { } cam)
{
// Camera world position from inverse of view matrix — same
// computation used by the scene-lighting UBO each frame.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
}
return System.Numerics.Vector3.Zero;
}
/// <summary>Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.</summary>
private float GetDebugPlayerHeadingDeg()
{
float deg;
if (_playerMode && _playerController is not null)
{
deg = _playerController.Yaw * (180f / MathF.PI);
}
else if (_cameraController?.Active is { } cam)
{
// Camera-relative heading from view matrix forward vector. Use
// the same -invView.Mxx convention the snapshot block used.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33);
deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI);
}
else
{
return 0f;
}
deg %= 360f;
if (deg < 0f) deg += 360f;
return deg;
}
private uint GetDebugPlayerCellId() =>
_playerMode && _playerController is not null ? _playerController.CellId : 0u;
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
private float GetActiveSensitivity()
{
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
if (_cameraController?.IsFlyMode == true) return _sensFly;
return _sensOrbit;
}
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugVm?.AddToast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugVm?.AddToast("Time override cleared");
}
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}");
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
_debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
// Phase K.3 settings state remains runtime-owned; the presenter owns the
// optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
return _retailUiRuntime?.ToggleWindow(name) ?? false;
}
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
}
private void SetRetailUiLocked(bool locked)
{
if (_persistedGameplay.LockUI == locked)
return;
_persistedGameplay = _persistedGameplay with { LockUI = locked };
if (_uiHost is not null)
_uiHost.Root.UiLocked = locked;
if (_settingsVm is not null)
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
try
{
_settingsStore?.SaveGameplay(_persistedGameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
}
}
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{
session.SendSetAfkMode(away);
}
private void SetRetailAcceptLootPermits(bool enabled)
{
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
_settingsVm?.SetGameplay(
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
_settingsStore?.SaveGameplay(_persistedGameplay);
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
/// flip the live display flag and persist it through the same settings
/// path used by the Display panel.
/// </summary>
private void ToggleRetailFrameRate()
{
_persistedDisplay = _persistedDisplay with
{
ShowFps = !_persistedDisplay.ShowFps,
};
if (_settingsVm is not null)
_settingsVm.ApplyExternalDisplayChange(display => display with
{
ShowFps = _persistedDisplay.ShowFps,
});
try
{
_settingsStore?.SaveDisplay(_persistedDisplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
}
}
private void SetRetailCombatGameplay(
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
{
_persistedGameplay = gameplay;
_settingsVm?.SetGameplay(gameplay);
try
{
_settingsStore?.SaveGameplay(gameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: combat option save failed: {ex.Message}");
}
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
}
// L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction
// — which IS gated on devtools — reads these fields when wiring
// SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings
// overwrites them with values from settings.json (or per-section
// defaults when the file is missing/corrupt).
private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
private readonly AcDream.App.Combat.GameplaySettingsState
_gameplaySettings = new();
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
{
get => _gameplaySettings.Value;
set => _gameplaySettings.Value = value;
}
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
= AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default;
/// <summary>
/// L.0 follow-up: load every section from settings.json + apply the
/// runtime-affecting ones (Display window state + Audio engine
/// volumes) at startup. Runs unconditionally — settings are runtime
/// state, not devtools state. Without this, a user running with
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
/// defaults instead of their saved Display/Audio preferences.
/// </summary>
private void LoadAndApplyPersistedSettings()
{
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
_persistedDisplay = _settingsStore.LoadDisplay();
_persistedAudio = _settingsStore.LoadAudio();
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
// Apply Display to the Silk.NET window. VSync and its bounded
// refresh-rate fallback go through the shared pacing owner;
// resolution + fullscreen use the on-Save path below.
if (_window is not null)
{
_displayFramePacing.RefreshActiveMonitor();
_displayFramePacing.ApplyPreference(_persistedDisplay.VSync);
ApplyDisplayWindowState(_persistedDisplay);
}
// Apply Audio to the OpenAL engine. Master + Sfx are wired
// through to the engine; Music + Ambient are stored but inert
// until R5 MIDI/ambient-loop engines exist (assigning them is
// harmless — the engine just doesn't read them yet).
if (_audioEngine is not null && _audioEngine.IsAvailable)
{
_audioEngine.MasterVolume = _persistedAudio.Master;
_audioEngine.MusicVolume = _persistedAudio.Music;
_audioEngine.SfxVolume = _persistedAudio.Sfx;
_audioEngine.AmbientVolume = _persistedAudio.Ambient;
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: transactionally reconciles the existing
/// <see cref="_streamingController"/> against its published world;
/// the worker and controller retain their identity.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset)
{
var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset);
var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase);
Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}");
// MSAA samples cannot change at runtime — warn if preset would differ.
if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples)
{
Console.WriteLine(
$"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " +
$"{newResolved.MsaaSamples}) requires a restart — skipped for this session.");
}
_resolvedQuality = newResolved;
// A2C gate — immediate toggle, no GL context restart needed.
if (_wbDrawDispatcher is not null)
_wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage;
// Anisotropic — immediate GL TexParameter call on the terrain atlas.
_terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel);
// Reconcile streaming radii against the current published world. This
// preserves resident blocks that remain in range and never rebuilds
// the worker merely to change a completion budget.
if (_streamingController is not null)
{
_nearRadius = newResolved.NearRadius;
_farRadius = newResolved.FarRadius;
_streamingController.ReconfigureRadii(_nearRadius, _farRadius);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(
$"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " +
$"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}");
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
/// the corner OR by ApplyDisplayWindowState applying a saved
/// Resolution). Without this, the viewport stays pinned at the
/// startup size, producing a small render inside a big window.
/// Also force-resets ImGui panel layout so panels that were
/// previously off the new viewport snap back to default positions.
/// </summary>
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
{
if (newSize.X <= 0 || newSize.Y <= 0) return;
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
_viewportAspect.Update(newSize.X, newSize.Y);
_cameraController?.SetAspect(newSize.X / (float)newSize.Y);
// Resize is always a force-reset — the alternative ("clamp
// existing positions") would require tracking each panel's
// current pos+size, which ImGuiNET doesn't expose by name.
// Force-reset is acceptable UX because resizing happens rarely
// and the user can always drag panels back where they want.
_devToolsFramePresenter?.ResetLayout(
newSize.X,
newSize.Y,
AcDream.App.Rendering.DevToolsPanelLayoutCondition.Always);
}
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private static bool TryParseResolution(string spec, out int width, out int height)
{
width = height = 0;
if (string.IsNullOrWhiteSpace(spec)) return false;
var parts = spec.Split('x', 2);
if (parts.Length != 2) return false;
return int.TryParse(parts[0], out width)
&& int.TryParse(parts[1], out height)
&& width > 0
&& height > 0;
}
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
// handlers are gone; everything now flows through InputDispatcher.Fired
// → here. New behaviors register a new InputAction in the enum + a
// case in this switch + a binding in KeyBindings.
/// <summary>
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
/// Handles every game-side reaction to a keyboard/mouse-button chord.
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
{
// Diagnostic — kept from K.1a; helpful for K.1c verification.
Console.WriteLine($"[input] {action} {activation}");
// RMB-orbit hold: track press/release transitions explicitly so
// _rmbHeld is true exactly while the chord is held. Hold-type
// chords also fire Press on key-down + Release on key-up; we
// ignore the in-between Hold ticks here (the mouse-move handler
// checks _rmbHeld each frame anyway).
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
return;
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
// cursor + activates yaw drive; release restores. WantCapture
// edge handling lives in OnUpdate; only Press needs to read it
// for the initial gate (defense in depth — the dispatcher
// already filters on WantCaptureMouse in OnMouseDown).
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
// wheel tick. Press is the only activation type for wheel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
|| action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown)
{
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
HandleScrollAction(action);
return;
}
// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic
// combat before the movement command enters CommandInterpreter. This
// notification does not consume the input; the movement owner below
// still receives it normally.
// Retail attack actions consume their full transition stream: key-down
// starts the power build and key-up commits it. Handle them before the
// generic Press-only gate below.
if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
return;
// Every other action fires on Press only (no Release / Hold side-
// effects in the K.1b set). Filter out non-Press activations early
// so subscribers that have Release-mode bindings don't accidentally
// re-fire. B.4b exception: DoubleClick must pass through so
// SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch.
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
// Wave 4.1: one retained-UI delegation seam. The runtime routes only
// toolbar-owned semantic actions; every other action continues through
// the game switch below. Keyboard capture is already enforced at the
// InputDispatcher source, matching retail's stack-entry focus gate.
if (_retailUiRuntime?.HandleInputAction(action) == true)
return;
if (_selectionInteractions?.HandleInputAction(action) == true)
return;
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
// Q to start, press Q again to stop. Pressing Backup / Stop /
// StrafeLeft / StrafeRight while autorun is active also cancels it
// — retail-faithful: any deliberate movement input wins. (Pressing
// Forward AGAIN does NOT cancel — retail's autorun stays active
// even when you press W; the two stack.)
if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
return;
switch (action)
{
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
_devToolsFramePresenter?.ToggleDebugPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWires();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby:
DumpPlayerAndNearbyEntities();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown:
AdjustActiveSensitivity(1f / 1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp:
AdjustActiveSensitivity(1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather:
CycleWeather();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
// K-fix3 (2026-04-26): proper round-trip when player has
// an active chase camera. ToggleFly() only swaps
// Fly↔Orbit, so a user who flew out of player mode used
// to land in Holtburg-orbit on toggle-back. With a chase
// camera available, prefer Fly→Chase / Chase→Fly so the
// user round-trips back to the same player view.
_playerModeController?.ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
_playerModeController?.Toggle();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
// sets a one-shot flag that emits SetKeyboardFocusHere on
// the next render. No-op when the devtools presenter is absent
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
_devToolsFramePresenter?.FocusChatInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
// K.3: F11 toggles the Settings panel. Null-safe vs.
// devtools-off / panel-not-registered — the [input] log
// line above still records the press regardless.
_devToolsFramePresenter?.ToggleSettingsPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_itemInteractionController?.IsAnyTargetModeActive == true)
_itemInteractionController.CancelTargetMode();
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
_playerModeController?.Exit();
else
_window!.Close();
break;
}
}
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
Objects.GetEquippedBy(_playerServerGuid);
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
.GetDefaultCombatMode(orderedEquipment);
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
_debugVm?.AddToast(text);
}
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
/// retail's drawing-sphere broadphase followed by flat visual-polygon
/// intersection. <paramref name="includeSelf"/> allows item target-use to
/// pick the local player while plain selection excludes it.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> _selectionInteractions?.PickAtCursor(includeSelf)
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
// Contained/toolbar activation is not a world-selection interaction: it
// sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
return;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
}
private bool IsWithinExternalContainerUseRange(uint targetGuid)
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
private void SendUse(uint guid)
=> _selectionInteractions?.SendUse(guid);
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
private uint? SelectClosestCombatTarget(bool showToast)
=> _selectionInteractions?.SelectClosestCombatTarget(showToast);
private bool IsHealthBarTarget(uint guid)
=> _worldSelectionQuery?.ShouldShowHealth(guid) == true;
private (System.Numerics.Matrix4x4 View,
System.Numerics.Matrix4x4 Projection,
System.Numerics.Vector2 Viewport) GetSelectionCamera()
{
if (_cameraController is null || _window is null)
return (System.Numerics.Matrix4x4.Identity,
System.Numerics.Matrix4x4.Identity,
System.Numerics.Vector2.Zero);
var camera = _localPlayerTeleport?.ApplyViewPlane(_cameraController.Active)
?? _cameraController.Active;
return (camera.View, camera.Projection,
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
}
private AcDream.App.UI.Layout.VividTargetInfo? ResolveVividTargetInfo(uint guid)
=> _worldSelectionQuery?.ResolveVividTargetInfo(guid);
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
// hold so a garbage claim doesn't stall login forever; the Resolve-head
// safety net demotes it loudly once entry proceeds.
private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
private bool IsSpawnClaimUnhydratable(uint claim)
{
if ((claim & 0xFFFFu) < 0x0100u) return false;
if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
bool unhydratable = false;
if (_dats is not null)
{
DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
lock (_datLock)
{
lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(claim & 0xFFFF0000u) | 0xFFFEu);
}
uint low = claim & 0xFFFFu;
unhydratable = lbInfo is null || lbInfo.NumCells == 0
|| low >= 0x0100u + lbInfo.NumCells;
}
_spawnClaimRangeMemo = (claim, unhydratable);
return unhydratable;
}
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
/// given factor and clamps to [0.005, 3.0].
/// </summary>
private void AdjustActiveSensitivity(float factor)
{
string modeLabel;
float current;
if (_playerMode && _cameraController?.IsChaseMode == true)
{ modeLabel = "Chase"; current = _sensChase; }
else if (_cameraController?.IsFlyMode == true)
{ modeLabel = "Fly"; current = _sensFly; }
else
{ modeLabel = "Orbit"; current = _sensOrbit; }
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
if (modeLabel == "Chase") _sensChase = next;
else if (modeLabel == "Fly") _sensFly = next;
else _sensOrbit = next;
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
}
/// <summary>
/// K.1b: F3 dump handler extracted into a method. Same body as the
/// previous in-line F3 branch — prints the player's position +
/// nearby visible entities + nearby shadow physics objects.
/// </summary>
private void DumpPlayerAndNearbyEntities()
{
System.Numerics.Vector3 pos;
if (_playerMode && _playerController is not null)
pos = _playerController.Position;
else
{
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
Console.WriteLine(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
$" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" +
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
foreach (var e in _worldState.Entities)
{
float dx = e.Position.X - pos.X;
float dy = e.Position.Y - pos.Y;
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
}
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
{
float d = (e.Position - pos).Length();
Console.WriteLine(
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
}
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = o.Position.X - pos.X;
float dy = o.Position.Y - pos.Y;
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d < 15f) sorted.Add((o, d));
}
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
foreach (var (o, d) in sorted.Take(12))
{
Console.WriteLine(
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
}
}
/// <summary>
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
/// camera distance is current — chase camera distance in player mode,
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
/// is fixed-step (the previous proportional scroll.Y was lost when we
/// moved scroll into the dispatcher, but the discrete step matches
/// retail wheel feel).
/// </summary>
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
{
if (_cameraController is null) return;
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
if (_playerMode && _cameraController.IsChaseMode)
{
// Chase mode: zoom (closer on ScrollUp).
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
_retailChaseCamera.AdjustDistance(-dir * 0.8f);
else if (_chaseCamera is not null)
_chaseCamera.AdjustDistance(-dir * 0.8f);
}
else if (_cameraController.IsFlyMode)
{
// Fly mode: no-op (could adjust move speed later).
}
else
{
_cameraController.Orbit.Distance = Math.Clamp(
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
}
}
private void OnClosing()
=> CompleteShutdown();
private void CompleteShutdown()
{
_shutdown ??= CreateShutdownTransaction();
try
{
_shutdown.CompleteOrThrow();
}
catch (Exception error)
{
// The session/network stage runs first, so ACE is disconnected
// gracefully even if a persistent driver failure prevents full GL
// convergence. Later dependencies remain intact and the native
// context/process teardown is the final safety net.
Console.Error.WriteLine($"[shutdown] {error}");
}
}
private ResourceShutdownTransaction CreateShutdownTransaction() => new(
// Live-session reset retires the complete streaming window. Every
// reset callback owner, especially LandblockStreamer, must remain live
// until that transaction converges.
new ResourceShutdownStage("session lifetime",
[
new("live session", () =>
{
AcDream.App.Net.LiveSessionController? controller =
_liveSessionController;
if (controller is null)
return;
controller.Dispose();
if (!controller.IsDisposalComplete)
{
throw new InvalidOperationException(
"Live-session disposal was deferred by a reentrant lifecycle callback.");
}
if (ReferenceEquals(_liveSessionController, controller))
_liveSessionController = null;
}),
]),
// Frame composition borrows equipped, effect, audio, and render
// owners. Withdraw the graph before the first borrowed owner closes;
// the later render-frontend stage still disposes the GL owners.
new ResourceShutdownStage("frame borrowers",
[
new("world frame composition", () =>
{
_renderFrameOrchestrator = null;
_worldSceneSkyState = null;
}),
]),
new ResourceShutdownStage("session dependents",
[
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
new("retail UI", () =>
{
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
_retainedInputCapture.Root = null;
_uiHost = null;
}),
new("combat target", () =>
{
_combatTargetController?.Dispose();
_combatTargetController = null;
}),
new("combat attack", () =>
{
_combatAttackController?.Dispose();
_combatAttackController = null;
}),
new("item interaction", () =>
{
_itemInteractionController?.Dispose();
_itemInteractionController = null;
}),
new("external containers", () =>
{
_externalContainerLifecycle?.Dispose();
_externalContainerLifecycle = null;
}),
new("magic runtime", () =>
{
_magicRuntime = null;
_magicCatalog = null;
}),
new("streamer", () => _streamer?.Dispose()),
new("equipped children", () => _equippedChildRenderer?.Dispose()),
]),
// Retained tombstones are retried while every callback owner is alive.
new ResourceShutdownStage("live entities",
[
new("live entity runtime", () => _liveEntities?.Clear()),
]),
new ResourceShutdownStage("live entity dependents",
[
new("live lights", () =>
{
_liveEntityLights?.Dispose();
_liveEntityLights = null;
}),
new("live presentation", () => _liveEntityPresentation?.Dispose()),
new("remote teleport", () =>
{
_remoteTeleportController?.Dispose();
_remoteTeleportController = null;
}),
new("effect network state", () =>
{
_entityEffects?.ClearNetworkState();
_animationHookFrames?.Clear();
_effectPoses.Clear();
}),
new("audio", () => _audioEngine?.Dispose()),
]),
new ResourceShutdownStage("submitted GPU work",
[
new("frame flight drain", () => _gpuFrameFlights?.WaitForSubmittedWork()),
]),
new ResourceShutdownStage("render frontends",
[
new("developer tools", () =>
{
_devToolsFramePresenter = null;
_devToolsCameraMenu = null;
_devToolsCommandBus = null;
_devToolsBackend?.Dispose();
_devToolsBackend = null;
}),
new("portal tunnel", () =>
{
_localPlayerTeleport?.Dispose();
_localPlayerTeleport = null;
_portalTunnel?.Dispose();
_portalTunnel = null;
}),
new("paperdoll viewport", () =>
{
_paperdollViewportRenderer?.Dispose();
_paperdollViewportRenderer = null;
_paperdollFramePresenter = null;
}),
new("mesh draw dispatcher", () => _wbDrawDispatcher?.Dispose()),
new("environment cells", () => _envCellRenderer?.Dispose()),
new("portal depth mask", () => _portalDepthMask?.Dispose()),
new("clip frame", () => _clipFrame?.Dispose()),
new("sky", () => _skyRenderer?.Dispose()),
new("particles", () => _particleRenderer?.Dispose()),
]),
new ResourceShutdownStage("shared texture owners",
[
new("sampler cache", () => _samplerCache?.Dispose()),
new("texture cache", () => _textureCache?.Dispose()),
]),
new ResourceShutdownStage("mesh adapter",
[
new("WB mesh adapter", () =>
{
_wbMeshAdapter?.Dispose();
_wbMeshAdapter = null;
}),
]),
new ResourceShutdownStage("remaining render owners",
[
new("mesh shader", () => _meshShader?.Dispose()),
new("terrain", () =>
{
_terrain?.Dispose();
_terrain = null;
}),
new("terrain shader", () => _terrainModernShader?.Dispose()),
new("scene lighting", () => _sceneLightingUbo?.Dispose()),
new("debug lines", () => _debugLines?.Dispose()),
new("text renderer", () => _textRenderer?.Dispose()),
new("debug font", () => _debugFont?.Dispose()),
new("frame pacing", _displayFramePacing.Dispose),
new("frame profiler", _frameProfiler.Dispose),
]),
new ResourceShutdownStage("frame flight owner",
[
new("frame flights", () =>
{
_gpuFrameFlights?.Dispose();
_gpuFrameFlights = null;
}),
]),
new ResourceShutdownStage("content mappings",
[
new("DAT collection", () =>
{
_dats?.Dispose();
_dats = null;
}),
]),
new ResourceShutdownStage("input",
[
new("combat input subscription", ()
=> Combat.CombatModeChanged -= SetInputCombatScope),
new("input context", () =>
{
_input?.Dispose();
_input = null;
}),
]),
new ResourceShutdownStage("OpenGL context",
[
new("OpenGL", () =>
{
_gl?.Dispose();
_gl = null;
}),
]));
private void OnFocusChanged(bool focused)
{
if (!focused)
_gameplayInputFrame?.EndMouseLook();
}
public void Dispose()
{
// Closing is the normal path and runs while the render context is
// current. This direct call also covers a constructed-but-never-run
// window and exceptions during Window.Create/Run, so owned kernel
// handles (including the frame timer) never depend on a native window
// event for disposal.
CompleteShutdown();
_window?.Dispose();
_window = null;
}
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
/// factory when neither <c>_dats</c> nor the entity's setup is available.
/// Returns null for all animation lookups so the sequencer silently has
/// no data (same behaviour as a new empty Setup).
/// </summary>
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
}