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 record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); 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; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by /// at draw time but allocated + disposed here. private Shader? _terrainModernShader; private CameraController? _cameraController; private IMouse? _capturedMouse; 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; /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null /// after OnLoad completes (modern path is mandatory as of N.5). 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; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. 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 bool _debugCollisionVisible = false; private int _debugDrawLogOnce = 0; // 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); private long _frameDiagLastPublicationMilliseconds; // 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.WorldRenderDiagnostics? _worldRenderDiagnostics; 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.RenderFrameResourceController? _renderFrameResources; private AcDream.App.Rendering.RuntimeRenderFrameLivePreparation? _renderFrameLivePreparation; private AcDream.App.Rendering.RenderWeatherFrameController? _renderWeatherFrame; 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 int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback) private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9) private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25) // 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 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 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. ConcurrentDictionary so the off-thread // mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock. private System.Collections.Concurrent.ConcurrentDictionary? _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): the per-cell DrawInside flood + its per-frame entity partition. // _interiorRenderer is constructed once both renderers exist; _interiorPartition is rebuilt // each frame on an indoor root (null on the outdoor root). private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer; private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask; private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel; private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition; // 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; // Phase 3 (render unification, 2026-06-07): the synthetic outdoor cell node — the outdoor // world as a flood-graph cell (spec 2026-06-07-render-unification-outdoor-as-cell). Rebuilt // each outdoor frame from nearby building-entrance portals. ADDITIVE for now (built but not // yet rooted; the clipRoot flip + OutsideView terrain integration is the cutover step). private LoadedCell? _outdoorNode; private readonly List _outdoorNodeBuildingCells = new(); private readonly HashSet _outdoorSceneParticleEntityIds = new(); private readonly HashSet _visibleSceneParticleEntityIds = new(); private readonly HashSet _noSceneParticleEntityIds = new(); /// /// 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 . /// Static decorations and entities with no motion table never /// appear in this map. /// private readonly LiveEntityAnimationRuntimeView _animatedEntities; private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new(); // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically // (`animatedEntityIds is null || animatedEntityIds.Count == 0`), so this can // always be a live (possibly empty) HashSet instead of `new`ing one every // frame or passing null. private readonly HashSet _animatedIdsScratch = new(); /// /// Tier 1 cache (#53): per-entity classification results for static /// entities (those NOT in ). Conceptually /// paired with — that dictionary is the /// gating predicate, this cache is the lookup that depends on it. /// Passed to at /// construction time. Tasks 9-10 of the cache plan wire the per-entity /// miss-populate / hit-fast-path through the dispatcher's loop. /// 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(); // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. // Keep the experimental path available for DAT archaeology only. // Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug. // Diagnostic: hide a specific humanoid part (>=10 parts) at render. // Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex. // Issue #47 — use retail's close-detail GfxObj selection on // humanoid setups. When enabled, every per-part GfxObj id (after // server AnimPartChanges are applied) is replaced with Degrades[0] // from its DIDDegrade table when present. See GfxObjDegradeResolver // for the full retail-decomp citation. Default-on after visual // confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for // diagnostic before/after comparisons. // Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades. // Issue #48 diagnostic — dump per-scenery-spawn placement evidence // (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ, // mesh vertex Z range, DIDDegrade slot 0). One log line per spawn lets // the user identify a floating tree by its world coordinates and tell // whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift // (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md. // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ. /// private readonly HashSet _activeSkyPes = new(); private readonly HashSet _missingSkyPes = 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(); /// /// 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 /// InterpolationManager::adjust_offset every object tick. /// /// /// 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. /// /// /// /// 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 , consulted at the /// top of , and dropped by the live /// entity teardown owner. /// private AcDream.App.World.LiveEntityRuntime? _liveEntities; private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness; /// Soft-snap decay rate (1/sec). At this rate the residual /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a /// 2m residual fades within ~300ms without visible snap. private const float SnapResidualDecayRate = 8.0f; /// /// When the prediction error exceeds this many meters, we treat the /// update as a teleport / rubber-band and hard-snap (no soft lerp). /// Prevents the soft-snap logic from trying to smooth a genuine portal /// or force-move event. /// /// /// Matches retail's GetAutonomyBlipDistance (ACE /// PhysicsObj.cs:545): 20m for creatures, 25m for players. /// We use 20m as a conservative default — any delta larger than this /// must be a teleport (portal, recall, spawn). A running character /// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas /// of ~9.5m, well below this threshold, so normal movement flows /// through the interpolation queue instead of hard-snapping. /// /// private const float SnapHardSnapThreshold = 20.0f; /// /// Soft-snap window in seconds: after an UpdatePosition arrives for a /// remote entity, dead-reckoning continues but the "origin" for /// predicted position is the server pos. This matches retail's snap /// behavior — the server is authoritative, we just interpolate between /// authoritative samples. /// private const float DeadReckonMaxPredictSeconds = 1.0f; // 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(); /// Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source). public IReadOnlyList Shortcuts { get; private set; } = System.Array.Empty(); // 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(); // A7.L1 (2026-07-09) — last frame's rendered visible-cell set, fed into next // frame's Lighting.BuildPointLightSnapshot scoping. One frame of latency // (~16 ms) instead of a mid-DrawInside callback: DrawableCells only becomes // known partway through DrawInside (after the flood + look-in merge), and the // #176 correction already showed that rebuilding the light pool FROM a // freshly-recomputed frame flood (the deleted RebuildScopedLights callback, // c500912b) is a flicker mechanism. Reusing last frame's already-drawn set // (MP-Alloc: reused HashSet, no per-frame alloc) decouples the light-scoping // change from DrawInside's internals entirely. _lightPoolVisibleCellsValid is // false until the first indoor frame completes, and is cleared on any // outdoor-only frame so scoping fails open (unscoped) for one frame on // re-entry rather than filtering by a stale dungeon's cell ids. private readonly HashSet _lightPoolVisibleCells = new(); private bool _lightPoolVisibleCellsValid; 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. long.MinValue 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; private AcDream.Core.World.DayGroupData? _activeDayGroup; // 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. private bool LiveModeEnabled => _options.LiveMode; /// /// K-fix1 (2026-04-26): true iff live mode is configured AND we have /// NOT yet handed control to the chase camera. Gates initial /// streaming + scene rendering so the user never sees Holtburg flash /// before login completes — the screen stays at the sky/fog clear /// color until the player entity has spawned + the auto-entry guard /// has triggered the player-mode controller's auto-entry edge. /// Offline (LiveModeEnabled false) returns false → unchanged /// orbit-camera Holtburg view stays the foreground. Once chase mode /// is active the property latches false for the rest of the /// session, even if the user later toggles into fly mode (we don't /// want to re-blank the world after they've seen it). /// private bool IsLiveModeWaitingForLogin => LiveModeEnabled && !_chaseModeEverEntered; // Latches true the first time chase mode becomes active. Used by // IsLiveModeWaitingForLogin to suppress the pre-login render gate // for subsequent fly-mode toggles. private bool _chaseModeEverEntered => _localPlayerMode.ChaseModeEverEntered; /// /// 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. /// private static readonly IReadOnlyDictionary EmptyLiveEntityMap = new Dictionary(); private static readonly IReadOnlyDictionary EmptyLiveSpawnMap = new Dictionary(); private IReadOnlyDictionary _entitiesByServerGuid => _liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap; /// Visible-only view for radar, picking, status, and targets. private IReadOnlyDictionary _visibleEntitiesByServerGuid => _liveEntities?.WorldEntities ?? EmptyLiveEntityMap; /// /// Latest 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. /// reuses the cached /// position/setup/motion fields when a 0xF625 ObjDescEvent carries only /// updated visuals. /// private IReadOnlyDictionary 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. private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u; private static bool IsDoorName(string? name) => name == "Door"; // 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( _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(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() { // 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); _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(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: () => _debugCollisionVisible, 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(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(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(); // (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(); 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!, () => (_window!.Size.X, _window.Size.Y), 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.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(e.SourceGfxObjOrSetupId); if (setup is not null) { uint mtableId = (uint)setup.DefaultMotionTable; if (mtableId != 0) { var mtable = capturedDats.Get(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( 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 parts = e.IndexedPartTransforms; IReadOnlyList 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(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(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(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(); _retailPViewRenderer = new AcDream.App.Rendering.RetailPViewRenderer( _gl, _clipFrame, _envCellRenderer!, _wbDrawDispatcher!); // 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; _streamingRadius = sr; // keep debug overlay in sync _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( _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); _renderFrameLivePreparation = new AcDream.App.Rendering.RuntimeRenderFrameLivePreparation( _textureCache, _wbMeshAdapter, _worldReveal, teleportRenderState, new AcDream.App.Rendering.RenderLoginStateSource( _options.LiveMode, _localPlayerMode), new AcDream.App.Rendering.LiveLoginRevealCellSource( _liveEntities!, _localPlayerIdentity), _particleRenderer, _frameProfiler, _frameDiag); _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!), _renderFrameLivePreparation); _renderWeatherFrame = new AcDream.App.Rendering.RenderWeatherFrameController( WorldTime, Weather); 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(); 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(); } } /// /// Composes the exact inbound and outbound owners for one live session. /// Registration completes before Connect; outbound commands remain inert /// until EnterWorld succeeds. /// private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding( AcDream.Core.Net.WorldSession session) { var skillTable = _dats?.Get(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); private void UpdateSkyPes( float dayFraction, AcDream.Core.World.DayGroupData? dayGroup, System.Numerics.Vector3 cameraWorldPos, bool suppressSky) { if (_scriptRunner is null || _particleSink is null) return; var seen = new HashSet(); if (!suppressSky && dayGroup is not null) { for (int i = 0; i < dayGroup.SkyObjects.Count; i++) { var obj = dayGroup.SkyObjects[i]; if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction)) continue; var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene); seen.Add(key); uint skyEntityId = SkyPesEntityId(key); var renderPass = obj.IsPostScene ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; _particleSink.SetEntityRenderPass(skyEntityId, renderPass); var anchor = SkyPesAnchor(obj, cameraWorldPos); var rotation = SkyPesRotation(obj, dayFraction); _effectPoses.Publish( skyEntityId, System.Numerics.Matrix4x4.CreateFromQuaternion(rotation) * System.Numerics.Matrix4x4.CreateTranslation(anchor), System.Array.Empty(), cellId: 0u); if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) continue; _entityEffects?.RegisterSyntheticOwner(skyEntityId); if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) { _activeSkyPes.Add(key); } else { _missingSkyPes.Add(key); _entityEffects?.UnregisterSyntheticOwner(skyEntityId); _particleSink.ClearEntityRenderPass(skyEntityId); _effectPoses.Remove(skyEntityId); } } } foreach (var key in _activeSkyPes.ToArray()) { if (seen.Contains(key)) continue; uint skyEntityId = SkyPesEntityId(key); _scriptRunner.StopAllForEntity(skyEntityId); _entityEffects?.UnregisterSyntheticOwner(skyEntityId); _particleSink.StopAllForEntity(skyEntityId, fadeOut: true); _effectPoses.Remove(skyEntityId); _activeSkyPes.Remove(key); } foreach (var key in _missingSkyPes.ToArray()) { if (!seen.Contains(key)) _missingSkyPes.Remove(key); } } private static uint SkyPesEntityId(SkyPesKey key) { // 0xF0000000 prefix marks synthetic sky-PES entityIds (no real // server GUID lives in the 0xFxxxxxxx space). Reserve bit // 0x08000000 for the pre/post-scene flag and the lower 27 bits // for the object index — keeps the post-scene flag from sliding // into the index range if a future DayGroup ever ships >65k sky // objects (current Dereth max is 18, but the constraint is free). uint postBit = key.PostScene ? 0x08000000u : 0u; return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); } internal static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity) => entity.Id; private static System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) { if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); return cameraWorldPos; } private static System.Numerics.Quaternion SkyPesRotation( AcDream.Core.World.SkyObjectData obj, float dayFraction) { float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); return System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitY, -rotationRad); } /// /// Phase 5d — retail AdminEnvirons (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 /// EnvironChangeType → wave asset, which we don't yet /// have dat-indexed; follow-up will wire the thunder wave ids. /// 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; _capturedMouse = needsRawCursor ? mouse : null; } // 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) { _gpuFrameFlights!.BeginFrame(); Exception? renderFailure = null; try { GL gl = _gl!; var frameInput = new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, _window!.Size.X, _window.Size.Y); _renderFrameResources!.Prepare(frameInput); AcDream.App.Rendering.RenderFrameFoundation foundation = _renderFrameResources.Foundation; // gmSmartBoxUI::UseTime @ 0x004D6E30 swaps visibility between the // normal SmartBox viewport and UIElement_Viewport's portal // CreatureMode; it does not redraw the world behind portal space. bool portalViewportVisible = foundation.PortalViewportVisible; AcDream.Core.World.SkyKeyframe kf = foundation.Sky; AcDream.Core.World.AtmosphereSnapshot atmo = foundation.Atmosphere; bool environOverrideActive = foundation.EnvironOverrideActive; // Phase D.2a — begin ImGui frame. Paired with the Render() call // after the scene draws (below). ImGuiController.Update() // consumes buffered Silk.NET input events and calls ImGui.NewFrame. _devToolsFramePresenter?.BeginFrame((float)deltaSeconds); // Phase G.1: weather state machine — deterministic per-day roll // + transitions + lightning flash. _renderWeatherFrame!.Tick(deltaSeconds); // (Pre-Bug-A code spawned camera-attached rain/snow particle // emitters here as a workaround for missing weather-mesh // rendering. Deleted 2026-04-26 once the retail-faithful world- // space mesh path landed in SkyRenderer.RenderWeather. Retail // rain is GfxObj 0x01004C42/0x01004C44 — a hollow octagonal // cylinder anchored at player_pos + (0, 0, -120m) per // GameSky::UpdatePosition at 0x00506dd0 — drawn after the // landblock pass per LScape::draw at 0x00506330. There is no // server-driven weather event and no camera-attached emitter // in retail. Snow renders identically when a Snowy DayGroup is // active in some other Region; the partition by Properties&0x04 // and the SkyRenderer.RenderWeather pass both pick up snow // weather meshes for free.) int visibleLandblocks = 0; int totalLandblocks = 0; bool normalWorldDrawn = false; _retailSelectionScene?.BeginFrame(); if (_cameraController is not null && !portalViewportVisible) { _retailAlphaQueue.BeginFrame(); var activeCamera = _cameraController.Active; var camera = _localPlayerTeleport?.ApplyViewPlane(activeCamera) ?? activeCamera; var worldProjection = camera.Projection; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection); _retailSelectionScene?.SetViewFrustum(frustum); // Extract camera world position from the inverse of the view // matrix — needed by the scene-lighting UBO (for fog distance) // and by the sky renderer (for the camera-centered sky dome). System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); _particleVisibility.BeginFrame(camPos); _terrain?.BeginVisibilityFrame(); if (!IsLiveModeWaitingForLogin) { _particleVisibility.UseWorldView(); _worldReveal?.ObserveWorldViewportVisible(); _worldReveal?.Complete(); } // L.0 Audio tab: push the SettingsVM's live AudioDraft into the // engine each frame, so volume sliders preview audibly while // the user drags. Cancel reverts the draft and the engine // catches up on the very next frame; Save persists to // settings.json without changing engine state (already // applied). Cheap enough to run unconditionally on every // tick — four float assignments. if (_audioEngine is not null && _audioEngine.IsAvailable && _settingsVm is not null) { var a = _settingsVm.AudioDraft; _audioEngine.MasterVolume = a.Master; _audioEngine.MusicVolume = a.Music; _audioEngine.SfxVolume = a.Sfx; _audioEngine.AmbientVolume = a.Ambient; } // L.0 Display tab: push the live DisplayDraft into the // active rendering surfaces each frame. FOV is the live- // preview slider per the brainstorm — dragging it changes // camera FovY immediately. VSync change-detected to avoid // spamming the window. Resolution + Fullscreen apply on // Save (handled by ApplyDisplayWindowState — too jarring // to live-preview a resize). if (_settingsVm is not null && _cameraController is not null) { var d = _settingsVm.DisplayDraft; float fovYRad = d.FieldOfView * (MathF.PI / 180f); _cameraController.Orbit.FovY = fovYRad; _cameraController.Fly.FovY = fovYRad; if (_cameraController.Chase is not null) _cameraController.Chase.FovY = fovYRad; _displayFramePacing.ApplyPreference(d.VSync); } // Phase E.2 audio: update listener pose so 3D sounds pan/attenuate // correctly relative to where we're looking. if (_audioEngine is not null && _audioEngine.IsAvailable) { var fwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33); var up = new System.Numerics.Vector3( invView.M21, invView.M22, invView.M23); _audioEngine.SetListener( camPos.X, camPos.Y, camPos.Z, fwd.X, fwd.Y, fwd.Z, up.X, up.Y, up.Z); } // Step 4: portal visibility — compute BEFORE the UBO upload so // the indoor flag drives the sun's intensity to zero for // dungeons (r13 §13.7). // Phase W single-viewpoint V1 (2026-06-03): the render keys on ONE viewpoint — the // collided camera ("viewer") — exactly like retail (RenderNormalMode @ 0x453aa0 → // DrawInside(viewer_cell) pc:92675; InitCell side-test vs viewer.viewpoint pc:432991). // The viewer cell is the camera-collision sweep's swept cell // (RetailChaseCamera.ViewerCellId = retail viewer_cell = sphere_path.curr_cell): // graph-tracked, deterministic, NO AABB / NO grace frames — so the U.4c flap source // (stale FindCameraCell over grace frames) is gone WITHOUT splitting viewpoints. // SEPARATELY, lighting / seen_outside key on the PLAYER cell (CurrCell), matching retail // CellManager::ChangePosition @ 0x4559B0 — the player's cell, not the camera's, decides // whether the sun dies (sealed interior). retail player->cell (physics/lighting) vs // SmartBox->viewer_cell (render); the old per-render player-root + eye-projection split is gone. // ── Lighting root: the PLAYER cell (CurrCell). ── LoadedCell? playerRoot = null; if (_physicsEngine.DataCache?.CellGraph.CurrCell is AcDream.Core.World.Cells.EnvCell playerCellObj && _cellVisibility.TryGetCell(playerCellObj.Id, out var playerRegCell)) playerRoot = playerRegCell; bool playerSeenOutside = playerRoot?.SeenOutside ?? true; // ── Render root: the VIEWER (collided camera) cell + eye. ── // Default (player mode + retail chase cam): the sweep's viewer cell. Fallback for the // non-default legacy/debug camera paths: the player's registered cell (or none). uint viewerCellId = (_playerMode && _retailChaseCamera is not null && AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera) ? _retailChaseCamera.ViewerCellId : (playerRoot?.CellId ?? 0u); var viewerEyePos = camPos; // the collided eye drives the projection var playerViewPos = _playerController?.RenderPosition ?? _playerController?.Position ?? camPos; LoadedCell? viewerRoot = null; if (viewerCellId != 0u && _cellVisibility.TryGetCell(viewerCellId, out var viewerRegCell)) viewerRoot = viewerRegCell; // T4 (BR-6): the per-frame ACME BFS (ComputeVisibilityFromRoot) is // DELETED from the frame — it ran a full second visibility // computation whose only production consumer was this boolean, // which is exactly "the viewer root resolved to a loaded interior // cell" (TryGetCell above already proves cells are loaded). The // PView flood is the ONE visibility gate (feedback_render_one_gate). bool cameraInsideCell = viewerRoot is not null; // Retail render routing is owned by the collided camera/viewer cell. // The player cell still owns lighting state, but it must not force an // indoor draw while the camera is outside; that drops the outdoor pass // and leaves clear color around a floating doorway slice. bool rootSeenOutside = viewerRoot?.SeenOutside ?? true; // Phase U.4 (2026-05-30): the [vis] probe moved DOWN to the unified // gated-draw block (after envCellViewProj exists) where it can report // the real PortalVisibilityFrame — OutsideView polygon/plane counts and // per-cell slot plane counts — via RenderingDiagnostics.EmitVis, instead // of the old camera-state-only spike. See the U.4 ClipFrame assembly // below (gated on ACDREAM_PROBE_VIS=1, cell-change-throttled). // Stage 3 (2026-06-02): replace the IsInsideAnyCell AABB scan with the // seen_outside-derived predicate. Retail CellManager::ChangePosition (0x004559B0) // gates sun/lighting off seen_outside on the player's current cell, NOT off an // independent AABB containment scan. playerInsideCell = true (kill sunlight) only // when the player is inside a SEALED interior (seen_outside=false = dungeon). // Building interiors with seen_outside=true keep the sun (sky visible through door). // V1 (2026-06-03): keyed on the PLAYER cell (playerRoot/playerSeenOutside), independent // of the camera's viewer cell — retail kills the sun off the player's cell, not the eye. bool playerInsideCell = playerRoot is not null && !playerSeenOutside; // Phase C.1: tick retail PhysicsScript particle hooks. Named // retail decomp confirms SkyObject.PesObjectId is copied by // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is // debug-only and disabled for normal retail rendering. if (_options.EnableSkyPesDebug) UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); // Phase G.1/G.2: feed the sun, tick LightManager, build + upload // the scene-lighting UBO once per frame. Every shader that // consumes binding=1 reads the same data for the rest of the // frame — terrain, static mesh, instanced mesh, sky. UpdateSunFromSky(kf, playerInsideCell); // A7 indoor lighting: position retail's viewer fill light at the player // (SmartBox::set_viewer 0x00452c40) before the snapshot is built. It is // the primary interior fill (no sun indoors) and is indoor-only via the // AP-43 gate. playerViewPos is the player render position (or camPos when // there is no player), matching retail's player/viewer branch. Lighting.UpdateViewerLight(playerViewPos); Lighting.Tick(camPos); // Fix B (A7 #3): build this frame's point-light snapshot and hand it to // the entity dispatcher for per-OBJECT light selection // (minimize_object_lighting). Replaces the single global nearest-8-to- // camera UBO set for point/spot lights so a wall's torches stay tied to // the wall as the camera moves. The SUN + ambient still flow through the // SceneLighting UBO built below (binding=1) — terrain/sky read those. // #176 root cause: the pool is anchored at the PLAYER (retail // Render::insert_light sorts by Render::player_pos, 0x0054d1b0) and // collected from ALL registered (=resident-cell) lights — it is a // function of player position only, so camera rotation cannot change // it. playerViewPos carries retail's no-player fallback (camPos). // A7.L1: candidacy is additionally scoped to last frame's rendered // visible-cell set (see _lightPoolVisibleCells) when available — the // Town Network starvation fix. Unscoped (null) until the first indoor // frame completes or after any outdoor-only frame. Lighting.BuildPointLightSnapshot( playerViewPos, _lightPoolVisibleCellsValid ? _lightPoolVisibleCells : null); _wbDrawDispatcher?.SetSceneLights(Lighting.PointSnapshot); _envCellRenderer?.SetPointSnapshot(Lighting.PointSnapshot); // A7 Fix D (D-2) var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); // A.5 T22: override fog ramp with N₁/N₂-derived distances so the // horizon fog masks the N₁ scenery boundary. Sky keyframe fog is // retail-accurate at normal view distances but far too short for // the extended N₂=12 (25×25 LB) streaming window. // FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12). // FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults. // Multipliers exposed as env vars for fast iteration at visual gate. { const float LandblockSize = 192.0f; float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f); float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f); float fogStart = _nearRadius * LandblockSize * startMult; float fogEnd = _farRadius * LandblockSize * endMult; // Preserve fog color (xyz), lightning flash (z), and mode (w). ubo.FogParams = new System.Numerics.Vector4( fogStart, fogEnd, ubo.FogParams.Z, // lightning flash — unchanged ubo.FogParams.W); // fog mode — unchanged } _sceneLightingUbo?.Upload(ubo); // #133 A7 (2026-06-13): objective dungeon-lighting probe. One // rate-limited [light] line — insideCell / ambient / sun / // registered-point-lights / active-slot-count / player cell — so // the dungeon-dim question is self-verifiable from launch.log // without a screenshot. RegisteredCount is point/spot lights only // (the sun lives in LightManager.Sun, never in the _all list); // ubo.CellAmbient.W is the shader active-slot count, which counts // the (zeroed) sun slot indoors. Inert unless ACDREAM_PROBE_LIGHT=1. AcDream.Core.Rendering.RenderingDiagnostics.EmitLight( insideCell: playerInsideCell, ambientR: Lighting.CurrentAmbient.AmbientColor.X, ambientG: Lighting.CurrentAmbient.AmbientColor.Y, ambientB: Lighting.CurrentAmbient.AmbientColor.Z, sunIntensity: Lighting.Sun?.Intensity ?? 0f, registeredLights: Lighting.RegisteredCount, activeLights: (int)ubo.CellAmbient.W, playerCellId: playerRoot?.CellId ?? 0u, lights: Lighting); // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) { var pp = _playerController.Position; int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); } int renderCenterLbX = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); int renderCenterLbY = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); // Phase A8: update EnvCellRenderer's frustum. The per-frame shell snapshot // is prepared after the portal-visible cell filter is known. var envCellViewProj = camera.View * worldProjection; _envCellFrustum?.Update(envCellViewProj); // MP-Alloc: reuse _animatedIdsScratch instead of `new`ing a // HashSet every frame. Downstream consumers (WbDrawDispatcher. // WalkEntitiesInto) treat null and an empty set identically, so an // always-non-null (possibly empty) set is behaviorally the same as // the old null-when-nothing-animated local. // // Every entity in _animatedEntities (i.e. every entity with a // Sequencer) is added UNCONDITIONALLY: membership here is the // "re-classify me every frame, don't use the Tier-1 static cache" // flag, and the Tier-1 cache captures an entity's REST pose + // opacity-1.0 exactly once. Any entity whose rendered state can // depart that rest — a door held at its final OPEN frame, a wall // held FADED-OUT by a TransparentPartHook — MUST stay on the // per-frame path so the live sequencer pose + TranslucencyFadeManager // opacity are read; otherwise it flips back to the stale cached // closed/opaque state the instant its animation settles. // // DO NOT re-narrow this to "only if the current cycle is multi-frame" // (the reverted IsEntityCurrentlyMoving gate, 2026-07-09): a settled // one-shot hold IS pose-stable frame-to-frame, but stable at the // HELD pose the cache does NOT contain — that is the door/fade // flip-back. The per-frame re-classification cost that narrowing // saved was a DEBUG-build artifact; Release is GPU-bound (the CPU // work hides under GPU time), so the unconditional add is free where // it matters. If a real Release CPU cost from static-prop // re-classification is ever measured, gate on "entity is at its // captured rest state" (default motion AND no active fade), never on // "is mid-cycle". _animatedEntities.CopySpatialIdsTo(_animatedIdsScratch); _staticAnimationScheduler?.CopyAnimatedEntityIdsTo(_animatedIdsScratch); if (_equippedChildRenderer is not null) { foreach (uint id in _equippedChildRenderer.AttachedEntityIds) _animatedIdsScratch.Add(id); } HashSet? animatedIds = _animatedIdsScratch; // Phase G.1: sky renderer — draws the far-plane-infinity // celestial meshes FIRST so the rest of the scene z-tests // on top of them (depth mask off, no depth writes). // // Suppressed inside cells (the camera is in a sealed interior; // no sky is visible). Mirrors retail's LScape::draw at 0x00506330 // which calls GameSky::Draw(0) (sky pass) BEFORE the landblock // DrawBlock loop and GameSky::Draw(1) (weather pass) AFTER. The // split matters because weather meshes (the 815m-tall rain // cylinder 0x01004C42/0x01004C44) need to overlay terrain and // entities to look volumetric — see the post-scene RenderWeather // call further below. // Stage 3 (2026-06-02): sky gate uses seen_outside per retail RenderNormalMode:92649. // Outdoor root (cameraInsideCell=false): always render sky. // Building interior (cameraInsideCell=true, rootSeenOutside=true): render sky — clipped // to the doorway via the OutsideView (Stage 4, below). // Sealed dungeon (cameraInsideCell=true, rootSeenOutside=false): no sky. bool renderSky = viewerRoot is null || rootSeenOutside; // Phase W Stage 4 (2026-06-02): the sky/weather DRAW moved DOWN to its retail LScape // position — AFTER the portal-visibility ClipFrame is assembled — so it can be clipped to // the doorway (OutsideView) by sky.vert's gl_ClipDistance. See the "[Stage 4] sky // pre-scene" block after UploadShared. renderSky is the seen_outside policy gate; the draw // additionally requires an exit portal in view when indoors (drawSkyThisFrame, below). // K-fix1 (2026-04-26): the pre-login world-suppression gate (goto SkipWorldGeometry) // moved DOWN — below the sky pre-scene draw (Phase W Stage 4) — so the live sky still // draws during the connection + EnterWorld handshake while the world geometry is skipped. // See the gate just before the world-geometry clip bracket. // Phase U.4: build the SHARED per-frame clip data from the portal- // visibility result, ahead of both terrain and entity draws. // // Root: a non-null CameraCell means the camera is INSIDE a cell (indoor // root) — run the portal-frame BFS (PortalVisibilityBuilder) and assemble // a real ClipFrame (slot 0 no-clip, slot 1.. per visible cell + the // OutsideView) + a cellId→slot map. A null CameraCell is the OUTDOOR root: // no pvFrame, the frame stays no-clip, every instance is slot 0 and terrain // draws normally — bit-identical to U.3 (outdoor→building peering is U.5). // // The single _clipFrame instance is RESET + repacked in place each frame. // One SSBO and one range-addressed terrain UBO arena are reused per // GPU-fenced frame slot; each renderer re-binds binding=2 defensively. _clipFrame ??= ClipFrame.NoClip(); // Phase 3 (render unification, additive): build the synthetic outdoor cell node when // the eye is outdoors (no interior viewerRoot). Stored in _outdoorNode but NOT yet // rooted — behaviour is unchanged this commit. The nearby-building enumeration mirrors // the look-in candidate gather in the OUTDOOR branch below (Chebyshev <=1 landblocks); // OutdoorCellNode.Build filters to exit portals internally. The clipRoot flip + // OutsideView terrain integration that consumes this is the next (cutover) step. _outdoorNode = null; _outdoorNodeBuildingCells.Clear(); if (viewerRoot is not null || viewerCellId != 0u) { // T2 (BR-4): draw-driven flood gating. Retail floods a building's // interior exactly when its shell DRAWS and an aperture survives // the view (DrawBuilding Ghidra 0x0059f2a0: per-view viewconeCheck // → portal-BSP walk → ConstructView's GetClip; NO distance // constant anywhere on the chain). Port: a per-BUILDING frustum // pre-gate on the aperture bounds (Building.PortalBounds — the // tight equivalent of the shell viewconeCheck for FLOOD purposes), // replacing the old Chebyshev≤1 landblock cell-sweep; the 48 m // seed cap dies with it (RetailPViewRenderer seeds at ∞). The // per-portal admission stays BuildFromExterior's screen clip // (empty clip = no seed) — retail's GetClip-vs-view gate. // Per-building iteration is also the FPS fix the 2026-06-07 // Chebyshev hack approximated: dozens of AABB tests instead of an // O(all loaded cells) portal sweep. // #124: the gather now runs for INTERIOR roots too — retail's // look-in executes inside LScape::draw for ANY root with a // non-empty outside view (DrawCells pc:432719). The renderer // routes interior-root look-ins to its landscape-stage sub-pass // (DrawBuildingLookIns); the root's own building self-excludes // via the seed eye-side test. foreach (var registry in _landblockPresentationPipeline?.BuildingRegistries ?? Array.Empty()) { foreach (var b in registry.All()) { if (b.HasPortalBounds && !AcDream.App.Rendering.FrustumCuller.IsAabbVisible( frustum, b.PortalBounds.Min, b.PortalBounds.Max)) continue; foreach (uint cid in b.EnvCellIds) if (_cellVisibility.TryGetCell(cid, out var bc) && bc is not null) _outdoorNodeBuildingCells.Add(bc); } } if (viewerRoot is null) _outdoorNode = AcDream.App.Rendering.OutdoorCellNode.Build(viewerCellId); if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled) Console.WriteLine(System.FormattableString.Invariant( $"[outdoor-node] cell=0x{viewerCellId:X8} root={(viewerRoot is null ? "OUT" : "IN")} nearbyCells={_outdoorNodeBuildingCells.Count} (T2 frustum-gated per-building floods)")); } uint playerCellId = _physicsEngine.DataCache?.CellGraph.CurrCell?.Id ?? 0u; bool playerIndoorGate = AcDream.Core.Rendering.RenderingDiagnostics.ShouldRenderIndoor( playerCellId, playerRoot is not null); // Render unification (outdoor-as-cell, 2026-06-07 cutover): ONE render path rooted at the // VIEWER cell. Eye indoors -> its interior EnvCell (viewerRoot); eye outdoors -> the // synthetic outdoor node (_outdoorNode, built above from nearby building entrances). The // result is null ONLY when neither exists (pre-spawn / login / legacy non-chase camera) -> // the outdoor LScape block below still runs as the safety path (and login still shows the // live sky). There is no inside/outside branch to TOGGLE as the chase eye crosses the // doorway boundary, so the indoor FLAP dies by construction. playerIndoorGate stays // computed for the [render-sig] probe but no longer selects the path (handoff // docs/research/2026-06-07-render-unification-cutover-flip-handoff.md section 4 Step B). var clipRoot = viewerRoot ?? _outdoorNode; string renderBranch = clipRoot is null ? "OutdoorRoot" : "RetailPViewInside"; ClipFrameAssembly? clipAssembly = null; PortalVisibilityFrame? pvFrame = null; // R1: hoisted so the binary decision below reads OrderedVisibleCells var terrainClipMode = TerrainClipMode.Planes; // overwritten below for indoor root HashSet? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys) PortalVisibilityFrame? sigPvFrame = null; ClipFrameAssembly? sigClipAssembly = null; IReadOnlySet? sigDrawableCells = null; AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null; PortalVisibilityFrame? sigExteriorPvFrame = null; ClipFrameAssembly? sigExteriorClipAssembly = null; IReadOnlySet? sigExteriorDrawableCells = null; AcDream.App.Rendering.InteriorEntityPartition.Result? sigExteriorPartition = null; bool sigTerrainDrawn = false; bool sigSkyDrawn = false; bool sigDepthClear = false; bool sigOutdoorPortalDrawn = false; bool sigOutdoorSceneryDrawn = false; int sigOutdoorRootObjectCount = 0; int sigLiveDynamicDrawnCount = 0; string sigSceneParticles = "none"; _outdoorSceneParticleEntityIds.Clear(); _visibleSceneParticleEntityIds.Clear(); // Retail entry ownership: GameWindow never builds a second indoor PView product. // Outdoor frames begin no-clip; indoor frames skip the global landscape block and let // RetailPViewRenderer.DrawInside own ConstructView -> DrawCells. _clipFrame.Reset(); _wbDrawDispatcher?.ClearClipRouting(); _envCellRenderer?.SetClipRouting(null); _interiorPartition = null; if (clipRoot is not null) { clipAssembly = null; pvFrame = null; terrainClipMode = TerrainClipMode.Skip; envCellShellFilter = null; _interiorPartition = null; } if (clipRoot is null) { // Outdoor frames have one no-clip terrain state. Indoor frames // defer both allocations to RetailPViewRenderer, which knows the // exact slice count and packs every terrain state into one arena. _clipFrame.ReserveTerrainUploads(gl, 1); _clipFrame.UploadRegions(gl); TerrainClipBufferBinding terrainBinding = _clipFrame.UploadTerrainClip(gl); _wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo); _terrain?.SetClipUbo(terrainBinding); } bool drawSkyThisFrame = false; if (clipRoot is null) { // ── Outdoor LScape entry ───────────────────────────────────────────────── // Retail indoor frames do not pass through this block. If the player is indoors, // PView::DrawInside owns landscape drawing through outside_view and the depth-only // clear. This outdoor-only block is the LScape half of RenderNormalMode. drawSkyThisFrame = renderSky; sigSkyDrawn = drawSkyThisFrame; if (drawSkyThisFrame) { _clipFrame.BindTerrainClip(gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); } // K-fix1 (2026-04-26): suppress terrain + entity rendering while live mode is configured // but the chase camera hasn't engaged yet. The sky (above) still draws during login so the // user sees a live, time-of-day-correct sky through the connection + EnterWorld handshake. if (IsLiveModeWaitingForLogin) goto SkipWorldGeometry; // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup. EnableClipDistances(); _worldRenderDiagnostics?.BeginTerrainDraw(); sigTerrainDrawn = true; _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); PublishTerrainDrawDiagnostics(); } // Reaching the retail normal-world decision below proves this is // neither a portal replacement nor the sky-only login-wait path. normalWorldDrawn = true; // R1 — the binary render decision (retail RenderNormalMode @ 0x453aa0): // INDOOR root (clipRoot != null): run ONLY the per-cell DrawInside flood. The global // entity pass + global shell pass are NOT issued — visibility IS the cull, so the // outdoor world cannot bleed (it is never iterated; outdoor scenery entered above, // clipped to the doorway). DrawInside follows retail DrawCells order: reverse // cell_draw_list shell stage, then reverse object-list stage, per portal_view slice. // OUTDOOR root: draw the landscape/outdoor bucket first, then seed a reciprocal // portal frame from exterior-facing cell portals so peering through an open door // draws the indoor SHELL + its statics together. The old global pass drew indoor // statics without the EnvCell shells, which made walls look transparent from outside. if (clipRoot is not null) { if (_retailPViewRenderer is null) throw new InvalidOperationException("Retail PView renderer is required for indoor frames."); var pviewResult = _retailPViewRenderer.DrawInside(new AcDream.App.Rendering.RetailPViewDrawContext { RootCell = clipRoot, // R-A2: outdoor root floods each nearby building per-building (not via the root). // #124: interior roots get the gather too — the renderer routes them to the // landscape-stage look-in sub-pass instead of the merge. NearbyBuildingCells = _outdoorNodeBuildingCells, ViewerEyePos = viewerEyePos, ViewProjection = envCellViewProj, CellLookup = id => _cellVisibility.TryGetCell(id, out var c) ? c : null, Camera = camera, CameraWorldPosition = camPos, // (#176 correction: the former RebuildScopedLights callback — // rebuilding the light pool from the frame's FLOOD — was the // flicker mechanism and is deleted. The pool is built once per // frame above, player-anchored, from all resident lights.) Frustum = frustum, PlayerLandblockId = playerLb, AnimatedEntityIds = animatedIds, RenderCenterLbX = renderCenterLbX, RenderCenterLbY = renderCenterLbY, RenderRadius = _nearRadius, LandblockEntries = _worldState.LandblockEntries, SetTerrainClipUbo = binding => _terrain?.SetClipUbo(binding), DrawLandscapeSlice = sliceCtx => DrawRetailPViewLandscapeSlice( sliceCtx, camera, frustum, camPos, playerLb, animatedIds, renderSky, renderWeather: playerSeenOutside, kf, environOverrideActive), // #131/#132: the late phase — dynamics meshes + scene // particles + weather AFTER the look-ins (FlushAlphaList // deferral). DrawLandscapeSliceLate = lateCtx => DrawRetailPViewLandscapeSliceLate( lateCtx, camera, frustum, camPos, playerLb, animatedIds, renderSky, renderWeather: playerSeenOutside, kf, environOverrideActive, isOutdoorRoot: clipRoot.IsOutdoorNode), // T1: retail's depth discipline (PView::DrawCells, Ghidra 0x005a4840). // INTERIOR roots: one FULL depth clear between the outside stage and // the interior stage, then SEALS re-stamp every outside-leading // portal's TRUE depth (#108's protective mechanism). OUTDOOR roots: // no clear (the world's depth must survive) — instead each flooded // building's entry aperture gets a far-Z PUNCH so its interior shows // through the doorway. Both are safe ONLY because dynamics draw LAST // (DrawDynamicsLast) — the first BR-2 attempt punched after dynamics // and erased the player (reverted 88be519). ClearDepthForInterior = clipRoot.IsOutdoorNode ? null : () => { gl.Disable(EnableCap.ScissorTest); gl.DepthMask(true); // depth clears honor glDepthMask (c4df241 lesson) gl.Clear(ClearBufferMask.DepthBufferBit); }, DrawExitPortalMasks = sliceCtx => DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, forceFarZ: clipRoot.IsOutdoorNode), // #124: look-in apertures are ALWAYS the punch (retail // maxZ1), independent of the root-keyed selector above. DrawLookInPortalPunch = sliceCtx => DrawRetailPViewPortalDepthWrite(sliceCtx, envCellViewProj, forceFarZ: true), // #131: unattached emitters under an interior root — the // landscape-stage pass (the outdoor T3 pass below is gated // IsOutdoorNode, so the two never both run). DrawUnattachedSceneParticles = () => { if (_particleSystem is null || _particleRenderer is null) return; DisableClipDistances(); _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _noSceneParticleEntityIds, includeUnattached: true); }, FlushLandscapeAlpha = _retailAlphaQueue.Flush, DrawCellParticles = sliceCtx => DrawRetailPViewCellParticles(sliceCtx, camera, camPos), DrawDynamicsParticles = survivors => DrawRetailPViewDynamicsParticles(survivors, camera, camPos), EmitDiagnostics = result => _worldRenderDiagnostics?.EmitRetailPViewDiagnostics( AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled, AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled, AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, result, clipRoot, viewerCellId, playerCellId, camPos, playerViewPos, camera.View, _cellVisibility.LastCameraCellResolution), }); pvFrame = pviewResult.PortalFrame; clipAssembly = pviewResult.ClipAssembly; envCellShellFilter = pviewResult.DrawableCells; _interiorPartition = pviewResult.Partition; _particleVisibility.MarkVisibleCells(pviewResult.DrawableCells); // A7.L1: snapshot this frame's drawn cell set for NEXT frame's light- // pool scoping. DrawableCells is the renderer's own reused scratch set // (cleared/rebuilt every DrawInside call) — copy it, don't hold a // reference to it. _lightPoolVisibleCells.Clear(); _lightPoolVisibleCells.UnionWith(pviewResult.DrawableCells); _lightPoolVisibleCellsValid = true; // Flap root-cause apparatus (2026-06-07): per-frame, the EXACT Build inputs at 6 dp + // the resulting flood count. The live flap shows flood flipping 2↔6 at an eye/player // identical to cm; this answers whether the inputs differ sub-cm (jitter) or are // byte-identical (nondeterminism). See RenderingDiagnostics.ProbePvInputEnabled. if (AcDream.Core.Rendering.RenderingDiagnostics.ProbePvInputEnabled && pvFrame is not null) { // 2026-06-08: disambiguate the idle flap. eye=camera eye-point (drives the flood); // player=RenderPosition (Lerp of physics, what the eye chases); rawPlayer=raw physics // body Position; yaw=camera/player heading (F8 rad to catch micro-drift). If the flood // flickers while idle, exactly one of {eye, player, rawPlayer, yaw} is the varying input. var pvRawPlayer = _playerController?.Position ?? playerViewPos; float pvYaw = _playerController?.Yaw ?? 0f; // §4 outdoor flap (2026-06-09): terrain height under the EYE — if the // camera boom is buried in a hillside (eyeAbove < 0), every nearby // terrain triangle backface-culls and the view punches through the // world to the clear color. Pin or refute eye-under-terrain. float? pvTerrZ = _physicsEngine.SampleTerrainZ(camPos.X, camPos.Y); _worldRenderDiagnostics?.EmitPViewInput( enabled: true, pvFrame, envCellViewProj, clipRoot.IsOutdoorNode, camPos, playerViewPos, pvRawPlayer, pvYaw, pvTerrZ); } sigPvFrame = pviewResult.PortalFrame; sigClipAssembly = pviewResult.ClipAssembly; sigDrawableCells = pviewResult.DrawableCells; sigPartition = pviewResult.Partition; sigTerrainDrawn = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; sigSkyDrawn = renderSky && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; sigDepthClear = pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; sigSceneParticles = (pviewResult.Partition.ByCell.Count > 0 || pviewResult.ClipAssembly.OutsideViewSlices.Length > 0) ? "pviewScoped" : sigSceneParticles; sigOutdoorSceneryDrawn = pviewResult.Partition.OutdoorStatic.Count > 0 && pviewResult.ClipAssembly.OutsideViewSlices.Length > 0; // T1: DrawInside now draws ALL dynamics itself in its single // last entity pass (DrawDynamicsLast) — the old LiveDynamic // top-up draw is gone. sigLiveDynamicDrawnCount = pviewResult.Partition.Dynamics.Count; } else { // T4 (BR-6): the old clipRoot==null mini-pipeline (outdoor // partition + Chebyshev look-in gather + DrawPortal + dynamics // fallback) is DELETED — it was the SECOND render path the // one-gate rule forbids (legacy-outdoor-branch-remnant, // adjusted-confirmed). clipRoot is null only when NO viewer // cell exists at all (pre-login, fly/debug cameras, transient // streaming gaps — the outdoor node covers every normal outdoor // frame): draw the world flat through the dispatcher; floods // resume the moment a viewer cell resolves. _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds); // A7.L1: no indoor draw this frame — fail open (unscoped) rather than // scoping next frame's light pool by a stale dungeon's cell ids. _lightPoolVisibleCellsValid = false; } // Phase U.3: close the world-geometry clip bracket opened above. From here down the // scene particles, debug lines, and UI use shaders that do NOT write gl_ClipDistance, so // the planes must be OFF to avoid the undefined-behavior clip. (The weather/rain pass // below DOES use sky.vert — it re-enables the planes in its OWN local bracket; the sky // pre-scene pass above already did the same. Both are scissored to the doorway too.) for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) gl.Disable(EnableCap.ClipDistance0 + _cp); // Phase G.1 / E.3: draw all live particles after opaque // scene geometry so alpha blending composites correctly. // Runs with depth test on (particles occluded by walls) // but depth write off (no self-occlusion sorting needed). if (clipRoot is null && _particleSystem is not null && _particleRenderer is not null) { if (clipAssembly is not null) { sigSceneParticles = sigSceneParticles == "none" ? "filtered" : sigSceneParticles + "+filtered"; _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _visibleSceneParticleEntityIds, includeUnattached: true, excludedAttachedOwnerIds: _outdoorSceneParticleEntityIds); } else { sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global"; _particleRenderer.Draw( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene); } } else if (clipRoot is { IsOutdoorNode: true } && _particleSystem is not null && _particleRenderer is not null) { // T3 (BR-5): unattached emitters (campfires, ground effects — // AttachedObjectId == 0) under the OUTDOOR root. The outdoor // root's outside view is full-screen (cone pass-all); depth // test composites them against the world. // #132 outdoor sibling: ATTACHED outdoor-static scene emitters // (lantern/candle flames) moved here too — drawn in the // landscape slice they were overpainted by merged building // interiors (drawn later) whenever a punched aperture sat // behind them. Post-frame, depth is complete and the flames // composite correctly. The owner-id set is the late slice's // (full-screen cone outdoors). Cell-pass and dynamics-pass // emitters keep their own passes (no double-draw: their owners // are never in the outdoor-static id set). sigSceneParticles = sigSceneParticles == "none" ? "unattached" : sigSceneParticles + "+unattached"; _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _outdoorSceneParticleEntityIds, includeUnattached: true); } // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // Outdoor LScape post-scene weather. Indoor weather through an exit portal is // drawn by RetailPViewRenderer.DrawInside via DrawRetailPViewLandscapeSlice. if (clipRoot is null && drawSkyThisFrame) { sigSkyDrawn = true; _clipFrame.BindTerrainClip(gl); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) gl.Enable(EnableCap.ClipDistance0 + _cp); _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); for (int _cp = 0; _cp < ClipFrame.MaxPlanes; _cp++) gl.Disable(EnableCap.ClipDistance0 + _cp); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } _worldRenderDiagnostics?.EmitRenderSignatureIfChanged( AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled, renderBranch, clipRoot, viewerRoot, playerRoot, viewerCellId, playerCellId, playerIndoorGate, cameraInsideCell, renderSky, drawSkyThisFrame, sigTerrainDrawn, terrainClipMode, sigSkyDrawn, sigDepthClear, sigOutdoorSceneryDrawn, sigOutdoorPortalDrawn, sigOutdoorRootObjectCount, sigLiveDynamicDrawnCount, sigSceneParticles, sigPvFrame, sigClipAssembly, sigDrawableCells, sigPartition, sigExteriorPvFrame, sigExteriorClipAssembly, sigExteriorDrawableCells, sigExteriorPartition, camPos, playerViewPos); // Debug: draw collision shapes as wireframe cylinders around the // player so we can visually verify alignment with scenery meshes. if (_debugCollisionVisible && _debugLines is not null) { _debugLines.Begin(); // Pick the center for the debug radius. Prefer player // position in player mode, otherwise use camPos. System.Numerics.Vector3 center; if (_playerMode && _playerController is not null) center = _playerController.Position; else center = camPos; // Draw ALL registered shadow objects regardless of distance — // if it has collision, it gets a wireframe. This lets the user // see exactly what's in the collision registry at any moment. int drawn = 0; foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug()) { var dx = obj.Position.X - center.X; var dy = obj.Position.Y - center.Y; if (obj.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) { float h = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 2f; _debugLines.AddCylinder( obj.Position, obj.Radius, h, new System.Numerics.Vector3(0f, 1f, 0f)); // green cylinders } else { // BSP: show a bounding sphere as a cylinder for visibility _debugLines.AddCylinder( obj.Position - new System.Numerics.Vector3(0, 0, obj.Radius), obj.Radius, obj.Radius * 2f, new System.Numerics.Vector3(1f, 0.5f, 0f)); // orange BSP } drawn++; } // Draw the player's collision sphere as a red cylinder (0.48m radius, 1.8m tall) if (_playerMode && _playerController is not null) { var pp = _playerController.Position; _debugLines.AddCylinder( new System.Numerics.Vector3(pp.X, pp.Y, pp.Z - 0.0f), 0.48f, 1.8f, new System.Numerics.Vector3(1f, 0f, 0f)); // red player } if (_debugDrawLogOnce < 5 && _playerMode && _playerController is not null) { var pp = _playerController.Position; Console.WriteLine( $"debug frame {_debugDrawLogOnce}: player=({pp.X:F1},{pp.Y:F1},{pp.Z:F1}) drew={drawn} " + $"totalReg={_physicsEngine.ShadowObjects.TotalRegistered}"); // Sample 3 nearest shadow objects int logged = 0; foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) { var dx = o.Position.X - pp.X; var dy = o.Position.Y - pp.Y; float dh = MathF.Sqrt(dx * dx + dy * dy); if (dh < 10f) { Console.WriteLine($" near id=0x{o.EntityId:X8} type={o.CollisionType} pos=({o.Position.X:F1},{o.Position.Y:F1},{o.Position.Z:F1}) r={o.Radius:F2} h={o.CylHeight:F2} dh={dh:F2}"); if (++logged >= 5) break; } } _debugDrawLogOnce++; } _debugLines.Flush(camera.View, worldProjection); } // Count visible vs total for the perf overlay. foreach (var entry in _worldState.LandblockBounds) { totalLandblocks++; if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax)) visibleLandblocks++; } // Phase I.2: refresh per-frame fields that DebugVM closures // can't compute lazily (frustum-derived counters + nearest- // object scan). Every other DebugVM field reads through to // the live source via its closure. Skipped entirely when // devtools are off — avoids the nearest-object O(N) scan in // the hot path of an offline render. if (_debugVm is not null) { System.Numerics.Vector3 debugNearestOrigin = _playerMode && _playerController is not null ? _playerController.Position : camPos; _debugVmRenderFacts.PublishDebugVmFacts( consumerActive: true, visibleLandblocks, totalLandblocks, debugNearestOrigin, _physicsEngine.ShadowObjects.AllEntriesForDebug()); } // K-fix1 (2026-04-26): jump target for IsLiveModeWaitingForLogin — // skips the world geometry pass before login. ImGui (chat, // debug, settings panels) and the menu bar still render // below. Sky has already drawn before this label so the // pre-login screen shows a live, correctly-tinted sky and // nothing else. SkipWorldGeometry: _retailAlphaQueue.EndFrame(); if (_terrain is not null) _particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds); _particleVisibility.CompleteFrame(); } _retailSelectionScene?.CompleteFrame(); // Retail gmSmartBoxUI swaps the world SmartBox viewport for a // CreatureMode portal-space viewport. The world block above is skipped // while this replacement scene is visible. This scene and // its projection transition draws BEFORE retained UI, so chat, windows, // cursor, and toolbar remain visible and interactive in transit. _localPlayerTeleport?.DrawPortalViewport( _window!.Size.X, _window.Size.Y, _cameraController!.Active.Projection); // Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the // viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window // is open AND the paperdoll is in doll-view (the widget's Visible is driven by the Slots toggle). // The 3-D pass is fully sealed in a GLStateScope so it doesn't disturb the world/UI state. _paperdollFramePresenter?.Render(); // Phase D.2b — retail-look UI tree (render-only; input integration deferred). // Self-contained 2D pass: UiHost.Draw → TextRenderer.Flush sets its own // blend/depth state and restores. Drawn before ImGui so the devtools // overlay composites on top during development. if (_options.RetailUi && _retailUiRuntime is not null) { _retailUiRuntime.Tick(deltaSeconds); if (_input is not null) _retailUiRuntime.UpdateCursor(_input.Mice); _retailUiRuntime.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y)); } // Devtools remain above retained gameplay UI. The focused presenter // owns menu policy, panels, and ImGui draw-data upload. _devToolsFramePresenter?.Render( deltaSeconds, _window!.Size.X, _window.Size.Y); // Explicit diagnostic captures are requested by the retained-UI // automation tick above and executed only after every presentation // layer has drawn into the back buffer on this render thread. bool screenshotCaptured = _frameScreenshots?.CapturePending() == true; _renderFrameDiagnostics?.Publish( new AcDream.App.Rendering.RenderFrameInput( deltaSeconds, _window!.Size.X, _window.Size.Y), new AcDream.App.Rendering.RenderFrameOutcome( new AcDream.App.Rendering.WorldRenderFrameOutcome( visibleLandblocks, totalLandblocks, normalWorldDrawn), new AcDream.App.Rendering.PrivatePresentationFrameOutcome( portalViewportVisible, screenshotCaptured))); } catch (Exception ex) { renderFailure = ex; throw; } finally { try { _gpuFrameFlights.EndFrame(); } catch (Exception closeFailure) when (renderFailure is not null) { throw new AggregateException( "Rendering failed and the in-flight GPU frame could not be closed.", renderFailure, closeFailure); } } } 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). /// /// 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 /// feeding /// (for lighting interp) and the cached /// (for the sky-object render loop). /// /// /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks /// group N regardless of day index. Useful for A/B testing each /// weather preset against retail. See /// /// for the roller. /// /// 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, _activeDayGroup); if (!dayChanged && !groupChanged) return; _loadedSkyDayIndex = dayIndex; _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})"); } } private void DrawRetailPViewLandscapeSlice( AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx, ICamera camera, FrustumPlanes? frustum, System.Numerics.Vector3 camPos, uint? playerLb, HashSet? animatedIds, bool renderSky, bool renderWeather, AcDream.Core.World.SkyKeyframe kf, bool environOverrideActive) { var slice = sliceCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); _worldRenderDiagnostics?.EmitClipRouteScissorProbe( AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled, scissor, slice.NdcAabb); _clipFrame!.BindTerrainClip(_gl!); EnableClipDistances(); if (renderSky) _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); DisableClipDistances(); if (renderSky && _particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); EnableClipDistances(); _worldRenderDiagnostics?.BeginTerrainDraw(); _terrain?.Draw( camera, frustum, neverCullLandblockId: playerLb, clipPlanes: slice.Planes, ndcClipAabb: slice.NdcAabb); PublishTerrainDrawDiagnostics(); // T3 (BR-5): entities draw OUTSIDE the clip bracket — retail meshes // are viewcone-CHECKED, never hard-clipped (Ghidra 0x0054c250); the // sphere pre-filter already ran in RetailPViewRenderer (OutdoorEntities // is the per-slice survivor set). DisableClipDistances(); if (sliceCtx.OutdoorEntities.Count > 0) { var sceneryEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, sliceCtx.OutdoorEntities, (IReadOnlyDictionary?)null); _wbDrawDispatcher!.Draw(camera, new[] { sceneryEntry }, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds); } // #131/#132: scene particles + weather MOVED to the LATE phase // (DrawRetailPViewLandscapeSliceLate) — they must composite AFTER the // #124 look-ins (retail's FlushAlphaList deferral, DrawCells // pc:432722); drawn here they were overpainted by far-building // interiors wherever a look-in aperture sat behind them. if (scissor) _gl!.Disable(EnableCap.ScissorTest); DisableClipDistances(); } // #131/#132: the LATE landscape phase — per slice, invoked by the renderer // AFTER the #124 look-in sub-pass, still pre-clear. Outside-stage // dynamics' meshes (a translucent portal swirl blends over a far interior // instead of being overpainted by it — translucents write no depth to // protect themselves) + ALL attached scene particles (statics' flames // included — the #132 candle) + weather. Retail equivalent: alpha draws // collected during LScape::draw flush ONCE after it // (D3DPolyRender::FlushAlphaList, PView::DrawCells pc:432722). private void DrawRetailPViewLandscapeSliceLate( AcDream.App.Rendering.RetailPViewLandscapeLateSliceContext lateCtx, ICamera camera, FrustumPlanes? frustum, System.Numerics.Vector3 camPos, uint? playerLb, HashSet? animatedIds, bool renderSky, bool renderWeather, AcDream.Core.World.SkyKeyframe kf, bool environOverrideActive, bool isOutdoorRoot) { var slice = lateCtx.Slice; bool scissor = BeginDoorwayScissor(true, slice.NdcAabb); _clipFrame!.BindTerrainClip(_gl!); // Outside-stage dynamics' meshes — viewcone pre-filtered by the // renderer, never hard-clipped (T3). DisableClipDistances(); if (lateCtx.Dynamics.Count > 0) { var dynamicsEntry = (playerLb ?? 0u, System.Numerics.Vector3.Zero, System.Numerics.Vector3.Zero, lateCtx.Dynamics, (IReadOnlyDictionary?)null); _wbDrawDispatcher!.Draw(camera, new[] { dynamicsEntry }, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds); } _outdoorSceneParticleEntityIds.Clear(); foreach (var entity in lateCtx.ParticleOwners) _outdoorSceneParticleEntityIds.Add(ParticleEntityKey(entity)); // #131 [outstage-pt] probe: the slice Scene-particle id set + how many // live emitters the filter would actually match, plus the distinct // UNMATCHED attached owner ids (the portal-identification handle — // an emitter whose owner never lands in the set draws nowhere // indoors). Print-on-change. _worldRenderDiagnostics?.EmitOutStageParticles( AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled, _particleSystem, _outdoorSceneParticleEntityIds); // #132 outdoor sibling: under an OUTDOOR root the merged building // interiors draw AFTER this stage (DrawEnvCellShells) — a flame drawn // here is overpainted whenever a punched aperture sits behind it // (user-confirmed at the outdoor candle). Outdoor roots therefore // SKIP the slice Scene pass and draw attached scene particles in the // post-frame pass alongside the T3 unattached pass (the id set built // above carries over — the outdoor root has a single full-screen // slice). Interior roots draw here: the look-ins already ran and the // post-clear seal discipline owns the rest of the frame. if (!isOutdoorRoot && _outdoorSceneParticleEntityIds.Count > 0 && _particleSystem is not null && _particleRenderer is not null) { _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _outdoorSceneParticleEntityIds); } // T3 (BR-5): weather gates on the PLAYER being outside, not the viewer // root — retail draws weather only when is_player_outside (the rain // cylinder rides the player; an indoor player gets NO rain even while // looking out a doorway). Closes the rain-through-doorways divergence // (weather-gate-player-vs-viewer, adjusted-confirmed). EnableClipDistances(); if (renderSky && renderWeather) { _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); DisableClipDistances(); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } else { DisableClipDistances(); } if (scissor) _gl!.Disable(EnableCap.ScissorTest); DisableClipDistances(); } // T1: retail's invisible portal depth writes on every outside-leading // portal (other_cell_id==0xFFFF) of this cell, clipped to the slice's view // region — D3DPolyRender::DrawPortalPolyInternal (Ghidra 0x0059bc90), // dispatched by PView::DrawCells (Ghidra 0x005a4840). forceFarZ is // retail's maxZ1(true)/maxZ2(false) selector: // • INTERIOR root (false → SEAL, true depth): after the full depth clear, // stamp the door plane so interior geometry beyond the door z-fails // inside the aperture and the terrain drawn through the outside view // keeps its pixels (#108's protective mechanism). // • OUTDOOR root / look-in (true → PUNCH, far depth): erase the world's // depth inside a flooded building's entry aperture so the interior // drawn next shows THROUGH the doorway. // Both are safe ONLY because dynamics draw last (DrawDynamicsLast) — the // first BR-2 attempt punched after dynamics and erased the player // (reverted 88be519). Wiring only — the draw lives in // PortalDepthMaskRenderer. private void DrawRetailPViewPortalDepthWrite( AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, System.Numerics.Matrix4x4 viewProjection, bool forceFarZ) { if (_portalDepthMask is null) return; if (!_cellVisibility.TryGetCell(sliceCtx.CellId, out var cell) || cell is null) return; Span world = stackalloc System.Numerics.Vector3[32]; for (int i = 0; i < cell.Portals.Count; i++) { if (cell.Portals[i].OtherCellId != 0xFFFF) continue; // depth writes apply to portals leading OUTSIDE only if (i >= cell.PortalPolygons.Count) break; var localVerts = cell.PortalPolygons[i]; if (localVerts.Length < 3) continue; // cell.WorldTransform is the PHYSICS (unlifted) transform (f35cb8b); // the shell that rasterizes this aperture draws +ShellDrawLiftZ // higher. The seal/punch is a DRAW — stamp depth in the same lifted // space or the stamp sits 2 cm below the drawn hole (#130 family). int n = System.Math.Min(localVerts.Length, world.Length); for (int v = 0; v < n; v++) { world[v] = System.Numerics.Vector3.Transform(localVerts[v], cell.WorldTransform); world[v].Z += AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ; } // #176 seam-draw probe: a sealed dungeon must emit ZERO depth fans // (only OtherCellId==0xFFFF portals reach here) — any line in a // target cell is a finding (a depth stamp fighting the shell floor). if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeSeamDrawEnabled && AcDream.Core.Rendering.RenderingDiagnostics.SeamDrawTargetCells.Contains(sliceCtx.CellId)) { float seamZMin = float.MaxValue, seamZMax = float.MinValue; for (int v = 0; v < n; v++) { seamZMin = System.Math.Min(seamZMin, world[v].Z); seamZMax = System.Math.Max(seamZMax, world[v].Z); } Console.WriteLine(System.FormattableString.Invariant( $"[seam-mask] t={System.Environment.TickCount64} cell=0x{sliceCtx.CellId:X8} portal={i} far={forceFarZ} n={n} z=[{seamZMin:F3},{seamZMax:F3}]")); } _portalDepthMask.DrawDepthFan(world[..n], viewProjection, sliceCtx.Slice.Planes, forceFarZ); } } private void DrawRetailPViewCellParticles( AcDream.App.Rendering.RetailPViewCellSliceContext sliceCtx, ICamera camera, System.Numerics.Vector3 camPos) { if (_particleSystem is null || _particleRenderer is null || sliceCtx.CellEntities.Count == 0) return; _visibleSceneParticleEntityIds.Clear(); foreach (var entity in sliceCtx.CellEntities) _visibleSceneParticleEntityIds.Add(ParticleEntityKey(entity)); if (_visibleSceneParticleEntityIds.Count == 0) return; // T3 (BR-5): the scissor-AABB gate is DELETED — retail gates particles // like meshes (viewcone on the owner; depth does the pixels). The // CellEntities set is already the cone-surviving owner list, so the // id-predicate below IS the cone gate; the punch/seal depth discipline // composites the pixels. DisableClipDistances(); _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _visibleSceneParticleEntityIds); DisableClipDistances(); } // #121: the dynamics-owner particle pass — Scene-pass emitters attached to // the frame's cone-surviving dynamics (portal swirls on server-spawned // portal entities, creature effects). Retail draws emitters with their // owner object; before this pass, dynamics' emitters fell through every // pview particle filter (landscape slice = outdoor statics + #118 // outside-stage dynamics; cell callback = cell statics) once T4 deleted // the clipRoot==null global pass from normal frames — every world portal // went invisible. Mirror of DrawRetailPViewCellParticles with the // survivors' ids as the filter. private readonly HashSet _dynamicsSceneParticleEntityIds = new(); private void DrawRetailPViewDynamicsParticles( IReadOnlyList survivors, ICamera camera, System.Numerics.Vector3 camPos) { if (_particleSystem is null || _particleRenderer is null || survivors.Count == 0) return; _dynamicsSceneParticleEntityIds.Clear(); foreach (var entity in survivors) _dynamicsSceneParticleEntityIds.Add(ParticleEntityKey(entity)); if (_dynamicsSceneParticleEntityIds.Count == 0) return; DisableClipDistances(); _particleRenderer.DrawForOwners( camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene, _dynamicsSceneParticleEntityIds); DisableClipDistances(); } private void EnableClipDistances() { for (int i = 0; i < ClipFrame.MaxPlanes; i++) _gl!.Enable(EnableCap.ClipDistance0 + i); } private void DisableClipDistances() { for (int i = 0; i < ClipFrame.MaxPlanes; i++) _gl!.Disable(EnableCap.ClipDistance0 + i); } // Phase W Stage 4: set a glScissor to an NDC AABB (the doorway / OutsideView region) in // framebuffer pixels and enable the scissor test; returns true iff applied (the caller then // disables EnableCap.ScissorTest after its draw/clear). Used to bracket the landscape slice // (sky, terrain, statics, weather — particle.vert has no gl_ClipDistance). Returns false // (no scissor) when not applied (outdoor / no window). The box is the CONSERVATIVE outer // bound (NdcScissorRect): the previous Floor(origin)+Ceiling(size) form cut up to one pixel // off the TOP/RIGHT edges at unlucky alignments — the #130 doorway top-edge background strip. private bool BeginDoorwayScissor(bool apply, System.Numerics.Vector4 ndcAabb) { if (!apply || _window is null) return false; var fb = _window.FramebufferSize; var box = NdcScissorRect.ToPixels(ndcAabb, fb.X, fb.Y); _gl!.Enable(EnableCap.ScissorTest); _gl.Scissor(box.X, box.Y, (uint)box.Width, (uint)box.Height); return true; } /// /// Derive the current sun (directional light, slot 0 of the UBO) /// from the interpolated , /// plus the cell ambient. Indoor cells force the sun intensity to /// zero and substitute a flat 0.2 white ambient — exact retail /// behavior per CellManager::ChangePosition @ 0x004559B0, /// which calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) /// when the player's CObjCell::seen_outside flag is 0. /// Indoor brightness then comes from per-cell point lights /// (Setup.Lights on the cell's static objects, registered through /// ). /// The trigger is the PLAYER's cell, not the camera's — third-person /// chase camera enters interiors before the player body does, and /// retail keys lighting off the player position. /// private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell) { // Sun direction: points FROM the sun TOWARDS the world. Our // shader does dot(N, -forward) so a positive N·L means the // surface faces the sun. var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf); if (playerInsideCell) { // Indoor default — retail's flat 0.2 neutral ambient, sun // zeroed. See xref to retail decomp in the doc comment above. Lighting.Sun = new AcDream.Core.Lighting.LightSource { Kind = AcDream.Core.Lighting.LightKind.Directional, WorldForward = sunToWorld, ColorLinear = System.Numerics.Vector3.Zero, Intensity = 0f, Range = 1f, }; Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f), SunColor: System.Numerics.Vector3.Zero, SunDirection: sunToWorld); } else { // Outdoor: full keyframe sun + ambient. The SkyKeyframe stores // raw DirColor + DirBright (and AmbColor + AmbBright) for // retail-faithful per-channel keyframe interpolation; the // computed `kf.SunColor` / `kf.AmbientColor` properties return // the post-multiplied product the shader expects. Lighting.Sun = new AcDream.Core.Lighting.LightSource { Kind = AcDream.Core.Lighting.LightKind.Directional, WorldForward = sunToWorld, ColorLinear = kf.SunColor, Intensity = 1f, Range = 1f, }; Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( AmbientColor: kf.AmbientColor, SunColor: kf.SunColor, SunDirection: sunToWorld); } } // ── 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. /// Player-mode-aware position source for the DebugPanel. 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; } /// Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise. 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; } /// /// 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. /// 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"); } } /// /// Cycle the weather kind. Same body as the old F10 keybind handler. /// 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]}"); } /// /// Toggle the collision-wires debug renderer. Same body as the old /// F2 keybind handler. /// private void ToggleCollisionWires() { _debugCollisionVisible = !_debugCollisionVisible; _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "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); } /// /// Retail ClientCommunicationSystem::DoFrameRate @ 0x005707D0: /// flip the live display flag and persist it through the same settings /// path used by the Display panel. /// 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; /// /// 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 /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions /// defaults instead of their saved Display/Audio preferences. /// 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; } } /// /// A.5 T22.5: apply a new quality preset mid-session (called from the /// Settings panel Save path when /// changes). /// /// /// What changes immediately: /// /// Streaming radii: transactionally reconciles the existing /// against its published world; /// the worker and controller retain their identity. /// Anisotropic filtering: calls /// TerrainAtlas.SetAnisotropic. /// Alpha-to-coverage gate: sets /// WbDrawDispatcher.AlphaToCoverage. /// Max completions per frame: updates /// StreamingController.MaxCompletionsPerFrame. /// /// /// /// /// What requires a restart: /// MSAA samples are baked into the GL context via WindowOptions.Samples /// at window creation time and cannot change at runtime. If the new preset /// would change MsaaSamples, a warning is logged and MSAA is left /// at its current level until the next launch. /// /// 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}"); } } /// /// 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. /// private void OnFramebufferResize(Silk.NET.Maths.Vector2D 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); } /// /// L.0 Display tab: apply the window-state-dependent settings /// (Resolution + Fullscreen) from a /// to the live Silk.NET window. Called at startup (with persisted /// values) and on every Save (with the saved values). Resolution /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed /// or unparseable string is silently ignored to avoid crashing the /// client mid-session. /// 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(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. /// /// K.1b — multicast subscriber on . /// Handles every game-side reaction to a keyboard/mouse-button chord. /// Per-frame held-state polling (movement WASD/Shift/Space) lives in /// via ; /// this method handles transitional Press/Release events only. /// 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 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); } internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState( AcDream.App.Input.MovementResult result) => AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result); /// /// 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. allows item target-use to /// pick the local player while plain selection excludes it. /// 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( (claim & 0xFFFF0000u) | 0xFFFEu); } uint low = claim & 0xFFFFu; unhydratable = lbInfo is null || lbInfo.NumCells == 0 || low >= 0x0100u + lbInfo.NumCells; } _spawnClaimRangeMemo = (claim, unhydratable); return unhydratable; } /// /// 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]. /// 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"); } /// /// 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. /// 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(); 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}"); } } /// /// 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). /// 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 PublishTerrainDrawDiagnostics() { long now = _frameDiag ? Environment.TickCount64 : 0L; _worldRenderDiagnostics?.EndTerrainDraw(); if (!_frameDiag || now - _frameDiagLastPublicationMilliseconds <= 5000) return; // Preserve the original one-timestamp diagnostic transaction. A // failure in either write leaves the shared cadence uncommitted, so // the next terrain draw retries both TERRAIN and FRAME diagnostics. _worldRenderDiagnostics?.PublishTerrainDiagnostics( new AcDream.App.Rendering.TerrainRenderDiagnosticFacts( _terrain?.VisibleSlots ?? 0, _terrain?.LoadedSlots ?? 0, _terrain?.CapacitySlots ?? 0)); PublishFrameDiagnostics(); _frameDiagLastPublicationMilliseconds = now; } private void PublishFrameDiagnostics() { AcDream.App.Streaming.LandblockPresentationDiagnostics presentation = _landblockPresentationPipeline?.Diagnostics ?? default; AcDream.App.Streaming.LandblockRenderPublisherDiagnostics render = presentation.Render; AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physics = presentation.Physics; AcDream.App.Streaming.LandblockStaticPresentationDiagnostics statics = presentation.Statics; double ticksToMicros = 1_000_000.0 / System.Diagnostics.Stopwatch.Frequency; AcDream.App.Rendering.RollingTimingPercentiles upload = _renderFrameLivePreparation?.UploadTiming ?? default; double uploadMedian = upload.MedianHundredthsMicroseconds / 100.0; double uploadP95 = upload.Percentile95HundredthsMicroseconds / 100.0; int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0; _renderDiagnosticLog.WriteLine( $"[FRAME-DIAG] publish={render.BeginCount}/{render.CompleteCount} " + $"render_total_us=[terrain={render.TerrainPublishTicks * ticksToMicros:F1} " + $"begin={render.BeginPublishTicks * ticksToMicros:F1} " + $"complete={render.CompletePublishTicks * ticksToMicros:F1}] " + $"physics_total_us=[base={physics.BasePublishTicks * ticksToMicros:F1} " + $"gfx={physics.GfxCacheTicks * ticksToMicros:F1} " + $"complete={physics.CompletePublishTicks * ticksToMicros:F1}] " + $"static={statics.BeginCount}/{statics.CompleteCount}" + $"(active={statics.ActiveEntityCount}) " + $"entUpl_us={uploadMedian:F1}m/{uploadP95:F1}p95 " + $"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " + $"fullRetire={_streamingController?.FullWindowRetirementCount ?? 0}" + $"(landblocks={_streamingController?.LastFullWindowRetirementLandblockCount ?? 0}) " + $"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " + $"resident={_worldState.Entities.Count} walked={walked}"); } /// A.5 T22: parse a float environment variable, returning /// when the variable is absent or unparseable. private static float ParseEnvFloat(string name, float defaultValue) { var s = System.Environment.GetEnvironmentVariable(name); if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var v)) return v; return defaultValue; } 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; }), ]), 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("frame composition", () => { _renderFrameResources = null; _renderFrameLivePreparation = null; _renderWeatherFrame = 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; } /// /// Fallback for the /// sequencer /// factory when neither _dats 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). /// private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader { public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; } }