acdream/src/AcDream.App/Rendering/GameWindow.cs

8569 lines
433 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using AcDream.Core.Plugins;
using AcDream.App.Physics;
using AcDream.App.World;
using AcDream.Content;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
/ (double)System.Diagnostics.Stopwatch.Frequency;
private readonly 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;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private IDatReaderWriter? _dats;
private float _lastMouseX;
private float _lastMouseY;
private Shader? _meshShader;
private TextureCache? _textureCache;
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
/// after <c>OnLoad</c> completes (modern path is mandatory as of N.5).</summary>
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Vfx.EntityScriptActivator? _entityScriptActivator;
private RetailStaticAnimatingObjectScheduler? _staticAnimationScheduler;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
private AcDream.App.Rendering.Selection.RetailSelectionScene? _retailSelectionScene;
private AcDream.App.Interaction.WorldSelectionQuery? _worldSelectionQuery;
private AcDream.App.Interaction.SelectionInteractionController? _selectionInteractions;
/// <summary>Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters
/// support. Required at startup — missing bindless throws
/// <see cref="NotSupportedException"/> in <c>OnLoad</c>.</summary>
private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport;
private SamplerCache? _samplerCache;
private DebugLineRenderer? _debugLines;
// K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder
// wireframes are noisy outdoors and confuse first-time users into
// thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel
// → Diagnostics → "Toggle collision wires" button toggles too.
private 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;
// Last-computed perf values so the HUD always has something to show even
// though the title-bar FPS is only updated every 0.5s.
private double _lastFps = 60.0;
private double _lastFrameMs = 16.7;
// Phase I.2: per-frame counters surfaced through the ImGui DebugPanel
// VM closures. Computed once per render pass alongside the frustum
// walk + nearest-object scan; the VM closures just read the cached
// values. Skipped when DevTools are off (zero cost).
private int _lastVisibleLandblocks;
private int _lastTotalLandblocks;
private float _lastNearestObjDist = float.PositiveInfinity;
private string _lastNearestObjLabel = "-";
private bool _lastColliding;
// Phase N.5b: CPU timing for [TERRAIN-DIAG] under ACDREAM_WB_DIAG=1
// (parallel diagnostic to [WB-DIAG] in WbDrawDispatcher — same env var
// gate so flipping one switch turns on both dispatcher rollups). Mirrors
// the rolling-256-sample buffer pattern from WbDrawDispatcher.
private readonly System.Diagnostics.Stopwatch _terrainCpuStopwatch = new();
private readonly long[] _terrainCpuSamples = new long[256]; // microseconds
private int _terrainCpuSampleCursor;
private long _terrainLastDiagTick;
// [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 readonly long[] _entityUploadSamples = new long[256];
private int _entityUploadSampleCursor;
// 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 AcDream.App.Diagnostics.FrameScreenshotController? _frameScreenshots;
private AcDream.App.Diagnostics.WorldLifecycleAutomationController? _worldLifecycleAutomation;
private AcDream.App.Rendering.GpuFrameFlightController? _gpuFrameFlights;
private ResourceShutdownTransaction? _shutdown;
private readonly FramePacingController _framePacing = new();
private bool _requestedVSync =
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default.VSync;
private int? _activeMonitorRefreshHz;
// 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.Update.IStreamingFramePhase _streamingFrame = null!;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
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.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
private AcDream.App.Update.LiveSpatialPresentationReconciler
_liveSpatialReconciler = 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 readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
_liveEntityMotionBindings = new();
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
// the render thread (LandblockBuildFactory on the worker; live-spawn
// handlers + animation ticks on the render
// thread) corrupt that state and produce half-populated LandBlock.Height[]
// arrays, rendering as "a giant ball with spikes". All _dats.Get<T> call
// sites that can race with the streaming worker MUST hold this lock.
//
// Worker-thread DAT reads enter through LandblockBuildFactory.Build, which
// holds _datLock for the complete CPU transaction. The terrain-mesh closure
// consumes that completed heightmap and performs no DAT read. Render-thread
// live hydration holds this lock via its outer wrapper; all remaining
// render-thread
// _dats.Get calls run only when no worker dat read can be in flight (during
// initialization or within the same lock scope).
private readonly object _datLock = new();
// Terrain build context shared by the off-thread mesh-build closure across
// all streamed landblocks.
private float[]? _heightTable;
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
// Was: Dictionary<uint, SurfaceInfo>. ConcurrentDictionary so the off-thread
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A8 (2026-05-28): WB EnvCellRenderManager port. Cells render
// through this dedicated pipeline now, NOT through WbDrawDispatcher.
// The dispatcher's IndoorPass still walks cell-static stabs (WorldEntity
// records with real GfxObj MeshRefs); only the cell GEOMETRY (walls /
// floors / ceilings) flows through here.
private AcDream.App.Rendering.Wb.EnvCellRenderer? _envCellRenderer;
private AcDream.App.Rendering.Wb.WbFrustum? _envCellFrustum;
// R1 (render redesign): 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<LoadedCell> _outdoorNodeBuildingCells = new();
private readonly HashSet<uint> _outdoorSceneParticleEntityIds = new();
private readonly HashSet<uint> _visibleSceneParticleEntityIds = new();
private readonly HashSet<uint> _noSceneParticleEntityIds = new();
private string? _lastRenderSignature;
private int _renderSignatureFrame;
private int _renderSignatureStableFrames;
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
/// of these every frame, advances the current frame number, then
/// rebuilds the entity's MeshRefs by re-flattening the Setup against
/// the new <see cref="DatReaderWriter.Types.AnimationFrame"/>.
/// Static decorations and entities with no motion table never
/// appear in this map.
/// </summary>
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
// 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<uint> _animatedIdsScratch = new();
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
/// paired with <see cref="_animatedEntities"/> — that dictionary is the
/// gating predicate, this cache is the lookup that depends on it.
/// Passed to <see cref="AcDream.App.Rendering.Wb.WbDrawDispatcher"/> at
/// construction time. Tasks 9-10 of the cache plan wire the per-entity
/// miss-populate / hit-fast-path through the dispatcher's loop.
/// </summary>
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
private AcDream.Core.Physics.IAnimationLoader? _animLoader;
private AcDream.App.Physics.LiveEntityCollisionBuilder? _liveEntityCollisionBuilder;
// Phase E.1: central fan-out for animation hooks. Audio (E.2),
// particles (E.3), combat (E.4), and renderer state mutators all
// register sinks at startup. The router is always non-null so the
// per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
// failed to init; all three are set together.
private AcDream.App.Audio.OpenAlAudioEngine? _audioEngine;
private AcDream.Core.Audio.DatSoundCache? _soundCache;
private AcDream.App.Audio.DictionaryEntitySoundTable? _entitySoundTables;
private AcDream.App.Audio.AudioHookSink? _audioSink;
// Phase E.3 particles.
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private readonly AcDream.App.Rendering.Vfx.ParticleVisibilityController _particleVisibility = new();
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
// and typed PlayScriptType (0xF755) events through one effect owner, then
// fans every dat-defined hook to particles, audio, lights, translucency,
// and nested/default-script routing at its StartTime offset.
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
private AcDream.Content.Vfx.RetailPhysicsScriptLoader? _physicsScriptLoader;
private AcDream.App.Rendering.Vfx.EntityEffectController? _entityEffects;
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
private readonly AcDream.App.Rendering.RetailAlphaQueue _retailAlphaQueue = new();
// 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.
/// <summary>
private readonly HashSet<SkyPesKey> _activeSkyPes = new();
private readonly HashSet<SkyPesKey> _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();
/// <summary>
/// Per-remote-entity movement state for smoothing between server
/// UpdatePosition broadcasts. Retail queues each received position and
/// advances the body toward the queue head through
/// <c>InterpolationManager::adjust_offset</c> every object tick.
///
/// <para>
/// Each entry owns the retail interpolation queue, MovementManager,
/// PhysicsBody, latest authoritative position, and the velocity estimate
/// used only to select animation cycles for NPCs that receive no motion
/// messages. Body translation consumes queue catch-up plus the literal
/// root-motion Frame emitted by CSequence; it never guesses movement from
/// a Walk/Run command or from packet cadence.
/// </para>
/// </summary>
/// <summary>
/// L.2g S1 (DEV-6): per-entity inbound movement-event staleness gates,
/// keyed by server guid. Retail keeps these stamps on CPhysicsObj
/// (update_times); seeded from CreateObject's PhysicsDesc timestamp
/// block during <see cref="LiveEntityHydrationController.OnCreate"/>, consulted at the
/// top of <see cref="LiveEntityNetworkUpdateController.OnMotion"/>, and dropped by the live
/// entity teardown owner.
/// </summary>
private AcDream.App.World.LiveEntityRuntime? _liveEntities;
private AcDream.App.World.LiveEntityLivenessController? _liveEntityLiveness;
/// <summary>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.</summary>
private const float SnapResidualDecayRate = 8.0f;
/// <summary>
/// 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.
///
/// <para>
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
/// <c>PhysicsObj.cs:545</c>): 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.
/// </para>
/// </summary>
private const float SnapHardSnapThreshold = 20.0f;
/// <summary>
/// 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.
/// </summary>
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();
/// <summary>Persisted hotbar shortcuts from the last PlayerDescription (D.5.1 toolbar source).</summary>
public IReadOnlyList<AcDream.Core.Items.ShortcutEntry> Shortcuts { get; private set; }
= System.Array.Empty<AcDream.Core.Items.ShortcutEntry>();
// Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from
// PlayerDescription so the Vitals HUD can render those bars.
// Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment
// buffs into the max formula via Spellbook.GetVitalMod.
public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!;
// Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1.
// See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap;
private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost;
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 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 bool _paperdollDollDirty = true;
// 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. Lives for as long as
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
// DevToolsEnabled + DumpMoveTruthEnabled now read through _options
// (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for
// local readability via expression-bodied properties.
private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// 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<uint> _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. <c>long.MinValue</c> sentinel means
// "no day rolled yet" so the first RefreshSkyForCurrentDay call
// unconditionally installs a provider. See r12 §11 for the roller
// semantics.
private long _loadedSkyDayIndex = long.MinValue;
private AcDream.Core.World.DayGroupData? _activeDayGroup;
private double _weatherAccum;
// 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
{
get => _playerControllerSlot.Controller;
set => _playerControllerSlot.Controller = value;
}
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
private bool _playerMode
{
get => _localPlayerMode.IsPlayerMode;
set => _localPlayerMode.IsPlayerMode = value;
}
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 MovementTruthOutbound? _lastMovementTruthOutbound;
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly record struct MovementTruthOutbound(
string Kind,
uint Sequence,
System.DateTime TimeUtc,
System.Numerics.Vector3 LocalWorldPosition,
uint LocalCellId,
System.Numerics.Vector3 WirePosition,
uint WireCellId,
bool IsOnGround,
byte ContactByte,
System.Numerics.Vector3 Velocity);
// K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
// received from PlayerDescription. -1 = "not yet received, fall back
// to the controller's default (env-var or hardcoded 200/300)".
// Captured by the GameEventWiring.WireAll callback the moment PD
// arrives; pushed into _playerController via SetCharacterSkills both
// immediately (if the controller already exists from auto-entry) and
// again at every EnterPlayerModeNow so a player who Tab-toggles in
// and out keeps the right skills.
private int _lastSeenRunSkill = -1;
private int _lastSeenJumpSkill = -1;
// 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;
// K.1b: this field is RESERVED — written when entering / leaving player
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
// never consumed by MovementInput (mouse never drives character yaw —
// K.1b regression-prevention). Kept around as plumbing for the future
// K.2 MMB-mouse-look path which will re-enable mouse → character-yaw
// when MMB is held. The pragma silences the dead-write warning until K.2
// wires the read-side back in.
#pragma warning disable CS0414 // assigned but never used — see comment above
private float _playerMouseDeltaX;
#pragma warning restore CS0414
// 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 = 0.15f;
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;
// K-fix1 (2026-04-26): autorun is a TOGGLE — Press Q to start
// forward-running, press Q again (or any movement-cancel key like
// X / S / Backward / Forward) to stop. Mirrors retail's
// AutoRun action. While true, MovementInput.Forward is forced
// true regardless of W's state.
private bool _autoRunActive;
// 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.2 — MMB-hold instant mouse-look state. Live throughout
// the session; flips Active on Press/Release. Defense-in-depth on
// ImGui's WantCaptureMouse — the dispatcher already filters, but
// OnWantCaptureMouseChanged also suspends the state if a panel
// claims focus mid-hold.
private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook;
// Tracks the previous WantCaptureMouse value so we can fire the
// changed-edge callback once per transition (vs every frame).
private bool _lastWantCaptureMouse;
// Cursor mode prior to entering MMB mouse-look. Restored on
// release so the user lands back in the same camera mode as
// before (raw for chase/fly, normal for orbit). Set non-null while
// mouse-look is active.
private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode;
// 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;
// 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;
/// <summary>
/// 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 <see cref="EnterPlayerModeFromAutoEntry"/>.
/// 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).
/// </summary>
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
{
get => _localPlayerMode.ChaseModeEverEntered;
set => _localPlayerMode.ChaseModeEverEntered = value;
}
/// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. This is the canonical materialized top-level
/// projection, including live objects parked in pending landblocks;
/// visibility must never suppress authoritative mutation.
/// </summary>
private static readonly IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> EmptyLiveEntityMap =
new Dictionary<uint, AcDream.Core.World.WorldEntity>();
private static readonly IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> EmptyLiveSpawnMap =
new Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn>();
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid =>
_liveEntities?.MaterializedWorldEntities ?? EmptyLiveEntityMap;
/// <summary>Visible-only view for radar, picking, status, and targets.</summary>
private IReadOnlyDictionary<uint, AcDream.Core.World.WorldEntity> _visibleEntitiesByServerGuid =>
_liveEntities?.WorldEntities ?? EmptyLiveEntityMap;
/// <summary>
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
/// guid. Captured before the renderability gate so no-position inventory /
/// parented children retain Setup and parent metadata; hydration rereads
/// this canonical snapshot after every relationship callback.
/// <see cref="LiveEntityHydrationController.OnAppearance"/> reuses the cached
/// position/setup/motion fields when a 0xF625 ObjDescEvent carries only
/// updated visuals.
/// </summary>
private IReadOnlyDictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> LastSpawns =>
_liveEntities?.Snapshots ?? EmptyLiveSpawnMap;
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
// TargetManager voyeur system, stored on the exact live record so remote
// entities chasing the player resolve it. Replaces the AP-79
// _playerMoveToTarget* poll fields.
private EntityPhysicsHost? _playerHost;
// 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 in EnsureRemoteMotionBindings (remotes) + EnterPlayerModeNow
// (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));
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
// setup/motion dependency crosses the fail-fast construction bridge;
// the server-velocity animation cycle is stateless shared policy.
_remotePhysicsUpdater = new AcDream.App.Physics.RemotePhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder,
AcDream.App.Physics.RemoteServerControlledVelocityCycle.Apply);
_remoteInboundMotion = new AcDream.App.Physics.RemoteInboundMotionDispatcher(
(movement, cellId, update) =>
_liveEntityMotionBindings.RouteServerMoveTo(
movement, cellId, update),
(host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
_localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
? entity
: null,
liveCenterX: () => _liveCenterX,
liveCenterY: () => _liveCenterY,
syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId),
rebucket: (serverGuid, landblockId) =>
_liveEntities?.RebucketLiveEntity(serverGuid, landblockId),
isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection,
suspendShadow: SuspendLocalPlayerShadow);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
DumpMovementTruthOutbound);
_localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
canPresentPlayer: CanAdvanceLocalPlayer,
getController: () => _playerController,
captureInput: () =>
{
_playerMouseDeltaX = 0f;
return CaptureMovementInput();
},
resolveLocalEntityId: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
? entity.Id
: 0u,
handleTargeting: () => _playerHost?.HandleTargetting(),
isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true,
project: (controller, movement, hidden) =>
_localPlayerProjection.Project(controller, movement, hidden),
sendPreNetwork: (controller, movement, hidden) =>
_localPlayerOutbound.SendPreNetworkActions(
LiveSession,
controller,
movement,
hidden),
sendPostNetwork: (controller, hidden) =>
_localPlayerOutbound.SendPostNetworkPosition(
LiveSession,
controller,
hidden),
objectClockDisposition: () =>
_liveEntities?.GetRootObjectClockDisposition(_playerServerGuid)
?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
}
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);
_requestedVSync = startupDisplay.VSync;
FramePacingPolicy startupPacing = FramePacingPolicy.Resolve(
startupDisplay.VSync,
_options.UncappedRendering,
monitorRefreshHz: null);
_framePacing.Apply(startupPacing);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = startupPacing.UseVSync,
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples,
// #117 (2026-06-11): the aperture punch's depth gate needs a
// stencil buffer (PortalDepthMaskRenderer two-pass mark+punch).
// GLFW defaults to 8 stencil bits, but make the requirement
// explicit rather than platform-implicit.
PreferredStencilBufferBits = 8,
};
_window = Window.Create(options);
_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 += OnFrameRendered;
_window.Closing += OnClosing;
_window.FocusChanged += OnFocusChanged;
_window.Move += OnWindowMoved;
_window.StateChanged += 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);
_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,
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false),
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|| (_uiHost?.Root.WantsKeyboard ?? false));
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 /
// 0x00458310): the callback submits a filtered horizontal
// adjustment to the player's MotionInterpreter. It must never
// mutate visible Yaw directly; doing so leaves ACE's authoritative
// heading stale and makes targeted attacks skip their server turn.
_mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState(
applyHorizontalAdjustment: adjustment =>
{
_playerController?.SubmitMouseTurnAdjustment(
adjustment,
CaptureMovementInput());
});
// Phase K.2 — auto-enter player mode after EnterWorld
// succeeds. Predicates close over GameWindow state; the
// entry callback flips into player mode via the same code
// path TogglePlayerMode uses, just without the early-return
// when the entity isn't ready (the third predicate
// guarantees readiness before this fires).
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
isLiveInWorld: () => _liveSessionController?.IsInWorld == true,
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
isPlayerControllerReady: () => true,
// Retail SmartBox::UseTime (0x00455410) completes the player
// position only after CellManager's blocking load clears. The
// shared acdream barrier joins collision, static-mesh upload,
// and composite-texture readiness for both login and portals.
isWorldReady: LoginWorldReady,
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
// 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 ((DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false))
{
_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 (_mouseLook is not null && _mouseLook.Active)
{
// DirectInput may deliver several device records before
// one retail GetInput pass. Accumulate raw motion here;
// the update loop filters the aggregate exactly once.
_mouseLook.QueueDelta(dx, dy);
}
else if (_rmbHeld)
{
// Hold-RMB orbit: player stays the central point, camera
// free-orbits around. X rotates around, Y pitches. On release
// the camera STAYS at the new angle (no snap back).
// K.1b: this is the ONLY mouse-delta path that affects
// ANYTHING when in player mode — character yaw is
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
// above takes precedence when active.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
float nowSec = (float)(Environment.TickCount64 / 1000.0);
var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
_retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
}
else
{
_chaseCamera.YawOffset -= dx * 0.004f * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
}
// K-fix1 (2026-04-26): no default-pitch path. With
// neither MMB nor RMB held, mouse moves the cursor
// freely so the user can interact with panels +
// (eventually) click selectables in the world. Pitch
// is gated on a deliberate hold input.
}
else if (_cameraController.IsFlyMode)
{
float sens = _sensFly;
_cameraController.Fly.Look(dx * sens, dy * sens);
}
else
{
// Orbit-camera mode (offline / pre-login): hold LMB to
// drag-rotate. K.1b reads the mouse-button state via
// IMouseSource so all "is button held" queries go
// through the same abstraction the dispatcher uses.
float sens = _sensOrbit;
if (_mouseSource is not null && _mouseSource.IsHeld(MouseButton.Left))
{
_cameraController.Orbit.Yaw -= dx * 0.005f * sens;
_cameraController.Orbit.Pitch = Math.Clamp(
_cameraController.Orbit.Pitch + dy * 0.005f * sens,
0.1f, 1.5f);
}
}
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
};
}
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest);
// Phase U.3: the 8 hardware clip planes (GL_CLIP_DISTANCE0..7) are NOT
// enabled here. Only the mesh_modern / terrain_modern vertex shaders write
// gl_ClipDistance[0..7]; the sky / particle / weather / UI / debug-line
// shaders write gl_Position but no gl_ClipDistance. Enabling a clip plane
// for a draw whose vertex shader doesn't write that plane's distance is
// UNDEFINED per the GL/GLSL spec (a driver may read the unwritten value as
// negative and clip the primitive away). So instead of a permanent global
// enable, OnRender brackets glEnable/glDisable(GL_CLIP_DISTANCE0..7) around
// ONLY the clip-writing world-geometry draws (terrain + entities, plus
// U.4's EnvCellRenderer.Render); everything else draws with clipping off.
string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
// Phase N.5b: terrain_modern shader pair — bindless texture handles +
// glMultiDrawElementsIndirect dispatch path. The only terrain shader
// since Task 9 retired the legacy terrain.vert/.frag program.
_terrainModernShader = new Shader(_gl,
Path.Combine(shadersDir, "terrain_modern.vert"),
Path.Combine(shadersDir, "terrain_modern.frag"));
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that
// declares `layout(std140, binding = 1) uniform SceneLighting`
// reads from this without further intervention.
_sceneLightingUbo = new SceneLightingUboBinding(_gl);
_debugLines = new DebugLineRenderer(_gl, shadersDir);
// Phase I.2: load a system monospace font + TextRenderer for the
// future world-space HUD (D.6). The custom DebugOverlay is gone;
// the ImGui DebugPanel handles all dev surfaces now. These fields
// are reserved for future work — currently unused at the renderer
// level. Skips silently if no font is available.
var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
if (fontBytes is not null)
{
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
_textRenderer = new TextRenderer(_gl, shadersDir);
Console.WriteLine($"world-hud font: loaded {fontBytes.Length / 1024}KB, " +
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
$"lineHeight={_debugFont.LineHeight:F1}px (reserved for D.6 HUD)");
}
else
{
Console.WriteLine("world-hud font: no system monospace font found");
}
var orbit = new OrbitCamera { Aspect = _window!.Size.X / (float)_window.Size.Y };
var fly = new FlyCamera { Aspect = _window.Size.X / (float)_window.Size.Y };
_cameraController = new CameraController(orbit, fly);
_cameraController.ModeChanged += OnCameraModeChanged;
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_datDir);
_magicCatalog = AcDream.App.Spells.MagicCatalog.Load(_dats);
SpellBook.InstallMetadata(_magicCatalog.SpellTable);
Console.WriteLine($"spells: loaded {SpellTable.Count} entries from portal.dat");
_animLoader = new AcDream.Content.Vfx.RetailAnimationLoader(_dats);
_liveEntityCollisionBuilder = new AcDream.App.Physics.LiveEntityCollisionBuilder(
_physicsDataCache,
new AcDream.App.Physics.LiveEntityDefaultPoseResolver(
id => _dats.Get<DatReaderWriter.DBObjs.MotionTable>(id),
_animLoader,
_animationDiagnostics.DumpMotionEnabled));
_emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats);
// Phase E.3 particles: always-on, no driver dependency. Registered
// with the hook router so CreateParticle / DestroyParticle /
// StopParticle hooks fired from motion tables produce visible
// spawns. The Tick call is driven from OnRender.
_particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!);
_particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem, _effectPoses);
_particleSink.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_animationHookFrames = new AcDream.App.Rendering.Vfx.AnimationHookFrameQueue(
_hookRouter,
_effectPoses);
_hookRouter.Register(_particleSink);
// Phase 6c — PhysicsScript runner. Uses the DatCollection to
// resolve PlayScript ids, and the same ParticleHookSink the
// animation system uses, so CreateParticleHook fired from a
// script spawns through the normal particle pipeline.
_physicsScriptLoader = new AcDream.Content.Vfx.RetailPhysicsScriptLoader(_dats);
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(
_physicsScriptLoader.LoadPhysicsScript,
_hookRouter,
canAdvanceOwner: ownerId => _entityEffects?.CanAdvanceOwner(ownerId) ?? true);
// Phase G.2 lighting hooks: SetLightHook flips IsLit on
// owner-tagged lights so ignite-torch animations light up,
// extinguish-torch animations go dark.
_lightingSink = new AcDream.Core.Lighting.LightingHookSink(Lighting, _effectPoses);
_hookRouter.Register(_lightingSink);
// #188 — TransparentPartHook (per-part translucency fade, e.g.
// the "fading wall" secret-passage doors) routes here.
_translucencySink = new AcDream.Core.Rendering.TranslucencyHookSink(_translucencyFades);
_hookRouter.Register(_translucencySink);
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
if (!_options.NoAudio)
{
try
{
_soundCache = new AcDream.Core.Audio.DatSoundCache(_dats);
_audioEngine = new AcDream.App.Audio.OpenAlAudioEngine();
_entitySoundTables = new AcDream.App.Audio.DictionaryEntitySoundTable();
if (_audioEngine.IsAvailable)
{
_audioSink = new AcDream.App.Audio.AudioHookSink(
_audioEngine, _soundCache, _entitySoundTables);
_hookRouter.Register(_audioSink);
Console.WriteLine("audio: OpenAL engine ready (16 voices, 3D positional)");
}
else
{
Console.WriteLine("audio: OpenAL unavailable (driver missing / headless) — audio disabled");
}
}
catch (Exception ex)
{
Console.WriteLine($"audio: init failed: {ex.Message} — audio disabled");
}
}
// L.0 follow-up — load + apply persisted Display / Audio settings
// BEFORE the DevToolsEnabled block. The settings.json values
// (resolution, vsync, FOV, master volume, etc) are runtime
// settings, not devtools settings — a user running without
// ACDREAM_DEVTOOLS=1 still expects their saved values to take
// effect. The Settings PANEL (editing UI) is gated on devtools;
// the persisted state is not. Caches values into fields so the
// SettingsVM construction in the devtools block reads them
// without re-loading.
LoadAndApplyPersistedSettings();
// A.5 T22.5: resolve quality preset immediately after settings load so
// _resolvedQuality is available for TerrainAtlas.SetAnisotropic,
// WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below.
{
var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality);
_resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase);
if (!_resolvedQuality.Equals(qBase))
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}");
else
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}");
}
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
if (DevToolsEnabled)
{
try
{
_imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!);
_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);
_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)_lastFps,
PositionProvider = () => GetDebugPlayerPosition(),
};
_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: () => _lastVisibleLandblocks,
getLandblocksTotal: () => _lastTotalLandblocks,
getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered,
getNearestObjDist: () => _lastNearestObjDist,
getNearestObjLabel: () => _lastNearestObjLabel,
getColliding: () => _lastColliding,
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)_lastFps,
getFrameMs: () => (float)_lastFrameMs,
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 = ToggleFlyOrChase;
_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).
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.
ResetPanelLayout(ImGuiNET.ImGuiCond.FirstUseEver);
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
_imguiBootstrap?.Dispose();
_imguiBootstrap = null;
_panelHost = null;
_vitalsVm = null;
_vitalsPanel = null;
_debugVm = null;
_debugPanel = null;
_chatPanel = null;
_settingsVm = null;
_settingsPanel = null;
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
// celestial mesh layers) and swap WorldTime's provider over to the
// dat-backed keyframes. The stub default provider is only used if
// the Region lacks HasSkyInfo.
if (region is not null)
{
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
if (_loadedSkyDesc is not null)
{
// Phase 3d: do NOT assign WorldTime.TickSize from
// SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241
// FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick
// deadline" period — a throttle — NOT a game-time
// advancement rate. ACE's server advances PortalYearTicks at
// 1.0 ticks per real-second (Timers.cs: `PortalYearTicks +=
// worldTickTimer.Elapsed.TotalSeconds`). Our client
// extrapolation between TimeSyncs must match: 1.0.
//
// Previous behavior: WorldTime.TickSize = 0.8 (from the live
// SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4
// ticks behind the server, producing a visible "acdream sky
// is behind retail" time-of-day mismatch (user-verified
// 2026-04-23).
WorldTime.TickSize = 1.0;
// Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
// calendar-extraction offset. Dereth's dat value is 3600
// (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
// comment that "tick 0 = Morntide-and-Half" (3333.75
// offset = +7/16) is WRONG by 266.25 ticks against the
// authoritative dat. The mismatch cascaded into both the
// wrong hour label AND the wrong DayOfYear at boundary
// times (different LCG seed → different DayGroup roll),
// which explained the user's observation of "acdream
// clear night, retail stormy pre-dawn" at the same
// server PortalYearTicks.
if (region.GameTime is not null)
{
AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
region.GameTime.ZeroTimeOfYear);
Console.WriteLine(
$"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
$"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
}
Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
// Initial DayGroup roll using whatever WorldTime currently
// has (either the hardcoded boot seed or a pre-arrived
// server sync). RefreshSkyForCurrentDay will re-roll when
// ServerTimeUpdated delivers the real ConnectRequest tick.
RefreshSkyForCurrentDay();
}
}
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
// the server sends its first TimeSync packet (offline rendering in
// particular never receives one).
//
// "Noon" here means sun at zenith — dayFraction = 0.5. Because
// DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half,
// hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 =
// Midsong / Hour 9, which is what retail considers noon).
//
// Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were
// zero, but with our 3333.75-tick shift it lands on dayFraction
// 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight),
// producing a dim orange sky with the sun below the horizon until
// TimeSync arrives.
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
// building the terrain atlas / renderer — both consume BindlessSupport
// (atlas via Texture2DArray bindless handles, renderer for SSBO uploads).
// The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
// is mandatory as of Phase N.5 — missing extensions throw at startup with
// a clear error so users can file a real bug report rather than silently
// falling back to a half-working renderer.
if (AcDream.App.Rendering.Wb.BindlessSupport.TryCreate(_gl, out var bindless))
{
if (bindless!.HasShaderDrawParameters(_gl))
{
_bindlessSupport = bindless;
Console.WriteLine("[N.5] modern path capabilities present (bindless + ARB_shader_draw_parameters)");
}
else
{
Console.WriteLine("[N.5] GL_ARB_shader_draw_parameters not present — modern path not available");
}
}
else
{
Console.WriteLine("[N.5] GL_ARB_bindless_texture not present — modern path not available");
}
if (_bindlessSupport is null)
{
throw new NotSupportedException(
"acdream requires GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters " +
"(GL 4.3+ with bindless support). Your GPU/driver does not expose these extensions. " +
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// Build the terrain atlas once from the Region dat. Phase N.5b: the
// atlas exposes bindless handles for the modern terrain path, so
// BindlessSupport is threaded through.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport);
// A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect.
terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel);
_terrain = new TerrainModernRenderer(
_gl,
_bindlessSupport,
_terrainModernShader!,
terrainAtlas,
_gpuFrameFlights!);
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
// Build blending context from the terrain atlas. The worker-side
// LandblockBuildFactory captures this immutable table for each build.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// (Bindless detection moved above — must precede TerrainAtlas.Build /
// TerrainModernRenderer ctor so they can consume BindlessSupport.)
// Mesh shader always loads (modern path is the only path).
_meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh_modern.vert"),
Path.Combine(shadersDir, "mesh_modern.frag"));
Console.WriteLine("[N.5] mesh_modern shader loaded");
_textureCache = new TextureCache(_gl, _dats, _bindlessSupport, _gpuFrameFlights!);
// Two persistent GL sampler objects (Repeat + ClampToEdge) so
// the sky pass can pick wrap mode per submesh without mutating
// shared per-texture wrap state. See SamplerCache + the
// WorldBuilder reference at
// references/WorldBuilder/Chorizite.OpenGLSDLBackend/OpenGLGraphicsDevice.cs:115-132.
_samplerCache = new SamplerCache(_gl);
// Retail ClientCombatSystem attack-request owner. This exists independently
// of the retained UI so keyboard combat keeps the same press/hold/release
// semantics even when the retail renderer is disabled.
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
Combat,
CanStartLiveCombatAttack,
SendLiveCombatAttack,
prepareAttackRequest: PreparePlayerForAttackRequest,
sendCancelAttack: () => LiveSession?.SendCancelAttack(),
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
playerReadyForAttack: IsPlayerReadyForCombatAttack,
autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
_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);
_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: PreparePlayerForAttackRequest,
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionController?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
// pointer is over a widget — no double-handling.
foreach (var m in _input!.Mice) _uiHost.WireMouse(m);
foreach (var kb in _input!.Keyboards) _uiHost.WireKeyboard(kb);
var cache = _textureCache!;
(uint, int, int) ResolveChrome(uint id)
{
uint t = cache.GetOrUploadRenderSurface(id, out int w, out int h);
return (t, w, h);
}
// Phase D.5.1 — icon composer for the toolbar shortcut slots.
// Constructed once here so the closure below can capture it; needs
// the same cache reference that ResolveChrome uses above.
var iconComposer = new AcDream.App.UI.IconComposer(_dats!, cache);
// Phase D.2b — optional retail stylesheet. controls.ini lives under
// the AC install (ACDREAM_AC_DIR); absent → source-verified fallback.
var controls = _options.AcDir is { } acDir
? AcDream.App.UI.ControlsIni.Load(System.IO.Path.Combine(acDir, "controls", "controls.ini"))
: AcDream.App.UI.ControlsIni.Parse(string.Empty);
// Phase D.2b — retail dat-font for the vitals numbers (Font 0x40000000,
// Latin-1, 16px, outline atlas). Passed into the importer so the meter
// number overlay renders through the dat-font two-pass blit; falls back to
// the debug font only if it fails to load. Under _datLock like other reads.
AcDream.App.UI.UiDatFont? vitalsDatFont;
lock (_datLock)
vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
var datFontCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.App.UI.UiDatFont?>();
if (vitalsDatFont is not null)
datFontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, vitalsDatFont);
AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid)
=> datFontCache.GetOrAdd(fontDid, id =>
{
lock (_datLock)
return AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!, id);
});
Console.WriteLine(vitalsDatFont is not null
? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay."
: "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
_uiHost.Root.Width = _window!.Size.X;
_uiHost.Root.Height = _window.Size.Y;
var radarSnapshotProvider = new AcDream.App.UI.Layout.RadarSnapshotProvider(
Objects,
() => _visibleEntitiesByServerGuid,
() => LastSpawns,
playerGuid: () => _playerServerGuid,
playerYawRadians: () => _playerController?.Yaw ?? 0f,
playerCellId: () => _playerController?.CellId ?? 0u,
selectedGuid: () => _selection.SelectedObjectId,
coordinatesOnRadar: () => _persistedGameplay.CoordinatesOnRadar,
uiLocked: () => _persistedGameplay.LockUI,
playerEntities: () => _entitiesByServerGuid,
spatialQuery: () => _worldState);
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
_retailChatVm = retailChatVm;
AcDream.App.UI.RetailUiPersistenceBindings? persistence = _settingsStore is null
? null
: new AcDream.App.UI.RetailUiPersistenceBindings(
_settingsStore,
CharacterKey: () => _activeToonKey,
ScreenSize: () => (_window.Size.X, _window.Size.Y));
void UiProbeLog(string message) => Console.WriteLine("[UI-PROBE] " + message);
if (_options.UiProbeEnabled
&& _options.AutomationArtifactDirectory is { } artifactDirectory)
{
_frameScreenshots = new AcDream.App.Diagnostics.FrameScreenshotController(
_gl!,
() => (_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(
() => _lastFps,
// Retail displays either the user degrade bias or the live
// distance-driven multiplier. acdream does not yet implement
// adaptive distance degradation (tracked by TS-15), so its
// effective multiplier is exactly 1.0.
() => 1.0,
() => _settingsVm?.DisplayDraft.ShowFps
?? _persistedDisplay.ShowFps),
VividTarget: new AcDream.App.UI.Layout.VividTargetRuntimeBindings(
_selection,
() => _playerServerGuid,
() => _persistedGameplay.VividTargetingIndicator,
ResolveVividTargetInfo,
GetSelectionCamera),
Indicators: new AcDream.App.UI.IndicatorRuntimeBindings(
SpellBook,
Objects,
() => _playerServerGuid,
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => LiveSession?.LinkStatus
?? AcDream.Core.Net.LinkStatusSnapshot.Disconnected,
ClientTimerNow,
() => LiveSession?.RequestLinkStatusPing(),
() => _window?.Close()),
Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings(
Objects,
() => Shortcuts,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
UseItemByGuid,
Combat,
ItemMana,
ToggleLiveCombatMode,
_itemInteractionController,
entry => LiveSession?.SendAddShortcut(entry),
index => LiveSession?.SendRemoveShortcut(index),
_selection,
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
guid => Objects.Get(guid)?.GetAppropriateName(),
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
guid => LiveSession?.SendQueryHealth(guid),
guid => LiveSession?.SendQueryItemMana(guid),
() => _playerServerGuid,
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement)),
Character: new AcDream.App.UI.CharacterRuntimeBindings(_characterSheetProvider),
Inventory: new AcDream.App.UI.InventoryRuntimeBindings(
Objects,
() => _playerServerGuid,
(type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) => iconComposer.GetDragIcon(type, icon, under, over, effects),
() => LocalPlayer.GetAttribute(
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
(source, target, amount) =>
LiveSession?.SendStackableMerge(source, target, amount),
_itemInteractionController,
_selection),
ExternalContainer: new AcDream.App.UI.ExternalContainerRuntimeBindings(
_externalContainers,
Objects,
(type, icon, under, over, effects) =>
iconComposer.GetIcon(type, icon, under, over, effects),
(type, icon, under, over, effects) =>
iconComposer.GetDragIcon(type, icon, under, over, effects),
_itemInteractionController,
_selection,
guid => LiveSession?.SendUse(guid),
(item, container, placement) =>
LiveSession?.SendPutItemInContainer(item, container, placement),
(item, container, placement, amount) =>
LiveSession?.SendStackableSplitToContainer(
item, container, placement, amount),
IsWithinExternalContainerUseRange),
Cursor: new AcDream.App.UI.RetailUiCursorBindings(
cursorFeedbackController,
retailCursorManager),
Confirmations: new AcDream.App.UI.ConfirmationRuntimeBindings(
(type, context, accepted) =>
LiveSession?.SendConfirmationResponse(type, context, accepted)),
StackSplitQuantity: _stackSplitQuantity,
Plugins: _uiRegistry,
Persistence: persistence,
Probe: new AcDream.App.UI.RetailUiProbeBindings(
_options.UiProbeEnabled,
_options.UiProbeScript,
_options.UiProbeDump,
UiProbeLog,
action => _inputDispatcher?.TryInvokeAutomationAction(action) == true,
(action, held) =>
_inputDispatcher?.TrySetAutomationActionHeld(action, held) == true,
_worldLifecycleAutomation)));
}
// Phase N.4+N.5 — WB rendering pipeline foundation. The modern path is
// mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher
// always construct.
// Phase O (2026-05-21): WbMeshAdapter now consumes our DatCollection
// directly via DatCollectionAdapter. No second dat reader; index cache
// is shared with the rest of the client.
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger<AcDream.App.Rendering.Wb.WbMeshAdapter>.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(
_gl,
_dats,
wbLogger,
_gpuFrameFlights!);
Console.WriteLine("[N.4+N.5] WB foundation + modern path active — routing all content through ObjectMeshManager.");
}
// Phase N.4 Task 12: construct LandblockSpawnAdapter under the feature flag
// and rebuild _worldState so it threads the adapter in. _worldState starts
// as an unadorned GpuWorldState (field initializer); here we replace it with
// one that carries the adapter so AddLandblock/RemoveLandblock notify WB.
// Phase N.4 Task 17: also construct EntitySpawnAdapter for server-spawned
// per-instance content under the same flag.
// N.5 mandatory path: spawn adapters + dispatcher always construct.
// _wbMeshAdapter, _meshShader, _textureCache, and _bindlessSupport are
// all guaranteed non-null here (startup throws above if any are missing).
var liveEntityComponentLifecycle =
new AcDream.App.World.DeferredLiveEntityRuntimeComponentLifecycle();
AcDream.App.Physics.LiveEntityMotionRuntimeController?
liveEntityMotionRuntime = null;
{
var wbSpawnAdapter = new AcDream.App.Rendering.Wb.LandblockSpawnAdapter(_wbMeshAdapter!);
// Sequencer factory: look up Setup + MotionTable from dats and build
// an AnimationSequencer. Falls back to a no-op sequencer when the
// entity has no motion table (static props, etc.). Uses _animLoader
// which is initialised earlier in OnLoad; it is non-null here.
var capturedDats = _dats;
var capturedAnimLoader = _animLoader;
AcDream.Core.Physics.AnimationSequencer SequencerFactory(AcDream.Core.World.WorldEntity e)
{
if (capturedDats is not null && capturedAnimLoader is not null)
{
var setup = capturedDats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
uint mtableId = (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = capturedDats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader);
}
// Setup exists but no motion table — no-op sequencer.
return new AcDream.Core.Physics.AnimationSequencer(
setup,
new DatReaderWriter.DBObjs.MotionTable(),
capturedAnimLoader);
}
}
// Complete fallback: empty setup + empty motion table + null loader.
return new AcDream.Core.Physics.AnimationSequencer(
new DatReaderWriter.DBObjs.Setup(),
new DatReaderWriter.DBObjs.MotionTable(),
new NullAnimLoader());
}
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
// Phase C.1.5a/b: construct EntityScriptActivator so static entities
// (server-spawned AND dat-hydrated) fire Setup.DefaultScript through
// the PhysicsScriptRunner on enter-world. C.1.5b adds per-part
// transforms from the entity's exact current MeshRefs so
// multi-emitter scripts distribute across mesh parts (closes #56).
// Animated frames replace this snapshot through EntityEffectPoseRegistry.
// _scriptRunner and
// _particleSink are initialised earlier in OnLoad (line ~1083); both
// are non-null here. The resolver lambda captures _dats and swallows
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter(
_liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects,
_liveWorldOrigin,
_effectPoses);
var staticAnimationResidency = new LiveStaticAnimationResidency(
_liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!,
_animationHookFrames!.Capture,
_effectPoses.Publish,
staticAnimationResidency.IsResident,
(entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body),
staticAnimationResidency.ProjectionVersion);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
{
DatReaderWriter.DBObjs.Setup? setup =
capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(
e.SourceGfxObjOrSetupId);
if (setup is null) return null;
uint scriptId = setup.DefaultScript.DataId;
if (e.IndexedPartTransforms.Count == 0)
{
var indexed = AcDream.App.Rendering.Vfx.IndexedSetupPartPoseBuilder.Build(
setup,
e);
e.SetIndexedPartPoses(indexed.Poses, indexed.Available);
}
IReadOnlyList<System.Numerics.Matrix4x4> parts = e.IndexedPartTransforms;
IReadOnlyList<bool> availability = e.IndexedPartAvailable;
var profile = AcDream.App.Rendering.Vfx.EntityEffectProfile.CreateDatStatic(setup);
bool usesStaticAnimationWorkset = e.ServerGuid == 0
|| (_liveEntities?.TryGetRecord(
e.ServerGuid,
out LiveEntityRecord liveRecord) == true
&& (liveRecord.FinalPhysicsState
& AcDream.Core.Physics.PhysicsStateFlags.Static) != 0);
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(
scriptId,
parts,
profile,
availability,
setup,
(uint)setup.DefaultAnimation,
usesStaticAnimationWorkset);
}
catch
{
return null;
}
}
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
_scriptRunner!,
_particleSink!,
_effectPoses,
ResolveActivation,
(ownerId, entity, profile) =>
entityEffects?.OnDatStaticEntityReady(ownerId, entity, profile),
ownerId =>
{
entityEffects?.OnDatStaticEntityRemoved(ownerId);
_lightingSink?.UnregisterOwner(ownerId);
_translucencyFades.ClearEntity(ownerId);
},
(entity, info) => _staticAnimationScheduler.Register(entity, info),
ownerId => _staticAnimationScheduler.Unregister(ownerId),
(entity, info) => _staticAnimationScheduler.Rebind(entity, info));
_entityScriptActivator = entityScriptActivator;
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
entityScriptActivator: entityScriptActivator);
_liveEntities = new AcDream.App.World.LiveEntityRuntime(
_worldState,
new AcDream.App.World.CompositeLiveEntityResourceLifecycle(
new(
entity => wbEntitySpawnAdapter.OnCreate(entity),
entity => wbEntitySpawnAdapter.OnRemove(entity)),
new(
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
_liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities,
_physicsDataCache,
() => _selectionInteractions,
_selection,
_liveWorldOrigin);
_liveEntityMotionBindings.Bind(liveEntityMotionRuntime);
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
wbEntitySpawnAdapter.SetPresentationResident(entity, visible);
};
_liveEntities.ProjectionVisibilityChanged += (record, visible) =>
{
if (record.WorldEntity is { } entity)
_particleSink!.SetEntityPresentationVisible(entity.Id, visible);
};
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
new EntityRootPosePublisher(_effectPoses),
_liveWorldOrigin)
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
_liveEntityProjectionWithdrawal =
new AcDream.App.World.LiveEntityProjectionWithdrawalController(
_liveEntities,
_projectileController,
_worldGameState,
_worldEvents,
_physicsEngine.ShadowObjects,
_effectPoses,
_localPlayerShadow);
_liveEntityLights = new AcDream.App.Rendering.Vfx.LiveEntityLightController(
_liveEntities,
_effectPoses,
_lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
_liveEntities,
_localPlayerIdentity,
_remotePhysicsUpdater,
ordinaryPhysicsUpdater,
_projectileController,
new EntityRootPosePublisher(_effectPoses),
new AnimationHookCaptureSink(_animationHookFrames!));
_animationPresenter = new LiveEntityAnimationPresenter(
_liveEntities,
_staticAnimationScheduler,
_effectPoses,
new LiveAnimationPresentationContext(
_liveEntities,
_localPlayerIdentity,
_playerControllerSlot),
_animationDiagnostics,
_options.HidePartIndex);
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
_datLock,
Objects,
_liveEntities,
_effectPoses,
parentAcceptance.TryAccept,
(childRecord, positionVersion, projectionVersion) =>
_liveEntityProjectionWithdrawal!.WithdrawExact(
childRecord,
positionVersion,
projectionVersion,
_playerServerGuid));
var tableResolver = new AcDream.Core.Vfx.PhysicsScriptTableResolver(
id => _dats!.Get<DatReaderWriter.DBObjs.PhysicsScriptTable>(id));
entityEffects = new AcDream.App.Rendering.Vfx.EntityEffectController(
_liveEntities,
_scriptRunner!,
tableResolver,
_effectPoses,
(parentLocalId, partIndex) =>
_equippedChildRenderer.FindChildLocalIdAtPart(parentLocalId, partIndex),
childLocalId => _equippedChildRenderer.FindParentLocalId(childLocalId),
ownerId => _entitySoundTables?.Remove(ownerId),
(ownerId, soundTableDid) =>
{
_entitySoundTables?.Remove(ownerId);
if (soundTableDid is { } did)
_entitySoundTables?.Set(ownerId, did);
});
_entityEffects = entityEffects;
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities,
_physicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw,
_liveEntityMotionBindings.ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY),
handlePartArrayEnterWorld: localEntityId =>
{
if (_animatedEntities.TryGetValue(localEntityId, out var animated))
animated.Sequencer?.Manager.HandleEnterWorld();
});
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
_liveEntityMotionBindings.GetSetupCylinder,
CellLocalForSeed,
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
remote,
_liveCenterX,
_liveCenterY,
cellId),
(guid, generation, deferShadowRestore) =>
_liveEntityPresentation.CompleteAuthoritativePlacement(
guid,
generation,
deferShadowRestore),
(guid, generation) =>
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache, _translucencyFades,
_retailSelectionScene ??= new AcDream.App.Rendering.Selection.RetailSelectionScene(
new AcDream.App.Rendering.Selection.RetailSelectionGeometryCache(
_dats!, _datLock)),
_retailAlphaQueue);
_worldSelectionQuery ??= new AcDream.App.Interaction.WorldSelectionQuery(
_liveEntities,
Objects,
_retailSelectionScene,
() => _playerServerGuid,
() =>
{
var camera = GetSelectionCamera();
return new AcDream.App.Interaction.SelectionCameraSnapshot(
camera.View,
camera.Projection,
camera.Viewport);
},
() => new System.Numerics.Vector2(_lastMouseX, _lastMouseY),
() => _playerController is { } player
? new AcDream.App.Interaction.PlayerInteractionPose(
player.CellId,
player.Position)
: null,
_liveEntityMotionBindings.GetSetupCylinder,
setupId =>
{
if (_dats is null)
return null;
lock (_datLock)
{
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)
|| setup.SelectionSphere is not { } sphere)
{
return null;
}
return (sphere.Origin, sphere.Radius);
}
});
if (_itemInteractionController is { } itemInteractions)
{
_selectionInteractions ??= new AcDream.App.Interaction.SelectionInteractionController(
_selection,
_worldSelectionQuery,
itemInteractions,
new AcDream.App.Interaction.WorldSessionSelectionInteractionTransport(
() => LiveSession),
new AcDream.App.Interaction.PlayerInteractionMovementSink(
() => _playerController),
text => _debugVm?.AddToast(text));
}
// 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
&& _sceneLightingUbo is not null)
{
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
_gl, _wbDrawDispatcher, _sceneLightingUbo, _textureCache!);
paperdollViewport.Renderer = _paperdollViewportRenderer;
}
// 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);
// 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 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,
() => _playerServerGuid,
IsSealedDungeonCell,
Console.WriteLine);
var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController(
_liveEntities,
_liveEntityPresentation!,
_entityEffects!,
_remoteTeleportController!,
_selectionInteractions,
_selection,
_animatedEntities,
_remoteMovementObservations,
_translucencyFades,
_liveEntityProjectionWithdrawal!,
_equippedChildRenderer!,
_physicsEngine.ShadowObjects,
_liveEntityLights!,
_classificationCache,
() => _playerServerGuid);
liveEntityComponentLifecycle.Bind(liveEntityTeardown);
_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,
liveEntityTeardown,
PublishLocalPhysicsTimestamps,
() => _playerServerGuid,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityNetworkUpdates =
new AcDream.App.Physics.LiveEntityNetworkUpdateController(
_liveEntities,
Objects,
_liveEntityHydration,
_entityEffects!,
_liveEntityPresentation!,
_liveEntityLights!,
_equippedChildRenderer!,
_projectileController!,
_remoteTeleportController!,
_animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
_liveEntityRuntimeSlot),
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
liveEntityMotionRuntime!,
_physicsEngine,
_dats!,
_animLoader!,
_combatTargetController,
_liveWorldOrigin,
_teleportTransit,
() => _playerController,
_localPlayerOutbound,
() => _playerHost,
() => _playerServerGuid,
_updateFrameClock,
() => LiveSession,
PublishLocalPhysicsTimestamps,
AimTeleportDestination,
DumpMovementTruthServerEcho);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
() => _playerServerGuid,
candidate => _liveEntityHydration.OnPrune(candidate));
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
_equippedChildRenderer.EntityReady += candidate =>
_liveEntityHydration.OnEntityReady(candidate);
_liveEntityHydration.AppearanceApplied += guid =>
{
if (guid == _playerServerGuid)
_paperdollDollDirty = true;
};
// 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);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
_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 liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
_inboundEntityEvents,
_localPlayerFrame,
_selectionInteractions,
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveAnimationScheduler,
_staticAnimationScheduler,
_animationPresenter,
_animatedEntities,
_equippedChildRenderer!,
liveEffectFrame);
_liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
_equippedChildRenderer!,
_particleSink!,
_liveEntityLights!);
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
_liveSessionController,
_localPlayerFrame,
_liveSpatialReconciler);
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 = () => ResetTeleportTransitState(clearSession: true),
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()
{
bool wasActive = _mouseLook?.Active == true;
_mouseLook?.Release();
if (wasActive || _mouseLookSavedCursorMode.HasValue)
RestoreCursorAfterMouseLook();
}
private void ResetSessionPlayerPresentation()
{
_playerModeAutoEntry?.Cancel();
try
{
_cameraController?.ExitChaseMode();
}
finally
{
_localPlayerMode.ResetSession();
_playerController = null;
_playerHost = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerMouseDeltaX = 0f;
_rmbHeld = false;
_autoRunActive = false;
_lastMovementTruthOutbound = null;
_localPlayerShadow.Clear();
_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;
_lastSeenRunSkill = -1;
_lastSeenJumpSkill = -1;
_liveEntityNetworkUpdates?.ResetSessionState();
_liveEntityHydration?.ResetSessionState();
Shortcuts = Array.Empty<AcDream.Core.Items.ShortcutEntry>();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
_paperdollDollDirty = true;
// X/Y are ignored until the next logical player CreateObject claims a
// new origin. Keeping the last values avoids inventing a synthetic
// landblock while still forcing first-spawn initialization.
_liveWorldOrigin.Reset();
}
private void ResetSessionLiveRuntime()
{
AcDream.App.Net.LiveSessionEntityRuntimeReset.ClearAndRequireConvergence(
_liveEntities);
}
private void ResetSessionInteraction()
{
if (_selectionInteractions is { } interactions)
interactions.ResetSession();
else
{
_itemInteractionController?.ResetSession();
_selection.Reset();
}
}
/// <summary>
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
AcDream.Core.Net.WorldSession session)
{
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
if (_characterSheetProvider is not null && _dats is not null)
{
_characterSheetProvider.SkillTable = skillTable;
_characterSheetProvider.ExperienceTable =
AcDream.App.UI.Layout.CharacterSheetProvider.LoadExperienceTable(
_dats,
Console.WriteLine);
}
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
CreateLiveEntitySessionSink(),
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.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
Spawned: spawn => _inboundEntityEvents.Run(
_liveEntityHydration!,
spawn,
static (hydration, value) => hydration.OnCreate(value)),
Deleted: deletion => _inboundEntityEvents.Run(
_liveEntityHydration!, deletion,
static (hydration, value) => hydration.OnDelete(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
_liveEntityHydration!, pickup,
static (hydration, value) => hydration.OnPickup(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, motion,
static (updates, value) => updates.OnMotion(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, position,
static (updates, value) => updates.OnPosition(value)),
VectorUpdated: vector => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, vector,
static (updates, value) => updates.OnVector(value)),
StateUpdated: state => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, state,
static (updates, value) => updates.OnState(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
_liveEntityHydration!, parent,
static (hydration, value) => hydration.OnParent(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
this, teleport, static (window, value) => window.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
_liveEntityHydration!, appearance,
static (hydration, value) => hydration.OnAppearance(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
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) =>
{
if (runSkill >= 0)
_lastSeenRunSkill = runSkill;
if (jumpSkill >= 0)
_lastSeenJumpSkill = jumpSkill;
if (_playerController is not null
&& _lastSeenRunSkill >= 0
&& _lastSeenJumpSkill >= 0)
{
_playerController.SetCharacterSkills(
_lastSeenRunSkill,
_lastSeenJumpSkill);
Console.WriteLine(
$"player: applied server skills run={_lastSeenRunSkill} " +
$"jump={_lastSeenJumpSkill}");
}
},
OnConfirmationRequest: request =>
_retailUiRuntime?.HandleConfirmationRequest(request),
OnConfirmationDone: done =>
_retailUiRuntime?.HandleConfirmationDone(done),
OnCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
Chat,
TurbineChat,
Friends,
Squelch);
private AcDream.App.Net.LiveSessionCommandBindings CreateLiveSessionCommandBindings(
AcDream.Core.Net.WorldSession session) => new(
ClientCommands: new AcDream.App.UI.ClientCommandController.Bindings(
TeleportToLifestone: session.SendTeleportToLifestone,
TeleportToMarketplace: session.SendTeleportToMarketplace,
TeleportToPkArena: session.SendTeleportToPkArena,
TeleportToPkLiteArena: session.SendTeleportToPkLiteArena,
TeleportToHouse: session.SendTeleportToHouse,
TeleportToMansion: session.SendTeleportToMansion,
QueryAge: session.SendQueryAge,
QueryBirth: session.SendQueryBirth,
ToggleFrameRate: ToggleRetailFrameRate,
ToggleUiLock: () => SetRetailUiLocked(!_persistedGameplay.LockUI),
ShowSystemMessage: text => Chat.OnSystemMessage(text, 0u),
ShowWeenieError: code => Chat.OnWeenieError(code, null),
PlayerPublicWeenieBitfield: () =>
Objects.Get(_playerServerGuid)?.PublicWeenieBitfield,
ClientVersion: () =>
typeof(GameWindow).Assembly.GetName().Version?.ToString(3)
?? "unknown",
CurrentPosition: () => _playerController?.CellPosition,
LastOutsideCorpsePosition: () => LocalPlayer.GetPosition(0x0Eu),
ShowConfirmation: (message, completed) =>
_retailUiRuntime?.ShowConfirmation(message, completed),
Suicide: session.SendSuicide,
ClearChat: _ => Chat.Clear(),
SaveUi: name => _retailUiRuntime?.SaveNamedLayout(name),
LoadUi: name => _retailUiRuntime?.RestoreNamedLayout(name),
SaveAutoUi: () => _retailUiRuntime?.SaveLayout(),
LoadAutoUi: () => _retailUiRuntime?.RestoreLayout(),
IsAway: () =>
Objects.Get(_playerServerGuid)?.Properties.GetBool(0x6Eu) == true,
SetAway: away => SetRetailAway(session, away),
SetAwayMessage: session.SendSetAfkMessage,
AcceptLootPermits: () => _persistedGameplay.AcceptLootPermits,
SetAcceptLootPermits: SetRetailAcceptLootPermits,
DisplayConsent: session.SendDisplayConsent,
ClearConsent: session.SendClearConsent,
RemoveConsent: session.SendRemoveConsent,
SendEmote: session.SendEmote,
Friends,
AddFriend: session.SendAddFriend,
RemoveFriend: session.SendRemoveFriend,
ClearFriends: session.SendClearFriends,
RequestLegacyFriends: session.SendLegacyFriendsListRequest,
Squelch,
ModifyCharacterSquelch: session.SendModifyCharacterSquelch,
ModifyAccountSquelch: session.SendModifyAccountSquelch,
ModifyGlobalSquelch: session.SendModifyGlobalSquelch,
LastTeller: () => _retailChatVm?.LastIncomingTellSender,
ClearDesiredComponents: () =>
{
session.SendClearDesiredComponents();
DesiredComponents = Array.Empty<(uint Id, uint Amount)>();
},
HasOpenVendor: () => false,
FillComponentBuyList: (_, _) => { }),
Chat,
TurbineChat,
PlayerGuid: () => _playerServerGuid,
SendTalk: session.SendTalk,
SendTell: session.SendTell,
SendChannel: session.SendChannel,
SendTurbineChat: (roomId, chatType, dispatchType, senderGuid, text, cookie) =>
session.SendTurbineChatTo(
roomId, chatType, dispatchType, senderGuid, text, cookie),
Log: Console.WriteLine);
/// <summary>
/// Rebuilds the paperdoll doll from the live player entity (retail makeObject(player) +
/// DoObjDescChangesFromDefault). Clones the player's already-resolved Setup / MeshRefs / palette /
/// part overrides into a dedicated <see cref="DollEntityBuilder"/> entity posed at the scene origin
/// facing the viewer. The MeshRefs are COPIED so the doll holds a stable pose independent of the
/// player's live in-world animation. Returns false (leaving the dirty flag set to retry) when the
/// player entity isn't available yet.
/// </summary>
private bool RefreshPaperdollDoll()
{
if (_paperdollViewportRenderer is null) return false;
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) || pe.MeshRefs.Count == 0)
{
_paperdollViewportRenderer.SetDoll(null);
return false; // player not ready — retry next frame
}
uint? basePal = null;
List<(uint, byte, byte)>? subs = null;
if (pe.PaletteOverride is { } po)
{
basePal = po.BasePaletteId;
subs = new List<(uint, byte, byte)>();
foreach (var r in po.SubPalettes) subs.Add((r.SubPaletteId, r.Offset, r.Length));
}
List<(byte, uint)>? parts = null;
if (pe.PartOverrides.Count > 0)
{
parts = new List<(byte, uint)>(pe.PartOverrides.Count);
foreach (var p in pe.PartOverrides) parts.Add((p.PartIndex, p.GfxObjId));
}
var meshRefsCopy = new List<AcDream.Core.World.MeshRef>(pe.MeshRefs); // dressed parts (player's live frame)
var doll = AcDream.App.Rendering.DollEntityBuilder.Build(
pe.SourceGfxObjOrSetupId, meshRefsCopy, basePal, subs, parts);
ApplyPaperdollPose(doll, pe.SourceGfxObjOrSetupId); // re-pose into the paperdoll "posing" stance
_paperdollViewportRenderer.SetDoll(doll);
return true;
}
/// <summary>
/// Resolve the paperdoll "posing" stance DID (the model pose — left leg back) exactly as retail:
/// <c>gmPaperDollUI</c> ctor (decomp 174243) sets <c>m_didAnimation = DBObj::GetDIDByEnum(0x10000005, 7)</c>,
/// which is <c>DBCache::GetDIDFromEnumStatic</c> (decomp 20380) = <c>master[MasterMapId][0x10000005]</c>
/// → submap <c>0x25000009</c> → key <c>7</c>. (Icon effects use keys 1-6 of the same submap; key 7 is the
/// paperdoll pose.) Per-race <c>UpdateForRace</c> override deferred — the ctor default applies to all
/// body-types for now. Returns 0 if the chain can't resolve.
/// </summary>
private uint ResolvePaperdollPoseDid()
{
var dats = _dats;
if (dats is null) return 0u;
uint masterDid = (uint)dats.Portal.Db.Header.MasterMapId;
if (masterDid == 0) return 0u;
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)) return 0u;
// DBCache::GetDIDFromEnum (decomp 20380) resolves master[arg4] → submap, then submap[arg3].
// GetDIDFromEnumStatic(0x10000005, 7) ⇒ arg3=0x10000005, arg4=7 ⇒ master[7] → submap, submap[0x10000005].
if (!master.ClientEnumToID.TryGetValue(7u, out var subDid)) return 0u; // master-level key = 7
if (!dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)) return 0u;
return sub.ClientEnumToID.TryGetValue(0x10000005u, out var did) ? did : 0u; // submap index = 0x10000005
}
/// <summary>
/// Re-pose the doll's already-dressed parts into the paperdoll "posing" stance — the C# analog of
/// retail <c>set_sequence_animation(doll, m_didAnimation, …)</c>. Keeps each cloned part's GfxObjId +
/// surface overrides (the dressed appearance), but replaces its transform with the pose animation's
/// frame-0 per-part transform, using the same Scale·Rotate·Translate composition as the per-frame
/// animation tick (<see cref="TickAnimations"/>, GameWindow.cs ~9999). Static (the pose is fixed). If the
/// DID does not resolve to an <c>Animation</c>, the doll keeps its cloned pose (no regression) — the log
/// line surfaces what resolved so the pose is verified, not guessed.
/// </summary>
private void ApplyPaperdollPose(AcDream.Core.World.WorldEntity doll, uint setupId)
{
var dats = _dats;
if (dats is null) return;
// Retail's paperdoll doll is STATIC — it holds the pose animation's frame, it does NOT loop.
_animatedEntities.Remove(doll.Id);
// poseDID is cdb-CONFIRMED = 0x030003C0 (== retail gmPaperDollUI m_didAnimation for Horan; UpdateForRace
// did not override). Crash guard: only load a 0x03xxxxxx (Animation) DID (a non-animation id parses a
// garbage frame count → OOM).
uint poseDid = ResolvePaperdollPoseDid();
if ((poseDid >> 24) != 0x03u) return;
var anim = _animLoader?.LoadAnimation(poseDid);
var setup = dats.Get<DatReaderWriter.DBObjs.Setup>(setupId);
if (anim is null || setup is null || anim.PartFrames.Count == 0) return;
// Retail plays the pose animation once and HOLDS the last frame (set_sequence_animation framerate=0,
// RedressCreature decomp 0x004a3c22). For 0x030003C0 the animation has only two distinct keyframes —
// frame 0 (a transitional, raised/bent arm) and frames 1..N (all byte-identical: the settled stance,
// arms down + one leg back) — verified by dumping the dat. The held pose is therefore the last frame.
int frameIdx = anim.PartFrames.Count - 1;
var frame = anim.PartFrames[frameIdx];
var src = doll.MeshRefs;
var reposed = new List<AcDream.Core.World.MeshRef>(src.Count);
for (int i = 0; i < src.Count; i++)
{
var scale = i < setup.DefaultScale.Count ? setup.DefaultScale[i] : System.Numerics.Vector3.One;
System.Numerics.Vector3 origin = System.Numerics.Vector3.Zero;
System.Numerics.Quaternion orient = System.Numerics.Quaternion.Identity;
if (i < frame.Frames.Count) { origin = frame.Frames[i].Origin; orient = frame.Frames[i].Orientation; }
var transform = System.Numerics.Matrix4x4.CreateScale(scale)
* System.Numerics.Matrix4x4.CreateFromQuaternion(orient)
* System.Numerics.Matrix4x4.CreateTranslation(origin);
reposed.Add(new AcDream.Core.World.MeshRef(src[i].GfxObjId, transform) { SurfaceOverrides = src[i].SurfaceOverrides });
}
doll.MeshRefs = reposed;
}
private void PublishLocalPhysicsTimestamps(
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (guid != _playerServerGuid || session is null) return;
session.PublishAcceptedLocalPhysicsTimestamps(
timestamps.Instance,
timestamps.ServerControlledMove,
timestamps.Teleport,
timestamps.ForcePosition);
}
private void SyncLocalPlayerShadow(
AcDream.Core.World.WorldEntity playerEntity,
uint cellId,
bool force = false)
{
if (_liveEntities?.IsHidden(_playerServerGuid) == true
|| cellId == 0
|| !IsCurrentVisibleLocalPlayerProjection(playerEntity))
{
SuspendLocalPlayerShadow(playerEntity);
return;
}
if (!force
&& _localPlayerShadow.Current is { } last
&& last.CellId == cellId
&& System.Numerics.Vector3.DistanceSquared(
last.Position, playerEntity.Position) <= 1e-4f
&& MathF.Abs(System.Numerics.Quaternion.Dot(
last.Orientation, playerEntity.Rotation)) >= 0.99999f)
{
return;
}
AcDream.App.Physics.ShadowPositionSynchronizer.Sync(
_physicsEngine.ShadowObjects,
playerEntity.Id,
playerEntity.Position,
playerEntity.Rotation,
cellId,
_liveCenterX,
_liveCenterY);
_localPlayerShadow.Set(
playerEntity.Position,
playerEntity.Rotation,
cellId);
}
private bool IsCurrentVisibleLocalPlayerProjection(
AcDream.Core.World.WorldEntity playerEntity) =>
_liveEntities is { } runtime
&& runtime.TryGetRecord(playerEntity.ServerGuid, out var record)
&& ReferenceEquals(record.WorldEntity, playerEntity)
&& runtime.IsCurrentSpatialRootObject(record);
private void SuspendLocalPlayerShadow(
AcDream.Core.World.WorldEntity playerEntity)
{
_physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
_localPlayerShadow.Clear();
}
/// <summary>
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
/// the persistent <see cref="RemoteMotion.Sink"/>,
/// <c>Motion.DefaultSink</c> (so
/// <c>apply_current_movement</c>'s interpreted branch dispatches cycles
/// — the retail mechanism behind the deleted K-fix18 forced-Falling),
/// and the <c>RemoveLinkAnimations</c>/<c>InitializeMotionTables</c>
/// seams (retail CPhysicsObj::RemoveLinkAnimations 0x0050fe20 /
/// InitializeMotionTables). Idempotent; safe from both the UM path and
/// the VectorUpdate path regardless of arrival order.
/// </summary>
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
private readonly TeleportViewPlaneController _teleportViewPlane = new();
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter;
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
private bool _teleportForced; // true when the safety-net timeout force-places
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
// Loud safety net for a destination that never streams (worker crash / corrupt dat /
// OOB coords). WALL-CLOCK, not a frame count: during a dungeon-exit hold the source is
// unloaded and the destination not yet loaded → empty world → ~1000 fps, so a 600-FRAME
// ceiling fired in ~0.6s and force-placed into the skybox before the expand finished.
// Now rarely fires because priority-apply makes residency fast.
private const float TeleportMaxHoldSeconds = 10f;
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
// (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination —
// the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
// standing on loaded ground with wall collision and a non-empty immediate view. The far
// ring (out to the streaming window) still drains at the per-frame budget after portal
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius =
AcDream.App.Streaming.WorldRevealReadinessBarrier.OutdoorNeighborhoodRadius;
/// <summary>
/// Bind a sequence-correlated teleport Position to presentation and
/// asynchronous streaming. Canonical physics has already accepted this
/// packet; this method only establishes the destination viewport lifetime.
/// </summary>
private void AimTeleportDestination(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
var playerController = _playerController
?? throw new InvalidOperationException(
"A teleport destination was accepted before the local player controller existed.");
var p = update.Position;
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
uint streamingOriginLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
_liveCenterX, _liveCenterY);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
var worldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ) + origin;
// Retail preserves the full Position (objcell_id + Frame) through
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
// negative local frame origins, so XYZ cannot identify the source
// landblock (#215). Player crossing and streaming recentering are
// separate while a prior destination is aimed but not yet placed.
var landblockTransition =
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
playerController.CellId,
p.LandblockId,
streamingOriginLandblockId);
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
System.Numerics.Vector3 newWorldPos;
if (streamingCenterChanges)
{
// #145: retire the complete old streaming window while its shared
// origin is still current. Destination streaming remains blocked
// until every retained presentation ticket converges. This prevents
// old terrain and collision, which were baked in the prior frame,
// from overlapping the destination after the origin changes.
var recenter = _streamingOriginRecenter
?? throw new InvalidOperationException(
"A teleport changed streaming center before the recenter coordinator was wired.");
recenter.Begin(
lbX,
lbY,
IsSealedDungeonCell(p.LandblockId));
newWorldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ);
}
else
{
newWorldPos = worldPos;
}
// Do not snap here. The TAS holds portal space until the destination
// near ring is resident, then emits Place exactly once.
_pendingTeleportRot = new System.Numerics.Quaternion(
p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
_pendingTeleportPos = newWorldPos;
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_worldReveal?.Begin(AcDream.App.Streaming.WorldRevealKind.Portal);
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
_streamingController.PriorityRadius = TeleportNearRingRadius;
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"seq={update.TeleportSequence} lb={lbX},{lbY} " +
$"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
$"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
}
/// <summary>
/// End one logical teleport lifetime. This is the single symmetry seam
/// used by successful completion, replacement by a newer sequence, and
/// session teardown, so presentation state and destination state cannot
/// leak independently.
/// </summary>
private void ResetTeleportTransitState(bool clearSession = false)
{
bool originRecenterConverged =
_streamingOriginRecenter?.Reset(sessionEnding: clearSession) ?? true;
if (clearSession)
{
_teleportTransit.ClearSession();
_worldReveal?.Cancel();
}
else
_teleportTransit.EndActive();
_pendingTeleportPos = default;
_pendingTeleportCell = 0u;
_pendingTeleportRot = System.Numerics.Quaternion.Identity;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportAnim.Reset();
_teleportViewPlane.Reset();
_portalTunnel?.Exit();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId = 0u;
_streamingController.PriorityRadius = 0;
}
if (clearSession && !originRecenterConverged)
{
throw new InvalidOperationException(
"The ending session's streaming-origin retirement has not converged.");
}
}
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
// converting the render-frame world position into the wire's (cell, local). After
// seeding, physics carries (cell, local) forward without ever reading _liveCenter.
private System.Numerics.Vector3 CellLocalForSeed(System.Numerics.Vector3 worldPos, uint cellId)
{
int lbX = (int)((cellId >> 24) & 0xFFu);
int lbY = (int)((cellId >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
return worldPos - origin;
}
/// <summary>
/// worldReady for the TAS transit: is the player's teleport destination BOTH rendered
/// and collidable so we can materialize? Retail crosses this edge after its blocking cell
/// load. acdream's async equivalent must join the render publication barrier
/// (<see cref="StreamingController.IsRenderNeighborhoodResident"/>) with physics
/// residency. Indoor destinations require the center render landblock + EnvCell; outdoor
/// destinations require both domains for the priority near ring. An impossible claim
/// returns true so the TAS stops holding and the forced placement surfaces the failure
/// loudly rather than holding forever.
/// </summary>
private bool TeleportWorldReady(uint destCell)
=> EvaluateWorldRevealReadiness(destCell).IsReady;
private bool LoginWorldReady()
{
return TryGetLoginWorldCell(out uint cell)
&& EvaluateWorldRevealReadiness(cell).IsReady;
}
private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness(
uint destinationCell)
{
return _worldReveal?.Evaluate(destinationCell) ?? default;
}
private bool TryGetLoginWorldCell(out uint cell)
{
cell = 0;
if (_liveEntities is null
|| !_liveEntities.TryGetSnapshot(_playerServerGuid, out var playerSpawn)
|| playerSpawn.Position is not { } position)
return false;
cell = position.LandblockId;
return cell != 0;
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
// once the destination is ready (or force-run on impossible/timeout, logged loud).
private void PlaceTeleportArrival(
System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
var resolved = _physicsEngine.Resolve(
destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight);
var snappedPos = new System.Numerics.Vector3(
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
if (forced)
Console.WriteLine(
$"live: teleport HOLD gave up (impossible/timeout) — force-snapping " +
$"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}");
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.SetPosition(snappedPos);
pe.ParentCellId = resolved.CellId;
pe.Rotation = _pendingTeleportRot;
}
_playerController.SetPosition(snappedPos, resolved.CellId,
CellLocalForSeed(snappedPos, resolved.CellId));
// R5-V3 (#171, diff-review find): retail teleport_hook's TAIL
// (0x00514ed0 @0x00514f1b-0x00514f28) — after the manager teardown
// SetPosition just ran (moveto cancel + own UnStick), the teleporting
// object clears its OWN target subscription and tells every entity
// WATCHING IT that it teleported (NotifyVoyeurOfEvent(Teleported)).
// That notify is what tears down the mobs' sticks/target-tracking ON
// the player — without it, a melee pack stuck to the player keeps
// steering toward the post-teleport position at the 5× sticky follow
// speed for up to the 1 s lease (non-retail lurch on every recall).
_playerHost?.NotifyTeleported();
// Face the server-specified destination heading (retail drops you facing a fixed
// direction). The render entity already got _pendingTeleportRot above; sync the
// controller yaw so the camera + movement frame match it instead of the stale
// pre-teleport facing.
_playerController.SetBodyOrientation(_pendingTeleportRot);
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
// SmartBox::PlayerPositionUpdated (0x00453870) calls
// set_viewer(player_pos, reset_sought=1): the viewer and sought
// position both restart at the player, then the ordinary per-frame
// camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
_liveSpatialReconciler.Reconcile();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
// Do NOT flip to InWorld or send LoginComplete here — the player materializes while
// the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
// restores the destination projection and fires FireLoginComplete. This is the
// retail "pop out the other side" sequence.
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
/// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
/// the player behind the portal viewport until the destination is resident, then materializes
/// them (Place) and, after the destination view-plane restores, regains control + acks the server
/// (FireLoginComplete).
/// </summary>
private void OnTeleportStarted(uint sequence)
{
ushort teleportSequence = (ushort)sequence;
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
|| !_teleportTransit.CanBegin(teleportSequence))
return;
EndMouseLookAndRestoreCursor();
// A fresh sequence is a new logical transit. Discard the prior
// destination and presentation before this sequence can observe them;
// its destination PositionUpdate may arrive on a later network tick.
ResetTeleportTransitState();
_teleportTransit.QueueStart(teleportSequence);
TryActivatePendingTeleportPresentation();
Console.WriteLine($"live: teleport queued (seq={sequence})");
}
/// <summary>
/// Converge a queued F751 notification with the ordinary live-player
/// projection lifecycle. Usually this activates synchronously. If a
/// session transition has not materialized the player projection yet, the
/// notification and any correlated Position remain queued until it does.
/// </summary>
private void TryActivatePendingTeleportPresentation()
{
if (!_teleportTransit.HasPendingStart)
return;
// Retail has no detached fly/orbit gameplay mode. If acdream's live
// developer camera currently owns the view, restore the existing
// player-mode lifecycle before transit so placement still has the
// canonical local physics controller and chase-camera handoff.
var playerController = _playerController;
if (playerController is null)
{
if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
return;
_playerMode = true;
if (!EnterPlayerModeNow(loggingTag: "teleport"))
{
_playerMode = false;
return;
}
playerController = _playerController;
}
if (playerController is null)
return;
playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportViewPlane.Begin(
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
bool hasBufferedDestination = _teleportTransit.Activate(
out var bufferedDestination);
if (hasBufferedDestination)
AimTeleportDestination(bufferedDestination);
Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
}
/// <summary>
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// GUID translation and pre-materialization queueing.
///
/// <para>
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
/// typed PhysicsScriptTable.
/// </para>
/// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
{
_entityEffects?.HandleDirect(message);
}
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<SkyPesKey>();
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<System.Numerics.Matrix4x4>(),
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;
// #131 [outstage-pt] probe state (throwaway — strip when #131 closes).
private string? _lastOutStagePtSig;
private readonly HashSet<uint> _outStageUnmatchedScratch = new();
private readonly HashSet<uint> _outStageMatchedScratch = new();
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);
}
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
/// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc)
/// for now — actual sound playback needs a lookup table from
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
/// have dat-indexed; follow-up will wire the thunder wave ids.
/// </summary>
private void OnEnvironChanged(uint environChangeType)
{
// Fog presets — values match AcDream.Core.World.EnvironOverride
// byte-for-byte (we deliberately mirrored retail's enum).
if (environChangeType <= 0x06u)
{
Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType;
Console.WriteLine(
$"live: AdminEnvirons fog override = " +
$"{(AcDream.Core.World.EnvironOverride)environChangeType}");
return;
}
// Sound cues 0x65..0x7B. Log by retail name for now; audio
// binding is a separate follow-up (needs sound-table indexing
// plus a PlaySound API on OpenAlAudioEngine that takes a
// retail sound enum → wave-id mapping).
string name = environChangeType switch
{
0x65u => "RoarSound",
0x66u => "BellSound",
0x67u => "Chant1Sound",
0x68u => "Chant2Sound",
0x69u => "DarkWhispers1Sound",
0x6Au => "DarkWhispers2Sound",
0x6Bu => "DarkLaughSound",
0x6Cu => "DarkWindSound",
0x6Du => "DarkSpeechSound",
0x6Eu => "DrumsSound",
0x6Fu => "GhostSpeakSound",
0x70u => "BreathingSound",
0x71u => "HowlSound",
0x72u => "LostSoulsSound",
0x75u => "SquealSound",
0x76u => "Thunder1Sound",
0x77u => "Thunder2Sound",
0x78u => "Thunder3Sound",
0x79u => "Thunder4Sound",
0x7Au => "Thunder5Sound",
0x7Bu => "Thunder6Sound",
_ => $"Unknown(0x{environChangeType:X2})",
};
Console.WriteLine(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
private void OnUpdate(double dt)
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
// A resource callback can request deletion while the presentation
// owner is inside a synchronous resume/suspend transition. The first
// unregister leaves a canonical teardown tombstone; drain it only now,
// after that callback stack has unwound on the same update thread.
try
{
_liveEntityHydration?.RetryPendingTeardowns();
}
catch (AggregateException error)
{
// The tombstone retains every unfinished owner step. Report this
// attempt, but keep the frame advancing so the next update can
// retry after transient renderer/plugin teardown failures.
Console.Error.WriteLine($"[live-entity-teardown] {error}");
}
AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance(
new AcDream.App.Update.UpdateFrameInput(dt));
double frameSeconds = frameTiming.SimulationDeltaSeconds;
float frameDelta = frameTiming.SimulationDeltaSecondsSingle;
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
_scriptRunner?.PublishTime(frameTiming.ScriptTime);
// Streaming publishes before inbound CreateObject dispatch so newly
// accepted live projections can enter a resident bucket this frame.
_streamingFrame.Tick();
// Input callbacks feed the current object tick. Packets already read
// by Core.Net remain queued until the retail object/physics phase has
// consumed that input and published its final pose.
_inputDispatcher?.Tick();
// Phase K.2 — mouse-look is an input source for this object's
// movement tick, so sample it before the retail CPhysics/network
// barrier just like held keyboard actions.
if (_mouseLook is not null)
{
bool wantCaptureMouse = IsUiCapturingMouse();
if (wantCaptureMouse != _lastWantCaptureMouse)
{
if (wantCaptureMouse && _mouseLook.Active)
EndMouseLookAndRestoreCursor();
_mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse);
_lastWantCaptureMouse = wantCaptureMouse;
}
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
{
// GetInput synthesizes (0,0) only after >0.2 s idle.
// MouseLookHandler stops drift before filtering that sample;
// the filter tail may then start a smaller Rotate again.
if (rawX == 0f && rawY == 0f)
_playerController?.StopMouseDrift(CaptureMovementInput());
(float filteredX, float filteredY) =
AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
&& _retailChaseCamera is not null
? _retailChaseCamera.FilterMouseDelta(
rawX,
rawY,
weight: 0.5f,
nowSec: nowSeconds)
: (rawX, rawY);
_mouseLook.ApplyDelta(filteredX, _sensChase);
if (_retailChaseCamera is not null)
_retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
else
_chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
}
}
_combatAttackController?.Tick();
// Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
// Step 2: routed through the controller; functionally identical.
// Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and
// CPhysics before it drains the inbound event queue. Keep the complete
// live-object phase on that side of the barrier too. In particular,
// the recall action retires before ACE's Hidden SetState freezes its
// PartArray at the teleport boundary.
_liveFrameCoordinator.Tick(frameDelta);
_liveEntityLiveness?.Tick(ClientTimerNow());
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
// its normal live projection/controller has materialized.
TryActivatePendingTeleportPresentation();
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination
// that became resident this frame materializes the same frame. The TAS holds at
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
// continue + view-plane transitions fires FireLoginComplete (regain control + ack).
// PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
// retail switches the two 3-D viewports directly, below the retained UI.
if (_teleportTransit.IsActive)
{
bool haveDest = _pendingTeleportCell != 0u;
bool originReady = _streamingOriginRecenter?.IsPending != true;
bool ready = haveDest
&& originReady
&& TeleportWorldReady(_pendingTeleportCell);
if (haveDest && originReady && !ready)
{
_teleportHoldSeconds += frameDelta;
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
{
ready = true;
_teleportForced = true;
}
}
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick(frameDelta, ready, tunnelFrame);
_teleportViewPlane.Update(snap);
foreach (var e in evts)
{
switch (e)
{
case AcDream.Core.World.TeleportAnimEvent.Place:
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
_worldReveal?.ObserveMaterialized();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId = 0u;
_streamingController.PriorityRadius = 0;
}
break;
case AcDream.Core.World.TeleportAnimEvent.EnterTunnel:
_portalTunnel?.Enter();
break;
case AcDream.Core.World.TeleportAnimEvent.PlayExitSound:
_portalTunnel?.Exit();
break;
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// holtburger client/messages.rs:434 — re-send LoginComplete after
// each portal transition.
LiveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_worldReveal?.Complete();
ResetTeleportTransitState();
break;
default:
// The retained audio engine does not yet own the
// ClientUISystem sound-table enum path. Enter/exit
// visual edges are handled above.
break;
}
}
_portalTunnel?.Tick(frameDelta);
}
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no active session → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in
// OnInputAction (Ctrl+Tab → TogglePlayerMode) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
if (_cameraController is null || _input is null) return;
// Phase D.2a / K.1b — suppress game-side input polling when ImGui
// has keyboard focus (e.g. a text field is active). The InputDispatcher
// already gates KeyDown/MouseDown via WantCaptureKeyboard internally;
// this guard adds defense-in-depth for the per-frame IsActionHeld
// movement poll below (typing "walk" into a chat field shouldn't
// walk).
// ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a
// debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block
// below still runs so AUTORUN keeps driving the character while you type. Held WASD
// is silenced at the source instead — InputDispatcher.IsActionHeld returns false
// while WantCaptureKeyboard (which includes a focused chat input) is set.
bool suppressGameInput =
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
if (suppressGameInput) return;
if (_cameraController.IsFlyMode)
{
// K.1b: fly-camera input flows through the dispatcher. Reuses
// movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/
// RunLock) — in fly mode "TurnLeft" semantically maps to
// strafe-left because A/D in fly is strafe, not turn (mouse
// delta drives fly heading instead).
if (_inputDispatcher is null) return;
_cameraController.Fly.Update(
frameSeconds,
w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump),
down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown),
boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock));
}
else if (_playerMode && _playerController is not null && _chaseCamera is not null)
{
// Phase B.2 / K.1b: player movement mode — every input flows
// through the dispatcher. WASD walks/runs, A/D turns, Z/X
// strafes, Shift runs, Space jumps. Mouse delta NEVER drives
// character yaw (regression-prevention per K.1b plan §D);
// MouseDeltaX is hardcoded 0f. RMB held still pans the chase
// camera (handled in the mouse-move handler via _rmbHeld).
// The _playerMouseDeltaX field is preserved as plumbing for the
// future MMB-mouse-look behavior coming back in K.2.
if (_inputDispatcher is null) return;
_playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it
// Retail-style held-key offset integration. Only active when
// retail chase is selected; legacy camera ignores these.
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
{
float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta;
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
_retailChaseCamera.AdjustDistance(-adj);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
_retailChaseCamera.AdjustDistance(+adj);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise))
_retailChaseCamera.AdjustPitch(+adj * 0.02f);
if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower))
_retailChaseCamera.AdjustPitch(-adj * 0.02f);
}
// K-fix1 (2026-04-26): retail-faithful movement semantics.
// * Default speed = RUN. Forward / backward / strafe all run
// by default; holding Shift (MovementWalkMode) drops to
// walk speed.
// * Q = AUTORUN TOGGLE: pressing Q latches forward-running
// until Q is pressed again. Handled in OnInputAction; here
// we just OR _autoRunActive into the Forward flag.
// * Mouse never drives character yaw (K.1b regression-prevention).
if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
return;
AcDream.App.Input.MovementResult result = playerFrame.Movement;
bool localPlayerHidden = playerFrame.Hidden;
if (!playerFrame.AdvancedBeforeNetwork)
{
// The player was materialized by this frame's inbound pass.
// Its non-advancing initial projection must publish child and
// effect anchors before the first draw too.
_liveSpatialReconciler.Reconcile();
}
// Update chase camera(s). The CameraController exposes whichever
// is currently selected via CameraDiagnostics.UseRetailChaseCamera;
// both update every frame so toggling the flag swaps instantly
// with the new camera already warm.
//
// Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during
// jumps as a workaround for the visual feel retail gets from
// low-stiffness damping).
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround,
dt: frameDelta);
// RetailChaseCamera: heading is the player's facing direction
// projected onto the contact plane when grounded, or the
// world XY plane when airborne. The contact plane normal
// tilts the camera basis with terrain; the airborne
// fallback keeps the basis horizontal during jumps so the
// player visibly rises in frame without the camera
// swinging vertically (was the symptom of using raw
// velocity-vector heading).
System.Numerics.Vector3? trackedCombatTarget = GetCombatCameraTargetPoint();
_retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
playerVelocity: _playerController.BodyVelocity,
isOnGround: result.IsOnGround,
contactPlaneNormal: _playerController.ContactPlane.Normal,
dt: frameDelta,
cellId: _playerController.CellId,
selfEntityId: _playerController.LocalEntityId,
trackedTargetPoint: trackedCombatTarget);
// Update the player entity's animation cycle to match current motion.
}
}
private bool CanAdvanceLocalPlayer() =>
_cameraController is not null
&& _input is not null
&& !_cameraController.IsFlyMode
&& _playerMode
&& _playerController is not null
&& _chaseCamera is not null
&& _inputDispatcher is not null
&& !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
private void DumpMovementTruthOutbound(
string kind,
uint sequence,
AcDream.App.Input.MovementResult result,
System.Numerics.Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{
if (!DumpMoveTruthEnabled) return;
var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero;
_lastMovementTruthOutbound = new MovementTruthOutbound(
kind,
sequence,
System.DateTime.UtcNow,
result.Position,
result.CellId,
wirePosition,
wireCellId,
result.IsOnGround,
contactByte,
velocity);
Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
}
private void DumpMovementTruthServerEcho(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
System.Numerics.Vector3 serverWorldPosition)
{
if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return;
var now = System.DateTime.UtcNow;
var localPosition = _playerController?.Position;
var localCellId = _playerController?.CellId;
var deltaLocal = localPosition.HasValue
? serverWorldPosition - localPosition.Value
: (System.Numerics.Vector3?)null;
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
string localCellText = localCellId.HasValue
? System.FormattableString.Invariant($"0x{localCellId.Value:X8}")
: "-";
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
string deltaLocalLen = deltaLocal.HasValue
? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
: "-";
string lastText = "-";
if (_lastMovementTruthOutbound is { } last)
{
var deltaOut = serverWorldPosition - last.LocalWorldPosition;
var ageMs = (now - last.TimeUtc).TotalMilliseconds;
lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
}
string state = _playerController?.State.ToString() ?? "-";
string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-";
Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}"));
}
private static string Fmt(System.Numerics.Vector3 v) =>
System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})");
private static string FmtCmd(uint? command) =>
command.HasValue
? System.FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
private void OnCameraModeChanged(bool _modeBool)
{
if (_mouseLook?.Active == true
&& _cameraController?.IsChaseMode != true)
{
EndMouseLookAndRestoreCursor();
}
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 HideCursorForMouseLook /
// RestoreCursorAfterMouseLook.
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 double _perfAccum;
private int _perfFrameCount;
private void OnRender(double deltaSeconds)
{
_gpuFrameFlights!.BeginFrame();
Exception? renderFailure = null;
try
{
_textureCache?.BeginCompositeTextureFrame();
_textureCache?.TickCompositeTextureCache();
_wbDrawDispatcher?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_envCellRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_portalDepthMask?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_textRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_uiHost?.TextRenderer.BeginFrame(_gpuFrameFlights.CurrentSlot);
_clipFrame?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_terrain?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_sceneLightingUbo?.BeginFrame(_gpuFrameFlights.CurrentSlot);
_frameProfiler.FrameBoundary(_gl!);
// 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 = _portalTunnel?.IsVisible == true;
if (portalViewportVisible)
_particleVisibility.Reset();
// Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the
// rendered geometry. Fog blends to this color at max distance
// so there's no visible seam. Updated each frame from the
// interpolated keyframe.
var kf = WorldTime.CurrentSky;
var atmo = Weather.Snapshot(in kf);
bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None;
var fogColor = atmo.FogColor;
// Clear to fog color (horizon haze) so if sky meshes have alpha
// gaps or don't cover the full view, the "missing" area reads as
// distant haze, not as pitch-black. Fog color is clamped to 0..1
// since keyframes may pre-multiply DirBright and produce over-1
// values that some drivers interpret as "bright clamp" (pink/green
// frames).
if (portalViewportVisible)
{
// SceneTool::BeginScene @ 0x0043DAD0 begins every retail frame
// with RenderDevice.Clear(7, black, 1). The portal viewport later
// clears depth only, preserving this frame's black target—not a
// previous swap-chain image or the hidden destination world.
_gl!.ClearColor(0f, 0f, 0f, 1f);
}
else
{
_gl!.ClearColor(
System.Math.Clamp(fogColor.X, 0f, 1f),
System.Math.Clamp(fogColor.Y, 0f, 1f),
System.Math.Clamp(fogColor.Z, 0f, 1f),
1f);
}
// §4 outdoor full-world flap (2026-06-10): the depth clear DEPENDS on the depth
// write mask — glClear(GL_DEPTH_BUFFER_BIT) is silently gated by glDepthMask.
// A pass that leaked DepthMask(false) at frame end (EnvCellRenderer's empty
// Transparent pass, fixed same-day) turned this clear into a no-op and the whole
// world failed GL_LESS against its own previous-frame depth ghost. Per
// feedback_render_self_contained_gl_state: the clear site asserts the state it
// depends on rather than inheriting it. The [gl-state] tripwire still detects
// any future leak.
_gl!.DepthMask(true);
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit | ClearBufferMask.StencilBufferBit);
// WB GameScene.cs:830-843 establishes CW as the frame-global
// front-face convention; per-batch CullMode changes only the culled
// side. A8 indoor/env-cell geometry and setup meshes share that
// convention, so keep the GL state aligned before any scene pass.
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.CW);
// §4 flap apparatus: GL-state tripwire — one [gl-state] line whenever the
// state entering the world passes differs from the previous frame.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeGlStateEnabled)
EmitGlStateTripwireIfChanged();
// Phase N.6 slice 1: one-shot surface-format histogram dump under
// ACDREAM_DUMP_SURFACES=1. Zero cost when off.
_textureCache?.TickSurfaceHistogramDumpIfEnabled();
// Phase N.4: drain WB pipeline queues (staged mesh data +
// GL thread queue). Must happen before any draw work so that
// resources uploaded this frame are available immediately.
// No-op when ACDREAM_USE_WB_FOUNDATION is off (_wbMeshAdapter is null).
// [FRAME-DIAG]: this OnRender drain is where staged GfxObj/entity meshes hit
// the GPU (uncapped) — the H2 entity-upload suspect, separate from the apply.
long fdE0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
using (var _uplStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Upload))
{
_wbMeshAdapter?.Tick();
uint revealCell = _teleportTransit.IsActive
? _pendingTeleportCell
: IsLiveModeWaitingForLogin && TryGetLoginWorldCell(out uint loginCell)
? loginCell
: 0u;
if (revealCell != 0)
{
_worldReveal?.PrepareAndEvaluate(revealCell);
}
_particleRenderer?.BeginFrame(_gpuFrameFlights.CurrentSlot);
}
if (_frameDiag)
FrameDiagPush(_entityUploadSamples, ref _entityUploadSampleCursor,
System.Diagnostics.Stopwatch.GetTimestamp() - fdE0);
// 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.
if (DevToolsEnabled && _imguiBootstrap is not null)
_imguiBootstrap.BeginFrame((float)deltaSeconds);
// Phase G.1: weather state machine — deterministic per-day roll
// + transitions + lightning flash.
var cal = WorldTime.CurrentCalendar;
int dayIndex = cal.Year * (AcDream.Core.World.DerethDateTime.DaysInAMonth *
AcDream.Core.World.DerethDateTime.MonthsInAYear)
+ (int)cal.Month * AcDream.Core.World.DerethDateTime.DaysInAMonth
+ (cal.Day - 1);
Weather.Tick(nowSeconds: _weatherAccum, dayIndex: dayIndex, dtSeconds: (float)deltaSeconds);
_weatherAccum += 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;
_retailSelectionScene?.BeginFrame();
if (_cameraController is not null && !portalViewportVisible)
{
_retailAlphaQueue.BeginFrame();
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(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;
ApplyFramePacingPreference(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<uint> 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<uint>? 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<AcDream.App.Rendering.Wb.BuildingRegistry>())
{
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<uint>? envCellShellFilter = null; // drawable visible cells (cellIdToSlot keys)
PortalVisibilityFrame? sigPvFrame = null;
ClipFrameAssembly? sigClipAssembly = null;
IReadOnlySet<uint>? sigDrawableCells = null;
AcDream.App.Rendering.InteriorEntityPartition.Result? sigPartition = null;
PortalVisibilityFrame? sigExteriorPvFrame = null;
ClipFrameAssembly? sigExteriorClipAssembly = null;
IReadOnlySet<uint>? 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();
_terrainCpuStopwatch.Restart();
sigTerrainDrawn = true;
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_terrainCpuStopwatch.Stop();
_terrainCpuSamples[_terrainCpuSampleCursor] =
(long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);
_terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
MaybeFlushTerrainDiag();
}
// 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 =>
EmitRetailPViewDiagnostics(
result,
clipRoot,
viewerCellId,
playerCellId,
camPos,
playerViewPos),
});
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)
{
var vp = envCellViewProj;
char pvOutRoot = clipRoot.IsOutdoorNode ? 'Y' : 'n';
// 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);
string pvTerr = pvTerrZ is { } tz
? System.FormattableString.Invariant($"terrZ={tz:F3} eyeAbove={camPos.Z - tz:F3}")
: "terrZ=n/a eyeAbove=n/a";
Console.WriteLine(System.FormattableString.Invariant(
$"[pv-input] outRoot={pvOutRoot} flood={pvFrame.OrderedVisibleCells.Count} eye=({camPos.X:F6},{camPos.Y:F6},{camPos.Z:F6}) player=({playerViewPos.X:F6},{playerViewPos.Y:F6},{playerViewPos.Z:F6}) rawPlayer=({pvRawPlayer.X:F6},{pvRawPlayer.Y:F6},{pvRawPlayer.Z:F6}) yaw={pvYaw:F8} {pvTerr} vp=[{vp.M11:F6} {vp.M13:F6} {vp.M22:F6} {vp.M31:F6} {vp.M33:F6} {vp.M41:F6} {vp.M42:F6} {vp.M43:F6}]"));
}
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);
}
EmitRenderSignatureIfChanged(
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)
{
_lastVisibleLandblocks = visibleLandblocks;
_lastTotalLandblocks = totalLandblocks;
// Compute fly/orbit-mode camera position for the nearest-
// object scan when not in player mode.
System.Numerics.Vector3 nearOrigin;
if (_playerMode && _playerController is not null)
nearOrigin = _playerController.Position;
else
nearOrigin = camPos;
const float playerRadius = 0.48f;
float bestDist = float.PositiveInfinity;
string bestLabel = "-";
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = obj.Position.X - nearOrigin.X;
float dy = obj.Position.Y - nearOrigin.Y;
float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
if (d < bestDist)
{
bestDist = d;
bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
}
}
_lastColliding = bestDist < 0.05f;
_lastNearestObjDist = bestDist < 0f ? 0f : bestDist;
_lastNearestObjLabel = bestLabel;
}
// 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.
var portalProjection = _teleportViewPlane.Apply(
_cameraController!.Active.Projection);
_portalTunnel?.Draw(
_window!.Size.X,
_window.Size.Y,
portalProjection);
// 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.
if (_options.RetailUi && _paperdollViewportRenderer is not null
&& _retailUiRuntime?.PaperdollViewportWidget is { Visible: true } dollWidget
&& _retailUiRuntime.InventoryFrame is { Visible: true })
{
if (_paperdollDollDirty && RefreshPaperdollDoll())
_paperdollDollDirty = false;
dollWidget.TextureHandle = _paperdollViewportRenderer.Render(
(int)dollWidget.Width, (int)dollWidget.Height);
}
// 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));
}
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
// so ImGui composites on top. ImGuiController save/restores the
// GL state it touches (blend, scissor, VAO, shader, texture); any
// state not in its save-list (e.g. GL_FRAMEBUFFER_SRGB, unused
// today) would need manual protection.
if (DevToolsEnabled && _imguiBootstrap is not null && _panelHost is not null)
{
// Phase I.3 — prefer the live command bus when a live session
// is up so panel-emitted SendChatCmd actually flows server-ward.
// Fall back to NullCommandBus for offline / pre-connect renders.
AcDream.UI.Abstractions.ICommandBus bus =
_liveSessionController?.Commands
?? AcDream.UI.Abstractions.NullCommandBus.Instance;
var ctx = new AcDream.UI.Abstractions.PanelContext(
(float)deltaSeconds,
bus);
// Phase K.3 — top-of-screen menu bar. Provides discoverable
// entries for users who don't memorize the F-keys (View →
// Settings / Vitals / Chat / Debug). Uses ImGuiNET directly
// here because the panel host doesn't own a menu-bar
// surface; the abstraction (BeginMainMenuBar / BeginMenu /
// MenuItem) exists for backend portability + tests but only
// gets exercised here once per frame.
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
if (ImGuiNET.ImGui.BeginMenu("View"))
{
if (_settingsPanel is not null
&& ImGuiNET.ImGui.MenuItem("Settings", "F11"))
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
if (_vitalsPanel is not null
&& ImGuiNET.ImGui.MenuItem("Vitals"))
_vitalsPanel.IsVisible = !_vitalsPanel.IsVisible;
if (_chatPanel is not null
&& ImGuiNET.ImGui.MenuItem("Chat"))
_chatPanel.IsVisible = !_chatPanel.IsVisible;
if (_debugPanel is not null
&& ImGuiNET.ImGui.MenuItem("Debug", "Ctrl+F1"))
_debugPanel.IsVisible = !_debugPanel.IsVisible;
ImGuiNET.ImGui.Separator();
// L.0 Display tab: a manual reset for users whose
// imgui.ini has saved a panel position that's now
// off-screen (after a window shrink, monitor swap,
// or a malformed save). Force-resets every panel
// to its default landing position. The same code
// path runs automatically on FramebufferResize.
if (ImGuiNET.ImGui.MenuItem("Reset window layout"))
ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
ImGuiNET.ImGui.EndMenu();
}
// K-fix2 (2026-04-26): Camera submenu — discoverable
// free-fly toggle for users who don't know the
// Ctrl+Shift+F shortcut or the Debug-panel button.
if (ImGuiNET.ImGui.BeginMenu("Camera"))
{
if (_cameraController is not null)
{
string flyLabel = _cameraController.IsFlyMode
? "Exit Free-Fly Mode" : "Enter Free-Fly Mode";
if (ImGuiNET.ImGui.MenuItem(flyLabel, "Ctrl+Shift+F"))
ToggleFlyOrChase();
}
// A8.F: spring-arm camera collision (live A/B toggle).
if (ImGuiNET.ImGui.MenuItem("Collide Camera (spring arm)", "",
AcDream.Core.Rendering.CameraDiagnostics.CollideCamera))
AcDream.Core.Rendering.CameraDiagnostics.CollideCamera =
!AcDream.Core.Rendering.CameraDiagnostics.CollideCamera;
ImGuiNET.ImGui.EndMenu();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
_panelHost.RenderAll(ctx);
using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
{
_imguiBootstrap.Render();
}
}
// 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.
_frameScreenshots?.CapturePending();
// Update the window title with performance stats every ~0.5s.
_perfAccum += deltaSeconds;
_perfFrameCount++;
if (_perfAccum >= 0.5)
{
double avgFrameTime = _perfAccum / _perfFrameCount * 1000.0;
double fps = _perfFrameCount / _perfAccum;
int entityCount = _worldState.Entities.Count;
int animatedCount = _animatedEntities.Count;
_lastVisibleLandblocks = visibleLandblocks;
_lastTotalLandblocks = totalLandblocks;
// Keep the developer diagnostic title independent from retail's
// /framerate switch. That command owns the in-world SmartBox readout;
// conflating it with window chrome was the original incomplete port.
//
// Also include the in-game calendar/time —
// matches retail's @timestamp output ("Date: <Month> <Day>,
// PY <Year> Time: <HourName>"). Uses NowTicks (server-synced
// + wall-clock interpolation) so the user can read the same
// fields off both acdream and retail and confirm clock parity
// directly. Drift > 1 hour = real bug.
double tNow = WorldTime.NowTicks;
var titleCal = AcDream.Core.World.DerethDateTime.ToCalendar(tNow);
double df = WorldTime.DayFraction;
_window!.Title =
$"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | "
+ $"lb {visibleLandblocks}/{totalLandblocks} | ent {entityCount}/anim {animatedCount} | "
+ $"PY{titleCal.Year} {titleCal.Month} {titleCal.Day} {titleCal.Hour} (df={df:F4})";
// Script automation must remain performance-neutral. Only the
// explicit dump switch requests this allocation-heavy snapshot.
if (_options.UiProbeDump)
{
(int particleSets, long particleBytes) =
_particleRenderer?.DynamicBufferDiagnostics ?? default;
var meshManager = _wbMeshAdapter?.MeshManager;
var meshDiag = meshManager?.Diagnostics ?? default;
var globalMesh = meshManager?.GlobalBuffer;
var cpuMeshCache = _wbMeshAdapter?.CpuMeshCacheDiagnostics ?? default;
GCMemoryInfo gcMemory = GC.GetGCMemoryInfo();
Console.WriteLine(
$"[gpu-stream] emit={_particleSystem?.ActiveEmitterCount ?? 0} "
+ $"particles={_particleSystem?.ActiveParticleCount ?? 0} "
+ $"bindings={_particleSink?.ActiveBindingCount ?? 0} "
+ $"wbSets={_wbDrawDispatcher?.DynamicBufferSetCount ?? 0} "
+ $"cellSets={_envCellRenderer?.DynamicBufferSetCount ?? 0} "
+ $"particleSets={particleSets}/{particleBytes}B "
+ $"uiBytes={_uiHost?.TextRenderer.DynamicBufferCapacityBytes ?? 0} "
+ $"portalBytes={_portalDepthMask?.DynamicBufferCapacityBytes ?? 0} "
+ $"clipSets={_clipFrame?.DynamicBufferSetCount ?? 0} "
+ $"terrainBuffers={_terrain?.DynamicIndirectBufferCount ?? 0} "
+ $"lightBuffers={_sceneLightingUbo?.DynamicBufferCount ?? 0} "
+ $"mesh={meshDiag.RenderData}/{meshDiag.AtlasArrays}/{meshDiag.UnusedLru} "
+ $"meshEst={meshDiag.EstimatedBytes} "
+ $"meshUpload={globalMesh?.UploadCount ?? 0}/{globalMesh?.UploadedBytes ?? 0} "
+ $"uploadFrame={_wbMeshAdapter?.LastUploadCount ?? 0}/"
+ $"{_wbMeshAdapter?.LastUploadBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastArrayAllocationBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastPlannedMipmapBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastNewArrayCount ?? 0} "
+ $"bufferFrame={_wbMeshAdapter?.LastBufferUploadBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastBufferAllocationBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastBufferCopyBytes ?? 0}/"
+ $"{_wbMeshAdapter?.LastNewBufferCount ?? 0} "
+ $"staging={_wbMeshAdapter?.StagedUploadBacklog ?? 0}/"
+ $"{_wbMeshAdapter?.StagedUploadBytes ?? 0}/"
+ $"{_wbMeshAdapter?.StagingAtHighWater ?? false}/"
+ $"{_wbMeshAdapter?.LastStaleDiscardCount ?? 0} "
+ $"cpuMesh={cpuMeshCache.Count}/{cpuMeshCache.Bytes} "
+ $"mipmapFrame={_wbMeshAdapter?.LastMipmapArrayCount ?? 0}/"
+ $"{_wbMeshAdapter?.LastMipmapBytes ?? 0} "
+ $"meshCap={globalMesh?.CapacityBytes ?? 0} "
+ $"meshPhys={globalMesh?.PhysicalCapacityBytes ?? 0}/"
+ $"{globalMesh?.IsMigrationInProgress ?? false} "
+ $"gpuTrack={AcDream.App.Rendering.Wb.GpuMemoryTracker.AllocatedBytes}/"
+ $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.BufferCount}/"
+ $"{AcDream.App.Rendering.Wb.GpuMemoryTracker.TextureCount} "
+ $"managed={GC.GetTotalMemory(false)}/{gcMemory.TotalCommittedBytes} "
+ $"ownedTextures={_textureCache?.OwnedBindlessTextureCount ?? 0}/"
+ $"{_textureCache?.TextureOwnerCount ?? 0} "
+ $"particleTextures={_textureCache?.ActiveParticleTextureCount ?? 0}/"
+ $"{_textureCache?.ParticleTextureOwnerCount ?? 0}/"
+ $"{_textureCache?.CachedParticleTextureCount ?? 0}/"
+ $"{_textureCache?.CachedUnownedParticleTextureCount ?? 0}/"
+ $"{_textureCache?.CachedUnownedParticleTextureBytes ?? 0} "
+ $"compositeCache={_textureCache?.CachedCompositeTextureCount ?? 0}/"
+ $"{_textureCache?.CachedUnownedCompositeCount ?? 0}/"
+ $"{_textureCache?.CachedUnownedCompositeBytes ?? 0} "
+ $"compositeAtlas={_textureCache?.CompositeAtlasCount ?? 0}/"
+ $"{_textureCache?.CompositeAtlasBytes ?? 0} "
+ $"compositeUpload={_textureCache?.CompositeFrameUploadCount ?? 0}/"
+ $"{_textureCache?.CompositeFrameUploadBytes ?? 0}/"
+ $"{_wbDrawDispatcher?.LastCompositeWarmupPendingCount ?? 0}");
}
_lastFps = fps;
_lastFrameMs = avgFrameTime;
_perfAccum = 0;
_perfFrameCount = 0;
}
}
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;
GCMemoryInfo memory = GC.GetGCMemoryInfo();
return new AcDream.App.Diagnostics.WorldLifecycleResourceSnapshot(
LoadedLandblocks: _worldState.LoadedLandblockIds.Count,
WorldEntities: _worldState.Entities.Count,
AnimatedEntities: _animatedEntities.Count,
VisibleLandblocks: _lastVisibleLandblocks,
TotalLandblocks: _lastTotalLandblocks,
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: _lastFps,
FrameMilliseconds: _lastFrameMs,
LastFrameProfile: _frameProfiler.LastReport);
}
/// <summary>
/// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
/// [LowFrame..HighFrame] interval, then rebuild that entity's
/// MeshRefs from the new frame's per-part transforms. Static
/// entities (no LiveEntityAnimationState record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call.
/// </summary>
private void AdvanceLocalPlayerAnimationRoot(
float dt,
AcDream.Core.Physics.Motion.MotionDeltaFrame output)
{
ArgumentNullException.ThrowIfNull(output);
output.Reset();
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
|| !_animatedEntities.TryGetValue(entity.Id, out var ae)
|| ae.Sequencer is not { } sequencer
|| _liveEntities?.ShouldAdvanceRootRuntime(_playerServerGuid) == false
|| _liveEntities?.IsHidden(_playerServerGuid) == true)
{
return;
}
if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true)
_animationPresenter.PrepareAnimation(bindingRecord, ae);
DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch;
rootFrame.Origin = System.Numerics.Vector3.Zero;
rootFrame.Orientation = System.Numerics.Quaternion.Identity;
ae.PreparedSequenceFrames = ae.CaptureSequenceFrames(
sequencer.Advance(dt, rootFrame));
ae.SequenceAdvancedBeforeAnimationPass = true;
output.Origin = rootFrame.Origin;
output.Orientation = rootFrame.Orientation;
}
private void CaptureLocalPlayerAnimationHooks()
{
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
&& _animatedEntities.TryGetValue(entity.Id, out var ae)
&& ae.Sequencer is { } sequencer)
{
_animationHookFrames?.Capture(ae.Entity.Id, sequencer);
}
}
// IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the
// animatedIds build site — every Sequencer entity is now added
// unconditionally, which is the known-good pre-optimization behavior.
// R3-W6: UpdatePlayerAnimation DELETED — the player's sequencer is
// driven through the SAME MotionTableDispatchSink/DefaultSink funnel
// remotes use (edge-driven DoMotion/StopMotion/set_hold_run in
// PlayerMovementController; airborne-Falling falls out of
// contact_allows_move + apply_current_movement). The #45 sidestep
// 1.248x factor + ACDREAM_ANIM_SPEED_SCALE died with it —
// EXPECTED-DIFF: local sidestep pacing now matches how remotes have
// always played (w6-cutover-map.md R3).
/// <summary>
/// Phase 3a — re-roll the active DayGroup whenever the current
/// Dereth-day index differs from what we last installed. Idempotent
/// within the same server-day. Swaps both the
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="_activeDayGroup"/> (for the sky-object render loop).
///
/// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
/// group N regardless of day index. Useful for A/B testing each
/// weather preset against retail. See
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
/// for the roller.
/// </para>
/// </summary>
private void RefreshSkyForCurrentDay()
{
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
// Retail FUN_00501990 seeds the LCG with the triple stored in
// TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear)
//
// The decompile agent labeled +0x10 "SecondsPerDay (int copy)"
// but a live memory probe of retail's acclient.exe (2026-04-23,
// tools/RetailTimeProbe) shows the value is actually **360** —
// semantically DaysPerYear, not seconds. So the LCG seed is
// seed = Year × DaysPerYear + DayOfYear
// which is literally "total days since epoch" (a flat day index),
// confirmed against retail's Year=116, DayOfYear=47, seed=41807.
//
// Previously we passed 7620 (DayTicks), producing seed 883967 —
// a completely different LCG output → wrong DayGroup pick →
// user-observed weather mismatch (acdream clear while retail
// stormy, 2026-04-23). The live probe nailed the fix.
double ticks = WorldTime.NowTicks;
int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks);
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth
* AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360
// Composite day key for change-detection and logging only; the
// LCG seed is computed inside SelectDayGroupIndex from (absYear,
// secondsPerDay, dayOfYear).
long dayIndex = (long)absYear * 360 + dayOfYear;
int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[idx]
: null;
bool dayChanged = dayIndex != _loadedSkyDayIndex;
bool groupChanged = !ReferenceEquals(grp, _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 EmitRenderSignatureIfChanged(
string branch,
LoadedCell? clipRoot,
LoadedCell? viewerRoot,
LoadedCell? playerRoot,
uint viewerCellId,
uint playerCellId,
bool playerIndoorGate,
bool cameraInsideCell,
bool renderSkyGate,
bool drawSkyThisFrame,
bool terrainDrawn,
TerrainClipMode terrainClipMode,
bool skyDrawn,
bool depthClear,
bool outdoorSceneryDrawn,
bool outdoorPortalDrawn,
int outdoorRootObjectCount,
int liveDynamicDrawnCount,
string sceneParticles,
PortalVisibilityFrame? pvFrame,
ClipFrameAssembly? clipAssembly,
IReadOnlySet<uint>? drawableCells,
AcDream.App.Rendering.InteriorEntityPartition.Result? partition,
PortalVisibilityFrame? exteriorPvFrame,
ClipFrameAssembly? exteriorClipAssembly,
IReadOnlySet<uint>? exteriorDrawableCells,
AcDream.App.Rendering.InteriorEntityPartition.Result? exteriorPartition,
System.Numerics.Vector3 camPos,
System.Numerics.Vector3 playerViewPos)
{
if (!AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
return;
_renderSignatureFrame++;
bool eyeInRoot = clipRoot is not null && CellVisibility.PointInCell(camPos, clipRoot);
bool playerInRoot = clipRoot is not null && CellVisibility.PointInCell(playerViewPos, clipRoot);
var sb = new System.Text.StringBuilder(512);
sb.Append("branch=").Append(branch);
sb.Append(" root=0x").Append((clipRoot?.CellId ?? 0u).ToString("X8"));
sb.Append(" viewerRoot=0x").Append((viewerRoot?.CellId ?? 0u).ToString("X8"));
sb.Append(" playerRoot=0x").Append((playerRoot?.CellId ?? 0u).ToString("X8"));
sb.Append(" viewerCell=0x").Append(viewerCellId.ToString("X8"));
sb.Append(" playerCell=0x").Append(playerCellId.ToString("X8"));
sb.Append(" gate=").Append(playerIndoorGate ? "in" : "out");
sb.Append(" camIn=").Append(cameraInsideCell ? 'Y' : 'n');
sb.Append(" eyeInRoot=").Append(eyeInRoot ? 'Y' : 'n');
sb.Append(" playerInRoot=").Append(playerInRoot ? 'Y' : 'n');
sb.Append(" eye=").Append(FormatVecForSignature(camPos));
sb.Append(" player=").Append(FormatVecForSignature(playerViewPos));
sb.Append(" terrain=").Append(terrainClipMode);
sb.Append('/').Append(terrainDrawn ? "draw" : "skip");
sb.Append(" skyGate=").Append(renderSkyGate ? 'Y' : 'n');
sb.Append(" sky=").Append(skyDrawn ? 'Y' : 'n');
sb.Append(" skyFrame=").Append(drawSkyThisFrame ? 'Y' : 'n');
sb.Append(" zclear=").Append(depthClear ? 'Y' : 'n');
sb.Append(" sceneParticles=").Append(sceneParticles);
if (clipAssembly is not null)
{
sb.Append(" outSlices=").Append(clipAssembly.OutsideViewSlices.Length);
sb.Append(" outPolys=").Append(pvFrame?.OutsideView.Polygons.Count ?? 0);
sb.Append(" outMode=").Append(clipAssembly.TerrainMode);
}
else
{
sb.Append(" outSlices=0 outPolys=0 outMode=none");
}
sb.Append(" ids=").Append(FormatIds(pvFrame?.OrderedVisibleCells, preserveOrder: true));
sb.Append(" draw=").Append(FormatIds(drawableCells, preserveOrder: false));
sb.Append(" miss=").Append(FormatMissingDrawableCells(pvFrame, drawableCells));
sb.Append(" obj=").Append(FormatPartitionCounts(partition));
sb.Append(" outdoorDoor=").Append(outdoorSceneryDrawn ? 'Y' : 'n');
sb.Append(" outdoorRootObjs=").Append(outdoorRootObjectCount);
sb.Append(" liveDynDraw=").Append(liveDynamicDrawnCount);
// Diagnosis 2026-06-07: draw-vs-occlude probe for the see-through residual.
// outRoot=Y means clipRoot is the synthetic outdoor node (eye outdoors). bshell=total/withMesh
// counts the building ModelId exterior shells queued in partition.Outdoor for this frame — the
// GfxObj exteriors that SHOULD draw the solid walls. Correlate with ids= (the flooded interior
// cells): if bshell=N/N and ids=[node only] but the wall is still see-through, the exterior is
// failing to rasterize (draw/clip bug, not EnvCell sidedness); if ids includes interior cells,
// the outdoor flood is drawing interiors over the exterior.
sb.Append(" outRoot=").Append(clipRoot is { IsOutdoorNode: true } ? 'Y' : 'n');
if (partition is not null)
{
int shellTotal = 0, shellMesh = 0;
foreach (var e in partition.OutdoorStatic)
if (e.IsBuildingShell) { shellTotal++; if (e.MeshRefs.Count > 0) shellMesh++; }
sb.Append(" bshell=").Append(shellTotal).Append('/').Append(shellMesh);
}
if (outdoorPortalDrawn || exteriorPvFrame is not null || exteriorClipAssembly is not null)
{
sb.Append(" extPortal=").Append(outdoorPortalDrawn ? 'Y' : 'n');
sb.Append(" extSlices=").Append(exteriorClipAssembly?.OutsideViewSlices.Length ?? 0);
sb.Append(" extIds=").Append(FormatIds(exteriorPvFrame?.OrderedVisibleCells, preserveOrder: true));
sb.Append(" extDraw=").Append(FormatIds(exteriorDrawableCells, preserveOrder: false));
sb.Append(" extMiss=").Append(FormatMissingDrawableCells(exteriorPvFrame, exteriorDrawableCells));
sb.Append(" extObj=").Append(FormatPartitionCounts(exteriorPartition));
}
string signature = sb.ToString();
if (signature == _lastRenderSignature)
{
_renderSignatureStableFrames++;
return;
}
Console.WriteLine(
$"[render-sig] frame={_renderSignatureFrame} stable={_renderSignatureStableFrames} {signature}");
_lastRenderSignature = signature;
_renderSignatureStableFrames = 0;
}
private static string FormatVecForSignature(System.Numerics.Vector3 value)
{
static float Q(float v) => System.MathF.Round(v * 20f) / 20f;
return $"({Q(value.X):F2},{Q(value.Y):F2},{Q(value.Z):F2})";
}
private static string FormatIds(IEnumerable<uint>? ids, bool preserveOrder)
{
if (ids is null)
return "[]";
var values = new List<uint>();
foreach (uint id in ids)
values.Add(id);
if (!preserveOrder)
values.Sort();
var sb = new System.Text.StringBuilder(96);
sb.Append('[');
const int MaxIds = 12;
for (int i = 0; i < values.Count && i < MaxIds; i++)
{
if (i > 0)
sb.Append(',');
sb.Append("0x").Append(values[i].ToString("X8"));
}
if (values.Count > MaxIds)
sb.Append(",...");
sb.Append(']');
return sb.ToString();
}
private static string FormatMissingDrawableCells(
PortalVisibilityFrame? pvFrame,
IReadOnlySet<uint>? drawableCells)
{
if (pvFrame is null || drawableCells is null)
return "[]";
var sb = new System.Text.StringBuilder(96);
sb.Append('[');
int written = 0;
const int MaxCells = 8;
foreach (uint id in pvFrame.OrderedVisibleCells)
{
if (drawableCells.Contains(id))
continue;
if (written > 0)
sb.Append(',');
sb.Append("0x").Append(id.ToString("X8"));
if (pvFrame.CellViews.TryGetValue(id, out var view))
{
sb.Append(":p").Append(view.Polygons.Count);
if (view.IsEmpty)
sb.Append(":empty");
}
else
{
sb.Append(":noView");
}
written++;
if (written >= MaxCells)
{
sb.Append(",...");
break;
}
}
sb.Append(']');
return sb.ToString();
}
private static string FormatPartitionCounts(
AcDream.App.Rendering.InteriorEntityPartition.Result? partition)
{
if (partition is null)
return "cell=[] out=0 live=0";
var keys = new List<uint>(partition.ByCell.Keys);
keys.Sort();
var sb = new System.Text.StringBuilder(128);
sb.Append("cell=[");
const int MaxCells = 10;
for (int i = 0; i < keys.Count && i < MaxCells; i++)
{
uint id = keys[i];
if (i > 0)
sb.Append(',');
sb.Append("0x").Append(id.ToString("X8")).Append(':').Append(partition.ByCell[id].Count);
}
if (keys.Count > MaxCells)
sb.Append(",...");
sb.Append("] out=").Append(partition.OutdoorStatic.Count)
.Append(" live=").Append(partition.Dynamics.Count);
return sb.ToString();
}
private void DrawRetailPViewLandscapeSlice(
AcDream.App.Rendering.RetailPViewLandscapeSliceContext sliceCtx,
ICamera camera,
FrustumPlanes? frustum,
System.Numerics.Vector3 camPos,
uint? playerLb,
HashSet<uint>? animatedIds,
bool renderSky,
bool renderWeather,
AcDream.Core.World.SkyKeyframe kf,
bool environOverrideActive)
{
var slice = sliceCtx.Slice;
bool scissor = BeginDoorwayScissor(true, slice.NdcAabb);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeClipRouteEnabled)
EmitClipRouteScissorProbe(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();
_terrainCpuStopwatch.Restart();
_terrain?.Draw(
camera,
frustum,
neverCullLandblockId: playerLb,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb);
_terrainCpuStopwatch.Stop();
_terrainCpuSamples[_terrainCpuSampleCursor] =
(long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);
_terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
MaybeFlushTerrainDiag();
// 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<uint, AcDream.Core.World.WorldEntity>?)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<uint>? 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<uint, AcDream.Core.World.WorldEntity>?)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.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeOutStageEnabled
&& _particleSystem is not null)
{
int matched = 0, attached = 0, unattached = 0;
_outStageUnmatchedScratch.Clear();
_outStageMatchedScratch.Clear();
foreach (var (emitter, _) in _particleSystem.EnumerateLive())
{
if (emitter.AttachedObjectId == 0) { unattached++; continue; }
attached++;
if (_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId))
{
matched++;
if (_outStageMatchedScratch.Count < 48)
_outStageMatchedScratch.Add(emitter.AttachedObjectId);
}
else if (_outStageUnmatchedScratch.Count < 12)
_outStageUnmatchedScratch.Add(emitter.AttachedObjectId);
}
var unm = new System.Text.StringBuilder(96);
foreach (uint id in _outStageUnmatchedScratch)
unm.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
var mat = new System.Text.StringBuilder(192);
foreach (uint id in _outStageMatchedScratch)
mat.Append(System.FormattableString.Invariant($" 0x{id:X8}"));
string ptSig = System.FormattableString.Invariant(
$"ids={_outdoorSceneParticleEntityIds.Count} attachedEmitters={attached} matched={matched} unattached={unattached} matchedIds=[{mat}] unmatchedIds=[{unm}]");
if (ptSig != _lastOutStagePtSig)
{
_lastOutStagePtSig = ptSig;
Console.WriteLine("[outstage-pt] " + ptSig);
}
}
// #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<System.Numerics.Vector3> 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<uint> _dynamicsSceneParticleEntityIds = new();
private void DrawRetailPViewDynamicsParticles(
IReadOnlyList<AcDream.Core.World.WorldEntity> 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();
}
// #119-residual [viewer] probe state: print-on-change signature so a
// stable climb is silent and every flood/root flip emits exactly one line.
private string? _lastViewerProbeSig;
// #127: previous frame's admitted cell set — the [viewer-diff] line names
// exactly which cells entered/left the flood when the signature changes.
private readonly HashSet<uint> _lastViewerFloodCells = new();
private readonly List<uint> _viewerDiffScratch = new();
private void EmitRetailPViewDiagnostics(
AcDream.App.Rendering.RetailPViewFrameResult result,
LoadedCell clipRoot,
uint viewerCellId,
uint playerCellId,
System.Numerics.Vector3 camPos,
System.Numerics.Vector3 playerViewPos)
{
// #119-residual: the capture half of the tower capture→replay loop.
// One line per change of (root, flood, outPolys, playerCell); the eye
// at mm precision rides every line so the harness can replay the
// exact production (eye, root) pairs. ACDREAM_PROBE_VIEWER=1.
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeViewerEnabled)
{
string sig = System.FormattableString.Invariant(
$"root=0x{clipRoot.CellId:X8}{(clipRoot.IsOutdoorNode ? "(OUT)" : "")} flood={result.PortalFrame.OrderedVisibleCells.Count} outPolys={result.PortalFrame.OutsideView.Polygons.Count} pCell=0x{playerCellId:X8}");
if (sig != _lastViewerProbeSig)
{
_lastViewerProbeSig = sig;
// fwd = camera forward (3rd view column negated) — required to
// REPLAY a captured frame headlessly: Build's clip results
// depend on the view-projection, not just the eye.
var v = _cameraController?.Active.View ?? System.Numerics.Matrix4x4.Identity;
Console.WriteLine(System.FormattableString.Invariant(
$"[viewer] {sig} eye=({camPos.X:F3},{camPos.Y:F3},{camPos.Z:F3}) fwd=({-v.M13:F4},{-v.M23:F4},{-v.M33:F4}) viewerCell=0x{viewerCellId:X8}"));
// #127 [viewer-diff]: name the cells that entered/left since the
// last emitted signature — the bistable admission self-attributes.
_viewerDiffScratch.Clear();
var cur = result.PortalFrame.OrderedVisibleCells;
var sb = new System.Text.StringBuilder(96);
sb.Append("[viewer-diff] added=[");
bool first = true;
foreach (uint c in cur)
{
if (_lastViewerFloodCells.Contains(c)) continue;
if (!first) sb.Append(',');
sb.Append("0x").Append(c.ToString("X8"));
first = false;
}
sb.Append("] removed=[");
first = true;
foreach (uint c in _lastViewerFloodCells)
{
bool present = false;
for (int ci = 0; ci < cur.Count; ci++)
if (cur[ci] == c) { present = true; break; }
if (present) continue;
if (!first) sb.Append(',');
sb.Append("0x").Append(c.ToString("X8"));
first = false;
}
sb.Append(']');
Console.WriteLine(sb.ToString());
_lastViewerFloodCells.Clear();
foreach (uint c in cur) _lastViewerFloodCells.Add(c);
}
}
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeVisibilityEnabled)
AcDream.Core.Rendering.RenderingDiagnostics.EmitVis(
clipRoot.CellId,
result.PortalFrame.OrderedVisibleCells,
result.PortalFrame.OutsideView.Polygons.Count,
result.ClipAssembly.OutsidePlaneCount,
result.ClipAssembly.PerCellPlaneCounts,
result.ClipAssembly.ScissorFallbacks);
if (AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled)
{
bool eyeInRoot = CellVisibility.PointInCell(camPos, clipRoot);
bool playerInRoot = CellVisibility.PointInCell(playerViewPos, clipRoot);
Console.WriteLine(
$"[flap-cam] root=0x{clipRoot.CellId:X8} viewerCell=0x{viewerCellId:X8} playerCell=0x{playerCellId:X8} " +
$"res={_cellVisibility.LastCameraCellResolution} " +
$"eyeInRoot={(eyeInRoot ? "Y" : "n")} playerInRoot={(playerInRoot ? "Y" : "n")} " +
$"eye=({camPos.X:F2},{camPos.Y:F2},{camPos.Z:F2}) " +
$"player=({playerViewPos.X:F2},{playerViewPos.Y:F2},{playerViewPos.Z:F2}) " +
$"terrain={result.ClipAssembly.TerrainMode} outVisible={result.ClipAssembly.OutdoorVisible}");
}
}
// §4 flap apparatus (2026-06-09): GL-state tripwire. Snapshots the fixed-function
// state entering the world passes; prints [gl-state] only when it CHANGES from the
// previous frame. Every CPU-side input is probe-exonerated for the outdoor
// full-world flap; this pins or refutes the leaked-GL-state family
// (feedback_render_self_contained_gl_state — 3 prior hits). Throwaway.
private string? _lastGlStateSig;
private long _glStateFrame;
private long _glStateStable;
private void EmitGlStateTripwireIfChanged()
{
var gl = _gl;
if (gl is null) return;
_glStateFrame++;
Span<int> sbox = stackalloc int[4];
Span<int> vp = stackalloc int[4];
gl.GetInteger(GetPName.ScissorBox, sbox);
gl.GetInteger(GetPName.Viewport, vp);
int clipBits = 0;
for (int i = 0; i < ClipFrame.MaxPlanes; i++)
if (gl.IsEnabled(EnableCap.ClipDistance0 + i)) clipBits |= 1 << i;
var err = gl.GetError();
// All-integer fields — culture-safe with plain interpolation.
string sig =
$"depth={(gl.IsEnabled(EnableCap.DepthTest) ? 1 : 0)} " +
$"dmask={(gl.GetBoolean(GetPName.DepthWritemask) ? 1 : 0)} " +
$"dfunc=0x{gl.GetInteger(GetPName.DepthFunc):X} " +
$"blend={(gl.IsEnabled(EnableCap.Blend) ? 1 : 0)} " +
$"bsrc=0x{gl.GetInteger(GetPName.BlendSrcRgb):X} bdst=0x{gl.GetInteger(GetPName.BlendDstRgb):X} " +
$"cull={(gl.IsEnabled(EnableCap.CullFace) ? 1 : 0)} cmode=0x{gl.GetInteger(GetPName.CullFaceMode):X} " +
$"fface=0x{gl.GetInteger(GetPName.FrontFace):X} " +
$"scis={(gl.IsEnabled(EnableCap.ScissorTest) ? 1 : 0)} sbox=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) " +
$"vp=({vp[0]},{vp[1]},{vp[2]},{vp[3]}) " +
$"fbo={gl.GetInteger(GetPName.DrawFramebufferBinding)} " +
$"a2c={(gl.IsEnabled(EnableCap.SampleAlphaToCoverage) ? 1 : 0)} " +
$"stencil={(gl.IsEnabled(EnableCap.StencilTest) ? 1 : 0)} " +
$"clip=0x{clipBits:X2} err=0x{(int)err:X}";
if (sig == _lastGlStateSig)
{
_glStateStable++;
return;
}
Console.WriteLine($"[gl-state] frame={_glStateFrame} stable={_glStateStable} {sig}");
_lastGlStateSig = sig;
_glStateStable = 0;
}
// §4 flap [clip-route-scis] probe (2026-06-10, throwaway): the ACTUAL GL scissor state
// the landscape pass (sky + terrain + outdoor entities + the player) draws under, read
// back right after BeginDoorwayScissor. The whole pass is scissored to slice.NdcAabb —
// if the box reads doorway-sized here, the full-world flap is the scissor by
// construction, no RenderDoc needed. Print-on-change.
private string? _lastClipRouteScisSig;
private long _clipRouteScisSeq;
private void EmitClipRouteScissorProbe(bool applied, System.Numerics.Vector4 ndcAabb)
{
var gl = _gl;
if (gl is null) return;
Span<int> sbox = stackalloc int[4];
gl.GetInteger(GetPName.ScissorBox, sbox);
bool enabled = gl.IsEnabled(EnableCap.ScissorTest);
string sig = System.FormattableString.Invariant(
$"applied={(applied ? 1 : 0)} scis={(enabled ? 1 : 0)} box=({sbox[0]},{sbox[1]},{sbox[2]},{sbox[3]}) ndc=({ndcAabb.X:F3},{ndcAabb.Y:F3},{ndcAabb.Z:F3},{ndcAabb.W:F3})");
_clipRouteScisSeq++;
if (sig == _lastClipRouteScisSig)
return;
_lastClipRouteScisSig = sig;
Console.WriteLine($"[clip-route-scis] n={_clipRouteScisSeq} {sig}");
}
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;
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to
/// zero and substitute a flat 0.2 white ambient — exact retail
/// behavior per <c>CellManager::ChangePosition</c> @ 0x004559B0,
/// which calls <c>SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)</c>
/// when the player's <c>CObjCell::seen_outside</c> flag is 0.
/// Indoor brightness then comes from per-cell point lights
/// (Setup.Lights on the cell's static objects, registered through
/// <see cref="AcDream.Core.Lighting.LightingHookSink"/>).
/// 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.
/// </summary>
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.
/// <summary>Player-mode-aware position source for the DebugPanel.</summary>
private System.Numerics.Vector3 GetDebugPlayerPosition()
{
if (_playerMode && _playerController is not null)
return _playerController.Position;
if (_cameraController?.Active is { } cam)
{
// Camera world position from inverse of view matrix — same
// computation used by the scene-lighting UBO each frame.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
}
return System.Numerics.Vector3.Zero;
}
/// <summary>Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.</summary>
private float GetDebugPlayerHeadingDeg()
{
float deg;
if (_playerMode && _playerController is not null)
{
deg = _playerController.Yaw * (180f / MathF.PI);
}
else if (_cameraController?.Active is { } cam)
{
// Camera-relative heading from view matrix forward vector. Use
// the same -invView.Mxx convention the snapshot block used.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33);
deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI);
}
else
{
return 0f;
}
deg %= 360f;
if (deg < 0f) deg += 360f;
return deg;
}
private uint GetDebugPlayerCellId() =>
_playerMode && _playerController is not null ? _playerController.CellId : 0u;
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
private float GetActiveSensitivity()
{
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
if (_cameraController?.IsFlyMode == true) return _sensFly;
return _sensOrbit;
}
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugVm?.AddToast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugVm?.AddToast("Time override cleared");
}
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}");
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
/// <summary>
/// Yields the registered DebugPanel(s) so F1 can flip their
/// visibility. Returns nothing when devtools are off.
/// </summary>
private IEnumerable<AcDream.UI.Abstractions.IPanel> EnumerateDebugPanel()
{
// The current ImGuiPanelHost only exposes Register/Unregister,
// not enumerate. We track the DebugPanel through the VM presence
// — the panel is registered iff _debugVm is non-null. Look it
// up via the panel ID convention.
// Defer the actual lookup to the panel host once it grows an
// accessor; for now, no-op when devtools are off.
if (_debugPanel is not null) yield return _debugPanel;
}
// Cached panel reference so EnumerateDebugPanel can return it. Set
// in the DevToolsEnabled construction block above; null otherwise.
private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
// Cached chat-panel reference so the dispatcher's ToggleChatEntry
// (Tab) handler can call FocusInput() programmatically. Set in the
// DevToolsEnabled construction block; null otherwise.
private AcDream.UI.Abstractions.Panels.Chat.ChatPanel? _chatPanel;
// Phase K.3 — Settings panel (click-to-rebind keymap UI). Hidden by
// default; F11 / View → Settings toggles. Null when devtools are off.
private AcDream.UI.Abstractions.Panels.Settings.SettingsPanel? _settingsPanel;
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
return _retailUiRuntime?.ToggleWindow(name) ?? false;
}
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
}
private void SetRetailUiLocked(bool locked)
{
if (_persistedGameplay.LockUI == locked)
return;
_persistedGameplay = _persistedGameplay with { LockUI = locked };
if (_uiHost is not null)
_uiHost.Root.UiLocked = locked;
if (_settingsVm is not null)
_settingsVm.SetGameplay(_settingsVm.GameplayDraft with { LockUI = locked });
try
{
_settingsStore?.SaveGameplay(_persistedGameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: radar lock save failed: {ex.Message}");
}
}
private void SetRetailAway(AcDream.Core.Net.WorldSession session, bool away)
{
session.SendSetAfkMode(away);
}
private void SetRetailAcceptLootPermits(bool enabled)
{
_persistedGameplay = _persistedGameplay with { AcceptLootPermits = enabled };
_settingsVm?.SetGameplay(
_settingsVm.GameplayDraft with { AcceptLootPermits = enabled });
_settingsStore?.SaveGameplay(_persistedGameplay);
}
/// <summary>
/// Retail <c>ClientCommunicationSystem::DoFrameRate @ 0x005707D0</c>:
/// flip the live display flag and persist it through the same settings
/// path used by the Display panel.
/// </summary>
private void ToggleRetailFrameRate()
{
_persistedDisplay = _persistedDisplay with
{
ShowFps = !_persistedDisplay.ShowFps,
};
if (_settingsVm is not null)
_settingsVm.ApplyExternalDisplayChange(display => display with
{
ShowFps = _persistedDisplay.ShowFps,
});
try
{
_settingsStore?.SaveDisplay(_persistedDisplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: framerate display save failed: {ex.Message}");
}
}
private void SetRetailCombatGameplay(
AcDream.UI.Abstractions.Panels.Settings.GameplaySettings gameplay)
{
_persistedGameplay = gameplay;
_settingsVm?.SetGameplay(gameplay);
try
{
_settingsStore?.SaveGameplay(gameplay);
}
catch (Exception ex)
{
Console.WriteLine($"settings: combat option save failed: {ex.Message}");
}
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
}
// L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction
// — which IS gated on devtools — reads these fields when wiring
// SettingsVM. Defaults are placeholders; LoadAndApplyPersistedSettings
// overwrites them with values from settings.json (or per-section
// defaults when the file is missing/corrupt).
private AcDream.UI.Abstractions.Panels.Settings.DisplaySettings _persistedDisplay
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
= AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
= AcDream.UI.Abstractions.Panels.Settings.CharacterSettings.Default;
/// <summary>
/// L.0 follow-up: load every section from settings.json + apply the
/// runtime-affecting ones (Display window state + Audio engine
/// volumes) at startup. Runs unconditionally — settings are runtime
/// state, not devtools state. Without this, a user running with
/// <c>ACDREAM_DEVTOOLS=0</c> would silently get WindowOptions
/// defaults instead of their saved Display/Audio preferences.
/// </summary>
private void LoadAndApplyPersistedSettings()
{
_settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
_persistedDisplay = _settingsStore.LoadDisplay();
_persistedAudio = _settingsStore.LoadAudio();
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
// Apply Display to the Silk.NET window. VSync and its bounded
// refresh-rate fallback go through the shared pacing owner;
// resolution + fullscreen use the on-Save path below.
if (_window is not null)
{
RefreshActiveMonitorFramePacing();
ApplyFramePacingPreference(_persistedDisplay.VSync);
ApplyDisplayWindowState(_persistedDisplay);
}
// Apply Audio to the OpenAL engine. Master + Sfx are wired
// through to the engine; Music + Ambient are stored but inert
// until R5 MIDI/ambient-loop engines exist (assigning them is
// harmless — the engine just doesn't read them yet).
if (_audioEngine is not null && _audioEngine.IsAvailable)
{
_audioEngine.MasterVolume = _persistedAudio.Master;
_audioEngine.MusicVolume = _persistedAudio.Music;
_audioEngine.SfxVolume = _persistedAudio.Sfx;
_audioEngine.AmbientVolume = _persistedAudio.Ambient;
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: transactionally reconciles the existing
/// <see cref="_streamingController"/> against its published world;
/// the worker and controller retain their identity.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset)
{
var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset);
var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase);
Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}");
// MSAA samples cannot change at runtime — warn if preset would differ.
if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples)
{
Console.WriteLine(
$"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " +
$"{newResolved.MsaaSamples}) requires a restart — skipped for this session.");
}
_resolvedQuality = newResolved;
// A2C gate — immediate toggle, no GL context restart needed.
if (_wbDrawDispatcher is not null)
_wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage;
// Anisotropic — immediate GL TexParameter call on the terrain atlas.
_terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel);
// Reconcile streaming radii against the current published world. This
// preserves resident blocks that remain in range and never rebuilds
// the worker merely to change a completion budget.
if (_streamingController is not null)
{
_nearRadius = newResolved.NearRadius;
_farRadius = newResolved.FarRadius;
_streamingController.ReconfigureRadii(_nearRadius, _farRadius);
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(
$"[QUALITY] Streaming reconciled: nearRadius={_nearRadius}, " +
$"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}");
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
/// the corner OR by ApplyDisplayWindowState applying a saved
/// Resolution). Without this, the viewport stays pinned at the
/// startup size, producing a small render inside a big window.
/// Also force-resets ImGui panel layout so panels that were
/// previously off the new viewport snap back to default positions.
/// </summary>
private void OnFramebufferResize(Silk.NET.Maths.Vector2D<int> newSize)
{
if (newSize.X <= 0 || newSize.Y <= 0) return;
_gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y);
_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.
if (DevToolsEnabled && _imguiBootstrap is not null)
ResetPanelLayout(ImGuiNET.ImGuiCond.Always);
}
/// <summary>
/// L.0 Display tab: position every registered panel to its default
/// landing spot, computed relative to the current window size so
/// the layout adapts to any resolution. Called from:
/// <list type="bullet">
/// <item>OnFramebufferResize (cond=Always — force-reset on resize).</item>
/// <item>The View → "Reset window layout" menu item (cond=Always).</item>
/// <item>OnLoad after panel registration (cond=FirstUseEver — only
/// applies when imgui.ini has no saved position for that
/// panel; on subsequent launches the saved positions win).</item>
/// </list>
/// </summary>
private void ResetPanelLayout(ImGuiNET.ImGuiCond cond)
{
if (_window is null) return;
float w = _window.Size.X;
float h = _window.Size.Y;
// Sane minimums so the math doesn't blow up on a tiny window.
if (w < 480) w = 480;
if (h < 320) h = 320;
// Panel positions chosen to be classic-MMO discoverable on a
// 1280x720 window: vitals top-left under the menu bar, chat
// bottom-left, debug top-right, settings centered. All sizes
// are reasonable defaults the user can resize from.
SetPanelLayout(_vitalsPanel?.Title, new System.Numerics.Vector2(10f, 30f),
new System.Numerics.Vector2(220f, 110f), cond);
SetPanelLayout(_chatPanel?.Title, new System.Numerics.Vector2(10f, h - 320f),
new System.Numerics.Vector2(450f, 300f), cond);
SetPanelLayout(_debugPanel?.Title, new System.Numerics.Vector2(w - 380f, 30f),
new System.Numerics.Vector2(370f, 520f), cond);
SetPanelLayout(_settingsPanel?.Title, new System.Numerics.Vector2((w - 700f) * 0.5f, (h - 500f) * 0.5f),
new System.Numerics.Vector2(700f, 500f), cond);
}
private static void SetPanelLayout(
string? title,
System.Numerics.Vector2 pos,
System.Numerics.Vector2 size,
ImGuiNET.ImGuiCond cond)
{
if (string.IsNullOrEmpty(title)) return;
// SetWindowPos/SetWindowSize by name work even when the window
// has never been Begin'd — ImGui stores the value for next
// appearance.
ImGuiNET.ImGui.SetWindowPos(title, pos, cond);
ImGuiNET.ImGui.SetWindowSize(title, size, cond);
}
/// <summary>
/// L.0 Display tab: apply the window-state-dependent settings
/// (Resolution + Fullscreen) from a <see cref="AcDream.UI.Abstractions.Panels.Settings.DisplaySettings"/>
/// to the live Silk.NET window. Called at startup (with persisted
/// values) and on every Save (with the saved values). Resolution
/// parses "<c>WIDTHxHEIGHT</c>" (e.g. <c>"1920x1080"</c>); a malformed
/// or unparseable string is silently ignored to avoid crashing the
/// client mid-session.
/// </summary>
private void ApplyDisplayWindowState(
AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display)
{
if (_window is null) return;
// Resolution: parse and resize if changed.
if (TryParseResolution(display.Resolution, out int w, out int h))
{
if (_window.Size.X != w || _window.Size.Y != h)
_window.Size = new Silk.NET.Maths.Vector2D<int>(w, h);
}
// Fullscreen: borderless via Silk.NET's WindowState.Fullscreen
// (no exclusive-mode DXGI dance needed).
var desiredState = display.Fullscreen
? Silk.NET.Windowing.WindowState.Fullscreen
: Silk.NET.Windowing.WindowState.Normal;
if (_window.WindowState != desiredState)
_window.WindowState = desiredState;
}
private void ApplyFramePacingPreference(bool requestedVSync)
{
_requestedVSync = requestedVSync;
FramePacingPolicy policy = FramePacingPolicy.Resolve(
requestedVSync,
_options.UncappedRendering,
_activeMonitorRefreshHz);
_framePacing.Apply(policy);
if (_window is not null && _window.VSync != policy.UseVSync)
_window.VSync = policy.UseVSync;
}
private void RefreshActiveMonitorFramePacing()
{
int? refreshHz = _window?.Monitor?.VideoMode.RefreshRate;
_activeMonitorRefreshHz = refreshHz is > 0 ? refreshHz : null;
ApplyFramePacingPreference(_requestedVSync);
}
private void OnFrameRendered(double _)
{
using var pacingStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Pacing);
_framePacing.CompleteFrame();
}
private void OnWindowMoved(Silk.NET.Maths.Vector2D<int> _)
=> RefreshActiveMonitorFramePacing();
private void OnWindowStateChanged(Silk.NET.Windowing.WindowState _)
=> RefreshActiveMonitorFramePacing();
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;
}
// Vitals panel reference cached for the View menu's toggle entry.
private AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel? _vitalsPanel;
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
// handlers are gone; everything now flows through InputDispatcher.Fired
// → here. New behaviors register a new InputAction in the enum + a
// case in this switch + a binding in KeyBindings.
/// <summary>
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
/// Handles every game-side reaction to a keyboard/mouse-button chord.
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
{
// Diagnostic — kept from K.1a; helpful for K.1c verification.
Console.WriteLine($"[input] {action} {activation}");
// RMB-orbit hold: track press/release transitions explicitly so
// _rmbHeld is true exactly while the chord is held. Hold-type
// chords also fire Press on key-down + Release on key-up; we
// ignore the in-between Hold ticks here (the mouse-move handler
// checks _rmbHeld each frame anyway).
if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold)
{
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
_rmbHeld = _playerMode && _cameraController?.IsChaseMode == true;
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
_rmbHeld = false;
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).
if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook)
{
if (_mouseLook is null) return;
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
{
if (!_playerMode
|| _cameraController?.IsChaseMode != true
|| _playerController is not { State: AcDream.App.Input.PlayerState.InWorld })
return;
bool wcm = IsUiCapturingMouse();
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
_mouseLook.Press(_lastMouseX, _lastMouseY, wcm, nowSeconds);
if (_mouseLook.Active)
{
if (_playerController is not { } controller
|| !controller.BeginMouseLook(CaptureMovementInput()))
{
// Keep capture ownership atomic with the movement
// transition. A portal/state change between the outer
// gate and this call must not leave a hidden cursor
// with no active movement owner.
_mouseLook.Release();
return;
}
// ToggleMouseLook calls SendMovementEvent at the input
// boundary. This also preserves down+up events that occur
// before the next physics update.
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
HideCursorForMouseLook();
}
}
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
{
EndMouseLookAndRestoreCursor();
}
return;
}
// 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.
_combatAttackController?.HandleMovementInput(action, activation);
// 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 (_combatAttackController?.HandleInputAction(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 (action == AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)
{
_autoRunActive = !_autoRunActive;
return;
}
if (_autoRunActive
&& (action == AcDream.UI.Abstractions.Input.InputAction.MovementBackup
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStop
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight))
{
_autoRunActive = false;
// Fall through — these actions still need their normal handling
// (e.g. Stop is currently a no-op in the switch, but keeping the
// fall-through means future logic fires).
}
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:
foreach (var panel in EnumerateDebugPanel())
{
panel.IsVisible = !panel.IsVisible;
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
}
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.
ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
TogglePlayerMode();
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 devtools/_chatPanel is null
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
_chatPanel?.FocusInput();
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.
if (_settingsPanel is not null)
_settingsPanel.IsVisible = !_settingsPanel.IsVisible;
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)
{
EndMouseLookAndRestoreCursor();
_playerMode = false;
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
}
else
_window!.Close();
break;
}
}
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
Objects.GetEquippedBy(_playerServerGuid);
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
.GetDefaultCombatMode(orderedEquipment);
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
_debugVm?.AddToast(text);
}
private bool IsPlayerReadyForCombatAttack()
{
if (_playerController is null)
return false;
var motion = _playerController.Motion.InterpretedState;
return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack(
Combat.CurrentMode,
motion.CurrentStyle,
motion.ForwardCommand);
}
private bool CanStartLiveCombatAttack()
{
if (_liveSessionController?.IsInWorld != true)
return false;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
{
_debugVm?.AddToast("Enter melee or missile combat first");
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
return false;
}
uint? target = GetSelectedOrClosestCombatTarget();
if (target is null)
{
_debugVm?.AddToast("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
return false;
}
return true;
}
private bool SendLiveCombatAttack(
AcDream.Core.Combat.AttackHeight height,
float power)
{
if (!CanStartLiveCombatAttack())
return false;
AcDream.Core.Net.WorldSession? session = LiveSession;
if (session is null)
return false;
uint target = _selection.SelectedObjectId!.Value;
power = Math.Clamp(power, 0f, 1f);
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{
session.SendMissileAttack(target, height, power);
Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
session.SendMeleeAttack(target, height, power);
Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
return true;
}
/// <summary>
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
/// calls <c>MaybeStopCompletely</c>, whose successful path sends the
/// stopped movement state synchronously. Doing this in the host preserves
/// wire ordering: ACE sees the final player heading before the later
/// targeted attack request.
/// </summary>
private void PreparePlayerForAttackRequest()
{
if (_playerController is not { } controller
|| !controller.PrepareForAttackRequest())
return;
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
}
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
AcDream.App.Input.MovementResult result) =>
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
=> _localPlayerOutbound.TrySendMovement(
LiveSession,
_playerController,
result);
private uint? GetSelectedOrClosestCombatTarget()
=> _selectionInteractions?.GetSelectedOrClosestCombatTarget(
_persistedGameplay.AutoTarget);
/// <summary>
/// Resolves retail's combat-camera target. ClientCombatSystem::
/// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only
/// for melee/missile combat with the option enabled and a valid attack
/// target. TrackTarget applies target-local offset (0,0,0.5).
/// </summary>
private System.Numerics.Vector3? GetCombatCameraTargetPoint()
{
if (!_persistedGameplay.ViewCombatTarget
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|| _selection.SelectedObjectId is not uint selected)
return null;
return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
}
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
/// retail's drawing-sphere broadphase followed by flat visual-polygon
/// intersection. <paramref name="includeSelf"/> allows item target-use to
/// pick the local player while plain selection excludes it.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> _selectionInteractions?.PickAtCursor(includeSelf)
?? _worldSelectionQuery?.PickAtCursor(includeSelf);
// Contained/toolbar activation is not a world-selection interaction: it
// sends directly without speculative TurnToObject/MoveToObject.
private void UseItemByGuid(uint guid)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
return;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(
AcDream.Core.Net.Messages.InteractRequests.BuildUse(sequence, guid));
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={sequence}");
}
private bool IsWithinExternalContainerUseRange(uint targetGuid)
=> _worldSelectionQuery?.IsWithinExternalContainerUseRange(targetGuid) ?? true;
private void SendUse(uint guid)
=> _selectionInteractions?.SendUse(guid);
private void SendPickUp(uint itemGuid, uint destinationContainerId, int placement)
=> _selectionInteractions?.SendPickup(itemGuid, destinationContainerId, placement);
private void OnAutoWalkArrivedSendDeferredAction()
=> _selectionInteractions?.OnNaturalMoveToComplete();
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 = _teleportViewPlane.ApplyTo(_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);
private void TogglePlayerMode()
{
// Phase B.2 guard: only active when a live session is in-world.
if (_liveSessionController?.IsInWorld != true)
return;
// Manual toggle pre-empts the K.2 auto-entry trigger regardless
// of direction — entering means "I'm in player mode now"; exiting
// means "I want fly, don't snap me back".
_playerModeAutoEntry?.Cancel();
_playerMode = !_playerMode;
if (_playerMode)
{
if (!EnterPlayerModeNow(loggingTag: "Tab"))
_playerMode = false;
}
else
{
EndMouseLookAndRestoreCursor();
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerMouseDeltaX = 0f;
}
}
/// <summary>
/// K.2: callback the <see cref="AcDream.App.Input.PlayerModeAutoEntry"/>
/// guard invokes once login + entity stream + controller readiness
/// have all converged. Sets <c>_playerMode = true</c> and runs the
/// same construction path the manual Tab handler uses. Predicates on
/// the guard already guarantee <c>_entitiesByServerGuid</c> contains
/// the player guid, so the inner TryGetValue is a fast-path success.
/// </summary>
// #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
// check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
// hold so a garbage claim doesn't stall login forever; the Resolve-head
// safety net demotes it loudly once entry proceeds.
private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
private bool IsSpawnClaimUnhydratable(uint claim)
{
if ((claim & 0xFFFFu) < 0x0100u) return false;
if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
bool unhydratable = false;
if (_dats is not null)
{
DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
lock (_datLock)
{
lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(claim & 0xFFFF0000u) | 0xFFFEu);
}
uint low = claim & 0xFFFFu;
unhydratable = lbInfo is null || lbInfo.NumCells == 0
|| low >= 0x0100u + lbInfo.NumCells;
}
_spawnClaimRangeMemo = (claim, unhydratable);
return unhydratable;
}
// #135: is this server-sent cell id a SEALED dungeon EnvCell — an indoor cell
// (low 16 bits >= 0x0100) whose EnvCell dat flags lack SeenOutside? Distinguishes
// a real dungeon (collapse streaming to its single landblock) from a building
// interior (cottage/inn — SeenOutside, which keeps its outdoor surround) and from
// an outdoor cell, WITHOUT needing the cell hydrated. Reads the SAME dat flag as
// the hydration path (EnvCellLandblockBuildBuilder) and as the physics
// CurrCell.SeenOutside the per-frame insideDungeon gate reads — so the pre-collapse
// decision matches the eventual gate decision exactly. Returns false when the dat
// lacks the cell (out-of-range index / missing record) so we never collapse on a
// guess. The dat read is reentrant-safe under _datLock (Monitor) — callers may
// already hold it (the login spawn handler does).
private bool IsSealedDungeonCell(uint cellId)
{
// Not an EnvCell: the sub-0x0100 outdoor sub-cells AND the 0xFFFE/0xFFFF
// structural shell ids (LandBlockInfo / LandBlock heightmap). A naive
// `< 0x0100` test MISSES 0xFFFF (65535 is not < 256), and Get<EnvCell> on
// 0xXXYYFFFF would then type-confuse the LandBlock record living at that id as
// an EnvCell (its bytes unpack to a bogus Flags value). A real spawn/teleport
// position never carries a shell id, but exclude them so the read is sound.
uint low = cellId & 0xFFFFu;
if (low < 0x0100u || low >= 0xFFFEu) return false;
if (_dats is null) return false;
DatReaderWriter.DBObjs.EnvCell? envCell;
lock (_datLock)
envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(cellId);
return envCell is not null
&& !envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
}
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;
if (!EnterPlayerModeNow(loggingTag: "auto-entry"))
{
// Defense in depth: if construction failed (e.g. entity
// disappeared between predicate eval and here) drop back
// out cleanly. Re-arm so a later Tab still works.
_playerMode = false;
}
else
{
Console.WriteLine($"live: auto-entered player mode for 0x{_playerServerGuid:X8}");
}
}
/// <summary>
/// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine
/// when a chase camera is in play. <see cref="CameraController.ToggleFly"/>
/// only knows Fly↔Orbit and would strand a player-mode user in the
/// orbit camera (Holtburg view) when they exit fly. This wrapper
/// gives the round-trip the user actually wants:
/// <list type="bullet">
/// <item>Chase → Fly: cancel auto-entry (user's choice wins) and
/// switch to fly camera while keeping <c>_playerMode = true</c> +
/// the chase camera alive so we can return.</item>
/// <item>Fly → Chase: when <c>_playerMode</c> is still true and the
/// chase camera survived, re-enter chase via
/// <see cref="CameraController.EnterChaseMode"/>.</item>
/// <item>Otherwise (no chase available): the original Fly↔Orbit
/// toggle for offline / pre-login flows.</item>
/// </list>
/// </summary>
private void ToggleFlyOrChase()
{
if (_cameraController is null) return;
_playerModeAutoEntry?.Cancel();
if (_cameraController.IsFlyMode
&& _playerMode
&& _chaseCamera is not null)
{
if (_retailChaseCamera is null)
{
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
{
Aspect = _chaseCamera.Aspect,
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
};
}
_cameraController.EnterChaseMode(_chaseCamera, _retailChaseCamera);
return;
}
_cameraController.ToggleFly();
}
/// <summary>
/// K.2: shared "construct controller + chase camera + enter chase
/// mode" body extracted from the on-enter branch of
/// <see cref="TogglePlayerMode"/>. Returns false when the player
/// entity isn't in <c>_entitiesByServerGuid</c> yet — caller must
/// reset <c>_playerMode</c> in that case.
/// </summary>
private bool EnterPlayerModeNow(string loggingTag)
{
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
Console.WriteLine($"live: {loggingTag} — player entity 0x{_playerServerGuid:X8} not found yet");
return false;
}
if (_liveEntities is not { } playerLiveEntities
|| !playerLiveEntities.TryGetRecord(
_playerServerGuid,
out LiveEntityRecord playerRecord))
{
Console.WriteLine(
$"live: {loggingTag} — player record 0x{_playerServerGuid:X8} not found yet");
return false;
}
_playerController = new AcDream.App.Input.PlayerMovementController(
_physicsEngine,
playerRecord.ObjectClock);
_playerController.ApplyPhysicsState(playerRecord.FinalPhysicsState);
// R4-V5: the local player's verbatim MoveToManager — same seam
// wiring shape as EnsureRemoteMotionBindings, with three
// player-specific differences: (a) heading reads/writes use the
// controller's Yaw projection as the explicit Frame::get_heading /
// set_heading seam, while ordinary object ticks retain the complete
// authoritative body quaternion; (b) the contact seam reads the REAL Contact
// transient bit (retail UseTime gates transient_state & 1 —
// remotes force-assert Contact+OnWalkable every grounded tick, so
// OnWalkable was equivalent there); (c) isInterpolating is false —
// the local player has no InterpolationManager. Own radius/height
// are the real setup cylsphere values (R5-V3 — lazy reads because
// this method caches the player Setup a few paragraphs further
// down). The setHeading `send` argument remains unused because retail
// CPhysicsObj::set_heading (0x00514160) also ignores that parameter.
// Stationary heading changes reach the wire through the shared
// ShouldSendPositionEvent full-Frame comparison (0x006B45E0), including
// orientation, rather than through a MoveTo-specific send side channel.
var pcMoveTo = _playerController;
// R5-V2: forward-declared so the player MoveToManager's target seams
// route into the player's TargetManager (retail CPhysicsObj::set_target).
EntityPhysicsHost playerHost = null!;
// R5-V5: the construction is the player MovementManager's
// MoveToFactory (same facade shape as EnsureRemoteMotionBindings);
// MakeMoveToManager() below invokes it once, after playerHost exists
// for the sticky binds.
_playerController.Movement.MoveToFactory = () =>
{
var playerMoveTo = new AcDream.Core.Physics.Motion.MoveToManager(
pcMoveTo.Motion,
stopCompletely: () => pcMoveTo.StopCompletelyAtPhysicsObjectBoundary(),
getPosition: () => new AcDream.Core.Physics.Position(
pcMoveTo.CellId, pcMoveTo.Position, pcMoveTo.BodyOrientation),
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.HeadingFromYaw(pcMoveTo.Yaw),
setHeading: (h, _) => pcMoveTo.Yaw =
AcDream.Core.Physics.Motion.MoveToMath.YawFromHeading(h),
getOwnRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
getOwnHeight: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Height,
contact: () => pcMoveTo.BodyInContact,
isInterpolating: () => false,
getVelocity: () => pcMoveTo.BodyVelocity,
getSelfId: () => _playerServerGuid,
// R5-V2: retail CPhysicsObj::set_target/clear_target/quantum → the
// player's TargetManager (replaces the AP-79 _playerMoveToTarget* poll).
setTarget: (ctx, tlid, radius, q) => playerHost.SetTarget(ctx, tlid, radius, q),
clearTarget: () => playerHost.ClearTarget(),
getTargetQuantum: () => playerHost.TargetManager.GetTargetQuantum(),
setTargetQuantum: q => playerHost.TargetManager.SetTargetQuantum(q),
curTime: () => pcMoveTo.SimTimeSeconds);
// AD-27 re-anchored (was the deleted AutoWalkArrived event): fire
// the deferred close-range Use/PickUp action when the moveto
// completes NATURALLY (MoveToComplete is the documented client
// seam; it never fires on CancelMoveTo, so a user-input cancel
// doesn't send the action — same contract the old "arrived"-only
// event had).
playerMoveTo.MoveToComplete = err =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[autowalk-end] reason=complete err={err}");
if (err == AcDream.Core.Physics.WeenieError.None)
OnAutoWalkArrivedSendDeferredAction();
else
_selectionInteractions?.OnMoveToCancelled(err);
};
playerMoveTo.MoveToCancelled = err =>
_selectionInteractions?.OnMoveToCancelled(err);
// R5-V3 (#171, retires TS-39 — player side): bind the sticky
// seams to the player host's PositionManager (same trio as the
// remote bind: BeginNextNode arrival StickTo @0x00529d3a,
// PerformMovement-head Unstick).
playerMoveTo.StickTo = (tlid, radius, height) =>
playerHost.PositionManager.StickTo(tlid, radius, height);
playerMoveTo.Unstick = playerHost.PositionManager.UnStick;
return playerMoveTo;
};
// R5-V2: the player's CPhysicsObj stand-in + TargetManager. Position is
// the player's WORLD position (WorldEntity.Position — what the AP-79
// poll used), so an NPC watching the player receives the identical
// position. Stored on the exact live record so those NPCs' GetObjectA
// resolves the player; HandleTargetting is ticked in the player
// pre-Update block.
var exactPlayerMovement = pcMoveTo.Movement;
var configuredPlayerHost = new EntityPhysicsHost(
_playerServerGuid,
getPosition: () => new AcDream.Core.Physics.Position(
playerRecord.FullCellId,
playerRecord.WorldEntity?.Position ?? pcMoveTo.Position,
pcMoveTo.BodyOrientation),
getVelocity: () => pcMoveTo.BodyVelocity,
getRadius: () => _liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
inContact: () => pcMoveTo.BodyInContact,
minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(),
curTime: () => pcMoveTo.SimTimeSeconds,
physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
getObjectA: _liveEntityMotionBindings.ResolvePhysicsHost,
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
// relay); the host chains the PositionManager leg after it.
handleUpdateTarget: info =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(
$"[autowalk-target] object=0x{info.ObjectId:X8} "
+ $"status={info.Status} context={info.ContextId} "
+ $"target=({info.TargetPosition.Frame.Origin.X:F2},"
+ $"{info.TargetPosition.Frame.Origin.Y:F2},"
+ $"{info.TargetPosition.Frame.Origin.Z:F2})");
}
exactPlayerMovement.HandleUpdateTarget(info);
},
interruptCurrentMovement: () => exactPlayerMovement.CancelMoveTo(
AcDream.Core.Physics.WeenieError.ActionCancelled));
playerHost = EntityPhysicsHost.InstallOrRebind(
playerLiveEntities,
playerRecord,
configuredPlayerHost);
_playerHost = playerHost;
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
// player MoveToManager via the factory above (playerHost now exists
// for the sticky binds inside it). The UM-funnel-head unstick
// (CPhysicsObj::unstick_from_object 0x0050eaea) binds on the interp
// beside it, and the controller takes the PositionManager handoff it
// drives at the retail UpdatePositionInternal/UpdateObjectInternal
// points (AdjustOffset/UseTime).
var playerMovement = _playerController.Movement;
playerMovement.MakeMoveToManager();
_playerController.Motion.UnstickFromObject = playerHost.PositionManager.UnStick;
_playerController.PositionManager = playerHost.PositionManager;
// TS-36 RETIRED: the interp's interrupt seam is retail's
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
// chain (raw 278189-278200) — since R5-V5 the literal facade relay
// (0x005241b0). Every DoMotion/StopMotion/StopCompletely/jump/
// set_hold_run cancel site now genuinely cancels a running moveto
// (V2's reentrancy tests prove the chain is inert-safe). Captures
// THIS controller's facade (not the _playerController field) so a
// Tab-toggle's stale interp keeps cancelling its own manager.
_playerController.Motion.InterruptCurrentMovement = () =>
{
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled
&& playerMovement.IsMovingTo())
Console.WriteLine("[autowalk-end] reason=interrupt");
playerMovement.CancelMoveTo(AcDream.Core.Physics.WeenieError.ActionCancelled);
};
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them
// into the freshly-constructed controller so the runRate /
// jump-arc formulas use real character data instead of the
// hardcoded ACDREAM_*_SKILL defaults. PD always arrives at
// login before auto-entry fires, so this branch normally hits.
if (_lastSeenRunSkill >= 0 && _lastSeenJumpSkill >= 0)
{
_playerController.SetCharacterSkills(_lastSeenRunSkill, _lastSeenJumpSkill);
Console.WriteLine($"live: {loggingTag} — applied server skills run={_lastSeenRunSkill} jump={_lastSeenJumpSkill}");
}
// Read the real step heights from the player's Setup dat.
// L.2.3a (2026-04-29): retail's Setup.StepUpHeight for humans is
// ~0.4 m, NOT 2 m. With 2 m fallback the step-up scan reached
// small-building roofs and teleported the player onto them. Same
// for StepDownHeight — was hardcoded 0.04 m, causing stair-top
// contact-plane gaps. Both now come from Setup with retail-realistic
// 0.4 m fallbacks.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently.
DatReaderWriter.DBObjs.Setup? playerSetup;
lock (_datLock) { playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId); }
if (playerSetup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
: 0.4f;
_playerController.StepDownHeight = (playerSetup is not null && playerSetup.StepDownHeight > 0f)
? playerSetup.StepDownHeight
: 0.4f;
// L.2.3f (2026-04-29): diagnostic — confirm what the actual
// values from the player's Setup dat are. Retail's spec says ~0.4 m
// for humans, but we want to verify rather than guess. If the
// dat-derived value is large (e.g. 1.5 m+) it explains why the
// player can mount steep roofs via the step-up scan reach.
Console.WriteLine(
$"physics: player step heights — StepUp={_playerController.StepUpHeight:F3} m " +
$"(Setup.StepUpHeight={(playerSetup?.StepUpHeight ?? 0f):F3}), " +
$"StepDown={_playerController.StepDownHeight:F3} m " +
$"(Setup.StepDownHeight={(playerSetup?.StepDownHeight ?? 0f):F3})");
}
else
{
_playerController.StepUpHeight = 0.4f;
_playerController.StepDownHeight = 0.4f;
Console.WriteLine($"physics: player step heights — defaulting to 0.4 m (no setup dat)");
}
// Issue #92 (2026-05-20): seed the resolver with the SERVER's
// authoritative cell id from the spawn message instead of a
// hardcoded outdoor sentinel (`landblockPrefix | 0x0001`). When
// the player logs in INSIDE a building (server reports an indoor
// cell like `0xA9B4015A`), the old sentinel forced the resolver
// into the outdoor seed branch — for the first several ticks
// CheckBuildingTransit hadn't yet picked up the interior cell,
// so the player was classified outdoor, indoor BSP queries
// didn't run, and exterior walls were passable until the player
// moved far enough INWARD that the sphere overlap eventually
// promoted them. Visible symptom: "logged in inside the inn,
// ran out through the exterior wall."
//
// Fall back to the old sentinel when no spawn record is cached
// (defensive — should never fire in live play because the
// The inbound-state snapshot was accepted before render hydration.
// before EnterPlayerModeNow could possibly be reached).
uint pinitCellId;
if (LastSpawns.TryGetValue(_playerServerGuid, out var playerSpawn)
&& playerSpawn.Position is { } spawnPos
&& spawnPos.LandblockId != 0)
{
pinitCellId = spawnPos.LandblockId;
}
else
{
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
}
// R4-V5 moveto-stall fix #2 (2026-07-03, the [autowalk-gate] probe's
// one-immortal-node finding): the sequencer/sink bind block MUST run
// BEFORE the initial SetPosition. SetPosition → StopCompletely
// enqueues the A9 pending_motions node whose completable partner is
// the DefaultSink's type-5 motion-table entry — with the sink still
// null at login, the node was ORPHANED, and pending_motions never
// reached empty again (head-pop-any just relabels a backlog), so
// the MoveToManager's wait-for-anims gate never opened: every
// server MoveTo armed but the body never moved.
if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
_playerController.ObjectScale = playerAE.Scale;
_playerController.AttachAnimationRootMotionSource(
AdvanceLocalPlayerAnimationRoot,
CaptureLocalPlayerAnimationHooks);
// R3-W4: bind the player interp's retail seams to the player
// sequencer — LeaveGround/HitGround strip transition links here
// (the K-fix18 replacement).
// #174 (2026-07-05): the seam is HandleEnterWorld (strip + FULL
// queue drain), not the bare sequence strip — see the remote
// bind site's comment (retail 0x0050fe20 → 0x00517d70 →
// 0x0051bdd0). The bare strip orphaned every pending manager
// node on each jump's LeaveGround; the local player's
// pending_motions then never drained and every armed moveto
// starved (#174: doors unusable after the first jump/run).
_playerController.Motion.RemoveLinkAnimations = () => playerSeq.Manager.HandleEnterWorld();
_playerController.Motion.InitializeMotionTables =
() => playerSeq.Manager.InitializeState();
_playerController.Motion.CheckForCompletedMotions =
playerSeq.Manager.CheckForCompletedMotions;
// R3-W6: the player's cycles are driven through the same dispatch
// sink as remotes; both consume CSequence's complete root Frame.
_playerController.Motion.DefaultSink =
new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq);
}
var initResult = _physicsEngine.Resolve(
playerEntity.Position, pinitCellId,
System.Numerics.Vector3.Zero, 100f);
// Retail enter_world -> SetPosition(flags 0x11) runs
// CTransition::find_placement_pos after the initial cell/environment
// placement. That second phase is what seats a relogging character
// beside a monster that moved onto the saved logout position. The
// legacy Resolve above supplies the already-ported AdjustPosition +
// floor snap; this call supplies the missing object-aware ring search.
var (placementRadius, placementHeight) =
_liveEntityMotionBindings.GetSetupCylinder(_playerServerGuid, playerEntity);
if (placementRadius < 0.05f)
{
placementRadius = 0.48f;
placementHeight = 1.835f;
}
var placementResult = _physicsEngine.ResolvePlacement(
initResult.Position,
initResult.CellId,
placementRadius,
placementHeight,
_playerController.StepUpHeight,
_playerController.StepDownHeight,
AcDream.Core.Physics.ObjectInfoState.IsPlayer
| AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
playerEntity.Id);
if (placementResult.Ok)
initResult = placementResult;
_playerController.SetPosition(initResult.Position, initResult.CellId,
CellLocalForSeed(initResult.Position, initResult.CellId));
// #111 (2026-06-10): snap the ENTITY too — parity with the
// teleport-arrival path (entity.SetPosition + ParentCellId at
// GameWindow.cs:4914). Without this, the renderer keeps drawing the
// character at the server-restored position (ACE restored z=99.475;
// physics grounded to the 94.0 floor; the user saw the char floating
// 2 m up against the window while physics stood on the floor).
playerEntity.SetPosition(initResult.Position);
playerEntity.ParentCellId = initResult.CellId;
SyncLocalPlayerShadow(playerEntity, initResult.CellId, force: true);
_playerController.SetBodyOrientation(playerEntity.Rotation);
_chaseCamera = new AcDream.App.Rendering.ChaseCamera
{
Aspect = _window!.Size.X / (float)_window.Size.Y,
};
_retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
{
Aspect = _window!.Size.X / (float)_window.Size.Y,
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
};
// K.1b: _playerMouseDeltaX is no longer consumed by
// MovementInput, but we still reset it here so any stale
// accumulated value from a previous session doesn't leak
// into a future code path that re-enables mouse-yaw.
_playerMouseDeltaX = 0f;
_cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
// K-fix1 (2026-04-26): latch the "we have entered chase at least
// once" flag so the live-mode pre-login render gate stops
// suppressing the scene. From here on, the orbit camera (if the
// user ever returns to it via Escape) shows whatever's loaded —
// we don't re-blank the world.
_chaseModeEverEntered = true;
return true;
}
/// <summary>
/// Both presentation stacks participate in the same gameplay-input gate.
/// A retained retail panel capturing the pointer must suspend instant
/// mouse-look just as an ImGui developer panel does.
/// </summary>
private bool IsUiCapturingMouse()
=> (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false);
private AcDream.App.Input.MovementInput CaptureMovementInput()
{
if (_inputDispatcher is null)
return default;
bool walking = _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode);
bool forward = _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementForward);
return new AcDream.App.Input.MovementInput(
Forward: forward || _autoRunActive,
Backward: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
StrafeLeft: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft),
StrafeRight: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight),
TurnLeft: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
TurnRight: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
Run: !walking,
Jump: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementJump));
}
/// <summary>
/// Single idempotent teardown for every mouse-look exit path. Retail sends
/// the stopped movement state synchronously; doing so before nulling the
/// player controller preserves ACE's final authoritative heading.
/// </summary>
private void EndMouseLookAndRestoreCursor()
{
bool stateWasActive = _mouseLook?.Active == true;
_mouseLook?.Release();
if (_playerController is { } controller
&& controller.EndMouseLook(CaptureMovementInput()))
{
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
}
if (stateWasActive || _mouseLookSavedCursorMode.HasValue)
RestoreCursorAfterMouseLook();
}
/// <summary>
/// Phase K.2: hide the system cursor while MMB instant mouse-look is
/// held. Saves the previous CursorMode so <see cref="RestoreCursorAfterMouseLook"/>
/// can put it back exactly. Skips when no mouse / no input — tests
/// and headless runs stay clean.
/// </summary>
private void HideCursorForMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// Save previous mode (Normal in orbit, Raw in chase/fly) so the
// exact pre-hold mode is restored on release.
_mouseLookSavedCursorMode = mouse.Cursor.CursorMode;
mouse.Cursor.CursorMode = CursorMode.Hidden;
}
/// <summary>
/// Phase K.2: restore the saved cursor mode after MMB instant
/// mouse-look ends. Called from the Release branch and from the
/// WantCaptureMouse-edge suspend path so the cursor never gets
/// stuck hidden.
/// </summary>
private void RestoreCursorAfterMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
if (_mouseLookSavedCursorMode is { } saved)
{
mouse.Cursor.CursorMode = saved;
_mouseLookSavedCursorMode = null;
}
else
{
// Defense in depth: never observed the saved value, fall
// back to Normal so the user always gets a visible cursor.
mouse.Cursor.CursorMode = CursorMode.Normal;
}
}
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
/// given factor and clamps to [0.005, 3.0].
/// </summary>
private void AdjustActiveSensitivity(float factor)
{
string modeLabel;
float current;
if (_playerMode && _cameraController?.IsChaseMode == true)
{ modeLabel = "Chase"; current = _sensChase; }
else if (_cameraController?.IsFlyMode == true)
{ modeLabel = "Fly"; current = _sensFly; }
else
{ modeLabel = "Orbit"; current = _sensOrbit; }
float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor));
if (modeLabel == "Chase") _sensChase = next;
else if (modeLabel == "Fly") _sensFly = next;
else _sensOrbit = next;
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
}
/// <summary>
/// K.1b: F3 dump handler extracted into a method. Same body as the
/// previous in-line F3 branch — prints the player's position +
/// nearby visible entities + nearby shadow physics objects.
/// </summary>
private void DumpPlayerAndNearbyEntities()
{
System.Numerics.Vector3 pos;
if (_playerMode && _playerController is not null)
pos = _playerController.Position;
else
{
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
Console.WriteLine(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
$" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" +
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
foreach (var e in _worldState.Entities)
{
float dx = e.Position.X - pos.X;
float dy = e.Position.Y - pos.Y;
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
}
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
{
float d = (e.Position - pos).Length();
Console.WriteLine(
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
}
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = o.Position.X - pos.X;
float dy = o.Position.Y - pos.Y;
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d < 15f) sorted.Add((o, d));
}
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
foreach (var (o, d) in sorted.Take(12))
{
Console.WriteLine(
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
}
}
/// <summary>
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
/// camera distance is current — chase camera distance in player mode,
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
/// is fixed-step (the previous proportional scroll.Y was lost when we
/// moved scroll into the dispatcher, but the discrete step matches
/// retail wheel feel).
/// </summary>
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
{
if (_cameraController is null) return;
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
if (_playerMode && _cameraController.IsChaseMode)
{
// Chase mode: zoom (closer on ScrollUp).
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
_retailChaseCamera.AdjustDistance(-dir * 0.8f);
else if (_chaseCamera is not null)
_chaseCamera.AdjustDistance(-dir * 0.8f);
}
else if (_cameraController.IsFlyMode)
{
// Fly mode: no-op (could adjust move speed later).
}
else
{
_cameraController.Orbit.Distance = Math.Clamp(
_cameraController.Orbit.Distance - dir * 20f, 50f, 2000f);
}
}
private void MaybeFlushTerrainDiag()
{
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
return;
long now = Environment.TickCount64;
if (now - _terrainLastDiagTick <= 5000) return;
// Samples are stored as microseconds × 100 (so 1.23 µs becomes 123 long).
long cpuMedHundredthsUs = TerrainDiagMedianMicros(_terrainCpuSamples);
long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples);
double cpuMedUs = cpuMedHundredthsUs / 100.0;
double cpuP95Us = cpuP95HundredthsUs / 100.0;
// A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const double TerrainBudgetUs = 1000.0;
string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
$"draws={_terrain?.VisibleSlots ?? 0}/frame " +
$"visible={_terrain?.VisibleSlots ?? 0} " +
$"loaded={_terrain?.LoadedSlots ?? 0} " +
$"capacity={_terrain?.CapacitySlots ?? 0}");
// Publication metrics are cumulative typed snapshots from the focused
// owners. The only GameWindow-local distribution left is the render-
// thread entity upload drain.
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;
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
Console.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={entUplMedUs:F1}m/{entUplP95Us: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}");
_terrainLastDiagTick = now;
}
private static long TerrainDiagMedianMicros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// Sorted ascending: zero-padding at the front, samples at the back.
// Median of nz samples is the middle of the last nz entries; using
// (nz - 1) / 2 from the end keeps the offset >= 0 for all nz >= 1
// (the original nz / 2 form underflowed to copy.Length on first
// diag-flush when only 1 sample had been recorded).
return copy[copy.Length - 1 - (nz - 1) / 2];
}
private static long TerrainDiagPercentile95Micros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// 95th percentile = upper end of the sorted samples; clamp the
// offset to stay inside the populated tail when nz < 20.
int offset = (int)((nz - 1) * 0.05);
return copy[copy.Length - 1 - offset];
}
/// <summary>[FRAME-DIAG] helper: convert a <see cref="System.Diagnostics.Stopwatch"/>
/// timestamp-tick delta to the microseconds×100 fixed-point used by the rolling
/// sample rings and store it (zeros are ignored by the median/p95 helpers, so an
/// idle frame's 0-sample doesn't dilute the non-zero cost distribution).</summary>
private static void FrameDiagPush(long[] samples, ref int cursor, long ticks)
{
// µs×100 = ticks × 1e8 / Stopwatch.Frequency.
samples[cursor] = (long)(ticks * 100_000_000.0 / System.Diagnostics.Stopwatch.Frequency);
cursor = (cursor + 1) % samples.Length;
}
/// <summary>A.5 T22: parse a float environment variable, returning
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
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", EndMouseLookAndRestoreCursor),
new("retail UI", () =>
{
_retailUiRuntime?.Dispose();
_retailUiRuntime = 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("portal tunnel", () =>
{
_portalTunnel?.Dispose();
_portalTunnel = null;
}),
new("paperdoll viewport", () =>
{
_paperdollViewportRenderer?.Dispose();
_paperdollViewportRenderer = 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 profiler", _frameProfiler.Dispose),
new("frame pacing", _framePacing.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)
EndMouseLookAndRestoreCursor();
}
public void Dispose()
{
// Closing is the normal path and runs while the render context is
// current. This direct call also covers a constructed-but-never-run
// window and exceptions during Window.Create/Run, so owned kernel
// handles (including the frame timer) never depend on a native window
// event for disposal.
CompleteShutdown();
_window?.Dispose();
_window = null;
}
/// <summary>
/// Fallback <see cref="AcDream.Core.Physics.IAnimationLoader"/> for the
/// <see cref="AcDream.App.Rendering.Wb.EntitySpawnAdapter"/> sequencer
/// factory when neither <c>_dats</c> nor the entity's setup is available.
/// Returns null for all animation lookups so the sequencer silently has
/// no data (same behaviour as a new empty Setup).
/// </summary>
private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader
{
public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null;
}
}