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>
4623 lines
227 KiB
C#
4623 lines
227 KiB
C#
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;
|
||
}
|
||
}
|