Move full and near-tier presentation retirement out of GameWindow and into a concrete pipeline-owned stage mapper. Harden the retry ledger against reentrancy and committed visibility observer failures, enforce a single state/resource ownership graph, and split concrete versus legacy controller construction so callbacks cannot be silently ignored. Co-authored-by: Codex <codex@openai.com>
9011 lines
458 KiB
C#
9011 lines
458 KiB
C#
using AcDream.Core.Plugins;
|
||
using AcDream.App.Physics;
|
||
using AcDream.App.World;
|
||
using AcDream.Content;
|
||
using DatReaderWriter;
|
||
using Silk.NET.Input;
|
||
using Silk.NET.Maths;
|
||
using Silk.NET.OpenGL;
|
||
using Silk.NET.Windowing;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
|
||
{
|
||
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] (ACDREAM_WB_DIAG=1): per-frame cost split for the FPS deep-dive.
|
||
// The crux this measures: terrain APPLY runs in OnUpdate (under _datLock —
|
||
// dat reads + physics/ShadowObjects/BSP registration + the terrain GPU
|
||
// upload), while the DRAW + the title-bar ms run in OnRender, so a heavy
|
||
// apply stalls the loop turn yet is INVISIBLE to the render-only ms. We
|
||
// accumulate apply cost across each OnUpdate and flush it next to the
|
||
// [TERRAIN-DIAG] draw cost. Samples are µs×100 over a rolling 256-ring,
|
||
// reusing the [TERRAIN-DIAG] median/p95 helpers. Removable (mirrors the
|
||
// probes stripped in 92e95be). Gated once at construction — zero cost off.
|
||
private readonly bool _frameDiag = string.Equals(
|
||
System.Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", System.StringComparison.Ordinal);
|
||
private long _applyAccumTicks; // apply CPU accumulated across this OnUpdate
|
||
private long _applyUploadAccumTicks; // terrain glBufferSubData sub-span this OnUpdate
|
||
private int _appliesThisUpdate; // # landblock LOADS applied this OnUpdate
|
||
private int _frameDiagMaxAppliesPerUpdate; // burst size over the 5s window
|
||
private readonly long[] _applyCpuSamples = new long[256]; // apply µs×100 / Update
|
||
private int _applyCpuSampleCursor;
|
||
private readonly long[] _applyUploadSamples = new long[256]; // terrain-upload µs×100 / Update
|
||
private int _applyUploadSampleCursor;
|
||
private readonly long[] _entityUploadSamples = new long[256]; // _wbMeshAdapter.Tick µs×100 / Render
|
||
private int _entityUploadSampleCursor;
|
||
// Apply CPU split into three sub-spans (to name WHICH part of the 40-110ms
|
||
// apply dominates): cell-build (EnvCell dat reads + physics surfaces +
|
||
// buildings), gfxobj-BSP (per-entity physics BSP cache), ShadowObjects+lights
|
||
// registration. Accumulated per OnUpdate via checkpoints in ApplyLoadedTerrainLocked.
|
||
private long _applyCellAccumTicks;
|
||
private long _applyBspAccumTicks;
|
||
private long _applyShadowAccumTicks;
|
||
private readonly long[] _applyCellSamples = new long[256];
|
||
private int _applyCellSampleCursor;
|
||
private readonly long[] _applyBspSamples = new long[256];
|
||
private int _applyBspSampleCursor;
|
||
private readonly long[] _applyShadowSamples = new long[256];
|
||
private int _applyShadowSampleCursor;
|
||
// Direct measure of the _datLock acquisition wait (the streaming worker holds
|
||
// the lock for its full per-landblock build, stalling this apply). Confirms the
|
||
// apply≫sub-span gap is lock contention, not in-lock work.
|
||
private long _applyLockWaitAccumTicks;
|
||
private readonly long[] _applyLockWaitSamples = new long[256];
|
||
private int _applyLockWaitSampleCursor;
|
||
|
||
// 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.LandblockRenderPublisher? _landblockRenderPublisher;
|
||
private AcDream.App.Streaming.LandblockPhysicsPublisher? _landblockPhysicsPublisher;
|
||
private AcDream.App.Streaming.LandblockStaticPresentationPublisher?
|
||
_landblockStaticPresentationPublisher;
|
||
private AcDream.App.Rendering.EquippedChildRenderController? _equippedChildRenderer;
|
||
private AcDream.App.Streaming.StreamingController? _streamingController;
|
||
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 readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
|
||
private readonly LiveEntityAnimationPresenter _animationPresenter;
|
||
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
|
||
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
|
||
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
|
||
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
|
||
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;
|
||
|
||
// 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 double _physicsScriptGameTime;
|
||
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 AcDream.App.Input.PlayerMovementController? _playerController;
|
||
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
|
||
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
|
||
private bool _playerMode;
|
||
private uint _playerServerGuid;
|
||
// 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;
|
||
// #192 (2026-07-09): true once the player's first canonical Position has
|
||
// been accepted — i.e. _liveCenterX/Y reflects a real, server-confirmed
|
||
// position, not the Holtburg startup placeholder. Renderability is not a
|
||
// prerequisite: a partial CreateObject may carry Position before Setup.
|
||
// Gates
|
||
// streaming via StreamingReadinessGate.ShouldStream so no landblock is ever
|
||
// built around a still-guessed center in live mode. See
|
||
// AcDream.Core.World.StreamingReadinessGate for the full mechanism this
|
||
// closes (stale-centered geometry built during the InWorld-but-not-yet-
|
||
// located window, applied late once its build finished).
|
||
private bool _liveCenterKnown => _liveWorldOrigin.IsKnown;
|
||
|
||
// 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;
|
||
|
||
/// <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>(() => _liveEntities);
|
||
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));
|
||
var ordinaryPhysicsUpdater =
|
||
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
|
||
_physicsEngine,
|
||
_liveEntityMotionBindings.GetSetupCylinder);
|
||
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
|
||
() => _liveEntities,
|
||
() => _playerServerGuid,
|
||
_remotePhysicsUpdater,
|
||
ordinaryPhysicsUpdater,
|
||
() => _projectileController,
|
||
entity => _effectPoses.UpdateRoot(entity),
|
||
CaptureAnimationHooks);
|
||
_animationPresenter = new LiveEntityAnimationPresenter(
|
||
() => _liveEntities,
|
||
() => _staticAnimationScheduler,
|
||
_effectPoses,
|
||
this,
|
||
_animationDiagnostics,
|
||
options.HidePartIndex);
|
||
_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);
|
||
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
|
||
AdvanceLiveObjectRuntime,
|
||
() =>
|
||
{
|
||
using AcDream.App.Streaming.GpuWorldState.MutationBatch spatialBatch =
|
||
_worldState.BeginMutationBatch();
|
||
_liveSessionController?.Tick();
|
||
},
|
||
_localPlayerFrame.RunPostNetworkCommandPhase,
|
||
ReconcileLiveObjectSpatialPresentation);
|
||
}
|
||
|
||
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. Stored as fields so
|
||
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
|
||
// can call LandblockMesh.Build without re-deriving these every time.
|
||
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(
|
||
() => _liveEntities,
|
||
_physicsEngine.ShadowObjects,
|
||
() => (_liveCenterX, _liveCenterY),
|
||
entity => _effectPoses.UpdateRoot(entity));
|
||
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
|
||
capturedAnimLoader!,
|
||
CaptureAnimationHooks,
|
||
(entity, poses, availability) =>
|
||
_effectPoses.Publish(entity, poses, availability),
|
||
entity => entity.ServerGuid == 0
|
||
|| (_liveEntities?.TryGetRecord(
|
||
entity.ServerGuid,
|
||
out LiveEntityRecord resident) == true
|
||
&& resident.ProjectionKind is LiveEntityProjectionKind.World
|
||
&& ReferenceEquals(resident.WorldEntity, entity)
|
||
&& resident.IsSpatiallyProjected
|
||
&& resident.IsSpatiallyVisible
|
||
&& resident.FullCellId != 0),
|
||
(entity, body) =>
|
||
_ = staticLiveRootCommitter.Commit(entity, body),
|
||
entity => entity.ServerGuid == 0
|
||
? 0UL
|
||
: _liveEntities?.TryGetRecord(
|
||
entity.ServerGuid,
|
||
out LiveEntityRecord resident) == true
|
||
&& ReferenceEquals(resident.WorldEntity, entity)
|
||
? resident.ProjectionMutationVersion
|
||
: ulong.MaxValue);
|
||
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);
|
||
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,
|
||
setupId =>
|
||
{
|
||
// State can classify an already-live object as Missile
|
||
// outside the spawn handler's lock. DatCollection shares
|
||
// one mutable reader cursor with the streaming worker, so
|
||
// this resolver must serialize both initial and late bind.
|
||
lock (_datLock)
|
||
return _dats?.Get<DatReaderWriter.DBObjs.Setup>(setupId);
|
||
},
|
||
entity => _effectPoses.UpdateRoot(entity),
|
||
() => (_liveCenterX, _liveCenterY))
|
||
{
|
||
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));
|
||
|
||
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!);
|
||
_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);
|
||
_landblockPhysicsPublisher =
|
||
new AcDream.App.Streaming.LandblockPhysicsPublisher(
|
||
_physicsEngine,
|
||
_heightTable!);
|
||
_landblockStaticPresentationPublisher =
|
||
new AcDream.App.Streaming.LandblockStaticPresentationPublisher(
|
||
_lightingSink!,
|
||
_translucencyFades,
|
||
_worldGameState,
|
||
_worldEvents);
|
||
|
||
_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();
|
||
|
||
var landblockRetirementOwner =
|
||
new AcDream.App.Streaming.LandblockPresentationRetirementOwner(
|
||
_landblockRenderPublisher!,
|
||
_landblockPhysicsPublisher!,
|
||
_landblockStaticPresentationPublisher!,
|
||
_lightingSink!,
|
||
_translucencyFades);
|
||
var landblockPresentationPipeline =
|
||
new AcDream.App.Streaming.LandblockPresentationPipeline(
|
||
_landblockRenderPublisher!,
|
||
_landblockPhysicsPublisher!,
|
||
_landblockStaticPresentationPublisher!,
|
||
_worldState,
|
||
landblockRetirementOwner,
|
||
onLandblockLoaded: loadedLandblockId =>
|
||
_liveEntityHydration!.OnLandblockLoaded(loadedLandblockId),
|
||
ensureEnvCellMeshes:
|
||
_landblockRenderPublisher!.PrepareAfterRenderPins);
|
||
_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);
|
||
// 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,
|
||
() => _physicsScriptGameTime);
|
||
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>(
|
||
() => _liveEntities),
|
||
_remoteMovementObservations,
|
||
_remotePhysicsUpdater,
|
||
_remoteInboundMotion,
|
||
liveEntityMotionRuntime!,
|
||
_physicsEngine,
|
||
_dats!,
|
||
_animLoader!,
|
||
_combatTargetController,
|
||
_liveWorldOrigin,
|
||
_teleportTransit,
|
||
() => _playerController,
|
||
_localPlayerOutbound,
|
||
() => _playerHost,
|
||
() => _playerServerGuid,
|
||
() => _physicsScriptGameTime,
|
||
() => 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();
|
||
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
|
||
{
|
||
_playerMode = false;
|
||
_playerController = null;
|
||
_playerHost = null;
|
||
_chaseCamera = null;
|
||
_retailChaseCamera = null;
|
||
_playerMouseDeltaX = 0f;
|
||
_rmbHeld = false;
|
||
_autoRunActive = false;
|
||
_chaseModeEverEntered = 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 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: drop the stale STREAMING center landblock from the physics engine
|
||
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
|
||
// center), but the prior center was ALSO loaded at offset (0,0) and its
|
||
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
|
||
// cell-snap resolves the player into the stale center. On a replacement this
|
||
// can be the abandoned first destination, not the unplaced player's source.
|
||
_landblockPhysicsPublisher!.RemoveLandblock(streamingOriginLandblockId);
|
||
|
||
_liveWorldOrigin.Recenter(lbX, lbY);
|
||
newWorldPos = new System.Numerics.Vector3(
|
||
p.PositionX, p.PositionY, p.PositionZ);
|
||
|
||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||
else
|
||
_streamingController?.ForceReloadWindow();
|
||
}
|
||
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)
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
|
||
// #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);
|
||
ReconcileLiveObjectSpatialPresentation();
|
||
|
||
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");
|
||
}
|
||
/// <summary>
|
||
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
|
||
/// whenever a new landblock's terrain + entities are ready for GPU upload.
|
||
/// Phase A.5 T12: the worker pre-builds <paramref name="meshData"/> off the
|
||
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
|
||
/// this callback no longer pays that CPU cost.
|
||
/// Must only be called from the render thread.
|
||
/// </summary>
|
||
private void ApplyLoadedTerrain(AcDream.App.Streaming.LandblockBuild build,
|
||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||
{
|
||
var lb = build.Landblock;
|
||
if (_terrain is null || _dats is null) return;
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport("APPLY", lb.LandblockId);
|
||
|
||
// datLock fix (2026-06-23): ApplyLoadedTerrainLocked now makes ZERO
|
||
// DatCollection calls — every dat it needs was pre-read by the streaming
|
||
// worker into lb.PhysicsDats. Its remaining mutations are update-thread-
|
||
// only (physics engine, ShadowObjects, renderer, _worldState) or already
|
||
// concurrent-safe (PhysicsDataCache is ConcurrentDictionary). _datLock's
|
||
// only cross-thread job was serializing DatCollection, so with no Get call
|
||
// here the lock is unnecessary — removing it eliminates the measured
|
||
// 24ms-median / 88ms-p95 lockwait stall (the FPS 30↔200 swing). The method
|
||
// keeps its historical "Locked" name; the worker still serializes its OWN
|
||
// dat reads on _datLock — only the apply stops contending for it.
|
||
// [FRAME-DIAG]: lockwait now measures ~0 — the before/after proof.
|
||
long fdT0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||
if (_frameDiag)
|
||
_applyLockWaitAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||
try
|
||
{
|
||
ApplyLoadedTerrainLocked(build, meshData);
|
||
}
|
||
catch (AcDream.App.Streaming.StreamingMutationException)
|
||
{
|
||
throw;
|
||
}
|
||
catch (Exception error)
|
||
{
|
||
// This legacy body spans multiple concrete owners and cannot yet
|
||
// identify an exact failed suffix. Conservatively report that a
|
||
// prefix may have committed so the transaction coordinator never
|
||
// replays terrain/cells/collision/lights/plugin events. Slice-5
|
||
// checkpoints C-E replace this wrapper with per-owner receipts.
|
||
throw new AcDream.App.Streaming.StreamingMutationException(
|
||
$"Landblock 0x{lb.LandblockId:X8} presentation failed after an unknown commit point.",
|
||
mutationCommitted: true,
|
||
error);
|
||
}
|
||
if (_frameDiag)
|
||
{
|
||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||
_appliesThisUpdate++;
|
||
}
|
||
}
|
||
|
||
private void PublishLandblockStaticLightingBeforeCollision(
|
||
AcDream.Core.World.WorldEntity entity,
|
||
AcDream.Core.World.PhysicsDatBundle datBundle)
|
||
{
|
||
if (_lightingSink is null || _dats is null)
|
||
return;
|
||
|
||
uint sourceId = entity.SourceGfxObjOrSetupId;
|
||
if ((sourceId & 0xFF000000u) != 0x02000000u)
|
||
return;
|
||
if (!datBundle.Setups.TryGetValue(sourceId, out var setup)
|
||
|| setup.Lights.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// A landblock can reapply without an unload (#168 ForceReloadWindow
|
||
// and Far-to-Near promotion). Static IDs are deterministic, so replace
|
||
// the prior owner state before registering the Setup lights.
|
||
uint effectOwnerId = entity.Id;
|
||
_lightingSink.UnregisterOwner(effectOwnerId);
|
||
_translucencyFades.ClearEntity(effectOwnerId);
|
||
var lights = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||
setup,
|
||
ownerId: effectOwnerId,
|
||
entityPosition: entity.Position,
|
||
entityRotation: entity.Rotation,
|
||
cellId: entity.ParentCellId ?? 0u);
|
||
foreach (var light in lights)
|
||
_lightingSink.RegisterOwnedLight(light);
|
||
}
|
||
|
||
private void ApplyLoadedTerrainLocked(
|
||
AcDream.App.Streaming.LandblockBuild build,
|
||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||
{
|
||
AcDream.Core.World.LoadedLandblock landblock = build.Landblock;
|
||
if (_landblockRenderPublisher is null || _landblockPhysicsPublisher is null)
|
||
return;
|
||
|
||
AcDream.Core.World.PhysicsDatBundle datBundle =
|
||
landblock.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
|
||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderBefore =
|
||
_landblockRenderPublisher.Diagnostics;
|
||
AcDream.App.Streaming.LandblockRenderPublication renderPublication =
|
||
_landblockRenderPublisher.BeginPublication(build, meshData);
|
||
System.Numerics.Vector3 origin = renderPublication.Origin;
|
||
|
||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderPrefix =
|
||
_landblockRenderPublisher.Diagnostics;
|
||
if (_frameDiag)
|
||
{
|
||
long terrainTicks =
|
||
renderPrefix.TerrainPublishTicks - renderBefore.TerrainPublishTicks;
|
||
_applyUploadAccumTicks += terrainTicks;
|
||
_applyCellAccumTicks +=
|
||
renderPrefix.BeginPublishTicks
|
||
- renderBefore.BeginPublishTicks
|
||
- terrainTicks;
|
||
}
|
||
|
||
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsBefore =
|
||
_landblockPhysicsPublisher.Diagnostics;
|
||
AcDream.App.Streaming.LandblockPhysicsPublication physicsPublication =
|
||
_landblockPhysicsPublisher.BeginPublication(renderPublication);
|
||
|
||
// Retail initializes buildings/visible cells before ordinary static
|
||
// collision and scripts. The two receipts retain one captured origin
|
||
// while each focused owner commits its exact stage.
|
||
_landblockRenderPublisher.CompletePublication(renderPublication);
|
||
_landblockPhysicsPublisher.CompletePublication(
|
||
physicsPublication,
|
||
entity => PublishLandblockStaticLightingBeforeCollision(
|
||
entity,
|
||
datBundle));
|
||
|
||
if (_frameDiag)
|
||
{
|
||
AcDream.App.Streaming.LandblockPhysicsPublisherDiagnostics physicsAfter =
|
||
_landblockPhysicsPublisher.Diagnostics;
|
||
AcDream.App.Streaming.LandblockRenderPublisherDiagnostics renderAfter =
|
||
_landblockRenderPublisher.Diagnostics;
|
||
long gfxCacheTicks =
|
||
physicsAfter.GfxCacheTicks - physicsBefore.GfxCacheTicks;
|
||
_applyCellAccumTicks +=
|
||
physicsAfter.BasePublishTicks - physicsBefore.BasePublishTicks;
|
||
_applyCellAccumTicks +=
|
||
renderAfter.CompletePublishTicks
|
||
- renderPrefix.CompletePublishTicks;
|
||
_applyBspAccumTicks += gfxCacheTicks;
|
||
_applyShadowAccumTicks +=
|
||
physicsAfter.CompletePublishTicks
|
||
- physicsBefore.CompletePublishTicks
|
||
- gfxCacheTicks;
|
||
}
|
||
|
||
long pluginStarted =
|
||
_frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||
foreach (AcDream.Core.World.WorldEntity entity in landblock.Entities)
|
||
{
|
||
// Live objects publish through LiveEntityRuntime exactly once per
|
||
// accepted incarnation. This callback owns DAT statics only.
|
||
if (entity.ServerGuid != 0)
|
||
continue;
|
||
|
||
var snapshot =
|
||
new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
||
Id: entity.Id,
|
||
SourceId: entity.SourceGfxObjOrSetupId,
|
||
Position: entity.Position,
|
||
Rotation: entity.Rotation);
|
||
_worldGameState.Add(snapshot);
|
||
_worldEvents.FireEntitySpawned(snapshot);
|
||
}
|
||
if (_frameDiag)
|
||
{
|
||
_applyShadowAccumTicks +=
|
||
System.Diagnostics.Stopwatch.GetTimestamp() - pluginStarted;
|
||
}
|
||
}
|
||
|
||
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}");
|
||
}
|
||
|
||
double frameSeconds =
|
||
AcDream.App.World.RetailLiveFrameCoordinator.NormalizeDeltaSeconds(dt);
|
||
float frameDelta = (float)frameSeconds;
|
||
|
||
// 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.
|
||
_physicsScriptGameTime += frameSeconds;
|
||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||
|
||
// [FRAME-DIAG]: applies run later this frame inside _streamingController.Tick;
|
||
// flush the PREVIOUS OnUpdate's accumulated apply cost here (a clean per-frame
|
||
// boundary, independent of where in OnUpdate the applies landed) and reset.
|
||
if (_frameDiag)
|
||
{
|
||
if (_appliesThisUpdate > 0)
|
||
{
|
||
FrameDiagPush(_applyCpuSamples, ref _applyCpuSampleCursor, _applyAccumTicks);
|
||
FrameDiagPush(_applyUploadSamples, ref _applyUploadSampleCursor, _applyUploadAccumTicks);
|
||
FrameDiagPush(_applyCellSamples, ref _applyCellSampleCursor, _applyCellAccumTicks);
|
||
FrameDiagPush(_applyBspSamples, ref _applyBspSampleCursor, _applyBspAccumTicks);
|
||
FrameDiagPush(_applyShadowSamples, ref _applyShadowSampleCursor, _applyShadowAccumTicks);
|
||
FrameDiagPush(_applyLockWaitSamples, ref _applyLockWaitSampleCursor, _applyLockWaitAccumTicks);
|
||
if (_appliesThisUpdate > _frameDiagMaxAppliesPerUpdate)
|
||
_frameDiagMaxAppliesPerUpdate = _appliesThisUpdate;
|
||
}
|
||
_applyAccumTicks = 0;
|
||
_applyUploadAccumTicks = 0;
|
||
_applyCellAccumTicks = 0;
|
||
_applyBspAccumTicks = 0;
|
||
_applyShadowAccumTicks = 0;
|
||
_applyLockWaitAccumTicks = 0;
|
||
_appliesThisUpdate = 0;
|
||
}
|
||
|
||
// Phase A.1: advance the streaming controller FIRST so the initial
|
||
// landblocks are loaded into GpuWorldState before live-session
|
||
// CreateObject events drain. The earlier order (live tick first,
|
||
// streaming tick second) caused the initial CreateObject flood from
|
||
// login to land before any landblock was loaded; live projection placement
|
||
// is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were
|
||
// silently dropped on the first frame and never rendered.
|
||
//
|
||
// K-fix1 (2026-04-26): skip streaming entirely while live mode is
|
||
// configured but the chase camera hasn't engaged yet — otherwise
|
||
// the orbit camera at startup centers on the hardcoded
|
||
// 0xA9B4 (Holtburg) and Holtburg landblocks render briefly
|
||
// until the player spawn arrives + auto-entry switches to chase.
|
||
//
|
||
// #106 gate-3 (2026-06-09): narrowed to PRE-LOGIN only. The original
|
||
// form also blocked the in-world window between EnterWorld and
|
||
// auto-entry, which deadlocked the spawn-ground entry hold:
|
||
// auto-entry waits for terrain under the spawn, terrain streaming
|
||
// waited for chase mode. Once the session is InWorld the player's
|
||
// landblock is known (the fly-camera else-if below was written for
|
||
// exactly this window) — stream it. Pre-login, streaming stays
|
||
// blocked, which is the hardcoded-Holtburg flash K-fix1 targeted.
|
||
// The world-geometry RENDER gate (IsLiveModeWaitingForLogin at the
|
||
// draw site) is unchanged — the pre-entry screen still shows sky
|
||
// only.
|
||
bool liveInWorld = _liveSessionController?.IsInWorld == true;
|
||
// #192: liveInWorld alone used to be sufficient here — but InWorld fires
|
||
// right after the login handshake, before the player's own spawn
|
||
// CreateObject (and hence the real center) has arrived. Streaming that
|
||
// ran in that gap baked landblock offsets from the still-placeholder
|
||
// _liveCenterX/Y (Holtburg), producing stale-positioned geometry applied
|
||
// late once its build finished. Require _liveCenterKnown too — see
|
||
// AcDream.Core.World.StreamingReadinessGate for the full mechanism.
|
||
if (_streamingController is not null && _cameraController is not null
|
||
&& AcDream.Core.World.StreamingReadinessGate.ShouldStream(
|
||
LiveModeEnabled, _chaseModeEverEntered, liveInWorld, _liveCenterKnown))
|
||
{
|
||
int observerCx = _liveCenterX;
|
||
int observerCy = _liveCenterY;
|
||
|
||
if (_playerMode && _playerController is not null
|
||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
|
||
{
|
||
// Teleport hold (#135): the local player position is frozen at the
|
||
// PRE-teleport spot, expressed in the OLD center frame, but
|
||
// _liveCenterX/_liveCenterY were already recentered onto the
|
||
// destination landblock (OnLivePositionUpdated). Follow the
|
||
// destination directly — the stale position-derived offset
|
||
// (_liveCenterX + floor(frozenPos/192)) could land ≥2 landblocks off
|
||
// the dungeon and trip ExitDungeonExpand, re-streaming the very
|
||
// neighbor window the pre-collapse just suppressed. Correct for an
|
||
// outdoor teleport too: pre-load the destination during the hold.
|
||
//
|
||
// NOTE: these assignments equal the observerCx/Cy defaults initialized
|
||
// above — the LOAD-BEARING effect of this branch is INHIBITING the
|
||
// position-derived offset in the else-if below while the player position
|
||
// is frozen, not the (redundant) assignment. Kept explicit for clarity.
|
||
observerCx = _liveCenterX;
|
||
observerCy = _liveCenterY;
|
||
}
|
||
else if (_playerMode && _playerController is not null)
|
||
{
|
||
// Player mode: follow the physics-resolved player position.
|
||
// The player walks via the local physics engine; the server
|
||
// doesn't echo back our autonomous moves, so _lastLivePlayer*
|
||
// stays at the login position. Compute the landblock from the
|
||
// controller's current world-space position instead.
|
||
var pp = _playerController.Position;
|
||
observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||
observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||
}
|
||
else if (liveInWorld)
|
||
{
|
||
// Live, not yet in player mode: the login auto-entry hold, or a live
|
||
// fly-camera spectator. Follow the PLAYER's server-known landblock; if it
|
||
// hasn't arrived yet, KEEP the _liveCenterX/_liveCenterY default — which is
|
||
// the spawn/teleport recenter (the dungeon landblock at a dungeon login).
|
||
//
|
||
// #135 regression fix (2026-06-14): this MUST NOT fall through to the
|
||
// fly-camera projection below. During a dungeon-login hold the streaming is
|
||
// pre-collapsed onto the spawn landblock; a camera-derived observer far from
|
||
// it trips ExitDungeonExpand and unloads the dungeon before it can hydrate —
|
||
// the player is never placed and login hangs with no dungeon. Previously
|
||
// The last-player-landblock hint was once set by ANY entity, so a dungeon-local NPC
|
||
// kept this branch on the dungeon; once it was filtered to the player guid
|
||
// (line ~4507), a not-yet-arrived player UP dropped to the camera branch.
|
||
// The fly camera is the OFFLINE observer only.
|
||
if (_liveEntityNetworkUpdates?.LastLivePlayerLandblockId is { } lid)
|
||
{
|
||
observerCx = (int)((lid >> 24) & 0xFFu);
|
||
observerCy = (int)((lid >> 16) & 0xFFu);
|
||
}
|
||
// else: keep the _liveCenterX/_liveCenterY default (the spawn recenter).
|
||
}
|
||
else
|
||
{
|
||
// Offline: project the fly camera's world-space position back into
|
||
// landblock coordinates. OrbitCamera doesn't expose Position via
|
||
// ICamera, but FlyCamera.Position is always updated (even when the
|
||
// orbit camera is Active), so this is safe in both modes.
|
||
// Each landblock is 192 world units wide.
|
||
var camPos = _cameraController.Fly.Position;
|
||
observerCx = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f);
|
||
observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f);
|
||
}
|
||
|
||
// Dungeon gate (#133 FPS): when the player stands in a SEALED EnvCell
|
||
// (indoor cell that doesn't see outside — the same predicate that kills
|
||
// the sun/sky, playerInsideCell below), collapse streaming to the single
|
||
// dungeon landblock. AC dungeons have no adjacent landblocks; the 25×25
|
||
// window otherwise pulls in ~129 unrelated ocean-grid dungeons. Building
|
||
// interiors (cottage/inn) have SeenOutside cells, so they are NOT gated
|
||
// and keep their surrounding terrain.
|
||
// True only for a sealed indoor cell. Read the physics CurrCell's own
|
||
// SeenOutside (ObjCell.SeenOutside, set from the EnvCell dat flags) rather
|
||
// than the render registry: the registry lookup only succeeds AFTER the
|
||
// landblock FINALIZES (~tens of seconds for a 205-cell dungeon), which
|
||
// delayed the collapse and let the full 25×25 neighbor window churn in
|
||
// first (the "~30s to stabilize" report). CurrCell.SeenOutside is set the
|
||
// moment the player is placed, so the collapse now engages at the snap.
|
||
// AP-36 / #145: during a teleport HOLD the player is unplaced and CurrCell is
|
||
// the frozen SOURCE cell. Suppress the source-cell gate so a teleport OUT of a
|
||
// dungeon follows the destination (the PortalSpace observer pin above) instead
|
||
// of staying pinned to the source dungeon — see DungeonStreamingGate.
|
||
bool isTeleportHold =
|
||
_playerController is { State: AcDream.App.Input.PlayerState.PortalSpace };
|
||
var currCell = _physicsEngine.DataCache?.CellGraph.CurrCell;
|
||
bool currSealedDungeon =
|
||
currCell is AcDream.Core.World.Cells.EnvCell pcEnv && !pcEnv.SeenOutside;
|
||
var gate = AcDream.App.Streaming.DungeonStreamingGate.Compute(
|
||
isTeleportHold, currSealedDungeon, currCell?.Id ?? 0u);
|
||
bool insideDungeon = gate.InsideDungeon;
|
||
if (gate.ObserverLandblockKey is { } cellLb)
|
||
{
|
||
// Pin the collapse to the cell's OWN landblock (cell id high 16 bits),
|
||
// NOT the position-derived observer landblock. A dungeon's EnvCells sit
|
||
// at arbitrary world coords (the "ocean" placement) with negative local
|
||
// offsets, so floor(pp.Y/192) lands one landblock off — which collapses
|
||
// onto the WRONG landblock and unloads the real dungeon, nulling CurrCell
|
||
// and breaking the render (the Bug-A coordinate class). The cell id is the
|
||
// authoritative landblock.
|
||
observerCx = (int)((cellLb >> 8) & 0xFFu);
|
||
observerCy = (int)(cellLb & 0xFFu);
|
||
}
|
||
_streamingController.Tick(observerCx, observerCy, insideDungeon);
|
||
|
||
// Re-inject persistent entities rescued from unloaded landblocks
|
||
// into the current center landblock (the one the observer is in).
|
||
var rescued = _worldState.DrainRescued();
|
||
if (rescued.Count > 0)
|
||
{
|
||
uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF);
|
||
foreach (var entity in rescued)
|
||
_liveEntities?.RebucketLiveEntity(entity.ServerGuid, centerLb);
|
||
}
|
||
}
|
||
|
||
// 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 ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
||
if (haveDest && !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.
|
||
ReconcileLiveObjectSpatialPresentation();
|
||
}
|
||
|
||
// 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 _landblockRenderPublisher?.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>
|
||
/// Advances the live object's retail UseTime phase before inbound packet
|
||
/// dispatch. Final root/part poses and their animation hooks are published
|
||
/// together so rendering consumes one coherent update snapshot.
|
||
/// </summary>
|
||
private void AdvanceLiveObjectRuntime(float dt) =>
|
||
_inboundEntityEvents.Run(
|
||
this,
|
||
dt,
|
||
static (window, deltaTime) =>
|
||
window.AdvanceLiveObjectRuntimeCore(deltaTime));
|
||
|
||
private void AdvanceLiveObjectRuntimeCore(float dt)
|
||
{
|
||
// The local body participates in the same retail CPhysics phase as
|
||
// remote objects. Its outbound movement snapshot is completed here,
|
||
// before SmartBox dispatch can apply F751/ForcePosition.
|
||
_localPlayerFrame.AdvanceBeforeNetwork(dt);
|
||
// Retail input ordering: movement edges reach MoveToState before a
|
||
// later Use/PickUp action from the same input frame. ACE cancels an
|
||
// active server MoveTo chain when it receives MoveToState, so sending
|
||
// Use directly from Silk's key callback inverted that order and left
|
||
// the client walking to a corpse whose server callback was cancelled.
|
||
_selectionInteractions?.DrainOutbound();
|
||
_scriptRunner?.PublishTime(_physicsScriptGameTime);
|
||
System.Numerics.Vector3? playerPosition =
|
||
_entitiesByServerGuid.TryGetValue(
|
||
_playerServerGuid,
|
||
out var ordinaryObjectViewer)
|
||
? ordinaryObjectViewer.Position
|
||
: null;
|
||
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> animationSchedules =
|
||
_liveAnimationScheduler.Tick(
|
||
dt,
|
||
playerPosition,
|
||
_localPlayerFrame.HiddenPartPoseDirty,
|
||
_liveCenterX,
|
||
_liveCenterY,
|
||
_animationPresenter.PrepareAnimation);
|
||
// CPhysics::UseTime (0x00509950) processes the ordinary object table
|
||
// first, then its distinct static_animating_objects workset.
|
||
_staticAnimationScheduler?.Tick(dt);
|
||
if (_animatedEntities.Count > 0)
|
||
_animationPresenter.Present(animationSchedules);
|
||
_equippedChildRenderer?.Tick();
|
||
|
||
// CPhysicsObj::animate_static_object (0x00513DF0) reaches
|
||
// process_hooks only after UpdatePartsInternal and
|
||
// UpdateChildrenInternal. Ordinary objects complete AnimationDone in
|
||
// their earlier UpdatePositionInternal slot; the static workset has
|
||
// this distinct tail.
|
||
_staticAnimationScheduler?.ProcessHooks();
|
||
|
||
// Advance already-active FP hooks before routing hooks emitted by
|
||
// this PartArray update. A newly created translucency hook begins at
|
||
// its authored Start value this frame; it does not consume dt twice.
|
||
_translucencyFades.AdvanceAll(dt);
|
||
_animationHookFrames?.Drain();
|
||
_entityEffects?.RefreshLiveOwnerPoses();
|
||
_particleSink?.RefreshAttachedEmitters();
|
||
_liveEntityLights?.Refresh();
|
||
|
||
if (_particleSystem is not null)
|
||
{
|
||
var particleRange = _settingsVm?.DisplayDraft.ParticleRange
|
||
?? _persistedDisplay.ParticleRange;
|
||
float rangeMultiplier = particleRange
|
||
== AcDream.UI.Abstractions.Panels.Settings.ParticleRange.Extended
|
||
? AcDream.App.Rendering.Vfx.ParticleVisibilityController.ExtendedRangeMultiplier
|
||
: 1f;
|
||
_particleVisibility.Apply(_particleSystem, rangeMultiplier);
|
||
}
|
||
|
||
// Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
|
||
// ParticleManager before ScriptManager. A particle created by a PES
|
||
// hook therefore begins simulation on the following object frame.
|
||
_particleSystem?.Tick(dt);
|
||
_scriptRunner?.Tick(_physicsScriptGameTime);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-composes spatial derivatives after authoritative packet handlers
|
||
/// move roots. This pass advances no time, animation, scripts, particles,
|
||
/// or fades; it only makes children and live anchors agree with the root
|
||
/// that the same frame will draw.
|
||
/// </summary>
|
||
private void ReconcileLiveObjectSpatialPresentation()
|
||
{
|
||
// Authoritative handlers mutate WorldEntity roots directly. Publish
|
||
// those roots before children read their parent's pose; child Tick
|
||
// then publishes each newly composed child pose for attached effects.
|
||
_entityEffects?.RefreshLiveOwnerPoses();
|
||
_equippedChildRenderer?.Tick();
|
||
_particleSink?.RefreshAttachedEmitters();
|
||
_liveEntityLights?.Refresh();
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
CaptureAnimationHooks(ae.Entity.Id, sequencer);
|
||
}
|
||
}
|
||
|
||
private void CaptureAnimationHooks(
|
||
uint ownerLocalId,
|
||
AcDream.Core.Physics.AnimationSequencer sequencer) =>
|
||
_animationHookFrames?.Capture(ownerLocalId, sequencer);
|
||
|
||
uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid;
|
||
|
||
AcDream.Core.Physics.MotionInterpreter?
|
||
ILiveAnimationPresentationContext.ResolveMotionInterpreter(
|
||
LiveEntityRecord record)
|
||
{
|
||
if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true
|
||
|| !ReferenceEquals(current, record))
|
||
{
|
||
return null;
|
||
}
|
||
if (record.ServerGuid == _playerServerGuid)
|
||
return _playerController?.Motion;
|
||
return record.RemoteMotionRuntime is RemoteMotion remote
|
||
? remote.Motion
|
||
: null;
|
||
}
|
||
|
||
// 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}");
|
||
|
||
// [FRAME-DIAG]: the FPS-deep-dive per-frame cost split + leak/churn counters.
|
||
// apply_us = terrain-apply CPU in OnUpdate (dat reads + physics/ShadowObjects
|
||
// registration + terrain upload) — INVISIBLE to the title-bar ms (H1).
|
||
// upl_us = the terrain glBufferSubData sub-span of that apply (expected tiny).
|
||
// entUpl_us= the OnRender _wbMeshAdapter.Tick entity-mesh GPU drain (H2).
|
||
// For entity-DRAW CPU/GPU see the [WB-DIAG] line; walked = resident-N proxy (H3).
|
||
double applyMedUs = TerrainDiagMedianMicros(_applyCpuSamples) / 100.0;
|
||
double applyP95Us = TerrainDiagPercentile95Micros(_applyCpuSamples) / 100.0;
|
||
double uplMedUs = TerrainDiagMedianMicros(_applyUploadSamples) / 100.0;
|
||
double uplP95Us = TerrainDiagPercentile95Micros(_applyUploadSamples) / 100.0;
|
||
double entUplMedUs = TerrainDiagMedianMicros(_entityUploadSamples) / 100.0;
|
||
double entUplP95Us = TerrainDiagPercentile95Micros(_entityUploadSamples) / 100.0;
|
||
// apply CPU split: cell-build / gfxobj-BSP / ShadowObjects+lights — names
|
||
// WHICH part of the apply dominates (decides the fix shape).
|
||
double cellMedUs = TerrainDiagMedianMicros(_applyCellSamples) / 100.0;
|
||
double cellP95Us = TerrainDiagPercentile95Micros(_applyCellSamples) / 100.0;
|
||
double bspMedUs = TerrainDiagMedianMicros(_applyBspSamples) / 100.0;
|
||
double bspP95Us = TerrainDiagPercentile95Micros(_applyBspSamples) / 100.0;
|
||
double shadMedUs = TerrainDiagMedianMicros(_applyShadowSamples) / 100.0;
|
||
double shadP95Us = TerrainDiagPercentile95Micros(_applyShadowSamples) / 100.0;
|
||
double lockMedUs = TerrainDiagMedianMicros(_applyLockWaitSamples) / 100.0;
|
||
double lockP95Us = TerrainDiagPercentile95Micros(_applyLockWaitSamples) / 100.0;
|
||
int walked = _wbDrawDispatcher?.LastDrawStats.EntitiesWalked ?? 0;
|
||
Console.WriteLine(
|
||
$"[FRAME-DIAG] apply_us={applyMedUs:F1}m/{applyP95Us:F1}p95 " +
|
||
$"lockwait={lockMedUs:F1}m/{lockP95Us:F1}p95 " +
|
||
$"[cell={cellMedUs:F1}m/{cellP95Us:F1}p95 bsp={bspMedUs:F1}m/{bspP95Us:F1}p95 " +
|
||
$"shadow={shadMedUs:F1}m/{shadP95Us:F1}p95] " +
|
||
$"(upl={uplMedUs:F1}m/{uplP95Us:F1}p95) " +
|
||
$"entUpl_us={entUplMedUs:F1}m/{entUplP95Us:F1}p95 " +
|
||
$"applies_max/upd={_frameDiagMaxAppliesPerUpdate} " +
|
||
$"deferred={_streamingController?.DeferredApplyBacklog ?? 0} " +
|
||
$"forceReload={_streamingController?.ForceReloadCount ?? 0}" +
|
||
$"(drop={_streamingController?.LastForceReloadDropCount ?? 0}) " +
|
||
$"esg={_entitiesByServerGuid.Count} spawn={LastSpawns.Count} " +
|
||
$"resident={_worldState.Entities.Count} walked={walked}");
|
||
_frameDiagMaxAppliesPerUpdate = 0; // reset the per-window burst tracker
|
||
|
||
_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(
|
||
new ResourceShutdownStage("session owners",
|
||
[
|
||
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("live session", () =>
|
||
{
|
||
_liveSessionController?.Dispose();
|
||
_liveSessionController = null;
|
||
}),
|
||
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;
|
||
}
|
||
}
|