GameWindow gets one field (_frameProfiler), one FrameBoundary() call at
the top of OnRender, three stage scopes (Update in OnUpdate, Upload
around _wbMeshAdapter?.Tick(), ImGui around _imguiBootstrap.Render()),
and one Dispose() call in teardown before _dats?.Dispose() releases the
GpuFrameTimer query ring. No new feature bodies land in GameWindow.cs —
all profiler logic lives in AcDream.App.Diagnostics.
DebugVM.FrameProf mirrors RenderingDiagnostics.FrameProfEnabled so the
DebugPanel checkbox ("Frame profiler ([frame-prof])") toggles the 5s
report live. Note: the checkbox lives in
AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (IPanelRenderer,
backend-agnostic) alongside the other Diagnostics-section checkboxes —
not in AcDream.UI.ImGui, which holds no panel-drawing code per Code
Structure Rule 3.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
14112 lines
769 KiB
C#
14112 lines
769 KiB
C#
using AcDream.Core.Plugins;
|
||
using DatReaderWriter;
|
||
using DatReaderWriter.Options;
|
||
using Silk.NET.Input;
|
||
using Silk.NET.Maths;
|
||
using Silk.NET.OpenGL;
|
||
using Silk.NET.Windowing;
|
||
|
||
namespace AcDream.App.Rendering;
|
||
|
||
public sealed class GameWindow : IDisposable
|
||
{
|
||
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
|
||
|
||
private readonly AcDream.App.RuntimeOptions _options;
|
||
private readonly string _datDir;
|
||
private readonly WorldGameState _worldGameState;
|
||
private readonly WorldEvents _worldEvents;
|
||
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 DatCollection? _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 AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
|
||
/// <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();
|
||
|
||
// 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.StreamingController? _streamingController;
|
||
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);
|
||
private uint? _lastLivePlayerLandblockId;
|
||
|
||
// 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();
|
||
|
||
// 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 (BuildLandblockForStreaming on the worker;
|
||
// ApplyLoadedTerrain + 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 via the factory closures passed to
|
||
// LandblockStreamer at construction (loadLandblock + buildMeshOrNull).
|
||
// Those closures already acquire _datLock, so no additional wrapping is
|
||
// needed for reads inside BuildLandblockForStreamingLocked /
|
||
// BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming.
|
||
// Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already
|
||
// hold this lock via their outer wrappers; 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 across all streamed landblocks. Stored as
|
||
// fields so ApplyLoadedTerrain (render-thread callback) can call
|
||
// LandblockMesh.Build without re-deriving these each time.
|
||
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 A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
|
||
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
|
||
// this dict and uploads them via EnsureUploaded before the per-MeshRef loop.
|
||
// ConcurrentDictionary is required because the worker and render threads
|
||
// access this simultaneously without a broader lock.
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<
|
||
uint, System.Collections.Generic.IReadOnlyList<AcDream.Core.Meshing.GfxObjSubMesh>>
|
||
_pendingCellMeshes = new();
|
||
|
||
// Step 4: pending LoadedCell objects built on the worker thread, drained
|
||
// to _cellVisibility on the render thread in ApplyLoadedTerrain.
|
||
private readonly System.Collections.Concurrent.ConcurrentBag<LoadedCell> _pendingCells = new();
|
||
|
||
// Phase A8 (2026-05-26): per-landblock BuildingRegistry keyed by full landblock
|
||
// id (e.g. 0xA9B40000). Built from LandBlockInfo.Buildings at ApplyLoadedTerrain
|
||
// time; each entry's BuildingRegistry.GetBuildingsContainingCell drives render-frame
|
||
// indoor-cell scoping. Entries are removed in the removeTerrain callbacks.
|
||
// Only touched on the render thread — no lock required.
|
||
private readonly System.Collections.Generic.Dictionary<uint, AcDream.App.Rendering.Wb.BuildingRegistry>
|
||
_buildingRegistries = new();
|
||
|
||
// 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.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C)
|
||
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 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 Dictionary<uint, AnimatedEntity> _animatedEntities = 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 sealed class AnimatedEntity
|
||
{
|
||
public required AcDream.Core.World.WorldEntity Entity;
|
||
public required DatReaderWriter.DBObjs.Setup Setup;
|
||
public required DatReaderWriter.DBObjs.Animation Animation;
|
||
public required int LowFrame;
|
||
public required int HighFrame;
|
||
public required float Framerate; // frames per second
|
||
public required float Scale; // server ObjScale baked into part transforms each tick
|
||
/// <summary>
|
||
/// Per-part identity carried over from the hydration pass: the
|
||
/// (post-AnimPartChanges) GfxObjId and the (post-resolution)
|
||
/// surface override map. The transform is recomputed every tick
|
||
/// from the current animation frame; only these two fields are
|
||
/// reused unchanged.
|
||
/// </summary>
|
||
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
|
||
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
|
||
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
|
||
}
|
||
|
||
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
|
||
|
||
// 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;
|
||
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
|
||
// from the server and schedules the dat-defined hooks (particle spawns,
|
||
// sounds, light toggles) at their StartTime offsets.
|
||
private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner;
|
||
private AcDream.App.Rendering.ParticleRenderer? _particleRenderer;
|
||
// 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>
|
||
/// Issue #47 humanoid-setup detector. Matches Aluvian Male
|
||
/// (<c>0x02000001</c>) and the 34-part heritage sibling setups
|
||
/// (Aluvian Female, Sho M/F, Gharu M/F, Viamont/Empyrean, etc.)
|
||
/// by structure rather than id list: a humanoid setup has exactly
|
||
/// 34 parts, and the trailing attachment slots (parts 17–33) are
|
||
/// the AC null-part sentinel <c>0x010001EC</c>. Non-humanoid
|
||
/// 34-part setups (rare) won't have the sentinel pattern.
|
||
/// </summary>
|
||
private static bool IsIssue47HumanoidSetup(DatReaderWriter.DBObjs.Setup setup)
|
||
{
|
||
if (setup.Parts.Count != 34) return false;
|
||
const uint NullPartGfx = 0x010001ECu;
|
||
int nullSlots = 0;
|
||
for (int i = 17; i < setup.Parts.Count; i++)
|
||
if ((uint)setup.Parts[i] == NullPartGfx) nullSlots++;
|
||
// At least half of slots 17–33 wired to the null sentinel — enough
|
||
// to distinguish humanoids from any future 34-part creature setup.
|
||
return nullSlots >= 8;
|
||
}
|
||
|
||
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 Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
|
||
_remoteLastMove = new();
|
||
|
||
/// <summary>
|
||
/// Per-remote-entity dead-reckoning state for smoothing between server
|
||
/// UpdatePosition broadcasts. Without this, remote characters teleport
|
||
/// every ~100–200 ms when the server pushes a new position (the retail
|
||
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
|
||
/// velocity forward each tick — see chunk_00520000.c
|
||
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
|
||
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
|
||
///
|
||
/// <para>
|
||
/// Each entry records the last authoritative server position + time + a
|
||
/// measured velocity inferred from the delta between consecutive
|
||
/// UpdatePositions. The client's per-tick integrator uses the
|
||
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
|
||
/// entity's orientation) as the primary source and falls back to the
|
||
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
|
||
/// motion tables with HasVelocity=0).
|
||
/// </para>
|
||
/// </summary>
|
||
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
|
||
|
||
/// <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 in <see cref="OnLiveEntitySpawnedLocked"/>, consulted at the
|
||
/// top of <see cref="OnLiveMotionUpdated"/>, dropped with the entity in
|
||
/// <see cref="OnLiveEntityDeleted"/>.
|
||
/// </summary>
|
||
private readonly Dictionary<uint, AcDream.Core.Physics.MotionSequenceGate> _motionSequenceGates = new();
|
||
|
||
/// <summary>
|
||
/// Per-remote-entity physics + motion stack — verbatim application of
|
||
/// retail's client-side motion pipeline to every remote. Mirrors
|
||
/// retail <c>FUN_00515020</c> <c>update_object</c> → <c>FUN_00513730</c>
|
||
/// <c>UpdatePositionInternal</c> → <c>FUN_005111D0</c>
|
||
/// <c>UpdatePhysicsInternal</c>, and ACE's <c>PhysicsObj.cs</c> port.
|
||
///
|
||
/// <para>
|
||
/// Retail has NO special "interpolator" for remote entities — it runs
|
||
/// the full motion state machine on every entity, local or remote,
|
||
/// and reconciles via hard-snap on UpdatePosition. This class simply
|
||
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
|
||
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
|
||
/// remote gets the same treatment as the local player.
|
||
/// </para>
|
||
/// </summary>
|
||
internal sealed class RemoteMotion // internal: the R2-Q5 sink callbacks (ObservedOmega seam) capture it
|
||
{
|
||
public AcDream.Core.Physics.PhysicsBody Body;
|
||
|
||
/// <summary>R5-V5: retail <c>CPhysicsObj::movement_manager</c> — the
|
||
/// ONE per-entity owner of the interp + moveto pair (acclient.h
|
||
/// <c>/* 3463 */</c>). Constructed here with the interp; the
|
||
/// MoveToManager side arrives via <c>MoveToFactory</c> +
|
||
/// <c>MakeMoveToManager()</c> in EnsureRemoteMotionBindings.
|
||
/// <see cref="Motion"/>/<see cref="MoveTo"/> below are views of its
|
||
/// children (retail <c>get_minterp</c>-style access), kept so the
|
||
/// dozens of existing call sites read unchanged.</summary>
|
||
public AcDream.Core.Physics.Motion.MovementManager Movement;
|
||
|
||
public AcDream.Core.Physics.MotionInterpreter Motion => Movement.Minterp;
|
||
/// <summary>R3-W4: the persistent per-remote dispatch sink (created
|
||
/// once by EnsureRemoteMotionBindings; also bound as
|
||
/// Motion.DefaultSink so LeaveGround/HitGround re-applies dispatch
|
||
/// cycles through the motion-table backend).</summary>
|
||
public AcDream.Core.Physics.Motion.MotionTableDispatchSink? Sink;
|
||
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
|
||
public double LastServerPosTime;
|
||
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
|
||
public System.Numerics.Vector3 LastServerPos;
|
||
/// <summary>
|
||
/// Latest server-authoritative velocity for NPC/monster smoothing.
|
||
/// Prefer the HasVelocity vector from UpdatePosition; when ACE omits
|
||
/// it for a server-controlled creature, derive it from consecutive
|
||
/// authoritative positions instead of guessing from player RUM state.
|
||
/// </summary>
|
||
public System.Numerics.Vector3 ServerVelocity;
|
||
public bool HasServerVelocity;
|
||
|
||
/// <summary>R4-V4: the entity's verbatim retail MoveToManager
|
||
/// (server-directed movement), constructed + seam-bound by
|
||
/// EnsureRemoteMotionBindings beside the R2-Q5/R3 binds. R5-V5:
|
||
/// owned by <see cref="Movement"/>; this is the child view.</summary>
|
||
public AcDream.Core.Physics.Motion.MoveToManager? MoveTo => Movement.MoveTo;
|
||
|
||
// R5-V2: the entity's CPhysicsObj stand-in — owns the TargetManager
|
||
// voyeur system (retail CPhysicsObj::target_manager). Replaces the
|
||
// AP-79 TrackedTarget* poll fields: the MoveToManager's set_target/
|
||
// clear_target/quantum seams route here, HandleTargetting ticks per
|
||
// frame, and OTHER entities resolve this one via GameWindow._physicsHosts.
|
||
public EntityPhysicsHost? Host;
|
||
/// <summary>
|
||
/// True while a server MoveToObject/MoveToPosition packet is the
|
||
/// active locomotion source. Retail runs these through MoveToManager
|
||
/// and CMotionInterp; the per-tick remote driver consults this to
|
||
/// decide whether to feed body steering through
|
||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> instead of
|
||
/// the InterpretedMotionState path.
|
||
/// </summary>
|
||
public bool ServerMoveToActive;
|
||
|
||
/// <summary>
|
||
/// True once a MoveTo packet's full path payload (Origin + thresholds)
|
||
/// has been parsed and the world-converted destination is stored on
|
||
/// <see cref="MoveToDestinationWorld"/>. Cleared on arrival or when
|
||
/// the next non-MoveTo UpdateMotion replaces the locomotion source.
|
||
/// Phase L.1c (2026-04-28).
|
||
/// </summary>
|
||
public bool HasMoveToDestination;
|
||
|
||
/// <summary>
|
||
/// World-space destination from the most recent MoveTo packet's
|
||
/// <c>Origin</c> field, converted via the same landblock-grid
|
||
/// arithmetic <c>OnLivePositionUpdated</c> uses.
|
||
/// </summary>
|
||
public System.Numerics.Vector3 MoveToDestinationWorld;
|
||
|
||
/// <summary>
|
||
/// <c>min_distance</c> from the MoveTo packet's MovementParameters.
|
||
/// Used by <see cref="AcDream.Core.Physics.RemoteMoveToDriver"/> as
|
||
/// the chase-arrival threshold per retail
|
||
/// <c>MoveToManager::HandleMoveToPosition</c>.
|
||
/// </summary>
|
||
public float MoveToMinDistance;
|
||
|
||
/// <summary>
|
||
/// <c>distance_to_object</c> from the MoveTo packet. Reserved for
|
||
/// the flee branch (<c>move_away</c>); chase uses
|
||
/// <see cref="MoveToMinDistance"/>.
|
||
/// </summary>
|
||
public float MoveToDistanceToObject;
|
||
|
||
/// <summary>
|
||
/// True if MovementParameters bit 9 (<c>move_towards</c>, mask
|
||
/// <c>0x200</c>) is set on the active packet — i.e. this is a
|
||
/// chase. False = flee (<c>move_away</c>) or static target.
|
||
/// </summary>
|
||
public bool MoveToMoveTowards;
|
||
|
||
/// <summary>
|
||
/// Seconds-since-epoch timestamp of the most recent MoveTo packet
|
||
/// for this entity. Used by the per-tick driver to give up
|
||
/// steering when no refresh has arrived for
|
||
/// <see cref="AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds"/>
|
||
/// — typically because the entity left our streaming view and
|
||
/// the server stopped broadcasting its MoveTo updates.
|
||
/// </summary>
|
||
public double LastMoveToPacketTime;
|
||
/// <summary>
|
||
/// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed
|
||
/// (π/2 × turnSpeed, signed). Applied per tick to body orientation
|
||
/// via manual integration (bypassing <c>PhysicsBody.update_object</c>'s
|
||
/// MinQuantum 30fps gate that would otherwise skip most ticks).
|
||
/// Zeroed on UM with TurnCommand absent.
|
||
/// </summary>
|
||
public System.Numerics.Vector3 ObservedOmega;
|
||
/// <summary>
|
||
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
|
||
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
|
||
/// 0x0040, indoor 0x0100+). Fed into
|
||
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
|
||
/// so the retail sphere-sweep can look up the right terrain/EnvCell
|
||
/// polygons for each remote's per-frame motion. Zero until the first
|
||
/// UP lands, which disables the transition step for that frame
|
||
/// (Euler alone, matching pre-wire behavior).
|
||
/// </summary>
|
||
public uint CellId;
|
||
|
||
/// <summary>
|
||
/// K-fix9 (2026-04-26): true while the remote is airborne (jump
|
||
/// arc in flight). Set when a 0xF74E VectorUpdate arrives with
|
||
/// non-trivial +Z velocity; cleared when the next UpdatePosition
|
||
/// snaps to a new ground location. While true, the per-tick
|
||
/// remote update SKIPS the "force OnWalkable + apply_current_movement"
|
||
/// step that would otherwise stomp the body's Z velocity each
|
||
/// frame, AND enables gravity so the parabolic arc actually plays
|
||
/// out between server snaps.
|
||
/// </summary>
|
||
public bool Airborne;
|
||
|
||
/// <summary>
|
||
/// Per-remote position-waypoint queue + catch-up math (retail's
|
||
/// CPhysicsObj::InterpolateTo + InterpolationManager::adjust_offset).
|
||
/// Drives per-tick body translation for grounded player remotes
|
||
/// via <see cref="Position"/>.
|
||
/// </summary>
|
||
public AcDream.Core.Physics.InterpolationManager Interp { get; } =
|
||
new AcDream.Core.Physics.InterpolationManager();
|
||
|
||
/// <summary>
|
||
/// Per-frame combiner for animation root motion + InterpolationManager
|
||
/// correction. Mirrors retail UpdatePositionInternal @ 0x00512c30:
|
||
/// queue catch-up REPLACES anim when active; anim stands when queue
|
||
/// is idle.
|
||
/// </summary>
|
||
public AcDream.Core.Physics.RemoteMotionCombiner Position { get; } =
|
||
new AcDream.Core.Physics.RemoteMotionCombiner();
|
||
|
||
/// <summary>
|
||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): the
|
||
/// previous UpdatePosition's world position + timestamp. The per-tick
|
||
/// path computes <c>(serverPos - prevServerPos) / dt</c> and compares
|
||
/// it to the sequencer's <c>CurrentVelocity</c>. The ratio tells us
|
||
/// whether the local-prediction speed (animation root motion) is
|
||
/// outrunning the server's actual broadcast pace, which would cause
|
||
/// the InterpolationManager queue to walk back the body each UP and
|
||
/// produce visible 1-Hz blips. Read in TickAnimations and throttled
|
||
/// to one log line per remote per ~2 seconds.
|
||
/// </summary>
|
||
public System.Numerics.Vector3 PrevServerPos;
|
||
public double PrevServerPosTime;
|
||
public double LastOmegaDiagLogTime;
|
||
/// <summary>
|
||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): own
|
||
/// throttle clock for the SEQSTATE log line in TickAnimations.
|
||
/// Previously SEQSTATE shared <see cref="LastOmegaDiagLogTime"/> with
|
||
/// the OMEGA_DIAG block, which fires at 0.5s and resets the clock —
|
||
/// any remote that turned during a transition silently swallowed
|
||
/// SEQSTATE for 0.5–1.5s, masking the bug we're trying to diagnose
|
||
/// (walk↔run leg-cycle sticking on observed retail chars). Split
|
||
/// 2026-05-03 (Commit A).
|
||
/// </summary>
|
||
public double LastSeqStateLogTime;
|
||
/// <summary>
|
||
/// Diagnostic-only (gated on <c>ACDREAM_REMOTE_VEL_DIAG=1</c>): own
|
||
/// throttle clock for the PARTSDIAG log line in TickAnimations
|
||
/// (D5). One log per remote per ~1s.
|
||
/// </summary>
|
||
public double LastPartsDiagLogTime;
|
||
/// <summary>
|
||
/// Diagnostic-only: max |sequencer.CurrentVelocity| observed across
|
||
/// all per-tick samples since the last UpdatePosition arrival. The
|
||
/// next UP compares this against (LastServerPos - PrevServerPos) /
|
||
/// dtServer to compute the overshoot ratio. Reset on each UP.
|
||
/// </summary>
|
||
public float MaxSeqSpeedSinceLastUP;
|
||
|
||
public RemoteMotion()
|
||
{
|
||
Body = new AcDream.Core.Physics.PhysicsBody
|
||
{
|
||
// Remotes don't simulate gravity — server owns Z. Force
|
||
// Contact + OnWalkable + Active so apply_current_movement
|
||
// writes velocity through every tick (the gate in
|
||
// MotionInterpreter.apply_current_movement is
|
||
// PhysicsObj.OnWalkable).
|
||
State = AcDream.Core.Physics.PhysicsStateFlags.ReportCollisions,
|
||
TransientState = AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||
| AcDream.Core.Physics.TransientStateFlags.Active,
|
||
// R4-V5 door-swing fix: a RemoteMotion exists only for a
|
||
// WORLD entity — retail's physics_obj->cell is non-null the
|
||
// moment the object is placed. Without this, the interp's
|
||
// detached-object guard stripped every dispatched
|
||
// transition link (see PhysicsBody.InWorld).
|
||
InWorld = true,
|
||
};
|
||
// R5-V5: the interp is owned by the MovementManager facade
|
||
// (retail CPhysicsObj::movement_manager -> motion_interpreter).
|
||
// acdream constructs it eagerly here — retail's lazy-create
|
||
// window is never observable (see MovementManager's class doc).
|
||
Movement = new AcDream.Core.Physics.Motion.MovementManager(
|
||
new AcDream.Core.Physics.MotionInterpreter(Body)
|
||
{
|
||
// R4-V5 #160 fix: retail remotes carry a weenie whose
|
||
// InqRunRate FAILS, landing apply_run_to_command on
|
||
// my_run_rate (the M13 wire feed). A null weenie took the
|
||
// degenerate 1.0 branch — observer-side run movetos played
|
||
// and moved in slow motion. See RemoteWeenie.
|
||
WeenieObj = new AcDream.Core.Physics.RemoteWeenie(),
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <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();
|
||
// Issue #11 — load static spell metadata from data/spells.csv at startup.
|
||
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
|
||
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
|
||
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
|
||
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
|
||
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
|
||
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.Net.Messages.PlayerDescriptionParser.ShortcutEntry> Shortcuts { get; private set; }
|
||
= System.Array.Empty<AcDream.Core.Net.Messages.PlayerDescriptionParser.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;
|
||
// B.7 (2026-05-15): Vivid Target Indicator — four corner triangles
|
||
// around the selected entity, colour-coded by ItemType + PWD bits.
|
||
// Lives alongside the debug panels; cheap to construct + ignore
|
||
// when no selection. Spec: docs/superpowers/specs/2026-05-15-phase-b7-target-indicator-design.md
|
||
private AcDream.App.UI.TargetIndicatorPanel? _targetIndicator;
|
||
private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm;
|
||
// Phase D.2b — retail-look UI tree (dormant UiHost wired here). Null unless ACDREAM_RETAIL_UI=1.
|
||
private AcDream.App.UI.UiHost? _uiHost;
|
||
// Phase D.5.1 — toolbar controller (kept for lifetime clarity; mirrors _chatWindowController pattern).
|
||
private AcDream.App.UI.Layout.ToolbarController? _toolbarController;
|
||
// Phase D.5.3a — selected-object strip controller (name, overlay state, health meter).
|
||
private AcDream.App.UI.Layout.SelectedObjectController? _selectedObjectController;
|
||
// Phase D.2b-B — inventory controller (backpack grid + pack-selector + burden meter).
|
||
private AcDream.App.UI.Layout.InventoryController? _inventoryController;
|
||
// Phase D.2b Sub-phase C — paperdoll equip-slot controller (wield drag handler).
|
||
private AcDream.App.UI.Layout.PaperdollController? _paperdollController;
|
||
// Phase D.2b Sub-phase C Slice 2 — the 3-D doll viewport: an off-screen RTT renderer, the UiViewport
|
||
// widget that blits it, the inventory frame (for the open-gate), and a dirty flag (re-dress on 0xF625).
|
||
private AcDream.App.Rendering.PaperdollViewportRenderer? _paperdollViewportRenderer;
|
||
private AcDream.App.UI.UiViewport? _paperdollViewportWidget;
|
||
private AcDream.App.UI.UiNineSlicePanel? _inventoryFrame;
|
||
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;
|
||
|
||
// Phase I.3 — real ICommandBus for live sessions. Constructed when
|
||
// the live session spins up (so SendChatCmd handlers can close over
|
||
// _liveSession + Chat). Null when offline; PanelContext then falls
|
||
// back to NullCommandBus.Instance.
|
||
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
|
||
|
||
// Phase I.7 — bridges CombatState's typed events into ChatLog as
|
||
// retail-faithful "You hit ..." / "... evaded your attack." lines.
|
||
// Disposable; lives for the duration of the live session.
|
||
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
|
||
|
||
// Phase G.1-G.2 world lighting/time state.
|
||
public readonly AcDream.Core.World.WorldTimeService WorldTime =
|
||
new AcDream.Core.World.WorldTimeService(
|
||
AcDream.Core.World.SkyStateProvider.Default());
|
||
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
|
||
public readonly AcDream.Core.World.WeatherSystem Weather = new();
|
||
// Wired into the hook router in OnLoad so SetLightHook fires
|
||
// from the animation pipeline flip the matching LightSource.IsLit.
|
||
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
|
||
|
||
// 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;
|
||
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
|
||
private MovementTruthOutbound? _lastMovementTruthOutbound;
|
||
|
||
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;
|
||
// 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 the EnterWorld branch in BeginLiveSessionAsync; 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.
|
||
// Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle
|
||
// (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in
|
||
// LiveSessionController. _liveSession remains as a convenience handle
|
||
// for the ~60 outbound SendXxx call sites; it tracks Controller.Session.
|
||
private AcDream.App.Net.LiveSessionController? _liveSessionController;
|
||
private AcDream.Core.Net.WorldSession? _liveSession;
|
||
private int _liveCenterX;
|
||
private int _liveCenterY;
|
||
// Set by the login-spawn recenter (network thread) when the spawn landblock
|
||
// differs from the startup streaming center; consumed on the render thread
|
||
// in OnUpdateFrame to trigger StreamingController.ForceReloadWindow(). A far
|
||
// login-spawn moves the render origin just like an outdoor teleport, so the
|
||
// streaming region — bootstrapped around the stale startup center — must be
|
||
// rebuilt fresh around the spawn. Without this, RecenterTo treats the
|
||
// old/new window overlap as already-resident (MarkResidentFromBootstrap marks
|
||
// residence before the async loads land), leaving a permanent hole of
|
||
// resident-but-never-loaded landblocks where the player spawns — the
|
||
// cold-spawn "invisible player / world not loading" bug (2026-07-03).
|
||
private volatile bool _pendingForceReloadWindow;
|
||
private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id
|
||
|
||
// 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. The sequential <see cref="_liveEntityIdCounter"/>
|
||
/// keys the render list; this parallel dictionary keys by server guid.
|
||
/// </summary>
|
||
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
|
||
/// <summary>
|
||
/// Latest <see cref="AcDream.Core.Net.WorldSession.EntitySpawn"/> for each
|
||
/// guid. Captured at the end of <see cref="OnLiveEntitySpawnedLocked"/> so
|
||
/// <see cref="OnLiveAppearanceUpdated"/> can reuse the position/setup/motion
|
||
/// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals.
|
||
/// </summary>
|
||
private readonly Dictionary<uint, AcDream.Core.Net.WorldSession.EntitySpawn> _lastSpawnByGuid = new();
|
||
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
|
||
private uint? _selectedGuid;
|
||
/// <summary>Fires when the selected world object changes (retail gmToolbarUI selection-change event,
|
||
/// acclient_2013_pseudo_c.txt:198635). Private: only the internal SelectedObjectController subscribes.</summary>
|
||
private event Action<uint?>? SelectionChanged;
|
||
/// <summary>Currently-selected world object guid. The setter fires <see cref="SelectionChanged"/> only on
|
||
/// an actual change (dedup), so all writes go through here; reads may use the field directly.</summary>
|
||
private uint? SelectedGuid
|
||
{
|
||
get => _selectedGuid;
|
||
set
|
||
{
|
||
if (_selectedGuid == value) return;
|
||
_selectedGuid = value;
|
||
SelectionChanged?.Invoke(value);
|
||
}
|
||
}
|
||
|
||
// B.6/B.7 (2026-05-16): pending close-range action that will be fired
|
||
// once the local auto-walk overlay reports arrival (body has finished
|
||
// rotating to face the target). Only set for close-range Use/PickUp;
|
||
// far-range sends fire the wire packet immediately at SendUse/SendPickUp
|
||
// time. Cleared before the deferred send fires — single-fire, no retry.
|
||
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
|
||
|
||
// R5-V2: the local player's CPhysicsObj stand-in — owns the player's
|
||
// TargetManager voyeur system, registered in _physicsHosts 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 in RemoveLiveEntityByServerGuid.
|
||
private readonly Dictionary<uint, AcDream.Core.Physics.Motion.IPhysicsObjHost> _physicsHosts = new();
|
||
|
||
private static bool IsPlayerGuid(uint guid) => (guid & 0xFF000000u) == 0x50000000u;
|
||
private const double ServerControlledVelocityStaleSeconds = 0.60;
|
||
private int _liveSpawnReceived; // diagnostics
|
||
private int _liveSpawnHydrated;
|
||
private int _liveDropReasonNoPos;
|
||
private int _liveDropReasonNoSetup;
|
||
private int _liveDropReasonSetupDatMissing;
|
||
private int _liveDropReasonNoMeshRefs;
|
||
// Phase 6.4 animation-registration diagnostics
|
||
private int _liveAnimRejectNoCycle;
|
||
private int _liveAnimRejectFramerate;
|
||
private int _liveAnimRejectSingleFrame;
|
||
private int _liveAnimRejectPartFrames;
|
||
|
||
public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents,
|
||
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
|
||
{
|
||
_options = options ?? throw new System.ArgumentNullException(nameof(options));
|
||
_datDir = options.DatDir;
|
||
_worldGameState = worldGameState;
|
||
_worldEvents = worldEvents;
|
||
_uiRegistry = uiRegistry;
|
||
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
|
||
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
|
||
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
|
||
/// warning if the file is missing (e.g. when GameWindow is instantiated
|
||
/// from tooling contexts that don't include the data folder).
|
||
/// </summary>
|
||
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
|
||
{
|
||
string path = System.IO.Path.Combine(
|
||
System.AppContext.BaseDirectory, "data", "spells.csv");
|
||
try
|
||
{
|
||
if (System.IO.File.Exists(path))
|
||
{
|
||
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
|
||
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
|
||
return t;
|
||
}
|
||
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
|
||
}
|
||
return AcDream.Core.Spells.SpellTable.Empty;
|
||
}
|
||
|
||
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);
|
||
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 = false, // off during development so the perf overlay shows true framerate
|
||
// 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;
|
||
_window.Closing += OnClosing;
|
||
// 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!);
|
||
_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;
|
||
|
||
// Phase K.2 — MMB-hold instant mouse-look. The yaw mutator
|
||
// drives _playerController.Yaw (the chase camera reads this
|
||
// automatically via ChaseCamera.Update). Active only while
|
||
// _playerController and _chaseCamera are live; the lambda
|
||
// safely no-ops outside player mode.
|
||
_mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState(
|
||
applyYawDelta: dYaw =>
|
||
{
|
||
if (_playerController is not null) _playerController.Yaw += dYaw;
|
||
});
|
||
|
||
// 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: () => _liveSession is not null
|
||
&& _liveSession.CurrentState ==
|
||
AcDream.Core.Net.WorldSession.State.InWorld,
|
||
isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid),
|
||
isPlayerControllerReady: () => true,
|
||
// #106 gate-2: hold player-mode entry until the terrain under
|
||
// the spawn position has streamed in — entering earlier
|
||
// integrates gravity against an empty world and free-falls
|
||
// the player into the void (retail loads cells synchronously;
|
||
// this is the async-streaming equivalent of that invariant).
|
||
isSpawnGroundReady: () =>
|
||
{
|
||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;
|
||
|
||
// #107 / #135: spawn-ground readiness is spawn-claim aware. For an
|
||
// INDOOR claim (sealed dungeon / building interior) the ground the
|
||
// player lands on is the EnvCell FLOOR (its BSP), so gate on the
|
||
// cell's hydration (IsSpawnCellReady) — NOT the terrain heightmap.
|
||
// A dungeon's cells sit in their landblock at an arbitrary (often
|
||
// negative) offset, so the spawn's WORLD position can fall in a
|
||
// NEIGHBOUR terrain landblock that the #135 dungeon collapse
|
||
// deliberately does not load; requiring terrain there hangs login
|
||
// forever (cellReady true, SampleTerrainZ null). Retail loads the
|
||
// cell synchronously and places the player on the cell floor —
|
||
// cellReady is the faithful indoor equivalent (#106/#107, AD-2).
|
||
// (Before #135 this only passed by accident: the 25×25 window
|
||
// happened to stream the neighbour terrain.)
|
||
if (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|
||
&& sp.Position is { } spawnClaim
|
||
&& spawnClaim.LandblockId != 0
|
||
&& (spawnClaim.LandblockId & 0xFFFFu) >= 0x0100u
|
||
&& !IsSpawnClaimUnhydratable(spawnClaim.LandblockId))
|
||
return _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId);
|
||
|
||
// Outdoor spawn, OR an unhydratable indoor claim that will demote to
|
||
// an outdoor position: hold until the terrain under the spawn streams
|
||
// (the original #106 gate — entering against an empty world free-falls
|
||
// the player into the void).
|
||
return _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;
|
||
},
|
||
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)
|
||
{
|
||
// Phase K.2 — MMB instant mouse-look. dx drives
|
||
// character yaw via the MouseLookState callback
|
||
// (which mutates _playerController.Yaw); the chase
|
||
// camera tracks the character automatically because
|
||
// ChaseCamera.Update reads the player yaw. So mouse-X
|
||
// here goes ONLY through ApplyDelta — no separate
|
||
// YawOffset write. dy still pitches the camera only.
|
||
_mouseLook.ApplyDelta(dx, sens);
|
||
if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
|
||
{
|
||
float nowSec = (float)(Environment.TickCount64 / 1000.0);
|
||
var (_, filteredDy) = _retailChaseCamera.FilterMouseDelta(
|
||
rawX: 0f, rawY: dy, weight: 0.5f, nowSec: nowSec);
|
||
_retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
|
||
}
|
||
else
|
||
{
|
||
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
|
||
}
|
||
}
|
||
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 = new DatCollection(_datDir, DatAccessType.Read);
|
||
_animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats);
|
||
_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);
|
||
_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.
|
||
_scriptRunner = new AcDream.Core.Vfx.PhysicsScriptRunner(_dats, _particleSink);
|
||
|
||
// 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);
|
||
_hookRouter.Register(_lightingSink);
|
||
|
||
// 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();
|
||
|
||
// B.7 Vivid Target Indicator — corner-triangle highlight
|
||
// around the currently-selected entity. Delegates pull
|
||
// live state from this GameWindow instance every frame:
|
||
// - selected guid → _selectedGuid (set by PickAndStoreSelection)
|
||
// - entity resolver → position from _entitiesByServerGuid +
|
||
// itemType from ClientObjectTable (Objects) + last spawn
|
||
// - camera → _cameraController.Active or (zero) when not
|
||
// yet ready, in which case the panel bails on viewport==0.
|
||
_targetIndicator = new AcDream.App.UI.TargetIndicatorPanel(
|
||
selectedGuidProvider: () => _selectedGuid,
|
||
entityResolver: guid =>
|
||
{
|
||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity))
|
||
return null;
|
||
uint rawItemType = (uint)LiveItemType(guid);
|
||
uint pwdBits = 0;
|
||
uint? useability = null;
|
||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||
{
|
||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||
useability = spawn.Useability;
|
||
}
|
||
// 2026-05-16 — retail-faithful path. Pass the
|
||
// entity's Setup.SelectionSphere (scaled by entity
|
||
// scale, rotated into world coords) through so
|
||
// the panel projects the sphere as a screen
|
||
// circle. Matches SmartBox::GetObjectBoundingBox
|
||
// (decomp 0x00452e20). If the Setup didn't bake
|
||
// a selection sphere (rare, zero-radius), the
|
||
// panel falls back to per-type height heuristic.
|
||
System.Numerics.Vector3? sphereCenter = null;
|
||
float? sphereRadius = null;
|
||
if (TryGetEntitySelectionSphere(guid, out var sCenter, out var sRadius))
|
||
{
|
||
sphereCenter = sCenter;
|
||
sphereRadius = sRadius;
|
||
}
|
||
return new AcDream.App.UI.TargetIndicatorPanel.TargetInfo(
|
||
entity.Position, rawItemType, pwdBits, entity.Scale, useability,
|
||
sphereCenter, sphereRadius);
|
||
},
|
||
cameraProvider: () =>
|
||
{
|
||
if (_cameraController is null || _window is null)
|
||
return (System.Numerics.Matrix4x4.Identity,
|
||
System.Numerics.Matrix4x4.Identity,
|
||
System.Numerics.Vector2.Zero);
|
||
var cam = _cameraController.Active;
|
||
return (cam.View, cam.Projection,
|
||
new System.Numerics.Vector2(_window.Size.X, _window.Size.Y));
|
||
});
|
||
|
||
// 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);
|
||
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
|
||
// BeginLiveSessionAsync after EnterWorld
|
||
// so saving character settings always
|
||
// writes under the chosen character's
|
||
// name (or "default" pre-login).
|
||
settingsStore.SaveCharacter(_activeToonKey, 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);
|
||
|
||
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);
|
||
// 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);
|
||
|
||
// Phase D.2b — retail-look UI (ACDREAM_RETAIL_UI=1). Wires the existing
|
||
// UiHost retained-mode tree (dormant until now) + a first vitals panel.
|
||
// Render-only: UiHost input is NOT yet bridged to the InputDispatcher
|
||
// (next sub-phase), so the close button + window drag are inert. Coexists
|
||
// with the ImGui devtools path (ACDREAM_DEVTOOLS=1), which is unchanged.
|
||
if (_options.RetailUi)
|
||
{
|
||
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
|
||
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
|
||
|
||
// 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!);
|
||
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.");
|
||
|
||
// Phase D.2b — the vitals window is data-driven from the dat LayoutDesc
|
||
// (0x2100006C) via the LayoutImporter. The former hand-authored vitals.xml
|
||
// markup path was retired after the importer proved pixel-identical at the
|
||
// 2026-06-15 A/B gate. MarkupDocument stays for plugin/custom panels.
|
||
AcDream.App.UI.Layout.ImportedLayout? imported;
|
||
lock (_datLock)
|
||
imported = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||
_dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont);
|
||
if (imported is not null)
|
||
{
|
||
AcDream.App.UI.Layout.VitalsController.Bind(imported,
|
||
healthPct: () => _vitalsVm!.HealthPercent,
|
||
staminaPct: () => _vitalsVm!.StaminaPercent ?? 0f,
|
||
manaPct: () => _vitalsVm!.ManaPercent ?? 0f,
|
||
healthText: () => (_vitalsVm!.HealthCurrent, _vitalsVm.HealthMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
||
staminaText: () => (_vitalsVm!.StaminaCurrent, _vitalsVm.StaminaMax) is (uint c, uint m) ? $"{c}/{m}" : "",
|
||
manaText: () => (_vitalsVm!.ManaCurrent, _vitalsVm.ManaMax) is (uint c, uint m) ? $"{c}/{m}" : "");
|
||
// Top-level retail window: user-positioned (Anchors.None so the per-frame
|
||
// anchor pass doesn't reset it), movable, and horizontally resizable like
|
||
// retail. On a width change the dat edge-anchors reflow the pieces
|
||
// (UIElement::UpdateForParentSizeChange @0x00462640): top/bottom edges +
|
||
// the three bars stretch, corners stay 5px, the right edge/corners track
|
||
// the right side. Vertical resize is off (the layout has no vertical stretch).
|
||
var vitalsRoot = imported.Root;
|
||
vitalsRoot.Left = 10; vitalsRoot.Top = 30;
|
||
vitalsRoot.ClickThrough = false;
|
||
vitalsRoot.Anchors = AcDream.App.UI.AnchorEdges.None;
|
||
vitalsRoot.Draggable = true;
|
||
vitalsRoot.Resizable = true;
|
||
vitalsRoot.ResizeX = true;
|
||
vitalsRoot.ResizeY = false;
|
||
vitalsRoot.MinWidth = 40f;
|
||
_uiHost.Root.AddChild(vitalsRoot);
|
||
Console.WriteLine("[D.2b] retail UI active — vitals window from LayoutDesc importer (0x2100006C).");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine("[D.2b] vitals: LayoutDesc 0x2100006C not found — vitals unavailable.");
|
||
}
|
||
|
||
// Retail chat window — data-driven from LayoutDesc 0x21000006 (gmMainChatUI),
|
||
// the same importer path as vitals. ChatWindowController binds the transcript,
|
||
// input, scrollbar and channel menu and routes through ChatVM + ChatCommandRouter.
|
||
var retailChatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat, displayLimit: 200);
|
||
AcDream.App.UI.Layout.ElementInfo? chatRootInfo;
|
||
AcDream.App.UI.Layout.ImportedLayout? chatLayout;
|
||
lock (_datLock)
|
||
{
|
||
chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos(
|
||
_dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId);
|
||
chatLayout = chatRootInfo is null ? null
|
||
: AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont);
|
||
}
|
||
if (chatRootInfo is not null && chatLayout is not null)
|
||
{
|
||
var chatController = AcDream.App.UI.Layout.ChatWindowController.Bind(
|
||
chatRootInfo, chatLayout, retailChatVm,
|
||
() => _commandBus ?? (AcDream.UI.Abstractions.ICommandBus)AcDream.UI.Abstractions.NullCommandBus.Instance,
|
||
vitalsDatFont, _debugFont, ResolveChrome);
|
||
if (chatController is not null)
|
||
{
|
||
// Ctrl+C / Ctrl+A on the transcript + Ctrl+C/X/V/A on the input need the
|
||
// keyboard for clipboard + modifier (Ctrl/Shift) state. _uiHost.Keyboard
|
||
// is set by WireKeyboard above — it is non-null here.
|
||
chatController.Transcript.Keyboard = _uiHost.Keyboard;
|
||
chatController.Input.Keyboard = _uiHost.Keyboard;
|
||
// Wrap the dat content in the universal 8-piece beveled window chrome —
|
||
// the SAME UiNineSlicePanel the vitals window uses. The chat's own dat
|
||
// layout only carries flat background sprites, so without this the window
|
||
// has no retail-style border (the user asked for the vitals border). The
|
||
// nine-slice IS the movable/resizable window; the dat content fills its
|
||
// interior, inset by the border. The gmMainChatUI content is authored 490
|
||
// wide (its transcript/input panels) — KEEP that width + the dat-authored
|
||
// HEIGHT so the content's child anchors (input-bar-at-bottom, transcript-
|
||
// fills) capture correct margins on first layout; resizing the frame reflows
|
||
// them correctly from there.
|
||
const int chatBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||
var chatRoot = chatController.Root;
|
||
float contentW = 490f, contentH = chatRoot.Height; // dat-authored height
|
||
var chatFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||
{
|
||
Left = 10, Top = 440,
|
||
Width = contentW + 2 * chatBorder, Height = contentH + 2 * chatBorder,
|
||
MinWidth = 200f, MinHeight = 90f,
|
||
// Retail chat is translucent — fade the window's backgrounds/chrome
|
||
// (text stays opaque). Configurable opacity is a later step; 0.75 reads
|
||
// as see-through-but-readable. (retail SetDefaultOpacity ~0.5 / active 1.0)
|
||
Opacity = 0.75f,
|
||
};
|
||
chatRoot.Left = chatBorder; chatRoot.Top = chatBorder;
|
||
chatRoot.Width = contentW; chatRoot.Height = contentH;
|
||
chatRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||
| AcDream.App.UI.AnchorEdges.Right | AcDream.App.UI.AnchorEdges.Bottom;
|
||
chatRoot.Draggable = false; chatRoot.Resizable = false;
|
||
chatFrame.AddChild(chatRoot);
|
||
_uiHost.Root.AddChild(chatFrame);
|
||
// Tab / Enter enters "write mode" by focusing this input (retail's chat
|
||
// activation); a focused input suppresses character movement (see the
|
||
// WantsKeyboard gate in the movement poll).
|
||
_uiHost.Root.DefaultTextInput = chatController.Input;
|
||
Console.WriteLine("[D.2b] retail chat window from LayoutDesc importer (0x21000006).");
|
||
}
|
||
else Console.WriteLine("[D.2b] chat: required role elements missing in 0x21000006.");
|
||
}
|
||
else Console.WriteLine("[D.2b] chat: LayoutDesc 0x21000006 not found.");
|
||
|
||
// Phase D.5.1 — toolbar window, data-driven from LayoutDesc 0x21000016
|
||
// (gmToolbarUI). Mirrors the vitals/chat import+bind+mount pattern above.
|
||
|
||
// Read the shortcut-slot digit sprite DID arrays from LayoutDesc 0x21000037
|
||
// (the UIItem cell template): element 0x1000034A under composite 0x10000346.
|
||
// Retail ref: UIElement_UIItem::SetShortcutNum (acclient_2013_pseudo_c.txt:229465);
|
||
// gmToolbarUI::RecvNotice_SetCombatMode (196610-196621) re-stamps per stance.
|
||
// Occupancy branch (decomp 229481):
|
||
// occupied → StateDesc.Properties[0x10000042] (peace) / [0x10000043] (war)
|
||
// empty → StateDesc.Properties[0x1000005e] (background digit, stance-independent)
|
||
uint[]? toolbarPeaceDigits = null;
|
||
uint[]? toolbarWarDigits = null;
|
||
uint[]? toolbarEmptyDigits = null;
|
||
lock (_datLock)
|
||
{
|
||
var uiItemLd = _dats!.Get<DatReaderWriter.DBObjs.LayoutDesc>(0x21000037u);
|
||
if (uiItemLd is not null
|
||
&& uiItemLd.Elements.TryGetValue(0x10000346u, out var composite)
|
||
&& composite.Children.TryGetValue(0x1000034Au, out var shortcutNumElem)
|
||
&& shortcutNumElem.StateDesc is { } sd
|
||
&& sd.Properties is { } props)
|
||
{
|
||
// Mirror LayoutImporter.ReadState: Properties[key] is ArrayBaseProperty
|
||
// containing DataIdBaseProperty entries. Each DataIdBaseProperty.Value is
|
||
// the RenderSurface DID for that digit.
|
||
// Peace: property 0x10000042; War: property 0x10000043.
|
||
if (props.TryGetValue(0x10000042u, out var rawPeace)
|
||
&& rawPeace is DatReaderWriter.Types.ArrayBaseProperty arrPeace)
|
||
{
|
||
toolbarPeaceDigits = new uint[arrPeace.Value.Count];
|
||
for (int i = 0; i < arrPeace.Value.Count; i++)
|
||
if (arrPeace.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
|
||
toolbarPeaceDigits[i] = d.Value;
|
||
}
|
||
if (props.TryGetValue(0x10000043u, out var rawWar)
|
||
&& rawWar is DatReaderWriter.Types.ArrayBaseProperty arrWar)
|
||
{
|
||
toolbarWarDigits = new uint[arrWar.Value.Count];
|
||
for (int i = 0; i < arrWar.Value.Count; i++)
|
||
if (arrWar.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
|
||
toolbarWarDigits[i] = d.Value;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine("[D.5.1] digit arrays: element 0x1000034A/0x10000346 not found in LayoutDesc 0x21000037 — falling back to cited constants.");
|
||
}
|
||
|
||
// Empty-slot BACKGROUND digit lives under a DIFFERENT cell composite:
|
||
// composite 0x10000341 (the UIElement_UIItem-typed variant) carries property
|
||
// 0x1000005e (plainer digits 0x060010FA..0x06001102 for 1-9, 0x060074CF for the
|
||
// bottom row); composite 0x10000346 (peace/war, read above) does NOT carry it.
|
||
// Confirmed by a live dat property dump. Retail: UIElement_UIItem::SetShortcutNum
|
||
// (decomp 229481/229493) — empty branch (m_elem_Icon->m_state == 0x1000001c) reads
|
||
// 0x1000005e, stance-independent. No fallback constants (safe: no digit if absent).
|
||
if (uiItemLd is not null
|
||
&& uiItemLd.Elements.TryGetValue(0x10000341u, out var emptyComposite)
|
||
&& emptyComposite.Children.TryGetValue(0x1000034Au, out var emptyScn)
|
||
&& emptyScn.StateDesc is { } emptySd
|
||
&& emptySd.Properties is { } emptyProps
|
||
&& emptyProps.TryGetValue(0x1000005Eu, out var rawEmpty)
|
||
&& rawEmpty is DatReaderWriter.Types.ArrayBaseProperty arrEmpty)
|
||
{
|
||
toolbarEmptyDigits = new uint[arrEmpty.Value.Count];
|
||
for (int i = 0; i < arrEmpty.Value.Count; i++)
|
||
if (arrEmpty.Value[i] is DatReaderWriter.Types.DataIdBaseProperty d)
|
||
toolbarEmptyDigits[i] = d.Value;
|
||
}
|
||
Console.WriteLine($"[D.5.1] empty digit array (0x10000341/0x1000005e): {toolbarEmptyDigits?.Length ?? 0} entries.");
|
||
}
|
||
|
||
// Cited-constant fallback (UIElement_UIItem::SetShortcutNum, decomp 229465 + dat probe).
|
||
// Used when the dat navigation above fails (e.g. missing LayoutDesc in older dat).
|
||
if (toolbarPeaceDigits is null)
|
||
toolbarPeaceDigits = new uint[]
|
||
{ 0x0600109Eu, 0x0600109Fu, 0x060010A0u, 0x060010A1u, 0x060010A2u,
|
||
0x060010A3u, 0x060010A4u, 0x060010A5u, 0x060010A6u };
|
||
if (toolbarWarDigits is null)
|
||
toolbarWarDigits = new uint[]
|
||
{ 0x06001ACCu, 0x06001ACDu, 0x06001ACEu, 0x06001ACFu, 0x06001AD0u,
|
||
0x06001AD1u, 0x06001AD2u, 0x06001AD3u, 0x06001AD4u };
|
||
// Report the arrays actually used (after any fallback substitution).
|
||
Console.WriteLine($"[D.5.1] toolbar digit arrays ready: peace={toolbarPeaceDigits.Length}, war={toolbarWarDigits.Length}, empty={toolbarEmptyDigits?.Length ?? 0} entries.");
|
||
|
||
AcDream.App.UI.Layout.ImportedLayout? toolbarLayout;
|
||
lock (_datLock)
|
||
toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||
_dats!, 0x21000016u, ResolveChrome, vitalsDatFont);
|
||
if (toolbarLayout is not null)
|
||
{
|
||
_toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind(
|
||
toolbarLayout, Objects,
|
||
() => Shortcuts,
|
||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||
useItem: guid => UseItemByGuid(guid),
|
||
combatState: Combat,
|
||
peaceDigits: toolbarPeaceDigits,
|
||
warDigits: toolbarWarDigits,
|
||
emptyDigits: toolbarEmptyDigits,
|
||
sendAddShortcut: (i, g) => _liveSession?.SendAddShortcut(i, g),
|
||
sendRemoveShortcut: i => _liveSession?.SendRemoveShortcut(i));
|
||
|
||
// Phase D.5.3a — selected-object strip (name, overlay state, health meter).
|
||
// Analogue of retail gmToolbarUI::HandleSelectionChanged
|
||
// (acclient_2013_pseudo_c.txt:198635).
|
||
_selectedObjectController = AcDream.App.UI.Layout.SelectedObjectController.Bind(
|
||
toolbarLayout,
|
||
subscribeSelectionChanged: h => SelectionChanged += h,
|
||
subscribeHealthChanged: h => Combat.HealthChanged += h,
|
||
isHealthTarget: IsHealthBarTarget,
|
||
name: g => Objects.Get(g)?.Name,
|
||
healthPercent: g => Combat.GetHealthPercent(g),
|
||
hasHealth: g => Combat.HasHealth(g),
|
||
stackSize: g => (uint)(Objects.Get(g)?.StackSize ?? 0),
|
||
sendQueryHealth: g => _liveSession?.SendQueryHealth(g),
|
||
datFont: vitalsDatFont);
|
||
|
||
var toolbarRoot = toolbarLayout.Root;
|
||
// Wrap the dat content in the universal 8-piece beveled window chrome —
|
||
// the SAME UiNineSlicePanel used by the vitals and chat windows. The
|
||
// toolbar LayoutDesc (0x21000016) is 300×122; the frame adds one border
|
||
// thickness on every side, giving an outer window of 310×132.
|
||
const int toolbarBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||
float toolbarContentW = 300f, toolbarContentH = toolbarRoot.Height;
|
||
var toolbarFrame = new AcDream.App.UI.UiCollapsibleFrame(ResolveChrome)
|
||
{
|
||
Left = 10, Top = 300,
|
||
Width = toolbarContentW + 2 * toolbarBorder,
|
||
Height = toolbarContentH + 2 * toolbarBorder,
|
||
// The toolbar is fully opaque (not translucent like the chat window).
|
||
Opacity = 1.0f,
|
||
};
|
||
// Content is offset by the border so it sits inside the chrome.
|
||
toolbarRoot.Left = toolbarBorder;
|
||
toolbarRoot.Top = toolbarBorder;
|
||
toolbarRoot.Width = toolbarContentW;
|
||
toolbarRoot.Height = toolbarContentH;
|
||
// Anchor content to Left|Top|Right only — drop Bottom so the dat content
|
||
// keeps its full height when the frame collapses; row 2 hides via Visible,
|
||
// not reflow. (Width is fixed so the horizontal anchors are inert but harmless.)
|
||
toolbarRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||
| AcDream.App.UI.AnchorEdges.Right;
|
||
// The frame is the draggable window; the content itself is not. ClickThrough so the
|
||
// content panel never CLAIMS a hit — its behavioral children (slots, indicators) are
|
||
// hit children-first, and its empty areas fall through to the frame (move). Critically,
|
||
// when collapsed the row-2 band is hidden, so below the collapsed frame the content has
|
||
// no visible/hit children and ClickThrough lets clicks fall through (no phantom
|
||
// window-drag from where row 2 used to be). Same pattern as the chat content panel.
|
||
toolbarRoot.ClickThrough = true;
|
||
toolbarRoot.Draggable = false;
|
||
toolbarRoot.Resizable = false;
|
||
toolbarFrame.AddChild(toolbarRoot);
|
||
|
||
// Collapse-to-one-row: the frame's bottom edge snaps between a one-row (row 2 hidden)
|
||
// and two-row height. CollapsedHeight is computed from the layout (just above row 2),
|
||
// so there's no magic constant. Bottom-edge only; default expanded.
|
||
// The full row-2 band (all at content-y 90, toolbar dump 0x21000016): a 6px left
|
||
// edge-piece (0x100006B6) + the 9 slots (0x100006B7..BF) + an 8px right edge-piece
|
||
// (0x100006C0). Hide ALL 11 when collapsed — hiding only the 9 slots leaves the two
|
||
// edge-pieces drawing as black pillars below the bar.
|
||
uint[] row2Ids =
|
||
{
|
||
0x100006B6u,
|
||
0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu,
|
||
0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu,
|
||
0x100006C0u,
|
||
};
|
||
var toolbarRow2 = new System.Collections.Generic.List<AcDream.App.UI.UiElement>();
|
||
float minRow2Top = float.MaxValue;
|
||
foreach (var id in row2Ids)
|
||
if (toolbarLayout.FindElement(id) is { } e2)
|
||
{
|
||
toolbarRow2.Add(e2);
|
||
if (e2.Top < minRow2Top) minRow2Top = e2.Top;
|
||
}
|
||
if (toolbarRow2.Count > 0)
|
||
{
|
||
float expandedH = toolbarContentH + 2 * toolbarBorder; // today's full height
|
||
float collapsedH = minRow2Top + 2 * toolbarBorder; // just above row 2
|
||
toolbarFrame.CollapsedHeight = collapsedH;
|
||
toolbarFrame.ExpandedHeight = expandedH;
|
||
toolbarFrame.SecondRow = toolbarRow2;
|
||
toolbarFrame.Resizable = true;
|
||
toolbarFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom; // bottom edge only
|
||
toolbarFrame.MinHeight = collapsedH;
|
||
toolbarFrame.MaxHeight = expandedH;
|
||
// Height stays expandedH (already set at construction) = default expanded.
|
||
Console.WriteLine($"[D.2b] toolbar collapse: collapsed={collapsedH:0} expanded={expandedH:0} (row2Top={minRow2Top:0}).");
|
||
}
|
||
|
||
_uiHost.Root.AddChild(toolbarFrame);
|
||
|
||
Console.WriteLine("[D.5.1] retail toolbar window from LayoutDesc importer (0x21000016).");
|
||
}
|
||
else Console.WriteLine("[D.5.1] toolbar: LayoutDesc 0x21000016 not found.");
|
||
|
||
// Drain plugin-registered markup panels (buffered before the GL
|
||
// window opened) into the same UiRoot tree. A faulty plugin markup
|
||
// file is isolated — logged + skipped, never crashes the client.
|
||
if (_uiRegistry is not null)
|
||
{
|
||
foreach (var p in _uiRegistry.Drain())
|
||
{
|
||
try
|
||
{
|
||
string pluginXml = System.IO.File.ReadAllText(p.MarkupPath);
|
||
var pluginPanel = AcDream.App.UI.MarkupDocument.Build(
|
||
pluginXml, p.Binding, ResolveChrome, controls);
|
||
_uiHost.Root.AddChild(pluginPanel);
|
||
Console.WriteLine($"[D.2b] plugin UI panel loaded: {p.MarkupPath}");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"[D.2b] plugin UI panel '{p.MarkupPath}' failed to load: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase D.2b-B — the real inventory window from LayoutDesc 0x21000023 (gmInventoryUI),
|
||
// via the LayoutImporter. The sub-window mount pulls in the nested paperdoll/backpack/
|
||
// 3D-items panels (Type-0 leaves inheriting BaseLayoutId 0x21000024/22/21). Starts
|
||
// HIDDEN; F12 toggles it via the window manager. Position is the dat's own (X=500,Y=138).
|
||
AcDream.App.UI.Layout.ImportedLayout? invLayout;
|
||
lock (_datLock)
|
||
invLayout = AcDream.App.UI.Layout.LayoutImporter.Import(
|
||
_dats!, 0x21000023u, ResolveChrome, vitalsDatFont);
|
||
if (invLayout is not null)
|
||
{
|
||
var inventoryRoot = invLayout.Root;
|
||
// Wrap the dat content in the universal 8-piece beveled window chrome — the
|
||
// SAME UiNineSlicePanel the vitals/chat/toolbar windows use. The gmInventoryUI
|
||
// LayoutDesc (0x21000023) is 300×362; the frame adds one border thickness on
|
||
// every side. The frame is the draggable window; the dat content sits inside it.
|
||
const int invBorder = AcDream.App.UI.RetailChromeSprites.Border;
|
||
float invContentW = inventoryRoot.Width, invContentH = inventoryRoot.Height;
|
||
var inventoryFrame = new AcDream.App.UI.UiNineSlicePanel(ResolveChrome)
|
||
{
|
||
Left = inventoryRoot.Left, Top = inventoryRoot.Top, // keep the dat window position
|
||
Width = invContentW + 2 * invBorder,
|
||
Height = invContentH + 2 * invBorder,
|
||
Opacity = 1.0f,
|
||
Visible = false, // F12 toggles it
|
||
Anchors = AcDream.App.UI.AnchorEdges.None, // user-positioned
|
||
Draggable = true,
|
||
};
|
||
// Content offset by the border so it sits inside the chrome.
|
||
inventoryRoot.Left = invBorder; inventoryRoot.Top = invBorder;
|
||
inventoryRoot.Width = invContentW; inventoryRoot.Height = invContentH;
|
||
// Content fills the frame vertically (Left|Top|Bottom = fixed width + height tracks
|
||
// the frame) so a vertical resize grows the inner content.
|
||
inventoryRoot.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||
| AcDream.App.UI.AnchorEdges.Bottom;
|
||
inventoryRoot.Draggable = false;
|
||
inventoryFrame.AddChild(inventoryRoot);
|
||
|
||
// Vertical-only resize: drag the BOTTOM edge to expand and see more of the pack.
|
||
// ResizeX=false + ResizableEdges=Bottom blocks horizontal resize (user request).
|
||
// MinHeight = the dat default (no shrink below it); MaxHeight = toward full screen.
|
||
inventoryFrame.Resizable = true;
|
||
inventoryFrame.ResizeX = false;
|
||
inventoryFrame.ResizableEdges = AcDream.App.UI.ResizeEdges.Bottom;
|
||
inventoryFrame.MinHeight = invContentH + 2 * invBorder;
|
||
inventoryFrame.MaxHeight = 560f;
|
||
|
||
// Stretch the contents region with the window: the gm3DItemsUI sub-window + its grid
|
||
// + scrollbar + the full-window backdrop grow vertically (Left|Top|Bottom); the
|
||
// paperdoll + side-bag column stay pinned at the top (Left|Top). The grid's ViewHeight
|
||
// grows → more rows + the scrollbar thumb ratio (view/content) reflects it.
|
||
void StretchV(uint id)
|
||
{
|
||
if (invLayout!.FindElement(id) is { } el)
|
||
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top
|
||
| AcDream.App.UI.AnchorEdges.Bottom;
|
||
}
|
||
void PinTopLeft(uint id)
|
||
{
|
||
if (invLayout!.FindElement(id) is { } el)
|
||
el.Anchors = AcDream.App.UI.AnchorEdges.Left | AcDream.App.UI.AnchorEdges.Top;
|
||
}
|
||
StretchV(0x100001D0u); // full-window backdrop
|
||
StretchV(0x100001CFu); // gm3DItemsUI sub-window (contents-grid container)
|
||
StretchV(0x100001C6u); // contents grid (UiItemList)
|
||
StretchV(0x100001C7u); // contents scrollbar
|
||
PinTopLeft(0x100001CDu); // paperdoll (fixed at top)
|
||
PinTopLeft(0x100001CEu); // side-bag column (fixed at top)
|
||
|
||
_uiHost.Root.AddChild(inventoryFrame);
|
||
_uiHost.RegisterWindow(AcDream.App.UI.WindowNames.Inventory, inventoryFrame);
|
||
|
||
// Phase D.2b-B — populate the inventory from ClientObjectTable: the
|
||
// "Contents of Backpack" grid + the pack-selector strip + the burden meter +
|
||
// captions. Analogue of gmInventoryUI/gmBackpackUI/gm3DItemsUI::PostInit.
|
||
|
||
// Resolve each inventory list's empty-slot art from the dat cell template
|
||
// (retail UIElement_ItemList::InternalCreateItem 0x004e3570: attr 0x1000000e ->
|
||
// catalog 0x21000037 prototype's ItemSlot_Empty). The contents grid lives in
|
||
// gm3DItemsUI (0x21000021); the side-bag + main-pack in gmBackpackUI (0x21000022).
|
||
uint contentsEmpty, sideBagEmpty, mainPackEmpty;
|
||
lock (_datLock)
|
||
{
|
||
contentsEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000021u, 0x100001C6u);
|
||
sideBagEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001CAu);
|
||
mainPackEmpty = AcDream.App.UI.Layout.ItemListCellTemplate.ResolveEmptySprite(_dats!, 0x21000022u, 0x100001C9u);
|
||
}
|
||
Console.WriteLine($"[D.2b empty-slot] contents=0x{contentsEmpty:X8} sideBag=0x{sideBagEmpty:X8} mainPack=0x{mainPackEmpty:X8}");
|
||
|
||
_inventoryController = AcDream.App.UI.Layout.InventoryController.Bind(
|
||
invLayout, Objects,
|
||
playerGuid: () => _playerServerGuid,
|
||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||
strength: () => LocalPlayer.GetAttribute(
|
||
AcDream.Core.Player.LocalPlayerState.AttributeKind.Strength) is { } sa
|
||
? (int?)sa.Current : null,
|
||
datFont: vitalsDatFont,
|
||
contentsEmptySprite: contentsEmpty,
|
||
sideBagEmptySprite: sideBagEmpty,
|
||
mainPackEmptySprite: mainPackEmpty,
|
||
sendUse: g => _liveSession?.SendUse(g),
|
||
sendNoLongerViewing: g => _liveSession?.SendNoLongerViewingContents(g),
|
||
sendPutItemInContainer: (item, container, placement) =>
|
||
_liveSession?.SendPutItemInContainer(item, container, placement));
|
||
|
||
// Slice 1: bind the paperdoll equip slots (same imported subtree) — show equipped gear +
|
||
// wield-on-drop. Unwield is handled by InventoryController (drag an equipped item to the grid).
|
||
_paperdollController = AcDream.App.UI.Layout.PaperdollController.Bind(
|
||
invLayout, Objects,
|
||
playerGuid: () => _playerServerGuid,
|
||
iconIds: (type, icon, under, over, effects) => iconComposer.GetIcon(type, icon, under, over, effects),
|
||
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
|
||
// Empty equip slots show a visible frame (same square as the inventory grid) so every
|
||
// slot position is seen + usable; the live 3D character (the doll) is Slice 2.
|
||
emptySlotSprite: contentsEmpty,
|
||
datFont: vitalsDatFont); // Slice 2: caption the "Slots" toggle button
|
||
|
||
// Slice 2: capture the doll viewport widget (Type 0xD, element 0x100001D5) + the inventory
|
||
// frame so the per-frame pre-UI hook can render the 3-D doll into the widget's texture only
|
||
// when the window is open and in doll-view. The renderer itself is built after the
|
||
// WbDrawDispatcher exists (further down this method).
|
||
_paperdollViewportWidget = invLayout.FindElement(0x100001D5u) as AcDream.App.UI.UiViewport;
|
||
_inventoryFrame = inventoryFrame;
|
||
|
||
Console.WriteLine("[D.2b-B] retail inventory window from LayoutDesc importer (0x21000023).");
|
||
}
|
||
else Console.WriteLine("[D.2b-B] inventory: LayoutDesc 0x21000023 not found.");
|
||
}
|
||
|
||
// 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);
|
||
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 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 via SetupPartTransforms.Compute so multi-emitter scripts
|
||
// distribute across mesh parts (closes #56). _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.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
|
||
{
|
||
try
|
||
{
|
||
var setup = capturedDats?.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||
if (setup is null) return null;
|
||
uint scriptId = setup.DefaultScript.DataId;
|
||
if (scriptId == 0) return null;
|
||
var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
|
||
return new AcDream.App.Rendering.Vfx.ScriptActivationInfo(scriptId, parts);
|
||
}
|
||
catch
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
var entityScriptActivator = new AcDream.App.Rendering.Vfx.EntityScriptActivator(
|
||
_scriptRunner!, _particleSink!, ResolveActivation);
|
||
_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,
|
||
wbEntitySpawnAdapter,
|
||
onLandblockUnloaded: _classificationCache.InvalidateLandblock,
|
||
entityScriptActivator: entityScriptActivator);
|
||
|
||
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
|
||
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
|
||
_classificationCache);
|
||
// 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 (_paperdollViewportWidget is not null && _sceneLightingUbo is not null)
|
||
{
|
||
_paperdollViewportRenderer = new AcDream.App.Rendering.PaperdollViewportRenderer(
|
||
_gl, _wbDrawDispatcher, _sceneLightingUbo);
|
||
_paperdollViewportWidget.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!);
|
||
|
||
_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);
|
||
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
|
||
}
|
||
|
||
// 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 as billboard quads.
|
||
// Weather uses AttachLocal emitters so the rain volume follows
|
||
// the player.
|
||
_particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats);
|
||
|
||
// 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).
|
||
_streamer = new AcDream.App.Streaming.LandblockStreamer(
|
||
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
|
||
buildMeshOrNull: (id, lb) =>
|
||
{
|
||
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
|
||
return null;
|
||
uint lbX = (id >> 24) & 0xFFu;
|
||
uint lbY = (id >> 16) & 0xFFu;
|
||
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
|
||
// _heightTable and _blendCtx are read-only after initialization.
|
||
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
|
||
return AcDream.Core.Terrain.LandblockMesh.Build(
|
||
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
|
||
});
|
||
_streamer.Start();
|
||
|
||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
||
enqueueUnload: _streamer.EnqueueUnload,
|
||
drainCompletions: _streamer.DrainCompletions,
|
||
applyTerrain: ApplyLoadedTerrain,
|
||
state: _worldState,
|
||
nearRadius: _nearRadius,
|
||
farRadius: _farRadius,
|
||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||
removeTerrain: id =>
|
||
{
|
||
// Phase G.2: release any LightSources attached to entities
|
||
// in this landblock before their records disappear from
|
||
// _worldState — otherwise the LightManager accumulates
|
||
// stale entries for every walk across a landblock boundary.
|
||
if (_lightingSink is not null &&
|
||
_worldState.TryGetLandblock(id, out var lb))
|
||
{
|
||
foreach (var ent in lb!.Entities)
|
||
_lightingSink.UnregisterOwner(ent.Id);
|
||
}
|
||
_terrain?.RemoveLandblock(id);
|
||
_physicsEngine.RemoveLandblock(id);
|
||
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||
},
|
||
// #138: restore retained server objects when a landblock reloads
|
||
// (dungeon-exit expand or Far→Near promote). ACE won't re-send the
|
||
// objects it thinks we still know, so we re-project them ourselves.
|
||
onLandblockLoaded: RehydrateServerEntitiesForLandblock);
|
||
// A.5 T22.5: apply max-completions from resolved quality.
|
||
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
|
||
|
||
// 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.
|
||
_liveCenterX = centerX;
|
||
_liveCenterY = centerY;
|
||
TryStartLiveSession();
|
||
}
|
||
|
||
private void TryStartLiveSession()
|
||
{
|
||
// Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
|
||
// The controller owns DNS resolution + WorldSession instantiation + the
|
||
// wireEvents callback; this method keeps the Connect → CharacterList →
|
||
// EnterWorld → post-setup dance because those touch GameWindow state.
|
||
_liveSessionController = new AcDream.App.Net.LiveSessionController();
|
||
_liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
|
||
if (_liveSession is null)
|
||
{
|
||
_combatChatTranslator?.Dispose();
|
||
_combatChatTranslator = null;
|
||
_liveSessionController = null;
|
||
return;
|
||
}
|
||
|
||
var user = _options.LiveUser!;
|
||
var pass = _options.LivePass!;
|
||
var host = _options.LiveHost;
|
||
var port = _options.LivePort;
|
||
try
|
||
{
|
||
Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
|
||
_liveSession.Connect(user, pass);
|
||
Chat.OnSystemMessage("connected — character list received", chatType: 1);
|
||
|
||
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
|
||
{
|
||
Console.WriteLine("live: no characters on account; disconnecting");
|
||
_liveSessionController.Dispose();
|
||
_liveSessionController = null;
|
||
_liveSession = null;
|
||
return;
|
||
}
|
||
|
||
var chosen = _liveSession.Characters.Characters[0];
|
||
_playerServerGuid = chosen.Id;
|
||
_vitalsVm?.SetLocalPlayerGuid(chosen.Id);
|
||
Chat.SetLocalPlayerGuid(chosen.Id);
|
||
_worldState.MarkPersistent(chosen.Id);
|
||
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = chosen.Id; // TEMP #138-B probe
|
||
Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
|
||
_liveSession.EnterWorld(user, characterIndex: 0);
|
||
|
||
_activeToonKey = chosen.Name;
|
||
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();
|
||
Console.WriteLine($"live: in world — CreateObject stream active " +
|
||
$"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Console.WriteLine($"live: session failed: {ex.Message}");
|
||
_combatChatTranslator?.Dispose();
|
||
_combatChatTranslator = null;
|
||
_liveSessionController?.Dispose();
|
||
_liveSessionController = null;
|
||
_liveSession = null;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Step 2 helper: subscribes the live <paramref name="session"/> to all
|
||
/// the parsers / handlers / translators that <c>GameWindow</c> needs.
|
||
/// Called once by <see cref="LiveSessionController.CreateAndWire"/>
|
||
/// immediately after the <see cref="AcDream.Core.Net.WorldSession"/>
|
||
/// is constructed and BEFORE any network I/O.
|
||
/// </summary>
|
||
private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
|
||
{
|
||
_liveSession = session;
|
||
// D.5.4: ingest CreateObject into the object table (upsert) and wire Delete +
|
||
// UiEffects live update. Wire BEFORE EntitySpawned += OnLiveEntitySpawned so
|
||
// the table is populated before the render handler runs.
|
||
AcDream.Core.Net.ObjectTableWiring.Wire(session, Objects, () => _playerServerGuid);
|
||
_liveSession.EntitySpawned += OnLiveEntitySpawned;
|
||
_liveSession.EntityDeleted += OnLiveEntityDeleted;
|
||
_liveSession.MotionUpdated += OnLiveMotionUpdated;
|
||
_liveSession.PositionUpdated += OnLivePositionUpdated;
|
||
_liveSession.VectorUpdated += OnLiveVectorUpdated;
|
||
_liveSession.StateUpdated += OnLiveStateUpdated;
|
||
_liveSession.TeleportStarted += OnTeleportStarted;
|
||
_liveSession.AppearanceUpdated += OnLiveAppearanceUpdated;
|
||
|
||
// Phase 6c — PlayScript (0xF754) arrives from the server as
|
||
// a (guid, scriptId) pair. Resolve the guid's current world
|
||
// position and feed the PhysicsScript runner; it schedules
|
||
// the script's hooks (particle spawns, sound cues, light
|
||
// toggles) at their StartTime offsets. This is the channel
|
||
// retail uses for spell casts, combat flinches, emote
|
||
// gestures, AND — per Agent #5 research — lightning
|
||
// flashes during stormy weather.
|
||
_liveSession.PlayScriptReceived += OnPlayScriptReceived;
|
||
|
||
// Phase 5d — AdminEnvirons (0xEA60): fog presets + sound
|
||
// cues. Fog types (0x00..0x06) set WeatherSystem.Override;
|
||
// sound types (0x65..0x7B) play a one-shot audio cue.
|
||
// Lightning flashes arrive as a PAIRED PlayScript (the
|
||
// visual) + AdminEnvirons ThunderXSound (the audio) — both
|
||
// are handled here and in OnPlayScriptReceived respectively.
|
||
_liveSession.EnvironChanged += OnEnvironChanged;
|
||
|
||
// Phase G.1: keep the client's day/night clock in sync with
|
||
// server time. Fires once from ConnectRequest (initial seed)
|
||
// and repeatedly on TimeSync-flagged packets.
|
||
// Phase 3a: also re-roll the active DayGroup if the Dereth-day
|
||
// index changed — retail rolls one weather preset per server
|
||
// day (r12 §11), deterministic from the day index so retail
|
||
// and acdream converge without a wire message.
|
||
_liveSession.ServerTimeUpdated += ticks =>
|
||
{
|
||
WorldTime.SyncFromServer(ticks);
|
||
RefreshSkyForCurrentDay();
|
||
};
|
||
|
||
// Phase F.1-H.1: wire every parsed GameEvent into the right
|
||
// Core state class (chat, combat, spellbook, items). After
|
||
// this one call, server-sent ChannelBroadcast / damage
|
||
// notifications / spell learns / wield events all update
|
||
// the corresponding client-side state without further glue.
|
||
// K-fix13 (2026-04-26): cache portal.dat's SkillTable so the
|
||
// skill-formula resolver can apply the AttributeFormula
|
||
// contribution. Without this, our wire-derived "skill"
|
||
// value was missing the dominant attribute-derived term
|
||
// and jumps undershot retail by ~30 % at typical
|
||
// attribute levels.
|
||
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
|
||
|
||
AcDream.Core.Net.GameEventWiring.WireAll(
|
||
_liveSession.GameEvents, Objects, Combat, SpellBook, Chat, LocalPlayer,
|
||
TurbineChat,
|
||
resolveSkillFormulaBonus: (skillId, attrCurrents) =>
|
||
{
|
||
// ACE GetFormula (AttributeFormula.cs:55-): when
|
||
// formula.X (Attribute1Multiplier) is 0, the formula
|
||
// is "no attribute contribution" and the function
|
||
// returns 0. Otherwise:
|
||
// bonus = (attr1 * Mult1 + attr2 * Mult2) / Divisor + Additive
|
||
if (skillTable?.Skills is null) return 0u;
|
||
if (!skillTable.Skills.TryGetValue(
|
||
(DatReaderWriter.Enums.SkillId)skillId, out var skillBase))
|
||
return 0u;
|
||
var f = skillBase.Formula;
|
||
if (f.Attribute1Multiplier == 0 || f.Divisor == 0) return 0u;
|
||
attrCurrents.TryGetValue((uint)f.Attribute1, out uint a1);
|
||
attrCurrents.TryGetValue((uint)f.Attribute2, out uint a2);
|
||
long num = (long)a1 * f.Attribute1Multiplier
|
||
+ (long)a2 * f.Attribute2Multiplier;
|
||
long bonus = num / f.Divisor + f.AdditiveBonus;
|
||
return bonus < 0 ? 0u : (uint)bonus;
|
||
},
|
||
onSkillsUpdated: (runSkill, jumpSkill) =>
|
||
{
|
||
// K-fix7 (2026-04-26): cache the latest server-sent
|
||
// Run / Jump skill values so the next
|
||
// EnterPlayerModeNow can hand them to the new
|
||
// PlayerMovementController. Push immediately too,
|
||
// so a PD that arrives WHILE player mode is active
|
||
// (re-equip / log-in mid-session) updates the live
|
||
// controller. -1 from the wiring means "skill not
|
||
// present in this PD" — keep the previous cached
|
||
// value rather than overwriting with -1.
|
||
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}");
|
||
}
|
||
},
|
||
onShortcuts: list => Shortcuts = list,
|
||
playerGuid: () => _playerServerGuid);
|
||
|
||
// Phase I.7: subscribe to CombatState events and emit
|
||
// retail-faithful "You hit X for Y damage" chat lines into
|
||
// the unified ChatLog. The translator owns the wording
|
||
// (templates ported from holtburger chat.rs:221-308); the
|
||
// panel renders combat entries via TextColored.
|
||
_combatChatTranslator = new AcDream.Core.Chat.CombatChatTranslator(Combat, Chat);
|
||
|
||
// Phase H.1: feed inbound HearSpeech into the chat log.
|
||
_liveSession.SpeechHeard += speech =>
|
||
Chat.OnLocalSpeech(
|
||
sender: speech.SenderName,
|
||
text: speech.Text,
|
||
senderGuid: speech.SenderGuid,
|
||
isRanged: speech.IsRanged);
|
||
|
||
// Phase I.6: feed inbound TurbineChat events into the chat log.
|
||
// The Response variant is fire-and-forget (server-side ack);
|
||
// EventSendToRoom is a real chat message broadcast to a room.
|
||
// Phase J: ACE's GameMessageSystemChat (used for the login
|
||
// banner "Welcome to Asheron's Call ... type @acehelp" and
|
||
// for SystemChat broadcasts) rides opcode 0xF7E0 ServerMessage,
|
||
// parsed in I.5 but never wired. Surface it as a System
|
||
// chat line so the welcome banner appears + future server
|
||
// pushes (announcements, command responses) show.
|
||
_liveSession.ServerMessageReceived += sm =>
|
||
Chat.OnSystemMessage(sm.Message, sm.ChatType);
|
||
|
||
// Phase I.5 + J: emotes already had ChatLog adapters; wire
|
||
// their session events here so they actually reach chat.
|
||
_liveSession.EmoteHeard += emote =>
|
||
Chat.OnEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
||
_liveSession.SoulEmoteHeard += emote =>
|
||
Chat.OnSoulEmote(emote.SenderName, emote.Text, emote.SenderGuid);
|
||
_liveSession.PlayerKilledReceived += pk =>
|
||
Chat.OnPlayerKilled(pk.DeathMessage, pk.VictimGuid, pk.KillerGuid);
|
||
|
||
_liveSession.TurbineChatReceived += parsed =>
|
||
{
|
||
if (parsed.Body is AcDream.Core.Net.Messages.TurbineChat.Payload.EventSendToRoom ev)
|
||
{
|
||
// Pass the friendly channel name out-of-band via
|
||
// ChatLog.OnChannelBroadcast's channelName param so
|
||
// ChatVM.FormatEntry can render the retail-style
|
||
// "[Trade] +Acdream says, \"hello\"" without us
|
||
// mangling the payload text.
|
||
string label = TurbineRoomDisplayName(ev.RoomId, ev.ChatType);
|
||
Chat.OnChannelBroadcast(
|
||
channelId: ev.RoomId,
|
||
sender: ev.SenderName,
|
||
text: ev.Message,
|
||
channelName: label);
|
||
}
|
||
// Response (server ack of an outbound RequestSendToRoomById)
|
||
// and Unknown payloads are intentionally not surfaced —
|
||
// the inbound EventSendToRoom for our own message acts as
|
||
// the canonical echo.
|
||
};
|
||
|
||
// Phase I.3: real ICommandBus. Panels publish SendChatCmd here
|
||
// and we route it to the right wire opcode (Talk / Tell / ChatChannel)
|
||
// plus a local echo into ChatLog so the player sees their own
|
||
// message immediately. Closes over _liveSession + Chat so this
|
||
// wiring only exists for the lifetime of the live session.
|
||
// Step 2: capture the non-null `session` parameter rather than
|
||
// the nullable _liveSession field. Semantically identical (the
|
||
// field WAS set to `session` at the top of WireLiveSessionEvents)
|
||
// but the compiler can prove non-null for the lambda body.
|
||
var liveSession = session;
|
||
var chat = Chat;
|
||
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
|
||
var turbineChat = TurbineChat;
|
||
uint playerGuid = _playerServerGuid;
|
||
_commandBus.Register<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
|
||
{
|
||
if (string.IsNullOrEmpty(cmd.Text)) return;
|
||
switch (cmd.Channel)
|
||
{
|
||
case AcDream.UI.Abstractions.ChatChannelKind.Say:
|
||
// Phase J: drop optimistic /say echo. ACE's
|
||
// HandleActionTalk broadcasts a HearSpeech back
|
||
// to the sender too, and ChatLog.OnLocalSpeech
|
||
// detects own-guid match to render it as
|
||
// "You say, ...". Optimistic-echoing here
|
||
// doubled the line. ALSO: don't echo "@xxx"
|
||
// server-side admin commands — ACE consumes
|
||
// them silently and replies via SystemChat.
|
||
liveSession.SendTalk(cmd.Text);
|
||
break;
|
||
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
|
||
if (string.IsNullOrEmpty(cmd.TargetName)) return;
|
||
liveSession.SendTell(cmd.TargetName, cmd.Text);
|
||
chat.OnSelfSent(
|
||
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
|
||
targetOrChannel: cmd.TargetName);
|
||
break;
|
||
default:
|
||
// Phase I.6: try TurbineChat first for the global
|
||
// community channels (General/Trade/LFG/Roleplay/
|
||
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
|
||
// Allegiance is double-routed: try TurbineChat first
|
||
// (when the player has a Turbine allegiance room) and
|
||
// fall back to the legacy 0x0147 ChatChannel.
|
||
//
|
||
// We do NOT optimistic-echo channels: ACE's
|
||
// TurbineChatHandler broadcasts EventSendToRoom back
|
||
// to the sender too, so we always get the canonical
|
||
// echo from the server. Optimistic-echoing here
|
||
// double-prints the message (one as "[Trade] hello"
|
||
// from us, one as "[Trade] +Acdream says, \"hello\""
|
||
// from the server). Trust the server.
|
||
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
|
||
if (turbine is not null)
|
||
{
|
||
uint cookie = turbineChat.NextContextId();
|
||
// Use the live player guid if it's been captured;
|
||
// otherwise 0 (server treats unknown sender_id
|
||
// gracefully — the cookie is what we care about).
|
||
uint senderGuid = _playerServerGuid != 0u
|
||
? _playerServerGuid
|
||
: playerGuid;
|
||
Console.WriteLine(
|
||
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
|
||
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
|
||
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}");
|
||
liveSession.SendTurbineChatTo(
|
||
roomId: turbine.Value.RoomId,
|
||
chatType: turbine.Value.ChatType,
|
||
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
|
||
senderGuid: senderGuid,
|
||
text: cmd.Text,
|
||
cookie: cookie);
|
||
// No optimistic echo: server EventSendToRoom
|
||
// broadcast comes back with sender="+Acdream"
|
||
// and is rendered by ChatVM as
|
||
// "[Trade] +Acdream says, \"hello\"".
|
||
break;
|
||
}
|
||
|
||
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
|
||
if (resolved is null)
|
||
{
|
||
// Diagnostic: the user picked a channel kind that
|
||
// (a) isn't a Turbine channel TurbineChatState
|
||
// knows about and (b) has no legacy ChatChannel
|
||
// mapping. Most common cause: TurbineChat hasn't
|
||
// been enabled yet (server didn't send 0x0295)
|
||
// and the kind is General/Trade/LFG/etc.
|
||
Console.WriteLine(
|
||
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
|
||
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
|
||
return;
|
||
}
|
||
Console.WriteLine(
|
||
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
|
||
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
|
||
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
|
||
// Legacy channels (Fellowship / Allegiance / Patron /
|
||
// Monarch / Vassals / CoVassals) — keep the optimistic
|
||
// echo because legacy ChatChannel does NOT always
|
||
// broadcast back to the sender. ChannelName is the
|
||
// friendly display name so ChatVM renders it as
|
||
// "[Fellowship] +Acdream says, \"hello\"".
|
||
chat.OnSelfSent(
|
||
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
|
||
targetOrChannel: resolved.Value.DisplayName);
|
||
break;
|
||
}
|
||
});
|
||
|
||
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
|
||
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
|
||
_liveSession.VitalUpdated += v =>
|
||
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
|
||
_liveSession.VitalCurrentUpdated += v =>
|
||
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
|
||
|
||
}
|
||
|
||
/// <summary>
|
||
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
|
||
/// mesh refs and register it in IGameState. Called from WorldSession events
|
||
/// on the main thread (Tick runs in the Silk.NET Update callback).
|
||
/// </summary>
|
||
private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
|
||
{
|
||
// Phase A.1 hotfix: live CreateObject handler reads dats extensively
|
||
// (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned
|
||
// entity. All of it must run under the dat lock so it doesn't race
|
||
// with BuildLandblockForStreaming on the worker thread.
|
||
lock (_datLock)
|
||
{
|
||
OnLiveEntitySpawnedLocked(spawn);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #138: re-hydrate retained server objects (doors, NPCs, chests, portals)
|
||
/// into a landblock that just (re)loaded. Fired by
|
||
/// <see cref="AcDream.App.Streaming.StreamingController"/> after
|
||
/// <c>AddLandblock</c> / <c>AddEntitiesToExistingLandblock</c>.
|
||
///
|
||
/// <para>
|
||
/// The dungeon collapse (and Near→Far demote) drops a landblock's render
|
||
/// entities for FPS but keeps the parsed spawns in <see cref="_lastSpawnByGuid"/>
|
||
/// (our <c>weenie_object_table</c> for world objects). ACE never
|
||
/// re-broadcasts objects it believes we still know — its per-player
|
||
/// <c>KnownObjects</c> set is not cleared on a normal teleport (verified
|
||
/// against <c>references/ACE</c> <c>ObjectMaint</c>; a real client keeps its
|
||
/// table and re-renders from it, per <c>references/holtburger</c>). So on
|
||
/// reload the render side would stay empty. We re-project the objects from
|
||
/// our own retained table instead — independent of any server re-send.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Idempotent and cheap when nothing is missing: the selection skips guids
|
||
/// already present in <see cref="_worldState"/> (initial login, or objects
|
||
/// ACE did re-send), the player (owned by the persistent-entity rescue
|
||
/// path), and spawns with no world mesh. The replay's own
|
||
/// <see cref="RemoveLiveEntityByServerGuid"/> de-dup scrubs the state the
|
||
/// collapse orphaned — the entity lingers in <see cref="_entitiesByServerGuid"/>
|
||
/// after <c>RemoveLandblock</c> even though its render entity is gone.
|
||
/// </para>
|
||
/// </summary>
|
||
private void RehydrateServerEntitiesForLandblock(uint loadedLandblockId)
|
||
{
|
||
if (_lastSpawnByGuid.Count == 0) return;
|
||
|
||
// Server guids that already have a live render entity. The gate keys on
|
||
// GpuWorldState (the render projection), NOT _entitiesByServerGuid, which
|
||
// still holds collapse-orphaned entries whose render entity is gone.
|
||
var present = new HashSet<uint>();
|
||
foreach (var e in _worldState.Entities)
|
||
if (e.ServerGuid != 0) present.Add(e.ServerGuid);
|
||
|
||
// Snapshot the retained spawns — the replay mutates _lastSpawnByGuid
|
||
// (remove then re-add), so we must not iterate it live.
|
||
var retained = new List<AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn>(
|
||
_lastSpawnByGuid.Count);
|
||
foreach (var kv in _lastSpawnByGuid)
|
||
{
|
||
var sp = kv.Value;
|
||
bool hasWorldMesh = sp.Position is not null && sp.SetupTableId is not null;
|
||
uint spawnLb = sp.Position is { } p ? p.LandblockId : 0u;
|
||
retained.Add(new AcDream.App.Streaming.LandblockEntityRehydrator.RetainedSpawn(
|
||
kv.Key, spawnLb, hasWorldMesh));
|
||
}
|
||
|
||
var guids = AcDream.App.Streaming.LandblockEntityRehydrator.SelectGuidsToRehydrate(
|
||
loadedLandblockId, retained, present, _playerServerGuid);
|
||
if (guids.Count == 0) return;
|
||
|
||
// Replay through the normal live-spawn build under the dat lock (it reads
|
||
// Setup/GfxObj/Surface dats). Each replay rebuilds the render entity into
|
||
// the now-loaded landblock via AppendLiveEntity's hot path.
|
||
lock (_datLock)
|
||
{
|
||
foreach (var guid in guids)
|
||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||
OnLiveEntitySpawnedLocked(spawn);
|
||
}
|
||
|
||
if (_options.DumpLiveSpawns)
|
||
Console.WriteLine(
|
||
$"live: re-hydrated {guids.Count} server object(s) into landblock 0x{loadedLandblockId:X8}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Door detection by server-sent name. Doors fail the generic
|
||
/// multi-frame-idle gate at line 2692 (no idle cycle), so we register
|
||
/// them via a sibling branch with a state-seeded sequencer. Shared
|
||
/// with the [door-cycle] UM dispatch diagnostic — both sites must
|
||
/// agree on the name predicate.
|
||
/// </summary>
|
||
private static bool IsDoorName(string? name) => name == "Door";
|
||
|
||
private static bool IsDoorSpawn(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
|
||
=> IsDoorName(spawn.Name);
|
||
|
||
private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
|
||
{
|
||
_liveSpawnReceived++;
|
||
|
||
// L.2g S1 (DEV-6): seed the movement-event staleness gate from the
|
||
// CreateObject PhysicsDesc timestamp block, as retail seeds
|
||
// update_times at object creation. Seed() adopts wholesale on first
|
||
// sight and advance-only afterward, so the #138 rehydrate replay of
|
||
// a RETAINED spawn through this handler cannot regress live stamps.
|
||
if (!_motionSequenceGates.TryGetValue(spawn.Guid, out var seqGate))
|
||
{
|
||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||
_motionSequenceGates[spawn.Guid] = seqGate;
|
||
}
|
||
seqGate.Seed(spawn.InstanceSequence, spawn.MovementSequence, spawn.ServerControlSequence);
|
||
|
||
// De-dup: the server re-sends CreateObject for the same guid in
|
||
// several situations (visibility refresh, landblock crossing,
|
||
// appearance update). Without cleanup the OLD copy remains in
|
||
// GpuWorldState + WorldGameState + _animatedEntities, so the
|
||
// renderer draws both copies overlapped — producing the
|
||
// "NPC clothing changes when I turn the camera" bug because the
|
||
// depth test arbitrates between overlapping duplicates each frame.
|
||
//
|
||
// For a respawn, drop the previous rendering state here before we
|
||
// build the new one. `_entitiesByServerGuid` is the canonical map,
|
||
// its value is the live WorldEntity we need to dispose.
|
||
RemoveLiveEntityByServerGuid(spawn.Guid);
|
||
|
||
// When requested, log every spawn that arrives so we can inventory what the server
|
||
// sends (including the ones we can't render yet). The Name field
|
||
// is the critical one — we can grep the log for "Nullified Statue
|
||
// of a Drudge" or similar to find a specific weenie by its
|
||
// in-game name.
|
||
bool dumpLiveSpawns = _options.DumpLiveSpawns;
|
||
if (dumpLiveSpawns)
|
||
{
|
||
string posStr = spawn.Position is { } sp
|
||
? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}"
|
||
: "no-pos";
|
||
string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup";
|
||
string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name";
|
||
string itemTypeStr = spawn.ItemType is { } it ? $"0x{it:X8}" : "no-itemtype";
|
||
int animPartCount = spawn.AnimPartChanges?.Count ?? 0;
|
||
int texChangeCount = spawn.TextureChanges?.Count ?? 0;
|
||
int subPalCount = spawn.SubPalettes?.Count ?? 0;
|
||
Console.WriteLine(
|
||
$"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " +
|
||
$"itemType={itemTypeStr} animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
|
||
}
|
||
|
||
// Target the statue specifically for full diagnostic dump: Name match
|
||
// is cheap and gives us exactly one entity's worth of log regardless
|
||
// of arrival order.
|
||
bool isStatue = dumpLiveSpawns
|
||
&& spawn.Name is not null
|
||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||
if (isStatue)
|
||
{
|
||
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
|
||
Console.WriteLine($"live: [STATUE] mtable=0x{(spawn.MotionTableId ?? 0):X8} stance=0x{(spawn.MotionState?.Stance ?? 0):X4} cmd=0x{(spawn.MotionState?.ForwardCommand ?? 0):X4}");
|
||
if (spawn.TextureChanges is { } tcs)
|
||
{
|
||
foreach (var tc in tcs)
|
||
Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}");
|
||
}
|
||
if (spawn.SubPalettes is { } sps)
|
||
{
|
||
Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}");
|
||
foreach (var subPal in sps)
|
||
Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}");
|
||
}
|
||
if (spawn.AnimPartChanges is { } apcs)
|
||
{
|
||
foreach (var apc in apcs)
|
||
Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}");
|
||
}
|
||
|
||
// Dump the BASE setup's part list before AnimPartChanges, so we can
|
||
// see how many parts the statue's Setup actually has + what their
|
||
// default GfxObjs are. The retail statue may have additional parts
|
||
// (e.g. a pedestal sub-mesh) that our setup loader is dropping or
|
||
// we're rendering with wrong default GfxObjs.
|
||
if (spawn.SetupTableId is { } sid && _dats is not null)
|
||
{
|
||
var baseSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(sid);
|
||
if (baseSetup is not null)
|
||
{
|
||
Console.WriteLine($"live: [STATUE] base Setup 0x{sid:X8} has {baseSetup.Parts.Count} parts:");
|
||
for (int pi = 0; pi < baseSetup.Parts.Count; pi++)
|
||
{
|
||
uint partGfxId = (uint)baseSetup.Parts[pi];
|
||
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(partGfxId);
|
||
int subCount = pgfx?.Surfaces.Count ?? -1;
|
||
Console.WriteLine($"live: [STATUE] part[{pi}] gfxObj=0x{partGfxId:X8} surfaces={subCount}");
|
||
}
|
||
Console.WriteLine($"live: [STATUE] placementFrames count={baseSetup.PlacementFrames.Count}");
|
||
}
|
||
}
|
||
}
|
||
|
||
if (_dats is null) return;
|
||
if (spawn.Position is null || spawn.SetupTableId is null)
|
||
{
|
||
// Can't place a mesh without both. Most of these are inventory
|
||
// items anyway (no position because they're held), which have no
|
||
// visible world presence.
|
||
if (spawn.Position is null) _liveDropReasonNoPos++;
|
||
else _liveDropReasonNoSetup++;
|
||
return;
|
||
}
|
||
|
||
var p = spawn.Position.Value;
|
||
|
||
// Translate server position into acdream world space. The server sends
|
||
// (landblockId, local x/y/z). acdream's world origin is the center
|
||
// landblock; each neighbor landblock is offset by 192 units per step.
|
||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||
|
||
// G.3 (#133): recenter streaming onto the player's spawn landblock at
|
||
// login. The streaming center (_liveCenterX/_liveCenterY) is pinned to
|
||
// the startup default (Holtburg, 0xA9B4) and is otherwise only moved by
|
||
// the teleport-arrival path (OnLivePositionUpdated, ~line 4901). A
|
||
// character saved INSIDE a far dungeon spawns with that dungeon's
|
||
// landblock id, but the center never followed it, so the dungeon (tens
|
||
// of km away in world space) never streamed and the #107 auto-entry
|
||
// gate's SampleTerrainZ(pe.Position) waited forever — the player hung
|
||
// frozen at login. Mirror the teleport-arrival recenter HERE, for the
|
||
// PLAYER's spawn only, BEFORE the world-space translation below: when
|
||
// the spawn landblock differs from the current center, move the center
|
||
// onto it so the spawn maps to (PositionX, PositionY, PositionZ) in the
|
||
// new center frame (identical to the teleport path's
|
||
// `newWorldPos = new Vector3(p.PositionX, p.PositionY, p.PositionZ)`),
|
||
// and the next StreamingController.Tick observes the new center and
|
||
// streams the spawn landblock.
|
||
//
|
||
// No-op for a normal Holtburg login: the saved spawn landblock equals
|
||
// the default center, so the guard is false and origin/worldPos are
|
||
// byte-identical to the pre-fix path. Gated on the player guid so NPC /
|
||
// object spawns never move the center. Idempotent + thrash-free: a
|
||
// re-sent CreateObject for the same spawn landblock leaves the center
|
||
// already-equal, so the guard is false on every repeat.
|
||
if (spawn.Guid == _playerServerGuid
|
||
&& (lbX != _liveCenterX || lbY != _liveCenterY))
|
||
{
|
||
Console.WriteLine(
|
||
$"live: login spawn — recentering streaming from ({_liveCenterX},{_liveCenterY}) " +
|
||
$"to ({lbX},{lbY}) for player spawn @0x{p.LandblockId:X8}");
|
||
_liveCenterX = lbX;
|
||
_liveCenterY = lbY;
|
||
// The origin jumped — the streaming region is still bootstrapped
|
||
// around the stale startup center with residence marked before its
|
||
// async loads landed. Flag a full window rebuild (consumed on the
|
||
// render thread in OnUpdateFrame) so the region re-bootstraps fresh
|
||
// around the spawn, re-loading the landblocks RecenterTo would
|
||
// otherwise skip as already-resident — the cold-spawn streaming hole.
|
||
_pendingForceReloadWindow = true;
|
||
}
|
||
|
||
// #135: the instant we know the player spawned into a SEALED dungeon,
|
||
// pre-collapse streaming to that single landblock — BEFORE the first
|
||
// StreamingController.Tick bootstraps the 25×25 ocean-grid window. The
|
||
// player isn't placed yet (physics CurrCell is null), so the per-frame
|
||
// insideDungeon gate stays false for the entire hydration window and
|
||
// NormalTick would otherwise load ~24 neighbor dungeons then unload them
|
||
// (the login FPS ramp the user reported — 10 fps slowly climbing). Sealed-
|
||
// dungeon only: a cottage/inn interior (SeenOutside) keeps its outdoor
|
||
// surround. We hold _datLock here, and IsSealedDungeonCell re-takes it
|
||
// (reentrant); the controller call is render-thread-safe (Channel writes).
|
||
if (spawn.Guid == _playerServerGuid
|
||
&& _streamingController is not null
|
||
&& IsSealedDungeonCell(p.LandblockId))
|
||
{
|
||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||
}
|
||
|
||
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;
|
||
|
||
// AC quaternion wire order is (W, X, Y, Z); System.Numerics.Quaternion is (X, Y, Z, W).
|
||
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||
|
||
// Hydrate mesh refs from the Setup dat. This is the same code path
|
||
// used by the static scenery pipeline (see the Setup hydration above).
|
||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.SetupTableId.Value);
|
||
if (setup is not null)
|
||
_physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup);
|
||
if (setup is null)
|
||
{
|
||
_liveDropReasonSetupDatMissing++;
|
||
if (dumpLiveSpawns)
|
||
Console.WriteLine($"live: DROP setup dat 0x{spawn.SetupTableId.Value:X8} missing " +
|
||
$"(guid=0x{spawn.Guid:X8})");
|
||
return;
|
||
}
|
||
|
||
// Phase 6: resolve the entity's idle motion frame from its
|
||
// MotionTable chain. For creatures and characters this gives us
|
||
// the upright "Resting" pose instead of the Setup's Default
|
||
// (T-pose / aggressive crouch). Static items with no motion table
|
||
// get null and fall back to PlacementFrames in Flatten.
|
||
// Honor the server's CurrentMotionState (CreateObject MovementData)
|
||
// when present. The Foundry's drudge statue is the canonical case:
|
||
// its MotionTable's default style is upright "Ready" but the weenie
|
||
// is sent with a combat stance + Crouch ForwardCommand override, so
|
||
// resolving the cycle key from those gives the aggressive crouch.
|
||
ushort? stanceOverride = spawn.MotionState?.Stance;
|
||
ushort? commandOverride = spawn.MotionState?.ForwardCommand;
|
||
// Critical for entities like the Foundry's drudge statue: their
|
||
// base Setup has DefaultMotionTable=0, but the server tells us
|
||
// which motion table to use via PhysicsDescriptionFlag.MTable.
|
||
// Without this override the resolver returns null and we fall
|
||
// back to PlacementFrames[Default] which renders the wrong pose.
|
||
// Phase 6.4: prefer the full cycle so we can play it forward over
|
||
// time. Falls back to GetIdleFrame's static-frame behavior when
|
||
// the cycle resolves but only the first frame is rendered (no
|
||
// animated entry registered) — this happens for entities the
|
||
// resolver short-circuits on.
|
||
var idleCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
|
||
setup, _dats,
|
||
motionTableIdOverride: spawn.MotionTableId,
|
||
stanceOverride: stanceOverride,
|
||
commandOverride: commandOverride);
|
||
DatReaderWriter.Types.AnimationFrame? idleFrame = null;
|
||
if (idleCycle is not null)
|
||
{
|
||
int startIdx = idleCycle.LowFrame;
|
||
if (startIdx < 0 || startIdx >= idleCycle.Animation.PartFrames.Count) startIdx = 0;
|
||
idleFrame = idleCycle.Animation.PartFrames[startIdx];
|
||
}
|
||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup, idleFrame);
|
||
|
||
// Apply the server's AnimPartChanges: "replace part at index N
|
||
// with GfxObj M". This is how characters become clothed (head →
|
||
// helmet, torso → chestplate, ...) and how server-weenie statues
|
||
// and props pick up their unique visual meshes on top of a generic
|
||
// base Setup. Start with a mutable copy, patch in the replacements,
|
||
// then proceed with the normal upload loop.
|
||
var parts = new List<AcDream.Core.World.MeshRef>(flat);
|
||
var animPartChanges = spawn.AnimPartChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>();
|
||
// Diagnostic: dump AnimPartChanges + TextureChanges for humanoid setups
|
||
// gated on ACDREAM_DUMP_CLOTHING=1. Used to verify whether the server is
|
||
// sending coverage for the neck (part 9 for Aluvian Male) etc.
|
||
bool dumpClothing = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_CLOTHING"), "1", StringComparison.Ordinal)
|
||
&& setup.Parts.Count >= 10;
|
||
if (dumpClothing)
|
||
{
|
||
Console.WriteLine($"\n=== DUMP_CLOTHING: guid=0x{spawn.Guid:X8} name='{spawn.Name}' setup=0x{setup.Id:X8} setup.Parts.Count={setup.Parts.Count} flatten.Count={flat.Count} APC={animPartChanges.Count} ===");
|
||
// Dump the Setup's ParentIndex + DefaultScale arrays to verify hierarchy.
|
||
var parentStr = string.Join(",", setup.ParentIndex.Take(Math.Min(34, setup.ParentIndex.Count)).Select(p => p == 0xFFFFFFFFu ? "-1" : p.ToString()));
|
||
Console.WriteLine($" ParentIndex[{setup.ParentIndex.Count}]: {parentStr}");
|
||
var scaleStr = string.Join(",", setup.DefaultScale.Take(Math.Min(34, setup.DefaultScale.Count)).Select(s => $"({s.X:F2},{s.Y:F2},{s.Z:F2})"));
|
||
Console.WriteLine($" DefaultScale[{setup.DefaultScale.Count}]: {scaleStr}");
|
||
// Dump the resolved idle frame's per-part Origin + Orientation.
|
||
// If retail composes parent_world * animation_local but acdream
|
||
// treats animation_local as world-relative, we'd see specific
|
||
// patterns of non-zero per-part origins/rotations that should
|
||
// be parent-relative. For setups whose idle has all parts at
|
||
// (0,0,0)/identity, parent walking would be a no-op (which
|
||
// matches my earlier "no change" experiment if that was the
|
||
// human-idle case) — diagnostic confirms.
|
||
if (idleFrame is not null)
|
||
{
|
||
Console.WriteLine($" IdleFrame.Frames[{idleFrame.Frames.Count}]:");
|
||
int dumpCount = Math.Min(idleFrame.Frames.Count, 17); // first 17 (real body parts, not the 17-33 placeholders)
|
||
for (int fi = 0; fi < dumpCount; fi++)
|
||
{
|
||
var f = idleFrame.Frames[fi];
|
||
Console.WriteLine($" [{fi:D2}] Origin=({f.Origin.X:F3},{f.Origin.Y:F3},{f.Origin.Z:F3}) Orient=(W={f.Orientation.W:F3} X={f.Orientation.X:F3} Y={f.Orientation.Y:F3} Z={f.Orientation.Z:F3})");
|
||
}
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($" IdleFrame: NULL");
|
||
}
|
||
foreach (var c in animPartChanges)
|
||
Console.WriteLine($" APC part={c.PartIndex:D2} -> gfx=0x{c.NewModelId:X8}");
|
||
|
||
// #37: per-spawn palette swaps. The server's clothing pipeline
|
||
// sends a basePalette + a list of (subPaletteId, offset, length)
|
||
// triples that splice palette ranges into the rendered character.
|
||
// We need their IDs to know whether the coat texture's underlying
|
||
// palette is being overridden by a coat-tone subPalette or left
|
||
// alone (in which case the texture's DefaultPaletteId — a SKIN
|
||
// palette — leaks through and the coat ends up neck-colored).
|
||
Console.WriteLine($" basePalette=0x{(spawn.BasePaletteId ?? 0):X8} subPalettes={(spawn.SubPalettes?.Count ?? 0)}");
|
||
if (spawn.SubPalettes is { } subPaletteList)
|
||
{
|
||
foreach (var subPal in subPaletteList)
|
||
{
|
||
int rawOffset = subPal.Offset * 8;
|
||
int rawLen = subPal.Length == 0 ? 2048 : subPal.Length * 8;
|
||
var pal = _dats.Get<DatReaderWriter.DBObjs.Palette>(subPal.SubPaletteId);
|
||
string palInfo = pal is null ? "Palette dat NOT FOUND (might be PaletteSet 0x0F?)" : $"Colors.Count={pal.Colors.Count}";
|
||
Console.WriteLine($" SP id=0x{subPal.SubPaletteId:X8} wireOffset={subPal.Offset} wireLength={subPal.Length} -> rawIdx[{rawOffset}..{rawOffset + rawLen}) {palInfo}");
|
||
// If pal is non-null and small, show first 4 colors
|
||
if (pal is not null && pal.Colors.Count > 0)
|
||
{
|
||
int sample = Math.Min(4, pal.Colors.Count);
|
||
for (int s = 0; s < sample; s++)
|
||
{
|
||
var c = pal.Colors[s];
|
||
Console.WriteLine($" pal[{s:D3}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2}");
|
||
}
|
||
// Also probe at the rawOffset (if in range) — that's where overlay copies FROM in our code
|
||
if (rawOffset < pal.Colors.Count)
|
||
{
|
||
var c = pal.Colors[rawOffset];
|
||
Console.WriteLine($" pal[{rawOffset:D4}] R={c.Red:X2} G={c.Green:X2} B={c.Blue:X2} <-- our code reads here");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($" pal[{rawOffset:D4}] OUT OF RANGE (Colors.Count={pal.Colors.Count}) -- our code's read SKIPS the overlay !!");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
foreach (var change in animPartChanges)
|
||
{
|
||
if (change.PartIndex < parts.Count)
|
||
{
|
||
parts[change.PartIndex] = new AcDream.Core.World.MeshRef(
|
||
change.NewModelId, parts[change.PartIndex].PartTransform);
|
||
}
|
||
}
|
||
|
||
// Issue #47 — retail's close/player rendering path resolves each
|
||
// part's base GfxObj through its DIDDegrade table to the close-
|
||
// detail mesh in slot 0. Without this, humanoid arms/torso draw
|
||
// the LOW-detail base GfxObj (e.g. 0x01000055, 14 verts / 17
|
||
// polys) instead of the close mesh (0x01001795, 32 verts / 60
|
||
// polys), losing all bicep/shoulder/back geometry. See
|
||
// <see cref="GfxObjDegradeResolver"/> for the named-retail
|
||
// citation (CPhysicsPart::LoadGfxObjArray at 0x0050DCF0,
|
||
// ::UpdateViewerDistance at 0x0050E030, ::Draw at 0x0050D7A0).
|
||
//
|
||
// Order matters: the swap happens AFTER AnimPartChanges have
|
||
// installed the server's body/clothing/head ids, BEFORE texture
|
||
// changes resolve (which match against the resolved mesh's
|
||
// surfaces) and BEFORE the GfxObjMesh.Build / texture upload
|
||
// path consumes the part list.
|
||
if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup))
|
||
{
|
||
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
|
||
{
|
||
var part = parts[partIdx];
|
||
if (!AcDream.Core.Meshing.GfxObjDegradeResolver.TryResolveCloseGfxObj(
|
||
_dats, part.GfxObjId,
|
||
out uint resolvedId, out _))
|
||
continue;
|
||
if (resolvedId == part.GfxObjId)
|
||
continue;
|
||
|
||
parts[partIdx] = new AcDream.Core.World.MeshRef(
|
||
resolvedId, part.PartTransform);
|
||
|
||
if (dumpClothing)
|
||
Console.WriteLine($" DEGRADE part={partIdx:D2} gfx=0x{part.GfxObjId:X8} -> close=0x{resolvedId:X8}");
|
||
}
|
||
}
|
||
|
||
// Build per-part texture overrides. The server sends TextureChanges as
|
||
// (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids
|
||
// are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed
|
||
// by Surface (0x08) ids whose `OrigTextureId` field points to a
|
||
// SurfaceTexture. So we have to resolve each Surface → OrigTextureId,
|
||
// match that against the part's oldSurfaceTextureId set, and build
|
||
// a new dict keyed by Surface id → replacement OrigTextureId. The
|
||
// renderer then calls TextureCache.GetOrUploadWithOrigTextureOverride
|
||
// to get a texture decoded with the replacement SurfaceTexture
|
||
// substituted inside the Surface's decode chain.
|
||
var textureChanges = spawn.TextureChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>();
|
||
if (dumpClothing)
|
||
{
|
||
Console.WriteLine($" TextureChanges count={textureChanges.Count}");
|
||
foreach (var tc in textureChanges)
|
||
Console.WriteLine($" TC part={tc.PartIndex:D2} oldTex=0x{tc.OldTexture:X8} -> newTex=0x{tc.NewTexture:X8}");
|
||
|
||
// For each part (post-AnimPartChange), dump its Surface chain so we
|
||
// can see which OrigTextureIds the part references and check which
|
||
// are covered by our TextureChanges.
|
||
var tcByPart = new Dictionary<int, HashSet<uint>>();
|
||
foreach (var tc in textureChanges)
|
||
{
|
||
if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet<uint>(); tcByPart[tc.PartIndex] = set; }
|
||
set.Add(tc.OldTexture);
|
||
}
|
||
for (int pi = 0; pi < parts.Count; pi++)
|
||
{
|
||
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
|
||
if (pgfx is null) continue;
|
||
if (pgfx.Surfaces.Count == 0) continue;
|
||
tcByPart.TryGetValue(pi, out var coveredOldTex);
|
||
int matched = 0;
|
||
int unmatched = 0;
|
||
var unmatchedList = new List<string>();
|
||
foreach (var surfQid in pgfx.Surfaces)
|
||
{
|
||
uint surfId = (uint)surfQid;
|
||
var surf = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
|
||
if (surf is null) continue;
|
||
uint origTex = (uint)surf.OrigTextureId;
|
||
if (coveredOldTex is not null && coveredOldTex.Contains(origTex)) matched++;
|
||
else { unmatched++; unmatchedList.Add($"surf=0x{surfId:X8} origTex=0x{origTex:X8}"); }
|
||
}
|
||
if (pgfx.Surfaces.Count > 0)
|
||
Console.WriteLine($" part[{pi:D2}] gfx=0x{parts[pi].GfxObjId:X8} surfaces={pgfx.Surfaces.Count} matched={matched} unmatched={unmatched}");
|
||
foreach (var s in unmatchedList)
|
||
Console.WriteLine($" UNMATCHED {s}");
|
||
}
|
||
}
|
||
Dictionary<int, Dictionary<uint, uint>>? resolvedOverridesByPart = null;
|
||
if (textureChanges.Count > 0)
|
||
{
|
||
// First pass: group (oldOrigTex → newOrigTex) per part.
|
||
var perPartOldToNew = new Dictionary<int, Dictionary<uint, uint>>();
|
||
foreach (var tc in textureChanges)
|
||
{
|
||
if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict))
|
||
{
|
||
dict = new Dictionary<uint, uint>();
|
||
perPartOldToNew[tc.PartIndex] = dict;
|
||
}
|
||
// Last write wins — matches observed duplicate semantics.
|
||
dict[tc.OldTexture] = tc.NewTexture;
|
||
}
|
||
|
||
// Second pass: resolve each affected part's Surface chain and
|
||
// build the Surface-id-keyed override map the renderer consumes.
|
||
bool isStatueDiag = dumpLiveSpawns
|
||
&& spawn.Name is not null
|
||
&& spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
|
||
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
|
||
for (int pi = 0; pi < parts.Count; pi++)
|
||
{
|
||
if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue;
|
||
var partGfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
|
||
if (partGfx is null)
|
||
{
|
||
if (isStatueDiag)
|
||
Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing");
|
||
continue;
|
||
}
|
||
_physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx);
|
||
|
||
if (isStatueDiag)
|
||
Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}");
|
||
|
||
Dictionary<uint, uint>? resolved = null;
|
||
foreach (var surfQid in partGfx.Surfaces)
|
||
{
|
||
uint surfId = (uint)surfQid;
|
||
var surfDat = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
|
||
if (surfDat is null) continue;
|
||
uint origTexId = (uint)surfDat.OrigTextureId;
|
||
bool hit = origTexId != 0 && oldToNew.TryGetValue(origTexId, out uint newOrigTex) && (newOrigTex != 0 || true);
|
||
if (isStatueDiag)
|
||
Console.WriteLine($"live: [STATUE] surface=0x{surfId:X8} origTex=0x{origTexId:X8} " + (hit ? "[MATCH]" : "[miss]"));
|
||
if (origTexId == 0) continue;
|
||
if (oldToNew.TryGetValue(origTexId, out uint newId))
|
||
{
|
||
resolved ??= new Dictionary<uint, uint>();
|
||
resolved[surfId] = newId;
|
||
}
|
||
}
|
||
|
||
if (resolved is not null)
|
||
resolvedOverridesByPart[pi] = resolved;
|
||
}
|
||
}
|
||
|
||
// Apply ObjScale by baking a scale matrix into each MeshRef's
|
||
// PartTransform. Scenery hydration already does this pattern
|
||
// (scaleMat baked into PartTransform at Setup flatten time).
|
||
// Fallback to 1.0 if the server didn't send ObjScale (common for
|
||
// creatures/characters whose size is intrinsic to the mesh).
|
||
float scale = spawn.ObjScale ?? 1.0f;
|
||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
|
||
|
||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||
var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||
int dumpClothingTotalTris = 0;
|
||
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
|
||
{
|
||
var mr = parts[partIdx];
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||
if (gfx is null)
|
||
{
|
||
if (dumpClothing)
|
||
Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} GFXOBJ_DAT_MISSING -> 0 tris");
|
||
continue;
|
||
}
|
||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||
if (dumpClothing)
|
||
{
|
||
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||
int tris = 0; int subs = 0;
|
||
foreach (var sm in subMeshes) { tris += sm.Indices.Length / 3; subs++; }
|
||
dumpClothingTotalTris += tris;
|
||
Console.WriteLine($" EMIT part={partIdx:D2} gfx=0x{mr.GfxObjId:X8} subMeshes={subs} tris={tris}");
|
||
}
|
||
|
||
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
|
||
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
|
||
surfaceOverrides = partOverrides;
|
||
|
||
// Multiplication order matches offline scenery hydration:
|
||
// `PartTransform * scaleMat`. In row-vector semantics this means
|
||
// "apply PartTransform first (which includes the part-attachment
|
||
// translation), then scale in the resulting space." Using the
|
||
// opposite order (`scaleMat * PartTransform`) scales in mesh-local
|
||
// space first, which leaves the part-attachment offset unscaled —
|
||
// for multi-part entities like the Nullified Statue that causes
|
||
// the parts to drift relative to each other ("distorted") and the
|
||
// base anchor to end up below the ground ("sinks into foundry").
|
||
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
|
||
|
||
// #119 follow-up: vertex-derived root-local bounds (see WorldEntity.RefreshAabb).
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) liveBounds.Add(transform, pb.Value);
|
||
|
||
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform)
|
||
{
|
||
SurfaceOverrides = surfaceOverrides,
|
||
});
|
||
}
|
||
if (meshRefs.Count == 0)
|
||
{
|
||
_liveDropReasonNoMeshRefs++;
|
||
if (dumpLiveSpawns)
|
||
Console.WriteLine($"live: DROP no mesh refs from setup 0x{spawn.SetupTableId.Value:X8} " +
|
||
$"(guid=0x{spawn.Guid:X8})");
|
||
return;
|
||
}
|
||
if (dumpClothing)
|
||
Console.WriteLine($" TOTAL tris={dumpClothingTotalTris} meshRefs={meshRefs.Count} (parts.Count={parts.Count})");
|
||
|
||
// Build optional per-entity palette override from the server's base
|
||
// palette + subpalette overlays. The renderer applies these to
|
||
// palette-indexed textures (PFID_P8 / PFID_INDEX16) to get per-entity
|
||
// skin/hair/body colors and statue stone recoloring. Non-palette
|
||
// textures ignore the override.
|
||
AcDream.Core.World.PaletteOverride? paletteOverride = null;
|
||
if (spawn.SubPalettes is { Count: > 0 } spList)
|
||
{
|
||
var ranges = new AcDream.Core.World.PaletteOverride.SubPaletteRange[spList.Count];
|
||
for (int i = 0; i < spList.Count; i++)
|
||
ranges[i] = new AcDream.Core.World.PaletteOverride.SubPaletteRange(
|
||
spList[i].SubPaletteId, spList[i].Offset, spList[i].Length);
|
||
paletteOverride = new AcDream.Core.World.PaletteOverride(
|
||
BasePaletteId: spawn.BasePaletteId ?? 0,
|
||
SubPalettes: ranges);
|
||
}
|
||
|
||
AcDream.Core.World.PartOverride[] entityPartOverrides;
|
||
if (animPartChanges.Count == 0)
|
||
{
|
||
entityPartOverrides = Array.Empty<AcDream.Core.World.PartOverride>();
|
||
}
|
||
else
|
||
{
|
||
entityPartOverrides = new AcDream.Core.World.PartOverride[animPartChanges.Count];
|
||
for (int i = 0; i < animPartChanges.Count; i++)
|
||
entityPartOverrides[i] = new AcDream.Core.World.PartOverride(
|
||
animPartChanges[i].PartIndex, animPartChanges[i].NewModelId);
|
||
}
|
||
|
||
var entity = new AcDream.Core.World.WorldEntity
|
||
{
|
||
Id = _liveEntityIdCounter++,
|
||
ServerGuid = spawn.Guid,
|
||
SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
|
||
Position = worldPos,
|
||
Rotation = rot,
|
||
MeshRefs = meshRefs,
|
||
PaletteOverride = paletteOverride,
|
||
PartOverrides = entityPartOverrides,
|
||
ParentCellId = spawn.Position!.Value.LandblockId,
|
||
};
|
||
if (liveBounds.TryGet(out var liveBMin, out var liveBMax))
|
||
entity.SetLocalBounds(liveBMin, liveBMax);
|
||
|
||
// A7 indoor lighting: server-spawned weenie fixtures (lanterns,
|
||
// braziers, glowing items) carry their light in Setup.Lights exactly
|
||
// like dat-baked statics, but the static registration in
|
||
// ApplyLoadedTerrainLocked never sees CreateObject entities — so an
|
||
// interior's lanterns cast no light and the room reads dark. Register
|
||
// them here, mirroring that path (GameWindow.cs ~6742). Owned by
|
||
// entity.Id, so RemoveLiveEntityByServerGuid's UnregisterOwner tears
|
||
// them down on despawn/respawn. Retail registers object-borne lights
|
||
// regardless of static-vs-dynamic origin (insert_light 0x0054d1b0).
|
||
// The light is placed at the spawn frame and does NOT follow a moving
|
||
// light-bearer yet (register row AP-44) — fine for stationary fixtures,
|
||
// which is the common case. _dats is non-null here (checked above).
|
||
if (_lightingSink is not null
|
||
&& (entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
var liteSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(entity.SourceGfxObjOrSetupId);
|
||
if (liteSetup is not null && liteSetup.Lights.Count > 0)
|
||
{
|
||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||
liteSetup,
|
||
ownerId: entity.Id,
|
||
entityPosition: entity.Position,
|
||
entityRotation: entity.Rotation,
|
||
isDynamic: true); // #143: server-object lights take the D3D dynamic path (1/d att, range×1.5)
|
||
foreach (var ls in loaded)
|
||
_lightingSink.RegisterOwnedLight(ls);
|
||
}
|
||
}
|
||
|
||
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);
|
||
|
||
// Phase A.1: register entity into GpuWorldState so the next frame picks
|
||
// it up. AppendLiveEntity is a no-op if the landblock isn't loaded yet
|
||
// (can happen if the server sends CreateObjects before we finish loading).
|
||
_worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity);
|
||
_liveSpawnHydrated++;
|
||
|
||
// Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so
|
||
// UpdateMotion / UpdatePosition events can reseat this entity by guid.
|
||
_entitiesByServerGuid[spawn.Guid] = entity;
|
||
|
||
// Cache the spawn so OnLiveAppearanceUpdated can replay it with new
|
||
// appearance fields when a later 0xF625 ObjDescEvent arrives.
|
||
_lastSpawnByGuid[spawn.Guid] = spawn;
|
||
|
||
// Commit B 2026-04-29 — live-entity collision registration. The
|
||
// local player is the simulator (its PhysicsBody is the source of
|
||
// truth for our own movement); only remotes register as targets.
|
||
// Phantom-Setup entities (no CylSpheres / no Spheres / no Radius)
|
||
// are deliberately skipped — retail FUN's `FindObjCollisions`
|
||
// falls through to OK_TS for any object with no collision
|
||
// geometry (acclient_2013_pseudo_c.txt:276917,276987).
|
||
if (spawn.Guid != _playerServerGuid)
|
||
{
|
||
RegisterLiveEntityCollision(entity, setup, spawn, origin);
|
||
}
|
||
|
||
// Phase B.2: capture the server-sent MotionTableId for our own
|
||
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
|
||
// The Setup's DefaultMotionTable is often 0 for human characters;
|
||
// the real table comes from PhysicsDescriptionFlag.MTable.
|
||
if (spawn.Guid == _playerServerGuid && spawn.MotionTableId is not null)
|
||
_playerMotionTableId = spawn.MotionTableId;
|
||
|
||
// Phase 6.4: register for per-frame playback if we resolved a real
|
||
// cycle with a non-zero framerate and at least two frames in the
|
||
// cycle (single-frame poses are static and don't need ticking).
|
||
// Diagnostic: log why we did / didn't register so we can tell
|
||
// which entities fall through the filter.
|
||
if (idleCycle is null)
|
||
_liveAnimRejectNoCycle++;
|
||
else if (idleCycle.Framerate == 0f)
|
||
_liveAnimRejectFramerate++;
|
||
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
|
||
_liveAnimRejectSingleFrame++;
|
||
else if (idleCycle.Animation.PartFrames.Count <= 1)
|
||
_liveAnimRejectPartFrames++;
|
||
|
||
|
||
|
||
if (idleCycle is not null && idleCycle.Framerate != 0f
|
||
&& idleCycle.HighFrame > idleCycle.LowFrame
|
||
&& idleCycle.Animation.PartFrames.Count > 1)
|
||
{
|
||
// Snapshot per-part identity from the hydrated meshRefs so the
|
||
// tick can rebuild MeshRefs without redoing AnimPartChanges or
|
||
// texture-override resolution every frame.
|
||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||
for (int i = 0; i < meshRefs.Count; i++)
|
||
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
|
||
|
||
// Create an AnimationSequencer if we can load the MotionTable.
|
||
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
|
||
if (_animLoader is not null)
|
||
{
|
||
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
|
||
if (mtableId != 0)
|
||
{
|
||
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||
if (mtable is not null)
|
||
{
|
||
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
||
uint seqStyle = stanceOverride is > 0
|
||
? (0x80000000u | (uint)stanceOverride.Value)
|
||
: (uint)mtable.DefaultStyle;
|
||
uint seqMotion;
|
||
if (commandOverride is > 0)
|
||
{
|
||
uint resolved = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(commandOverride.Value);
|
||
seqMotion = resolved != 0
|
||
? resolved
|
||
: (0x40000000u | (uint)commandOverride.Value);
|
||
}
|
||
else
|
||
{
|
||
seqMotion = AcDream.Core.Physics.MotionCommand.Ready;
|
||
}
|
||
|
||
// R2-Q5: retail's enter-world sequence — initialize_
|
||
// state installs the table default (so the entity is
|
||
// NEVER without a cycle: the L.1c "torso on the
|
||
// ground" hazard is structurally gone), then the
|
||
// wire's initial motion dispatches through the
|
||
// verbatim GetObjectSequence (a missing cycle leaves
|
||
// the default playing — retail's own miss behavior;
|
||
// the Run→Walk→Ready fallback chain is deleted with
|
||
// RemoteMotionSink).
|
||
sequencer.InitializeState();
|
||
sequencer.SetCycle(seqStyle, seqMotion);
|
||
}
|
||
}
|
||
}
|
||
|
||
_animatedEntities[entity.Id] = new AnimatedEntity
|
||
{
|
||
Entity = entity,
|
||
Setup = setup,
|
||
Animation = idleCycle.Animation,
|
||
LowFrame = Math.Max(0, idleCycle.LowFrame),
|
||
HighFrame = Math.Min(idleCycle.HighFrame, idleCycle.Animation.PartFrames.Count - 1),
|
||
Framerate = idleCycle.Framerate,
|
||
Scale = scale,
|
||
PartTemplate = template,
|
||
CurrFrame = idleCycle.LowFrame,
|
||
Sequencer = sequencer,
|
||
};
|
||
|
||
// Phase E.2: register entity's SoundTable so SoundTableHook can
|
||
// resolve creature-specific sounds (footsteps, attack vocalizations,
|
||
// damage grunts, etc). Server-sent SoundTable override would take
|
||
// precedence here when the wire layer delivers it.
|
||
if (_entitySoundTables is not null)
|
||
{
|
||
uint soundTableId = (uint)setup.DefaultSoundTable;
|
||
if (soundTableId != 0)
|
||
_entitySoundTables.Set(entity.Id, soundTableId);
|
||
}
|
||
}
|
||
else if (IsDoorSpawn(spawn) && _animLoader is not null)
|
||
{
|
||
// Phase B.4c — Door swing animation. Doors fail the
|
||
// multi-frame-idle gate above (no idle cycle) but DO have a
|
||
// MotionTable with On/Off cycles that ACE drives via
|
||
// UpdateMotion. Register with a seeded sequencer so the
|
||
// per-frame tick has frames to advance from frame 1 (without
|
||
// the seed, Sequencer.Advance(dt) returns no frames and the
|
||
// MeshRefs rebuild at line 7691 collapses the door to origin).
|
||
//
|
||
// Initial cycle mirrors ACE's Door.cs:43
|
||
// (CurrentMotionState = motionClosed): Off when the door is
|
||
// closed at spawn, On when the spawn PhysicsState carries the
|
||
// ETHEREAL bit (door was already open in ACE's DB).
|
||
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
|
||
if (mtableId != 0)
|
||
{
|
||
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
|
||
if (mtable is not null)
|
||
{
|
||
var sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
|
||
|
||
// Style key is `0x80000000 | stance`. ACE's MotionStance.NonCombat
|
||
// is 0x3D (61 decimal), NOT 0x01. Verified live: ACE broadcasts
|
||
// UpdateMotion with stance=0x003D and the sequencer keys cycles
|
||
// by style=0x8000003D. An earlier B.4c seed used the wrong
|
||
// 0x80000001 value, which made HasCycle always return false ->
|
||
// SetCycle never fired -> sequencer empty -> Advance returned
|
||
// no frames -> per-frame tick collapsed all door parts to the
|
||
// entity origin (visible as "door halfway in the ground").
|
||
const uint NonCombatStyle = 0x8000003Du;
|
||
const uint MotionOn = 0x4000000Bu; // ACE MotionCommand.On (door open)
|
||
const uint MotionOff = 0x4000000Cu; // ACE MotionCommand.Off (door closed)
|
||
const uint EtherealPs = 0x4u;
|
||
// Prefer the spawn's wire-level stance if provided; else default
|
||
// to NonCombat. (Doors normally don't carry an initial MotionState
|
||
// on spawn — falling back to NonCombat matches ACE Door.cs:43.)
|
||
ushort spawnStance = spawn.MotionState?.Stance ?? 0;
|
||
uint initialStyle = spawnStance != 0
|
||
? (0x80000000u | (uint)spawnStance)
|
||
: NonCombatStyle;
|
||
uint spawnState = spawn.PhysicsState ?? 0u;
|
||
uint initialCycle = (spawnState & EtherealPs) != 0 ? MotionOn : MotionOff;
|
||
// R2-Q5: initialize_state guarantees a playing default;
|
||
// the On/Off dispatch no-ops harmlessly if the door's
|
||
// table lacks the cycle (HasCycle guard deleted).
|
||
sequencer.InitializeState();
|
||
sequencer.SetCycle(initialStyle, initialCycle);
|
||
|
||
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
|
||
for (int i = 0; i < meshRefs.Count; i++)
|
||
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
|
||
|
||
_animatedEntities[entity.Id] = new AnimatedEntity
|
||
{
|
||
Entity = entity,
|
||
Setup = setup,
|
||
Animation = null!, // sequencer-driven; tick reads sequencer state
|
||
LowFrame = 0,
|
||
HighFrame = 0,
|
||
Framerate = 0f,
|
||
Scale = scale,
|
||
PartTemplate = template,
|
||
CurrFrame = 0,
|
||
Sequencer = sequencer,
|
||
};
|
||
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[door-anim] registered guid=0x{spawn.Guid:X8} entityId=0x{entity.Id:X8} mtable=0x{mtableId:X8} initialStyle=0x{initialStyle:X8} initialCycle=0x{initialCycle:X8}"));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Dump a summary periodically so we can see drop breakdowns without
|
||
// waiting for a graceful shutdown.
|
||
if (dumpLiveSpawns && _liveSpawnReceived % 20 == 0)
|
||
{
|
||
Console.WriteLine(
|
||
$"live: animated={_animatedEntities.Count} " +
|
||
$"animReject: noCycle={_liveAnimRejectNoCycle} fr0={_liveAnimRejectFramerate} " +
|
||
$"1frame={_liveAnimRejectSingleFrame} partFrames={_liveAnimRejectPartFrames}");
|
||
Console.WriteLine(
|
||
$"live: summary recv={_liveSpawnReceived} hydrated={_liveSpawnHydrated} " +
|
||
$"drops: noPos={_liveDropReasonNoPos} noSetup={_liveDropReasonNoSetup} " +
|
||
$"setupMissing={_liveDropReasonSetupDatMissing} noMesh={_liveDropReasonNoMeshRefs}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Triangle-aware terrain Z sample directly from a landblock's raw
|
||
/// heightmap. Used as the bilinear fallback in scenery hydration when
|
||
/// physics hasn't built a <c>TerrainSurface</c> for the landblock yet
|
||
/// (streaming race). Delegates to
|
||
/// <see cref="AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap"/>
|
||
/// so this fallback and the player-physics path stay in lock-step on
|
||
/// sloped cells.
|
||
///
|
||
/// <para>
|
||
/// Issue #48: the previous in-place implementation here had its two
|
||
/// diagonal arms swapped (SWtoNE cells used the SEtoNW triangle test
|
||
/// and vice versa), so scenery on hilly terrain sat at a different Z
|
||
/// than the visible terrain mesh — a multi-meter offset in some
|
||
/// cells, the user-reported "floating trees" symptom.
|
||
/// </para>
|
||
/// </summary>
|
||
private static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float localX, float localY)
|
||
{
|
||
uint landblockX = (block.Id >> 24) & 0xFFu;
|
||
uint landblockY = (block.Id >> 16) & 0xFFu;
|
||
return AcDream.Core.Physics.TerrainSurface.SampleZFromHeightmap(
|
||
block.Height, heightTable, landblockX, landblockY, localX, localY);
|
||
}
|
||
|
||
private void OnLiveEntityDeleted(AcDream.Core.Net.Messages.DeleteObject.Parsed delete)
|
||
{
|
||
// L.2g S1: drop the staleness gate with the entity — a subsequent
|
||
// re-create adopts fresh stamps from its CreateObject (retail's
|
||
// update_times die with the CPhysicsObj).
|
||
_motionSequenceGates.Remove(delete.Guid);
|
||
|
||
if (RemoveLiveEntityByServerGuid(delete.Guid)
|
||
&& Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||
{
|
||
Console.WriteLine(
|
||
$"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Server broadcast a <c>0xF625 ObjDescEvent</c> — a creature/player's
|
||
/// appearance changed (equip / unequip / tailoring / recipe result /
|
||
/// character option toggle). The wire payload only carries the new
|
||
/// ModelData (palette + texture + animpart changes), not position or
|
||
/// motion, so we splice it onto the cached spawn and replay through
|
||
/// <see cref="OnLiveEntitySpawned"/>. The dedup at the start of
|
||
/// <see cref="OnLiveEntitySpawnedLocked"/> tears down the previous
|
||
/// rendering state (GpuWorldState entry, animated entity, collision
|
||
/// registration) before rebuilding.
|
||
/// </summary>
|
||
private void OnLiveAppearanceUpdated(AcDream.Core.Net.Messages.ObjDescEvent.Parsed update)
|
||
{
|
||
if (!_lastSpawnByGuid.TryGetValue(update.Guid, out var oldSpawn))
|
||
{
|
||
// Server can broadcast ObjDescEvent before we've seen a
|
||
// CreateObject for this guid (race on landblock entry, or
|
||
// if the entity is in a state we couldn't render). Drop —
|
||
// when CreateObject lands, ACE includes the same ModelData
|
||
// body inside it, so the appearance won't be lost.
|
||
return;
|
||
}
|
||
|
||
var md = update.ModelData;
|
||
var newSpawn = oldSpawn with
|
||
{
|
||
AnimPartChanges = md.AnimPartChanges,
|
||
TextureChanges = md.TextureChanges,
|
||
SubPalettes = md.SubPalettes,
|
||
BasePaletteId = md.BasePaletteId,
|
||
};
|
||
OnLiveEntitySpawned(newSpawn);
|
||
|
||
// Slice 2: a player appearance change (equip / unequip) rebuilt _entitiesByServerGuid[player]
|
||
// above; flag the paperdoll doll to re-clone from it on the next doll pass (the C# analog of
|
||
// RedressCreature). Cheap flag — the rebuild is deferred to the pre-UI hook when visible.
|
||
if (update.Guid == _playerServerGuid)
|
||
_paperdollDollDirty = true;
|
||
}
|
||
|
||
/// <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.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 = dats.Get<DatReaderWriter.DBObjs.Animation>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Commit B 2026-04-29 — register a live (server-spawned) entity into
|
||
/// the <see cref="ShadowObjectRegistry"/> as a single collision body.
|
||
/// One entry per entity (in contrast to static scenery's per-CylSphere
|
||
/// registration) so <c>RemoveLiveEntityByServerGuid</c>'s single
|
||
/// <c>Deregister(entity.Id)</c> cleans it up without leaks.
|
||
///
|
||
/// <para>
|
||
/// Geometry-priority order matches retail
|
||
/// (<c>acclient_2013_pseudo_c.txt:276858-276987</c>): CylSpheres >
|
||
/// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are
|
||
/// rejected — retail's <c>FindObjCollisions</c> falls through to
|
||
/// OK_TS in that case.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Carries <see cref="EntityCollisionFlags"/> derived from the PWD
|
||
/// bitfield (<c>acclient.h:6431-6463</c>) plus <c>IsCreature</c>
|
||
/// derived from the inbound ItemType. Commit C consumes these in
|
||
/// the PvP exemption block.
|
||
/// </para>
|
||
/// </summary>
|
||
private void RegisterLiveEntityCollision(
|
||
AcDream.Core.World.WorldEntity entity,
|
||
DatReaderWriter.DBObjs.Setup setup,
|
||
AcDream.Core.Net.WorldSession.EntitySpawn spawn,
|
||
System.Numerics.Vector3 origin)
|
||
{
|
||
if (spawn.Position is null) return;
|
||
|
||
bool hasCyl = setup.CylSpheres.Count > 0;
|
||
bool hasSphere = setup.Spheres.Count > 0;
|
||
bool hasRadius = setup.Radius > 0.0001f;
|
||
|
||
// NOTE: We intentionally do NOT gate here on `!hasCyl && !hasSphere && !hasRadius`.
|
||
// That premature check was wrong: ShadowShapeBuilder.FromSetup also emits a BSP shape
|
||
// for any Part whose GfxObj has a non-null PhysicsBSP (e.g. a candle holder or floor
|
||
// candelabra with only a BSP collision mesh, no CylSphere/Sphere/Radius). Gating here
|
||
// would silently drop those BSP-only entities before the builder runs, making them
|
||
// invisible walls or fully passable. The correct final gate is at shapes.Count==0
|
||
// below (after the builder + Radius fallback have run), which correctly handles ALL
|
||
// cases: BSP-only -> builder emits BSP shape -> registered; truly shapeless -> builder
|
||
// empty, no Radius fallback -> shapes.Count==0 -> return. Retail eligibility is
|
||
// "has physics_bsp OR cylsphere OR sphere" per CPhysicsObj::FindObjCollisions
|
||
// (acclient_2013_pseudo_c.txt:276917 context: the gate fires on the MOVER, not the
|
||
// target; no equivalent target-side gate skips BSP-only objects).
|
||
|
||
float entScale = spawn.ObjScale ?? 1.0f;
|
||
|
||
// A6.P4 door fix (2026-05-24): build the multi-part shape list.
|
||
// ShadowShapeBuilder emits one entry per CylSphere, one per Sphere
|
||
// (only when no CylSpheres), and one per Part with a non-null
|
||
// PhysicsBSP. Retail-faithful per CTransition::find_obj_collisions
|
||
// → CPartArray::FindObjCollisions
|
||
// (acclient_2013_pseudo_c.txt:286236). Pre-fix doors registered
|
||
// ONE small Cylinder via setup.Radius / Sphere — too narrow to
|
||
// span the doorway gap, so the player could walk through. With
|
||
// this change the door also registers the part-0 BSP slab
|
||
// (1.9 × 0.26 × 2.5 m) that retail uses for the real block.
|
||
var raw = AcDream.Core.Physics.ShadowShapeBuilder.FromSetup(
|
||
setup, entScale,
|
||
id => _physicsDataCache.GetGfxObj(id)?.BSP?.Root is not null);
|
||
|
||
// Substitute the real bounding-sphere radius for BSP shapes —
|
||
// the pure builder's 2.0 placeholder works for typical doors
|
||
// (BS radius ≈ 1.975 m) but is loose for larger entities and
|
||
// tight for smaller ones. Mirrors the landblock-static path's
|
||
// pattern of pulling the real radius from PhysicsDataCache.
|
||
var shapes = new List<AcDream.Core.Physics.ShadowShape>(raw.Count);
|
||
foreach (var s in raw)
|
||
{
|
||
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.BSP)
|
||
{
|
||
var phys = _physicsDataCache.GetGfxObj(s.GfxObjId);
|
||
float bspR = (phys?.BoundingSphere?.Radius ?? 2f) * entScale;
|
||
shapes.Add(s with { Radius = bspR });
|
||
}
|
||
else
|
||
{
|
||
shapes.Add(s);
|
||
}
|
||
}
|
||
|
||
// setup.Radius fallback: the builder doesn't emit a Radius-only
|
||
// shape (it only walks CylSpheres / Spheres / Parts). For entities
|
||
// with no CylSpheres / Spheres / BSP-bearing Parts but a non-zero
|
||
// Radius (rare — simple decorative props), preserve the prior
|
||
// behavior of registering a setup.Radius cylinder so we don't
|
||
// silently regress those entities' collision.
|
||
if (shapes.Count == 0 && hasRadius)
|
||
{
|
||
shapes.Add(new AcDream.Core.Physics.ShadowShape(
|
||
GfxObjId: 0u,
|
||
LocalPosition: System.Numerics.Vector3.Zero,
|
||
LocalRotation: System.Numerics.Quaternion.Identity,
|
||
Scale: entScale,
|
||
CollisionType: AcDream.Core.Physics.ShadowCollisionType.Cylinder,
|
||
Radius: setup.Radius * entScale,
|
||
CylHeight: (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale));
|
||
}
|
||
|
||
if (shapes.Count == 0)
|
||
return;
|
||
|
||
// Decode PvP / Player / Impenetrable from PWD._bitfield.
|
||
// IsCreature comes from the spawn's ItemType (server-known type).
|
||
var flags = AcDream.Core.Physics.EntityCollisionFlags.None;
|
||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||
flags = AcDream.Core.Physics.EntityCollisionFlagsExt.FromPwdBitfield(odf);
|
||
if (spawn.ItemType == (uint)AcDream.Core.Items.ItemType.Creature)
|
||
flags |= AcDream.Core.Physics.EntityCollisionFlags.IsCreature;
|
||
|
||
uint state = spawn.PhysicsState ?? 0u;
|
||
|
||
_physicsEngine.ShadowObjects.RegisterMultiPart(
|
||
entityId: entity.Id,
|
||
entityWorldPos: entity.Position,
|
||
entityWorldRot: entity.Rotation,
|
||
shapes: shapes,
|
||
state: state,
|
||
flags: flags,
|
||
worldOffsetX: origin.X,
|
||
worldOffsetY: origin.Y,
|
||
landblockId: spawn.Position.Value.LandblockId,
|
||
// BR-7 / A6.P4 (2026-06-11): the server position's full cell id
|
||
// is the registration flood seed (retail m_position.objcell_id
|
||
// into CObjCell::find_cell_list). A door whose spheres straddle
|
||
// the doorway lands in BOTH the outdoor landcell and the
|
||
// vestibule's shadow list at registration — the architectural
|
||
// close of #99. Dynamic objects use calc_cross_cells (no
|
||
// do_not_load prune), hence isStatic: false.
|
||
seedCellId: spawn.Position.Value.LandblockId,
|
||
isStatic: false);
|
||
|
||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||
// Per-shape detail appears in [resolve-bldg] when collisions fire;
|
||
// this entity-level line keeps the spawn-time identification.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
{
|
||
int nCyl = 0, nBsp = 0;
|
||
foreach (var s in shapes)
|
||
{
|
||
if (s.CollisionType == AcDream.Core.Physics.ShadowCollisionType.Cylinder) nCyl++;
|
||
else nBsp++;
|
||
}
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} shapes=cyl{nCyl}+bsp{nBsp} note=server-spawn-root state=0x{state:X8} flags={flags}"));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// R3-W4: one-time per-remote wiring of the animation-dispatch stack —
|
||
/// the persistent <see cref="RemoteMotion.Sink"/> (ObservedOmega turn
|
||
/// callbacks), <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 AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings(
|
||
RemoteMotion rm, AnimatedEntity ae, uint serverGuid)
|
||
{
|
||
if (ae.Sequencer is null)
|
||
return rm.Sink;
|
||
if (rm.Sink is not null)
|
||
return rm.Sink;
|
||
|
||
var rmForSink = rm;
|
||
rm.Sink = new AcDream.Core.Physics.Motion.MotionTableDispatchSink(ae.Sequencer)
|
||
{
|
||
TurnApplied = (turnMotion, turnSpeed) =>
|
||
{
|
||
float signed = (turnMotion & 0xFFu) == 0x0E
|
||
? -System.MathF.Abs(turnSpeed)
|
||
: turnSpeed;
|
||
rmForSink.ObservedOmega = new System.Numerics.Vector3(
|
||
0f, 0f, -(System.MathF.PI / 2f) * signed);
|
||
},
|
||
TurnStopped = () =>
|
||
rmForSink.ObservedOmega = System.Numerics.Vector3.Zero,
|
||
};
|
||
rm.Motion.DefaultSink = rm.Sink;
|
||
rm.Motion.RemoveLinkAnimations = ae.Sequencer.RemoveAllLinkAnimations;
|
||
rm.Motion.InitializeMotionTables = () => ae.Sequencer.Manager.InitializeState();
|
||
// R3-W5: the per-op zero-tick flush (CPhysicsObj::CheckForCompletedMotions
|
||
// 0x0050fe30 -> MotionTableManager.CheckForCompletedMotions).
|
||
rm.Motion.CheckForCompletedMotions = ae.Sequencer.Manager.CheckForCompletedMotions;
|
||
|
||
// R4-V4: the verbatim MoveToManager, seam-bound per the V2 harness
|
||
// wiring (the conformance-tested reference). Positions are WORLD
|
||
// space on both sides (getPosition + the MovementStruct positions
|
||
// the UM router builds) — one consistent space, so the manager's
|
||
// distance/heading math matches the harness exactly.
|
||
// R5-V5: the construction is now the MovementManager facade's
|
||
// MoveToFactory (the acdream stand-in for the physics_obj/weenie_obj
|
||
// backpointers retail's MakeMoveToManager 0x00524000 constructs
|
||
// from); MakeMoveToManager() below invokes it once, preserving the
|
||
// pre-facade eager timing.
|
||
var rmT = rm;
|
||
var mtBody = rm.Body;
|
||
// R5-V3 (#171): real setup cylsphere radii — retail CPartArray::
|
||
// GetRadius/GetHeight (0x005180a0/0x005180b0). Lazy per-call resolve
|
||
// (a dictionary hit) so the read never races the spawn path's
|
||
// CacheSetup. Feeds GetCurrentDistance's UseSpheres cylinder math
|
||
// (own side) and StickyManager::adjust_offset's own-radius gap term.
|
||
// Replaces the R4 `() => 0f` pins ("setup cylsphere radius lands with
|
||
// R5-V3").
|
||
var selfEntity = ae.Entity;
|
||
// R5-V2: forward-declared so the MoveToManager's target seams route
|
||
// into the entity's TargetManager (retail CPhysicsObj::set_target →
|
||
// TargetManager::SetTarget). Assigned right after the manager is built.
|
||
EntityPhysicsHost host = null!;
|
||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
rm.Movement.MoveToFactory = () =>
|
||
{
|
||
var mtm = new AcDream.Core.Physics.Motion.MoveToManager(
|
||
rm.Motion,
|
||
stopCompletely: () => rmT.Motion.StopCompletely(),
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
rmT.CellId, mtBody.Position, mtBody.Orientation),
|
||
getHeading: () => AcDream.Core.Physics.Motion.MoveToMath.GetHeading(
|
||
mtBody.Orientation),
|
||
setHeading: (h, _) => mtBody.Orientation =
|
||
AcDream.Core.Physics.Motion.MoveToMath.SetHeading(mtBody.Orientation, h),
|
||
getOwnRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||
getOwnHeight: () => GetSetupCylinder(serverGuid, selfEntity).Height,
|
||
contact: () => mtBody.OnWalkable,
|
||
isInterpolating: () => rmT.Interp.IsActive,
|
||
getVelocity: () => mtBody.Velocity,
|
||
getSelfId: () => serverGuid,
|
||
// R5-V2: retail CPhysicsObj::set_target/clear_target/target_quantum
|
||
// → the entity's TargetManager (replaces the AP-79 TrackedTarget*
|
||
// poll). The manager passes (0, top_level_id, 0.5, 0.0).
|
||
setTarget: (ctx, tlid, radius, q) => host.SetTarget(ctx, tlid, radius, q),
|
||
clearTarget: () => host.ClearTarget(),
|
||
getTargetQuantum: () => host.TargetManager.GetTargetQuantum(),
|
||
setTargetQuantum: q => host.TargetManager.SetTargetQuantum(q),
|
||
// R4-V5: real clock (same epoch-seconds base the per-remote tick uses).
|
||
curTime: NowSeconds);
|
||
// R5-V3 (#171, retires TS-39): bind the sticky seams to the host's
|
||
// PositionManager (host is constructed before MakeMoveToManager
|
||
// invokes this factory) —
|
||
// * BeginNextNode's sticky-arrival handoff: PositionManager::StickTo
|
||
// (retail MoveToManager::BeginNextNode @0x00529d3a);
|
||
// * PerformMovement's head unstick: unstick_from_object →
|
||
// PositionManager::UnStick (MoveToManager.PerformMovement:414).
|
||
mtm.StickTo = (tlid, radius, height) =>
|
||
host.PositionManager.StickTo(tlid, radius, height);
|
||
mtm.Unstick = host.PositionManager.UnStick;
|
||
return mtm;
|
||
};
|
||
// TS-36 (remote side): the interp's interrupt seam is retail's
|
||
// interrupt_current_movement → MovementManager::CancelMoveTo(0x36)
|
||
// chain (V2's reentrancy tests prove the wiring is inert-safe;
|
||
// R5-V5: the chain now lands on the literal facade relay 0x005241b0).
|
||
rm.Motion.InterruptCurrentMovement =
|
||
() => rmT.Movement.CancelMoveTo(
|
||
AcDream.Core.Physics.WeenieError.ActionCancelled);
|
||
|
||
// R5-V2: the entity's CPhysicsObj stand-in + its TargetManager. Position
|
||
// is the entity's WORLD position (WorldEntity.Position — exactly the
|
||
// source the AP-79 poll used for a tracked target, so the voyeur system
|
||
// delivers the identical position for a moveto's quantum-0 case).
|
||
// HandleTargetting ticks per frame (added in the per-remote loop);
|
||
// HandleUpdateTarget fans deliveries to this entity's MoveToManager
|
||
// and (R5-V3, inside the host) its PositionManager — retail's
|
||
// CPhysicsObj::HandleUpdateTarget order.
|
||
host = new EntityPhysicsHost(
|
||
serverGuid,
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
rmT.CellId,
|
||
_entitiesByServerGuid.TryGetValue(serverGuid, out var selfEnt)
|
||
? selfEnt.Position : mtBody.Position,
|
||
mtBody.Orientation),
|
||
getVelocity: () => mtBody.Velocity,
|
||
getRadius: () => GetSetupCylinder(serverGuid, selfEntity).Radius,
|
||
inContact: () => mtBody.OnWalkable,
|
||
minterpMaxSpeed: () => rmT.Motion.GetMaxSpeed(),
|
||
curTime: NowSeconds,
|
||
physicsTimerTime: NowSeconds,
|
||
getObjectA: ResolvePhysicsHost,
|
||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
|
||
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
|
||
// relay to the moveto side); the host chains the PositionManager
|
||
// leg after it.
|
||
handleUpdateTarget: info => rmT.Movement.HandleUpdateTarget(info),
|
||
interruptCurrentMovement: () => rmT.Movement.CancelMoveTo(
|
||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||
rm.Host = host;
|
||
_physicsHosts[serverGuid] = host;
|
||
|
||
// R5-V5: retail MakeMoveToManager (0x00524000) — constructs the
|
||
// MoveToManager via the factory above (host now exists for the
|
||
// sticky binds inside it). The UM funnel head's unstick
|
||
// (CPhysicsObj::unstick_from_object 0x0050eaea, invoked at the mt-0
|
||
// routing sites) binds on the interp beside it.
|
||
rm.Movement.MakeMoveToManager();
|
||
rm.Motion.UnstickFromObject = host.PositionManager.UnStick;
|
||
return rm.Sink;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V2 — retail <c>CObjectMaint::GetObjectA(id)</c>: resolve any entity's
|
||
/// <see cref="AcDream.Core.Physics.Motion.IPhysicsObjHost"/> by guid, the
|
||
/// cross-entity seam every host's <c>GetObjectA</c> uses. If the entity has
|
||
/// a bound host (from <see cref="EnsureRemoteMotionBindings"/> or
|
||
/// <c>EnterPlayerModeNow</c>) return it; otherwise, for any entity that
|
||
/// EXISTS in the world, lazily create a minimal position-only host. Retail
|
||
/// gives every <c>CPhysicsObj</c> the capacity to host a
|
||
/// <c>target_manager</c> — a STATIC object (chest, corpse) must still answer
|
||
/// <c>add_voyeur</c> so a mover can moveto/stick to it (without this,
|
||
/// auto-walk to a never-animated object would arm the moveto but never
|
||
/// receive the immediate target snapshot, and never start). The minimal
|
||
/// host is registered so subsequent lookups reuse it; it is NOT ticked
|
||
/// (a static object never drifts, so the AddVoyeur immediate snapshot is
|
||
/// all a watcher needs; despawn still fires ExitWorld via the registry).
|
||
/// </summary>
|
||
private AcDream.Core.Physics.Motion.IPhysicsObjHost? ResolvePhysicsHost(uint id)
|
||
{
|
||
if (_physicsHosts.TryGetValue(id, out var existing))
|
||
return existing;
|
||
if (!_entitiesByServerGuid.ContainsKey(id))
|
||
return null;
|
||
|
||
double NowSeconds() => (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
var minimal = new EntityPhysicsHost(
|
||
id,
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
0u,
|
||
_entitiesByServerGuid.TryGetValue(id, out var e)
|
||
? e.Position : System.Numerics.Vector3.Zero,
|
||
System.Numerics.Quaternion.Identity),
|
||
getVelocity: () => System.Numerics.Vector3.Zero, // static target
|
||
getRadius: () => 0f,
|
||
inContact: () => true,
|
||
minterpMaxSpeed: () => null,
|
||
curTime: NowSeconds,
|
||
physicsTimerTime: NowSeconds,
|
||
getObjectA: ResolvePhysicsHost,
|
||
handleUpdateTarget: _ => { }, // not a watcher
|
||
interruptCurrentMovement: () => { });
|
||
_physicsHosts[id] = minimal;
|
||
return minimal;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V3 (#171): retail <c>CPartArray::GetRadius</c>/<c>GetHeight</c>
|
||
/// (0x005180a0/0x005180b0 — <c>setup->radius/height × scale</c>; ACE
|
||
/// <c>PartArray.cs:189-207</c> reads the same Setup-level fields). Returns
|
||
/// (0, 0) when the entity has no resolvable Setup (bare-GfxObj props) —
|
||
/// ACE's <c>PartArray != null ? … : 0</c> fallback shape
|
||
/// (<c>PhysicsObj.cs:951-952</c>). Live spawns cache their Setup in
|
||
/// <c>_physicsDataCache</c> at spawn time (CacheSetup, ~3250), so this is
|
||
/// a dictionary hit for every creature.
|
||
/// </summary>
|
||
private (float Radius, float Height) GetSetupCylinder(
|
||
uint serverGuid, AcDream.Core.World.WorldEntity entity)
|
||
{
|
||
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||
if (setup is null)
|
||
return (0f, 0f);
|
||
// Live spawns bake ObjScale into MeshRefs and never populate
|
||
// WorldEntity.Scale (a scenery-pipeline field, default 1.0) — the
|
||
// authoritative per-entity scale is the SPAWN RECORD's ObjScale, the
|
||
// same source the collision registration's entScale uses (~4137).
|
||
// entity.Scale remains the fallback for non-spawn entities (scenery —
|
||
// never a chase/sticky participant). Diff-review find: without this,
|
||
// a server-scaled creature variant read unscaled radii and kept the
|
||
// #171 interpenetration exactly for scaled bodies.
|
||
float scale =
|
||
_lastSpawnByGuid.TryGetValue(serverGuid, out var sp)
|
||
&& sp.ObjScale is { } objScale && objScale > 0f
|
||
? objScale
|
||
: (entity.Scale > 0f ? entity.Scale : 1f);
|
||
return (setup.Radius * scale, setup.Height * scale);
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V3 (#171): apply a <see cref="AcDream.Core.Physics.Motion.MotionDeltaFrame"/>
|
||
/// written by <c>PositionManager.AdjustOffset</c> onto a body — acdream's
|
||
/// stand-in for retail's <c>Frame::combine</c> in
|
||
/// <c>CPhysicsObj::UpdatePositionInternal</c> (0x00512c30, combine
|
||
/// @0x00512d22). The delta's Origin is mover-LOCAL (sticky writes
|
||
/// <c>globaltolocalvec</c> output — 0x00555430), so combining = rotating it
|
||
/// out by the body orientation. The rotation carries a RELATIVE heading;
|
||
/// an untouched (identity) rotation means "no turn" — retail distinguishes
|
||
/// an unwritten offset rotation from <c>set_heading(0)</c> by the identity
|
||
/// VALUE, not the angle, and the P5 pin (identity quaternion = heading 0)
|
||
/// makes compass addition the exact frame-combine here.
|
||
/// </summary>
|
||
private static void ApplyPositionManagerDelta(
|
||
AcDream.Core.Physics.PhysicsBody body,
|
||
AcDream.Core.Physics.Motion.MotionDeltaFrame delta)
|
||
{
|
||
if (delta.Origin != System.Numerics.Vector3.Zero)
|
||
body.Position += System.Numerics.Vector3.Transform(delta.Origin, body.Orientation);
|
||
if (!delta.Orientation.IsIdentity)
|
||
body.Orientation = AcDream.Core.Physics.Motion.MoveToMath.SetHeading(
|
||
body.Orientation,
|
||
AcDream.Core.Physics.Motion.MoveToMath.GetHeading(body.Orientation)
|
||
+ delta.GetHeading());
|
||
}
|
||
|
||
/// <summary>
|
||
/// R5-V4: retail <c>CPhysicsObj::stick_to_object</c> (0x005127e0) — the
|
||
/// mt-0 WIRE sticky (unpack_movement case 0 @00524589, gated on the
|
||
/// motionFlags 0x1 trailer guid): resolve the target, read its PartArray
|
||
/// radius/height (0 when shapeless — retail's null-PartArray arm), then
|
||
/// <c>PositionManager::StickTo</c>. Unresolvable target → no stick at
|
||
/// all (retail's GetObjectA-null path). Retail resolves the target's
|
||
/// top-level PARENT first (<c>parent ?? self</c>); acdream's entity
|
||
/// table is flat (no client-side parenting), so the guid is used as-is —
|
||
/// the same convention as RouteServerMoveTo's TopLevelId.
|
||
/// </summary>
|
||
private void StickToObjectFromWire(EntityPhysicsHost? host, uint targetGuid)
|
||
{
|
||
if (host is null)
|
||
return;
|
||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var tgtEnt))
|
||
return;
|
||
var (radius, height) = GetSetupCylinder(targetGuid, tgtEnt);
|
||
host.PositionManager.StickTo(targetGuid, radius, height);
|
||
}
|
||
|
||
private bool RemoveLiveEntityByServerGuid(uint serverGuid)
|
||
{
|
||
if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity))
|
||
return false;
|
||
|
||
_worldState.RemoveEntityByServerGuid(serverGuid);
|
||
_worldGameState.RemoveById(existingEntity.Id);
|
||
// R2-Q5 + R3-W2: retail runs BOTH layers' exit-world drains
|
||
// independently (r3-port-plan §4): the manager's (each pending
|
||
// animation fires MotionDone(success:false) → the bound interp pops
|
||
// in step) THEN the interp's own (flushes any remainder —
|
||
// MovementManager::HandleExitWorld 0x00524350, the R5-V5 facade
|
||
// relay: interp ONLY → CMotionInterp::HandleExitWorld 0x00527f30).
|
||
if (_animatedEntities.TryGetValue(existingEntity.Id, out var aeGone))
|
||
aeGone.Sequencer?.Manager.HandleExitWorld();
|
||
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmGone))
|
||
rmGone.Movement.HandleExitWorld();
|
||
// R5-V2: retail CPhysicsObj::exit_world tells every voyeur of this
|
||
// entity that it left the world (TargetManager::NotifyVoyeurOfEvent
|
||
// ExitWorld) — a watcher moving-to/stuck-to this entity drops the
|
||
// moveto/stick. This is the ONLY clean-up for a watcher whose target
|
||
// already sent an Ok (once status != Undefined the 10 s staleness
|
||
// timeout never fires), so it must run before the host is pruned.
|
||
if (_physicsHosts.TryGetValue(serverGuid, out var hostGone)
|
||
&& hostGone is EntityPhysicsHost ephGone)
|
||
{
|
||
// R5-V3 (#171): retail exit_world (0x00514e60) order — the
|
||
// departing entity first tears down its OWN stick
|
||
// (PositionManager::UnStick @0x00514e88 — drops its voyeur
|
||
// subscription on whatever it was stuck to) and its own target
|
||
// subscription (TargetManager::ClearTarget @0x00514e97 — same
|
||
// for a plain mid-chase moveto), THEN NotifyVoyeurOfEvent
|
||
// (ExitWorld) tells the entities watching IT. Without the first
|
||
// two, a despawning attacker leaves a dead voyeur entry on its
|
||
// target until send-failure pruning.
|
||
ephGone.PositionManager.UnStick();
|
||
ephGone.ClearTarget();
|
||
ephGone.NotifyExitWorld();
|
||
}
|
||
_physicsHosts.Remove(serverGuid);
|
||
_animatedEntities.Remove(existingEntity.Id);
|
||
_classificationCache.InvalidateEntity(existingEntity.Id);
|
||
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
|
||
|
||
// Dead-reckon state is keyed by SERVER guid (not local id) so we
|
||
// clear using the same guid the next spawn/update would use.
|
||
_remoteDeadReckon.Remove(serverGuid);
|
||
_remoteLastMove.Remove(serverGuid);
|
||
_entitiesByServerGuid.Remove(serverGuid);
|
||
_lastSpawnByGuid.Remove(serverGuid);
|
||
if (_selectedGuid == serverGuid)
|
||
SelectedGuid = null;
|
||
|
||
// A7 indoor lighting: release this entity's owned lights on EVERY
|
||
// removal, including the respawn-dedup path (former logDelete=false).
|
||
// A respawned weenie fixture would otherwise leak its old light set and
|
||
// double-register the new one. (Was gated on logDelete — harmless only
|
||
// while live weenies registered no lights, which is no longer true.)
|
||
_lightingSink?.UnregisterOwner(existingEntity.Id);
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R4-V5: shared retail <c>unpack_movement</c> type-6..9 routing
|
||
/// (<c>0x00524440</c> cases 6/7/8/9, decomp §2f) — one body for remotes
|
||
/// (R4-V4) and the local player (R4-V5). Writes the wire run rate to
|
||
/// the interp (<c>my_run_rate</c>, unpack @300603/@300660 — plan M13),
|
||
/// builds the <c>MovementStruct</c> (mt 6/8 resolve the target guid
|
||
/// against the entity table; unresolvable degrades to
|
||
/// MoveToPosition(wire origin) / TurnToHeading(wire heading) per §2f —
|
||
/// NOT an error), and calls the R5-V5 facade's <c>PerformMovement</c>
|
||
/// (<c>MovementManager::PerformMovement</c> 0x005240d0 — types 6-9
|
||
/// MakeMoveToManager + delegate; retail's cases call MakeMoveToManager
|
||
/// at each head, a no-op here since the bind sites create eagerly).
|
||
/// Returns true when the event was a type-6..9 moveto (consumed); false
|
||
/// for every other movement type (caller falls through to its funnel /
|
||
/// skip posture).
|
||
/// </summary>
|
||
private bool RouteServerMoveTo(
|
||
AcDream.Core.Physics.Motion.MovementManager movement,
|
||
uint cellId,
|
||
AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||
{
|
||
if (update.MotionState.IsServerControlledMoveTo
|
||
&& update.MotionState.MoveToPath is { } path)
|
||
{
|
||
// my_run_rate write (unpack_movement @300603).
|
||
if (update.MotionState.MoveToRunRate is { } mtRunRate)
|
||
movement.Minterp.MyRunRate = mtRunRate;
|
||
|
||
var destWorld = AcDream.Core.Physics.Motion.MoveToMath
|
||
.OriginToWorld(
|
||
path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ,
|
||
_liveCenterX, _liveCenterY);
|
||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWire(
|
||
path.Bitfield,
|
||
path.DistanceToObject,
|
||
path.MinDistance,
|
||
path.FailDistance,
|
||
update.MotionState.MoveToSpeed ?? 1f,
|
||
path.WalkRunThreshold,
|
||
path.DesiredHeading);
|
||
|
||
var ms = new AcDream.Core.Physics.MovementStruct
|
||
{
|
||
Params = mp,
|
||
};
|
||
// mt 6 with a resolvable target → MoveToObject (the P4 tracker
|
||
// feeds position updates per tick); else degrade to
|
||
// MoveToPosition at the wire origin (§2f).
|
||
if (update.MotionState.MovementType == 6
|
||
&& path.TargetGuid is { } tgtGuid
|
||
&& _entitiesByServerGuid.TryGetValue(tgtGuid, out var tgtEnt))
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.MoveToObject;
|
||
ms.ObjectId = tgtGuid;
|
||
ms.TopLevelId = tgtGuid;
|
||
// R5-V3 (#171): retail resolves the TARGET object's PartArray
|
||
// radius/height at the MoveToObject call site
|
||
// (CPhysicsObj::MoveToObject 0x005128e9/0x00512903; ACE
|
||
// PhysicsObj.cs:951-952) — they become SoughtObjectRadius/
|
||
// Height, feeding GetCurrentDistance's edge-to-edge arrival
|
||
// (UseSpheres) and the sticky-arrival handoff's target radius.
|
||
// Was unset (0): every attacker closed ~one body-radius deeper
|
||
// than retail before stopping — the #171 dogpile term.
|
||
(ms.Radius, ms.Height) = GetSetupCylinder(tgtGuid, tgtEnt);
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, tgtEnt.Position,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
else
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.MoveToPosition;
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, destWorld,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
movement.PerformMovement(ms);
|
||
return true;
|
||
}
|
||
|
||
if (update.MotionState.IsServerControlledTurnTo
|
||
&& update.MotionState.TurnToPath is { } turnPath)
|
||
{
|
||
var mp = AcDream.Core.Physics.Motion.MovementParameters.FromWireTurnTo(
|
||
turnPath.Bitfield,
|
||
turnPath.Speed,
|
||
turnPath.DesiredHeading);
|
||
|
||
var ms = new AcDream.Core.Physics.MovementStruct { Params = mp };
|
||
if (update.MotionState.MovementType == 8
|
||
&& turnPath.TargetGuid is { } turnTgt
|
||
&& _entitiesByServerGuid.TryGetValue(turnTgt, out var turnEnt))
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.TurnToObject;
|
||
ms.ObjectId = turnTgt;
|
||
ms.TopLevelId = turnTgt;
|
||
ms.Pos = new AcDream.Core.Physics.Position(
|
||
cellId, turnEnt.Position,
|
||
System.Numerics.Quaternion.Identity);
|
||
}
|
||
else
|
||
{
|
||
ms.Type = AcDream.Core.Physics.MovementType.TurnToHeading;
|
||
// Retail's mt-8 unresolvable-object fallback substitutes the
|
||
// STANDALONE wire heading into the params before degrading
|
||
// (decomp §2f case 8: `params.desired_heading = wire_heading`).
|
||
// Invisible against ACE (P6: both heading fields written from
|
||
// the same source) but required for the verbatim degrade —
|
||
// closes the V4 carry-over the adversarial review caught
|
||
// (TurnToPathData.WireHeading was parsed but never consumed).
|
||
if (update.MotionState.MovementType == 8
|
||
&& turnPath.WireHeading is { } wireHeading)
|
||
{
|
||
mp.DesiredHeading = wireHeading;
|
||
}
|
||
}
|
||
movement.PerformMovement(ms);
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// R4-V5 / R5-V2: the per-tick <see cref="AcDream.Core.Physics.Motion.MovementManager"/>
|
||
/// drive (retail <c>MovementManager::UseTime</c> 0x005242f0 — the moveto
|
||
/// side's steering, arrival, fail-distance; R5-V5 facade relay). The P4
|
||
/// TargetTracker POLL is gone (R5-V2): target-position delivery now flows
|
||
/// through the <see cref="AcDream.Core.Physics.Motion.TargetManager"/>
|
||
/// voyeur system — the target's own <c>HandleTargetting</c> pushes updates
|
||
/// into this entity's <c>HandleUpdateTarget</c>, which is called
|
||
/// unconditionally per remote in the per-tick loop BEFORE this drive
|
||
/// (retail <c>UpdateObjectInternal</c> order). Safe for any remote: a
|
||
/// manager with no armed moveto no-ops, airborne bodies held by
|
||
/// <c>UseTime</c>'s contact gate.
|
||
/// </summary>
|
||
private void TickRemoteMoveTo(RemoteMotion rm)
|
||
{
|
||
rm.Movement.UseTime();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 6.6: the server says an entity's motion has changed. Look up
|
||
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
|
||
/// new (stance, forward-command) override, and if the cycle is still
|
||
/// animated, swap in the new animation/frame range. Entities not in
|
||
/// the animated map (static props, entities rejected at spawn time)
|
||
/// are simply ignored — there's nothing to tick for them.
|
||
/// </summary>
|
||
private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
|
||
{
|
||
if (_dats is null) return;
|
||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
|
||
|
||
// L.2g S1 (DEV-6): retail staleness gate — BEFORE any state mutation.
|
||
// Retail drops stale/duplicate/superseded movement events at
|
||
// DispatchSmartBoxEvent (INSTANCE_TS, pseudo-C:357214) +
|
||
// CPhysics::SetObjectMovement (MOVEMENT_TS strictly-newer +
|
||
// SERVER_CONTROLLED_MOVE_TS, 0x00509690). Without this, a reordered
|
||
// straggler re-applies an old gait or un-stops a stop.
|
||
if (!_motionSequenceGates.TryGetValue(update.Guid, out var seqGate))
|
||
{
|
||
// UM for an entity whose CreateObject we never parsed (rare —
|
||
// the entity lookup above implies a spawn). Adopt-on-first-seed
|
||
// keeps the gate correct from this event onward.
|
||
seqGate = new AcDream.Core.Physics.MotionSequenceGate();
|
||
_motionSequenceGates[update.Guid] = seqGate;
|
||
seqGate.Seed(update.InstanceSequence, update.MovementSequence, update.ServerControlSequence);
|
||
}
|
||
else if (!seqGate.TryAcceptMovementEvent(
|
||
update.InstanceSequence, update.MovementSequence, update.ServerControlSequence))
|
||
{
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||
|| Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
Console.WriteLine(
|
||
$"[UM_STALE] guid={update.Guid:X8} inst={update.InstanceSequence} "
|
||
+ $"mov={update.MovementSequence} sc={update.ServerControlSequence} dropped");
|
||
}
|
||
return;
|
||
}
|
||
|
||
// R4-V5 (pin P1): retail CPhysics::SetObjectMovement's autonomous
|
||
// gate (0x00509690 @0050972e, raw 271370-271431) — a movement event
|
||
// whose wire autonomous byte is set is DROPPED ENTIRELY (no state
|
||
// application, no interrupt) when the addressed object IsThePlayer.
|
||
// ACE reflects the client's own outbound MoveToState back to the
|
||
// sender with IsAutonomous=1 hardcoded (MovementData.cs:162 +
|
||
// Player_Networking.cs:365) and retail never lets that echo reach
|
||
// unpack_movement — which is what makes the unconditional
|
||
// unpack-head interrupt in the player branch below safe against
|
||
// ACE. Order matches retail: the sequence gates above run FIRST.
|
||
// last_move_was_autonomous is NOT stored for dropped events (stored
|
||
// only on the unpack path). This retires the row-less "don't cancel
|
||
// on non-MoveTo UM" adaptation that lived here pre-V5 (its causal
|
||
// story was stale — V0-pins.md P1). Run-rate sync is re-anchored to
|
||
// retail's own feeds: PlayerDescription skills (SetCharacterSkills,
|
||
// K-fix7) + the mt-6/7 my_run_rate wire write below (M13) — the
|
||
// former ApplyServerRunRate echo tap is deleted, not gated.
|
||
if (update.Guid == _playerServerGuid && update.IsAutonomous)
|
||
return;
|
||
|
||
// Re-resolve using the new stance/command. Keep the setup and
|
||
// motion-table we already know about — the server's motion
|
||
// updates override state within the same table, not swap tables.
|
||
//
|
||
// IMPORTANT: stance and command are BOTH optional. Remote-player
|
||
// autonomous broadcasts frequently set only one flag (e.g. just
|
||
// ForwardCommand) with currentStyle=0x0000 meaning "no stance
|
||
// change — keep current." Treating stance=0 as "default stance"
|
||
// drops the real state; instead we preserve the sequencer's
|
||
// current style.
|
||
ushort stance = update.MotionState.Stance;
|
||
ushort? command = update.MotionState.ForwardCommand;
|
||
|
||
// A.1 (Commit A.1 2026-05-03): UM_RAW — every inbound UM, one line,
|
||
// gated on ACDREAM_REMOTE_VEL_DIAG=1. Skips the local player. Tells
|
||
// us the actual UM arrival rate per remote and which fields are set
|
||
// on each. The bug-suspect is "ACE sends UMs without ForwardCommand
|
||
// bit during running, our picker resolves to Ready, SetCycle(Ready)
|
||
// resets the cycle". This diag lets us count how often that happens.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||
&& update.Guid != _playerServerGuid)
|
||
{
|
||
string cmdStrRaw = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||
string sideStr = update.MotionState.SideStepCommand is { } s ? $"0x{s:X4}" : "null";
|
||
string turnStr = update.MotionState.TurnCommand is { } t ? $"0x{t:X4}" : "null";
|
||
string fwdSpdStr = update.MotionState.ForwardSpeed is { } fs ? $"{fs:F2}" : "null";
|
||
uint seqMot = ae.Sequencer?.CurrentMotion ?? 0;
|
||
System.Console.WriteLine(
|
||
$"[UM_RAW] guid={update.Guid:X8} stance=0x{stance:X4} fwd={cmdStrRaw} fwdSpd={fwdSpdStr} "
|
||
+ $"side={sideStr} turn={turnStr} mt=0x{update.MotionState.MovementType:X2} "
|
||
+ $"isMoveTo={update.MotionState.IsServerControlledMoveTo} "
|
||
+ $"seq.CurrentMotion=0x{seqMot:X8}");
|
||
}
|
||
|
||
// Diagnostic: dump every inbound UpdateMotion so we can trace why
|
||
// remote chars don't transition off RunForward when they stop.
|
||
// Enable with ACDREAM_DUMP_MOTION=1.
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||
&& update.Guid != _playerServerGuid)
|
||
{
|
||
string cmdStr = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||
float spd = update.MotionState.ForwardSpeed
|
||
?? ((update.MotionState.MoveToSpeed ?? 0f)
|
||
* (update.MotionState.MoveToRunRate ?? 0f));
|
||
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
|
||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||
Console.WriteLine(
|
||
$"UM guid=0x{update.Guid:X8} mt=0x{update.MotionState.MovementType:X2} stance=0x{stance:X4} cmd={cmdStr} spd={spd:F2} " +
|
||
$"| seq now style=0x{seqStyle:X8} motion=0x{seqMotion:X8}");
|
||
}
|
||
|
||
// Per-Door UM dispatch trail; grep [door-cycle] in launch.log to verify door animation.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled
|
||
&& IsDoorName(LiveName(update.Guid)))
|
||
{
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[door-cycle] guid=0x{update.Guid:X8} stance=0x{stance:X4} cmd=0x{(command ?? 0u):X4}"));
|
||
}
|
||
|
||
// ── Sequencer path (preferred) ──────────────────────────────────
|
||
// Call SetCycle directly. The sequencer already handles:
|
||
// - left→right / backward→forward remapping via adjust_motion
|
||
// - style and motion as u32 MotionCommand values
|
||
// - fast-path for identical state
|
||
//
|
||
// When the server omits a field (stance flag not set, or command
|
||
// flag not set), "no change" means we must preserve the sequencer's
|
||
// current state, NOT fall back to a table default.
|
||
if (ae.Sequencer is not null)
|
||
{
|
||
uint fullStyle = stance != 0
|
||
? (0x80000000u | (uint)stance)
|
||
: ae.Sequencer.CurrentStyle;
|
||
|
||
// ACE's stop signal: ForwardCommand flag CLEARED on the wire.
|
||
// Per ACE InterpretedMotionState(MovementData) ctor + BuildMovementFlags,
|
||
// when the player releases keys the InterpretedMotionState has
|
||
// ForwardCommand = Invalid (default) and BuildMovementFlags doesn't
|
||
// set bit 0x02 — so the field is absent. Retail's decompiled
|
||
// handler (FUN_005295D0 → FUN_0051F260 @ chunk_00510000.c:13957)
|
||
// bulk-copies Invalid/0 into the physics obj, which StopCompletely
|
||
// treats as "return to style default (Ready)."
|
||
//
|
||
// command == null → retail stop signal → Ready
|
||
// command.Value == 0 → explicit 0 (rare) → Ready
|
||
// otherwise → resolve class byte and use full cmd
|
||
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
|
||
uint fullMotion;
|
||
// R4-V4: the PlanMoveToStart seed is DELETED — MoveTo UMs no
|
||
// longer flow through the interpreted funnel at all (retail
|
||
// unpack_movement routes types 6-9 to MoveToManager; only type
|
||
// 0 does the interpreted-state copy). The manager's own
|
||
// BeginMoveForward -> get_command -> _DoMotion produces the
|
||
// cycle through the same sink every other motion uses.
|
||
if (!command.HasValue || command.Value == 0)
|
||
{
|
||
fullMotion = 0x41000003u;
|
||
}
|
||
else
|
||
{
|
||
// Use MotionCommandResolver to restore the proper class
|
||
// byte from the wire's 16-bit ForwardCommand.
|
||
uint resolved = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(command.Value);
|
||
fullMotion = resolved != 0
|
||
? resolved
|
||
: (ae.Sequencer.CurrentMotion & 0xFF000000u) | (uint)command.Value;
|
||
if (fullMotion == (uint)command.Value) // no class bits yet
|
||
fullMotion = 0x40000000u | (uint)command.Value;
|
||
}
|
||
|
||
// ForwardSpeed from the InterpretedMotionState (flag 0x04).
|
||
// ACE omits this field when speed == 1.0 (only sets the flag
|
||
// when ForwardSpeed != 1.0 — InterpretedMotionState.cs:101).
|
||
// So:
|
||
// - field absent → default 1.0 (normal speed)
|
||
// - field present → USE THE VALUE, including zero.
|
||
//
|
||
// Zero is a VALID stop signal: when the retail client releases
|
||
// W, ACE broadcasts WalkForward with ForwardSpeed=0 (via
|
||
// apply_run_to_command). Treating zero as "unspecified / 1.0"
|
||
// produces "slow walk that never stops" — exactly what the
|
||
// stop bug looked like.
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1"
|
||
&& update.Guid != _playerServerGuid)
|
||
Console.WriteLine(
|
||
$"UM ↳ SetCycle(style=0x{fullStyle:X8}, motion=0x{fullMotion:X8}, speed={speedMod:F2})");
|
||
|
||
// No-op if same; the sequencer's fast path guards against that.
|
||
uint priorMotion = ae.Sequencer.CurrentMotion;
|
||
|
||
// CRITICAL: for the local player, UpdatePlayerAnimation is the
|
||
// authoritative driver of the sequencer. ACE's BroadcastMovement
|
||
// echoes the player's own motion back, but:
|
||
// (a) ACE's own ForwardSpeed is `creature.GetRunRate()`, which
|
||
// may differ from our locally-computed runRate (ACDREAM_RUN_SKILL
|
||
// vs real server-side skills).
|
||
// (b) ACE omits the ForwardSpeed flag when speed == 1.0 (per
|
||
// InterpretedMotionState.BuildMovementFlags). When omitted,
|
||
// our wire parser returns null and we'd default to 1.0 —
|
||
// clobbering our locally-authoritative 2.375 × animScale
|
||
// and leaving the legs at 30 fps cadence regardless of
|
||
// actual run rate.
|
||
// So: for the player's own guid, skip the wire-echo SetCycle.
|
||
// UpdatePlayerAnimation has already set the correct cycle with
|
||
// our locally-chosen speedMod, and that value should persist
|
||
// until the next local motion change.
|
||
if (update.Guid == _playerServerGuid)
|
||
{
|
||
// Still update the stance echo (_playerMotionTableId, etc) via
|
||
// the paths above, but don't stomp the animation sequencer.
|
||
|
||
// B.6 slice 1 (2026-05-14): trace inbound motion for the
|
||
// local player. One line per inbound UM, gated on
|
||
// ACDREAM_PROBE_AUTOWALK=1 (name kept through R4-V5).
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
string cmdHex = command.HasValue ? $"0x{command.Value:X4}" : "null";
|
||
string pathStr = update.MotionState.MoveToPath is { } p
|
||
? $"path=cell=0x{p.OriginCellId:X8},xyz=({p.OriginX:F2},{p.OriginY:F2},{p.OriginZ:F2}),minDist={p.MinDistance:F2},objDist={p.DistanceToObject:F2}"
|
||
: "path=null";
|
||
string spd = update.MotionState.ForwardSpeed is { } fs
|
||
? $"fwdSpd={fs:F2}"
|
||
: "fwdSpd=null";
|
||
string mtsSpd = update.MotionState.MoveToSpeed is { } ms
|
||
? $"mtSpd={ms:F2}"
|
||
: "mtSpd=null";
|
||
string mtsRun = update.MotionState.MoveToRunRate is { } mr
|
||
? $"mtRun={mr:F2}"
|
||
: "mtRun=null";
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-mt] stance=0x{stance:X4} cmd={cmdHex} mt=0x{update.MotionState.MovementType:X2} isMoveTo={update.MotionState.IsServerControlledMoveTo} moveTowards={update.MotionState.MoveTowards} {pathStr} {spd} {mtsSpd} {mtsRun}"));
|
||
}
|
||
|
||
// R4-V5: retail unpack_movement dispatch for the local
|
||
// player — the SAME shape the remote branch uses below.
|
||
// Head (@300566): interrupt + unstick fire for EVERY
|
||
// movement event that reached unpack (the P1 gate above
|
||
// already dropped the autonomous echoes that would have
|
||
// made this unsafe against ACE); then types 6-9 route to
|
||
// the player's MoveToManager. mt-0 falls through to the
|
||
// existing skip-sequencer posture (the interpreted-state
|
||
// copy + LoseControlToServer autonomy handoff is
|
||
// R5/MovementManager scope — V0-pins.md P1 adjacent seam).
|
||
if (_playerController is not null)
|
||
{
|
||
// P1 tail (00509730): the unpack path stores the wire
|
||
// autonomous byte BEFORE unpack_movement — always false
|
||
// here (the gate above dropped autonomous events). This
|
||
// is what routes the controller's per-tick pump (A3
|
||
// dual dispatch) to the INTERPRETED branch during a
|
||
// server moveto. LOCAL PLAYER ONLY for now: remotes'
|
||
// interps have no WeenieObj, which A3 treats as
|
||
// IsThePlayer — storing a remote player's autonomous
|
||
// byte would flip their per-tick apply onto the raw
|
||
// branch and clobber their funnel state; the remote
|
||
// store lands with real remote weenies (R5+).
|
||
_playerController.SetLastMoveWasAutonomous(update.IsAutonomous);
|
||
|
||
_playerController.Motion.InterruptCurrentMovement?.Invoke();
|
||
_playerController.Motion.UnstickFromObject?.Invoke();
|
||
|
||
// R5-V4a: unpack_movement HEAD style-on-change
|
||
// (0x00524440 @00524502-0052452c): wire style index →
|
||
// command word (command_ids[]; style-class indices map to
|
||
// 0x80000000|index — the funnel's S0-verified conversion)
|
||
// and `if (current style != wire style) DoMotion(style,
|
||
// ctor defaults)` BEFORE the movement-type switch — for
|
||
// EVERY type. acdream previously applied style only via
|
||
// the mt-0 funnel copy, so a chase/turn UM (mt 6-9)
|
||
// carrying a stance change started the move in the OLD
|
||
// stance (the RetailObserverTraceConformanceTests "S3
|
||
// wires the unpack-level style-on-change" exclusion —
|
||
// this is that wiring).
|
||
uint wireStylePlayer = stance != 0
|
||
? (0x80000000u | (uint)stance) : 0x8000003Du;
|
||
if (_playerController.Motion.InterpretedState.CurrentStyle != wireStylePlayer)
|
||
_playerController.Motion.DoMotion(wireStylePlayer,
|
||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||
|
||
if (_playerController.MoveTo is not null
|
||
&& RouteServerMoveTo(_playerController.Movement,
|
||
_playerController.CellId, update))
|
||
{
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-begin] mt=0x{update.MotionState.MovementType:X2} movingTo={_playerController.Movement.IsMovingTo()} type={_playerController.MoveTo.MovementTypeState}"));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// R5-V4: retail unpack_movement case-0 TAIL for the local
|
||
// player (@00524583-0052458e) — stick_to_object when the
|
||
// motionFlags 0x1 trailer carried a guid, then
|
||
// standing_longjump ← motionFlags 0x2 (UNCONDITIONAL —
|
||
// an absent flag clears it). The interpreted-state COPY
|
||
// stays skipped for the local player (the
|
||
// sequencer-authority posture above); these two writes
|
||
// are the only case-0 effects that apply here.
|
||
if (update.MotionState.MovementType == 0)
|
||
{
|
||
if (update.MotionState.StickyObjectGuid is { } playerSticky
|
||
&& playerSticky != 0)
|
||
StickToObjectFromWire(_playerHost, playerSticky);
|
||
_playerController.Motion.StandingLongJump =
|
||
update.MotionState.StandingLongJump;
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// ── L.2g S2b (2026-07-02): remote entities flow through the
|
||
// verbatim CMotionInterp funnel. The wire state becomes an
|
||
// InboundInterpretedState (retail UnPack defaults for absent
|
||
// fields — a flags=0 UM is a wholesale stop, S0-verified),
|
||
// MotionInterpreter.MoveToInterpretedState applies it with
|
||
// retail's exact dispatch order + the 15-bit action-stamp
|
||
// gate (conformance: RetailObserverTraceConformanceTests,
|
||
// 183/183 vs the live retail-observer cdb trace), and the
|
||
// gate-passed dispatches go straight into the motion-table
|
||
// stack via MotionTableDispatchSink -> PerformMovement
|
||
// (R2-Q5; GetObjectSequence + is_allowed decide — no sink-
|
||
// side pick; airborne handling is the funnel's
|
||
// contact_allows_move gate, the retail mechanism behind the
|
||
// old K-fix17 guard).
|
||
//
|
||
// R4-V4: retail unpack_movement dispatch (0x00524440) — the
|
||
// head-interrupt fires for EVERY movement type, then types
|
||
// 6/7/8/9 route to MoveToManager.PerformMovement (which
|
||
// cancels again itself — retail does both) while ONLY type 0
|
||
// flows through the interpreted-state funnel below.
|
||
//
|
||
// R4-V5 door fix (2026-07-03 user report: doors stopped
|
||
// animating): entities that never receive an UpdatePosition
|
||
// (doors, levers — static animated objects) never got a
|
||
// RemoteMotion (created only in the UP handler), so since
|
||
// the L.2g S2b funnel cutover NOTHING applied their UM
|
||
// forward-command (the old direct SetCycle became the
|
||
// rm-only funnel) — a used door flipped ETHEREAL but never
|
||
// played its On/Off cycle. Retail runs the SAME unpack →
|
||
// CMotionInterp pipeline for every entity class; create the
|
||
// rm on first UM exactly like the UP handler does.
|
||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
|
||
{
|
||
remoteMot = new RemoteMotion();
|
||
remoteMot.Body.Orientation = entity.Rotation;
|
||
remoteMot.Body.Position = entity.Position;
|
||
_remoteDeadReckon[update.Guid] = remoteMot;
|
||
}
|
||
{
|
||
var sink = EnsureRemoteMotionBindings(remoteMot, ae, update.Guid);
|
||
|
||
// unpack_movement head (@300566): interrupt + unstick.
|
||
remoteMot.Motion.InterruptCurrentMovement?.Invoke();
|
||
remoteMot.Motion.UnstickFromObject?.Invoke();
|
||
|
||
// R5-V4a: unpack_movement head style-on-change — see the
|
||
// player-side comment (@00524502-0052452c). Runs BEFORE
|
||
// the type routing for every movement type; the mt-0
|
||
// funnel below still performs its own full style
|
||
// adoption (retail has BOTH — the head fires on CHANGE
|
||
// only, so an unchanged stance is a no-op here).
|
||
uint wireStyleRemote = stance != 0
|
||
? (0x80000000u | (uint)stance) : 0x8000003Du;
|
||
if (remoteMot.Motion.InterpretedState.CurrentStyle != wireStyleRemote)
|
||
remoteMot.Motion.DoMotion(wireStyleRemote,
|
||
new AcDream.Core.Physics.Motion.MovementParameters());
|
||
|
||
// R4-V5: the type-6..9 routing body is shared with the
|
||
// local player (RouteServerMoveTo) — behavior identical
|
||
// to the R4-V4 inline blocks it was extracted from.
|
||
// (MoveTo null = EnsureRemoteMotionBindings early-outed
|
||
// on a sequencer-less entity — same skip as pre-V5.)
|
||
if (remoteMot.MoveTo is not null
|
||
&& RouteServerMoveTo(remoteMot.Movement,
|
||
remoteMot.CellId, update))
|
||
{
|
||
return;
|
||
}
|
||
|
||
// [FWD_WIRE] + observed-velocity history invalidation on
|
||
// a forward-command change (pre-S2 behavior, unchanged:
|
||
// the per-tick scaling must not reuse a stale ratio
|
||
// derived from the OLD motion).
|
||
if (remoteMot.Motion.InterpretedState.ForwardCommand != fullMotion)
|
||
{
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[FWD_WIRE] guid={update.Guid:X8} "
|
||
+ $"oldCmd=0x{remoteMot.Motion.InterpretedState.ForwardCommand:X8} "
|
||
+ $"newCmd=0x{fullMotion:X8} "
|
||
+ $"newLow=0x{fullMotion & 0xFFu:X2} speed={speedMod:F3}");
|
||
}
|
||
remoteMot.PrevServerPosTime = 0.0;
|
||
}
|
||
|
||
// Build the funnel input. fullMotion/speedMod already
|
||
// encode retail's absent-field forward defaults (null/0
|
||
// → Ready; mt 6-9 never reach here post-V4). Style:
|
||
// retail's InterpretedMotionState::UnPack (0x0051f400)
|
||
// defaults an absent stance to NonCombat 0x8000003D —
|
||
// NOT keep-current (S0 trace: every empty UM applied
|
||
// 0x8000003d on the retail observer).
|
||
var ims = new AcDream.Core.Physics.InboundInterpretedState
|
||
{
|
||
CurrentStyle = stance != 0 ? (0x80000000u | (uint)stance) : 0x8000003Du,
|
||
ForwardCommand = fullMotion,
|
||
ForwardSpeed = speedMod,
|
||
SideStepCommand = 0u,
|
||
SideStepSpeed = update.MotionState.SideStepSpeed ?? 1f,
|
||
TurnCommand = 0u,
|
||
TurnSpeed = update.MotionState.TurnSpeed ?? 1f,
|
||
};
|
||
if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0)
|
||
{
|
||
uint sideFull = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(sideCmd16);
|
||
ims.SideStepCommand = sideFull != 0 ? sideFull : (0x65000000u | sideCmd16);
|
||
}
|
||
if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0)
|
||
{
|
||
uint turnFull = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(turnCmd16);
|
||
ims.TurnCommand = turnFull != 0 ? turnFull : (0x65000000u | turnCmd16);
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} "
|
||
+ $"turnFull=0x{ims.TurnCommand:X8} speed={ims.TurnSpeed:F3}");
|
||
}
|
||
}
|
||
|
||
// Command list → the funnel's action list. Retail applies
|
||
// EVERY entry via DoInterpretedMotion under the 15-bit
|
||
// server_action_stamp gate — the gate is what makes ACE's
|
||
// re-bundled stale entries (e.g. the Ready bundled into a
|
||
// RunForward UM, 2026-05-03 finding) inert, replacing the
|
||
// old skip-SubState-class workaround.
|
||
if (update.MotionState.Commands is { Count: > 0 } cmdList)
|
||
{
|
||
var actionList = new System.Collections.Generic.List<AcDream.Core.Physics.InboundMotionAction>(cmdList.Count);
|
||
foreach (var item in cmdList)
|
||
{
|
||
uint full = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(item.Command);
|
||
if (full == 0) full = 0x10000000u | item.Command;
|
||
actionList.Add(new AcDream.Core.Physics.InboundMotionAction(
|
||
full,
|
||
Stamp: item.PackedSequence & 0x7FFF,
|
||
Autonomous: (item.PackedSequence & 0x8000) != 0,
|
||
Speed: item.Speed));
|
||
}
|
||
ims.Actions = actionList;
|
||
}
|
||
|
||
// R2-Q5/R3-W4: funnel dispatches go STRAIGHT into the
|
||
// motion-table stack (GetObjectSequence + is_allowed
|
||
// decide) — mt-0 only post-V4 (types 6-9 returned above).
|
||
remoteMot.Motion.MoveToInterpretedState(ims, sink);
|
||
|
||
// R5-V4: retail unpack_movement case-0 TAIL order
|
||
// (@00524583-0052458e): move_to_interpreted_state FIRST
|
||
// (above), THEN stick_to_object when the motionFlags 0x1
|
||
// trailer carried a guid, THEN standing_longjump ←
|
||
// motionFlags 0x2 (UNCONDITIONAL — an absent flag
|
||
// CLEARS it).
|
||
if (update.MotionState.StickyObjectGuid is { } stickyGuid
|
||
&& stickyGuid != 0)
|
||
StickToObjectFromWire(remoteMot.Host, stickyGuid);
|
||
remoteMot.Motion.StandingLongJump =
|
||
update.MotionState.StandingLongJump;
|
||
}
|
||
}
|
||
|
||
// CRITICAL: when we enter a locomotion cycle (Walk/Run/etc),
|
||
// stamp the _remoteLastMove timestamp to "now". Without this,
|
||
// the stop-detection loop in TickAnimations sees the previous
|
||
// _remoteLastMove timestamp (set by the last UpdatePosition,
|
||
// often >300ms ago during idle) and fires the stop signal
|
||
// IMMEDIATELY — flipping the sequencer straight back to Ready.
|
||
// The visible symptom was "remote char never animates; just
|
||
// stands there, teleporting position every UpdatePosition."
|
||
// Fresh timestamp gives the stop-timer a full 300ms window to
|
||
// observe genuine position stagnation before reverting.
|
||
uint newLo = fullMotion & 0xFFu;
|
||
bool enteringLocomotion = newLo == 0x05 || newLo == 0x06
|
||
|| newLo == 0x07
|
||
|| newLo == 0x0F || newLo == 0x10;
|
||
uint oldLo = priorMotion & 0xFFu;
|
||
bool wasLocomotion = oldLo == 0x05 || oldLo == 0x06
|
||
|| oldLo == 0x07
|
||
|| oldLo == 0x0F || oldLo == 0x10;
|
||
if (enteringLocomotion && !wasLocomotion && update.Guid != _playerServerGuid)
|
||
{
|
||
// Reset both stop signals so stop-detection starts a fresh
|
||
// window from this transition. Without this, the entity
|
||
// starts its run animation and is instantly interrupted.
|
||
var refreshedTime = System.DateTime.UtcNow;
|
||
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
||
_remoteLastMove[update.Guid] = (prev.Pos, refreshedTime);
|
||
if (_remoteDeadReckon.TryGetValue(update.Guid, out var dr))
|
||
dr.LastServerPosTime = (refreshedTime - System.DateTime.UnixEpoch).TotalSeconds;
|
||
}
|
||
|
||
// Route command-list entries through the shared Core router.
|
||
// Retail/ACE send these as 16-bit MotionCommand lows in
|
||
// InterpretedMotionState.Commands[]; the router reconstructs the
|
||
// class byte and chooses PlayAction for actions/modifiers/emotes
|
||
// or SetCycle for persistent substates.
|
||
//
|
||
// 2026-05-03: SKIP SubState class commands (high-byte 0x40-0x4F).
|
||
// The animCycle picker above already chose the correct SubState
|
||
// cycle based on Forward/Sidestep/Turn command priority and just
|
||
// called SetCycle for it. Letting the Commands list also call
|
||
// SetCycle(SubState) would OVERRIDE our chosen cycle — e.g. ACE
|
||
// bundles Ready (0x41000003) into the Commands list of a
|
||
// RunForward UpdateMotion (cdb trace 2026-05-03 confirmed retail
|
||
// does the same), and our router would silently re-cycle the
|
||
// sequencer back to Ready right after we set RunForward. That's
|
||
// why observed retail-driven characters never visibly switched
|
||
// their leg cycle even though SETCYCLE diags fired correctly:
|
||
// a second SetCycle call wiped the first within the same UM
|
||
// packet processing. Only Actions/Modifiers/ChatEmotes (overlays
|
||
// that interleave with the cycle) belong in the list iteration.
|
||
if (update.Guid == _playerServerGuid // L.2g S2b: LOCAL ONLY — remote command
|
||
// lists flow through the funnel's
|
||
// action-stamp gate (retail's actual
|
||
// mechanism for bundled stale entries)
|
||
&& update.MotionState.Commands is { Count: > 0 } cmds)
|
||
{
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
var sb = new System.Text.StringBuilder();
|
||
sb.Append($"[CMD_LIST] guid={update.Guid:X8} fwd=0x{fullMotion:X8} cmds=[");
|
||
for (int i = 0; i < cmds.Count; i++)
|
||
{
|
||
if (i > 0) sb.Append(", ");
|
||
uint fc = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(cmds[i].Command);
|
||
var rt = AcDream.Core.Physics.AnimationCommandRouter.Classify(fc);
|
||
sb.Append($"0x{fc:X8}({rt})");
|
||
}
|
||
sb.Append("]");
|
||
System.Console.WriteLine(sb.ToString());
|
||
}
|
||
foreach (var item in cmds)
|
||
{
|
||
uint fullItemCommand = AcDream.Core.Physics.MotionCommandResolver
|
||
.ReconstructFullCommand(item.Command);
|
||
var itemRoute = AcDream.Core.Physics.AnimationCommandRouter
|
||
.Classify(fullItemCommand);
|
||
if (itemRoute == AcDream.Core.Physics.AnimationCommandRouteKind.SubState)
|
||
continue;
|
||
AcDream.Core.Physics.AnimationCommandRouter.RouteWireCommand(
|
||
ae.Sequencer!, // guarded by the enclosing `if (ae.Sequencer is not null)`
|
||
fullStyle,
|
||
item.Command,
|
||
item.Speed);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// ── Legacy path (entities without a sequencer) ──────────────────
|
||
// Here we DO use GetIdleCycle because the legacy tick loop needs
|
||
// a concrete Animation + frame range. Only swap when the resolver
|
||
// returns a clearly-better cycle.
|
||
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
|
||
ae.Setup, _dats,
|
||
motionTableIdOverride: null,
|
||
stanceOverride: stance,
|
||
commandOverride: command);
|
||
bool newCycleIsGood = newCycle is not null
|
||
&& newCycle.Framerate != 0f
|
||
&& newCycle.HighFrame >= newCycle.LowFrame
|
||
&& newCycle.Animation.PartFrames.Count >= 1;
|
||
if (!newCycleIsGood) return;
|
||
|
||
ae.Animation = newCycle!.Animation;
|
||
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
|
||
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
|
||
ae.Framerate = newCycle.Framerate;
|
||
ae.CurrFrame = ae.LowFrame;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 6.7: the server says an entity moved. Translate its new
|
||
/// landblock-local position into acdream world space (same math as
|
||
/// CreateObject hydration) and update the entity's Position/Rotation
|
||
/// in place so the next Draw picks up the new transform.
|
||
///
|
||
/// Phase B.3 extension: if the player controller is in PortalSpace and
|
||
/// this update is for our own character, detect a large position change
|
||
/// (different landblock or > 100 units distance). If detected, recenter
|
||
/// the streaming controller, resolve the new position through physics,
|
||
/// snap the player entity + controller, and return to InWorld. Also sends
|
||
/// LoginComplete so the server knows the client has loaded the destination.
|
||
/// </summary>
|
||
/// <summary>
|
||
/// K-fix9 (2026-04-26): handle 0xF74E VectorUpdate — ACE broadcasts
|
||
/// this on remote-player JUMPS (Player.cs:954). The payload carries
|
||
/// the world-space launch velocity. Without handling it, remote
|
||
/// jumps render as a tiny lift-and-back because we never see the
|
||
/// +Z velocity that would integrate into a proper arc.
|
||
/// </summary>
|
||
private void OnLiveVectorUpdated(AcDream.Core.Net.Messages.VectorUpdate.Parsed update)
|
||
{
|
||
if (update.Guid == _playerServerGuid) return; // local jump uses our own physics
|
||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rm)) return;
|
||
|
||
// World-space velocity. Apply directly to the body — the per-tick
|
||
// remote update will integrate Position += Velocity × dt + 0.5 × Accel × dt².
|
||
rm.Body.Velocity = update.Velocity;
|
||
|
||
// L.3.1 Task 6: apply Omega too. Was parsed but ignored, leaving
|
||
// remote jumping/turning arcs flat. Mirrors retail SmartBox::
|
||
// DoVectorUpdate (acclient @ 0x004521C0) which calls both
|
||
// CPhysicsObj::set_velocity AND CPhysicsObj::set_omega.
|
||
rm.Body.Omega = update.Omega;
|
||
|
||
// Mark airborne when the launch has meaningful +Z. Threshold
|
||
// 0.5 m/s rejects noise / horizontal-only updates (server might
|
||
// also use VectorUpdate for non-jump events). The per-tick
|
||
// remote update reads .Airborne to skip the ground-clamp branch
|
||
// and apply gravity instead.
|
||
if (update.Velocity.Z > 0.5f)
|
||
{
|
||
rm.Airborne = true;
|
||
// Clear ground-contact bits + enable gravity so calc_acceleration
|
||
// returns (0, 0, -9.8) instead of zero. UpdatePhysicsInternal then
|
||
// produces the parabolic arc.
|
||
rm.Body.TransientState &= ~(AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable);
|
||
rm.Body.State |= AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||
|
||
// R3-W4 (J19 — K-fix10/K-fix18 DELETED): the retail mechanism.
|
||
// The remote's ground departure fires LeaveGround (0x00528b00):
|
||
// strips pending transition links (the RemoveLinkAnimations
|
||
// seam) + re-applies movement through DefaultSink, whose
|
||
// contact-gated funnel dispatch engages Falling — no forced
|
||
// SetCycle, no skip flag. The wire velocity/omega are re-applied
|
||
// AFTER so they stay authoritative over LeaveGround's
|
||
// state-derived velocity write (adaptation note: retail's
|
||
// equivalence comes from the per-tick transition-sweep order —
|
||
// R6 scope).
|
||
if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent)
|
||
&& _animatedEntities.TryGetValue(ent.Id, out var ae)
|
||
&& ae.Sequencer is not null)
|
||
{
|
||
EnsureRemoteMotionBindings(rm, ae, update.Guid);
|
||
rm.Motion.LeaveGround();
|
||
rm.Body.Velocity = update.Velocity;
|
||
rm.Body.Omega = update.Omega;
|
||
}
|
||
}
|
||
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||
{
|
||
Console.WriteLine(
|
||
$"VU guid=0x{update.Guid:X8} vel=({update.Velocity.X:F2},{update.Velocity.Y:F2},{update.Velocity.Z:F2}) airborne={rm.Airborne}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// L.2g slice 1: inbound SetState (0xF74B) handler. Propagates the
|
||
/// new <c>PhysicsState</c> bits into ShadowObjectRegistry so the
|
||
/// existing <see cref="CollisionExemption.ShouldSkip"/> check honors
|
||
/// the flip on the next resolver tick. Chiefly doors:
|
||
/// server flips <c>ETHEREAL_PS = 0x4</c> on Use, the door's
|
||
/// cylinder collision stops blocking the threshold.
|
||
/// </summary>
|
||
private void OnLiveStateUpdated(AcDream.Core.Net.Messages.SetState.Parsed parsed)
|
||
{
|
||
// L.2g slice 1c (2026-05-13): the server addresses entities by
|
||
// ServerGuid (parsed.Guid, e.g. 0x7A9B4015), but
|
||
// ShadowObjectRegistry's cell index is keyed by local entity.Id
|
||
// (e.g. 0x000F4245). Translate via _entitiesByServerGuid before
|
||
// mutating the registry — otherwise the lookup misses and the
|
||
// state flip silently no-ops, leaving doors blocked even though
|
||
// ACE flipped the ETHEREAL bit.
|
||
uint registryKey = parsed.Guid;
|
||
if (_entitiesByServerGuid.TryGetValue(parsed.Guid, out var entity))
|
||
registryKey = entity.Id;
|
||
|
||
_physicsEngine.ShadowObjects.UpdatePhysicsState(registryKey, parsed.PhysicsState);
|
||
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[setstate] guid=0x{parsed.Guid:X8} entityId=0x{registryKey:X8} state=0x{parsed.PhysicsState:X8} instSeq={parsed.InstanceSequence} stateSeq={parsed.StateSequence}"));
|
||
}
|
||
|
||
private static bool IsRemoteLocomotion(uint motion)
|
||
{
|
||
uint low = motion & 0xFFu;
|
||
return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10;
|
||
}
|
||
|
||
private void ApplyServerControlledVelocityCycle(
|
||
uint serverGuid,
|
||
AnimatedEntity ae,
|
||
RemoteMotion rm,
|
||
System.Numerics.Vector3 velocity)
|
||
{
|
||
if (rm.Airborne) return;
|
||
if (ae.Sequencer is null) return;
|
||
// R4-V4: an active MoveToManager owns the cycle (BeginMoveForward's
|
||
// get_command dispatch). Velocity-estimated cycle planning would
|
||
// fight it — same rationale as the pre-V4 ServerMoveToActive guard.
|
||
if (rm.MoveTo is { MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid }) return;
|
||
|
||
if (IsPlayerGuid(serverGuid))
|
||
{
|
||
// L.2g S5 (2026-07-02, DEV-2 DELETED): player remotes get NO
|
||
// pace-derived cycle refinement — retail has no such mechanism
|
||
// anywhere in its inbound pipeline (deviation map DEV-2, two
|
||
// independent decomp dives + ACE cross-check). The #39-era
|
||
// premise ("retail's outbound goes silent on Shift toggle") was
|
||
// refuted at all three oracles + the S0 live capture: retail
|
||
// sends a fresh MoveToState on HoldRun toggle while moving
|
||
// (CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE
|
||
// rebroadcasts every MoveToState (GameActionMoveToState.cs:36),
|
||
// and the wire shows explicit 0x0005↔0x0007 UMs on each toggle
|
||
// (launch-s0-wireprobe.log). The refinement layer's re-promote
|
||
// after legitimate flags=0 Ready UMs was itself the observed
|
||
// Ready↔Run thrash. Cycle changes for player remotes come from
|
||
// UpdateMotion ONLY, exactly like retail; position error is the
|
||
// InterpolationManager chase's job.
|
||
//
|
||
// NPC/monster remotes below keep PlanFromVelocity until S6
|
||
// unifies all entity classes onto the CMotionInterp funnel.
|
||
return;
|
||
}
|
||
|
||
var plan = AcDream.Core.Physics.ServerControlledLocomotion
|
||
.PlanFromVelocity(velocity);
|
||
uint currentMotion = ae.Sequencer.CurrentMotion;
|
||
if (!plan.IsMoving && !IsRemoteLocomotion(currentMotion))
|
||
return;
|
||
|
||
uint style = ae.Sequencer.CurrentStyle != 0
|
||
? ae.Sequencer.CurrentStyle
|
||
: 0x8000003Du;
|
||
|
||
// D2 (Commit A 2026-05-03): UPCYCLE diag — proves whether
|
||
// ApplyServerControlledVelocityCycle is racing UpdateMotion-driven
|
||
// SetCycle for non-player remotes (NPCs / monsters).
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[UPCYCLE] guid={serverGuid:X8} "
|
||
+ $"vel=({velocity.X:F2},{velocity.Y:F2},{velocity.Z:F2}) "
|
||
+ $"|v|={velocity.Length():F2} "
|
||
+ $"-> motion=0x{plan.Motion:X8} speedMod={plan.SpeedMod:F2} "
|
||
+ $"prev=0x{currentMotion:X8} "
|
||
+ $"airborne={rm.Airborne} moveTo={rm.MoveTo?.MovementTypeState ?? AcDream.Core.Physics.MovementType.Invalid}");
|
||
}
|
||
ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod);
|
||
}
|
||
|
||
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
|
||
{
|
||
// Phase A.1 / #135: track the PLAYER's last server-known landblock so the
|
||
// streaming controller can follow the player in the fly-camera / pre-player-mode
|
||
// (login hold) views. Filtered to our OWN character guid — resolving the original
|
||
// Phase A.1 TODO. An arbitrary NPC's UpdatePosition from a far outdoor landblock
|
||
// must NOT move the streaming observer: during a dungeon-login hold (player not
|
||
// yet placed, so _playerController is null and the PortalSpace observer branch
|
||
// can't apply) that would drift the observer off the pre-collapsed dungeon
|
||
// landblock and trip ExitDungeonExpand, re-streaming the 25×25 neighbor window
|
||
// the pre-collapse just suppressed. _playerServerGuid is set from CharacterList
|
||
// (~line 1984) before world entry, so it is valid by the time updates arrive.
|
||
if (update.Guid == _playerServerGuid)
|
||
_lastLivePlayerLandblockId = update.Position.LandblockId;
|
||
|
||
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
|
||
|
||
var p = update.Position;
|
||
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
|
||
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
|
||
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;
|
||
|
||
// B.6 slice 1 (2026-05-14): trace inbound UpdatePosition cadence for
|
||
// the local player. Combined with [autowalk-mt] this answers
|
||
// whether ACE's broadcast frequency during a server-initiated
|
||
// auto-walk is dense enough to drive smooth visible motion (the
|
||
// Option C viability check from the design spec). Gated on
|
||
// ACDREAM_PROBE_AUTOWALK=1; skips remote entities.
|
||
if (update.Guid == _playerServerGuid
|
||
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
string velStr = update.Velocity is { } v
|
||
? $"vel=({v.X:F2},{v.Y:F2},{v.Z:F2})"
|
||
: "vel=null";
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-up] cell=0x{p.LandblockId:X8} pos=({p.PositionX:F2},{p.PositionY:F2},{p.PositionZ:F2}) world=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2}) {velStr} grounded={update.IsGrounded}"));
|
||
}
|
||
var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
|
||
DumpMovementTruthServerEcho(update, worldPos);
|
||
|
||
// Keep the cached spawn's Position in sync with server truth so a
|
||
// later ObjDescEvent (which only carries new appearance, not new
|
||
// position) re-applies at the entity's CURRENT location instead of
|
||
// popping back to its login spot. See OnLiveAppearanceUpdated.
|
||
if (_lastSpawnByGuid.TryGetValue(update.Guid, out var cached))
|
||
_lastSpawnByGuid[update.Guid] = cached with { Position = update.Position };
|
||
|
||
// Capture the pre-update render position for the soft-snap residual
|
||
// calculation below. Assign entity.Position to the server truth up
|
||
// front; if we then compute a snap residual, we restore the rendered
|
||
// position by adding the residual back (so the visual doesn't jerk
|
||
// for one frame before the residual decay kicks in on the next tick).
|
||
System.Numerics.Vector3 preSnapPos = entity.Position;
|
||
entity.SetPosition(worldPos);
|
||
entity.ParentCellId = p.LandblockId;
|
||
entity.Rotation = rot;
|
||
|
||
// Commit B 2026-04-29 — keep the shadow registry in sync with
|
||
// server-authoritative position so the player's collision broadphase
|
||
// tests against the up-to-date target body. Skip the local player
|
||
// (its body is the simulator, not a target). Retail does the
|
||
// equivalent via SetPosition → change_cell → AddShadowObject
|
||
// (acclient_2013_pseudo_c.txt:284276 / 281200 / 282862).
|
||
if (update.Guid != _playerServerGuid)
|
||
{
|
||
// BR-7: the wire position's full cell id seeds the re-flood
|
||
// (retail SetPosition → calc_cross_cells from m_position).
|
||
_physicsEngine.ShadowObjects.UpdatePosition(
|
||
entity.Id, worldPos, rot, origin.X, origin.Y, p.LandblockId,
|
||
seedCellId: p.LandblockId);
|
||
}
|
||
|
||
// Track remote-entity motion for stop detection. Only record the
|
||
// timestamp when position moved MEANINGFULLY (> 0.05m). Updates
|
||
// that report the same position keep the old Time, so the
|
||
// TickAnimations check can see when motion last changed.
|
||
//
|
||
// Also populate the dead-reckon state so TickAnimations can
|
||
// integrate velocity between server updates and avoid teleport jitter.
|
||
// Observed-velocity is computed from the position delta across
|
||
// consecutive updates — this is the fallback when the motion table's
|
||
// MotionData.Velocity is zero (NPCs without HasVelocity).
|
||
if (update.Guid != _playerServerGuid)
|
||
{
|
||
var now = System.DateTime.UtcNow;
|
||
if (_remoteLastMove.TryGetValue(update.Guid, out var prev))
|
||
{
|
||
float moveDist = System.Numerics.Vector3.Distance(prev.Pos, worldPos);
|
||
if (moveDist > 0.05f)
|
||
_remoteLastMove[update.Guid] = (worldPos, now);
|
||
// else: leave old entry so "Time" = last real movement time
|
||
}
|
||
else
|
||
{
|
||
_remoteLastMove[update.Guid] = (worldPos, now);
|
||
}
|
||
|
||
// Retail-faithful hard-snap on UpdatePosition.
|
||
// Decompile: FUN_00559030 @ chunk_00550000.c:8232 writes
|
||
// pos/rot directly into PhysicsObj+0x80..0xBC with no blending.
|
||
// Between UpdatePositions, per-tick velocity integration keeps
|
||
// the rendered position close to server truth so each snap is
|
||
// small. When HasVelocity is set, we also seed PhysicsBody
|
||
// velocity (matches retail's set_velocity call in the same
|
||
// dispatcher).
|
||
if (!_remoteDeadReckon.TryGetValue(update.Guid, out var rmState))
|
||
{
|
||
rmState = new RemoteMotion();
|
||
_remoteDeadReckon[update.Guid] = rmState;
|
||
// Hard-snap orientation on first spawn so the per-tick
|
||
// slerp doesn't visibly rotate from Identity to truth.
|
||
rmState.Body.Orientation = rot;
|
||
}
|
||
|
||
// L.3 M2 (2026-05-05): retail-faithful MoveOrTeleport routing for
|
||
// player remotes. Mirrors CPhysicsObj::MoveOrTeleport
|
||
// (acclient @ 0x00516330) — airborne no-op, far-snap, near
|
||
// InterpolateTo. Gated on IsPlayerGuid so NPCs continue through
|
||
// the legacy synth-velocity branch below; their motion comes
|
||
// from ServerVelocity / ServerMoveTo which the legacy path
|
||
// already handles correctly.
|
||
//
|
||
if (IsPlayerGuid(update.Guid))
|
||
{
|
||
// Orientation always snaps on receipt — InterpolationManager walks
|
||
// position only; heading would otherwise lag the queue.
|
||
rmState.Body.Orientation = rot;
|
||
|
||
// Adopt server's cell ID on every UP (airborne or grounded).
|
||
// Required by the legacy airborne path's per-tick
|
||
// ResolveWithTransition gate (rm.CellId != 0); without this
|
||
// an airborne player remote falls through the floor because
|
||
// the sphere sweep is skipped. Note: enabling the sweep also
|
||
// exposes a pre-existing depenetration bug — see #42.
|
||
rmState.CellId = p.LandblockId;
|
||
|
||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): roll the previous
|
||
// server-pos snapshot forward AND print the per-UP comparison
|
||
// between the max sequencer speed observed since last UP and
|
||
// the actual server broadcast pace. Both sides are now sampled
|
||
// over the same window so the ratio reflects real overshoot.
|
||
{
|
||
double nowSecDiag = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||
&& rmState.LastServerPosTime > 0.0)
|
||
{
|
||
double dtServer = nowSecDiag - rmState.LastServerPosTime;
|
||
if (dtServer > 0.001)
|
||
{
|
||
var serverDelta = worldPos - rmState.LastServerPos;
|
||
float serverSpeed = (float)(serverDelta.Length() / dtServer);
|
||
float seqSpeed = rmState.MaxSeqSpeedSinceLastUP;
|
||
if (serverSpeed > 0.1f || seqSpeed > 0.1f)
|
||
{
|
||
System.Console.WriteLine(
|
||
$"[VEL_DIAG] guid={update.Guid:X8} maxSeqSpeed={seqSpeed:F3} m/s "
|
||
+ $"serverSpeed={serverSpeed:F3} m/s dtServer={dtServer:F3}s "
|
||
+ $"ratio={(serverSpeed > 1e-3f ? seqSpeed / serverSpeed : 0f):F3}");
|
||
}
|
||
}
|
||
}
|
||
rmState.MaxSeqSpeedSinceLastUP = 0f;
|
||
rmState.PrevServerPos = rmState.LastServerPos;
|
||
rmState.PrevServerPosTime = rmState.LastServerPosTime;
|
||
rmState.LastServerPos = worldPos;
|
||
rmState.LastServerPosTime = nowSecDiag;
|
||
}
|
||
|
||
// ── AIRBORNE NO-OP ────────────────────────────────────────────
|
||
// Mirrors retail CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330):
|
||
// when has_contact==0, return false (don't touch body, don't queue).
|
||
// body.Velocity (set once by OnLiveVectorUpdated at jump start) keeps
|
||
// integrating gravity via per-frame UpdatePhysicsInternal. Server is
|
||
// authoritative for the arc; we don't predict it locally.
|
||
if (!update.IsGrounded)
|
||
{
|
||
// Undo the unconditional entity hard-snap at the top of the
|
||
// function (entity.SetPosition(worldPos)): the body is mid-arc
|
||
// and TickAnimations will write entity = body next frame
|
||
// anyway. Setting entity = body now prevents a 1-frame
|
||
// teleport-to-server-then-yank-back rubber-band.
|
||
entity.SetPosition(rmState.Body.Position);
|
||
return;
|
||
}
|
||
|
||
// ── LANDING TRANSITION ────────────────────────────────────────
|
||
// First IsGrounded=true UP after rmState.Airborne signals landed.
|
||
// Clear airborne flags, hard-snap to authoritative landing position,
|
||
// clear interpolation queue (any pre-jump waypoints are stale).
|
||
if (rmState.Airborne)
|
||
{
|
||
rmState.Airborne = false;
|
||
rmState.Body.Velocity = System.Numerics.Vector3.Zero;
|
||
rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||
rmState.Interp.Clear();
|
||
rmState.Body.Position = worldPos;
|
||
|
||
// #161: retail landing = MovementManager::HitGround
|
||
// (minterp → moveto, 0x00524300 — the R5-V5 facade
|
||
// relay) with the Gravity state bit STILL SET
|
||
// (CMotionInterp::HitGround gates on state&0x400). The
|
||
// re-apply dispatches the PRESERVED pre-fall forward
|
||
// command → landing link → cycle. This replaces the
|
||
// forced SetCycle, which read the then-clobbered
|
||
// ForwardCommand (Falling) and re-set the pose it meant
|
||
// to clear. See the twin block in TickAnimations
|
||
// (VU.land).
|
||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand)
|
||
&& aeForLand.Sequencer is not null)
|
||
{
|
||
EnsureRemoteMotionBindings(rmState, aeForLand, update.Guid);
|
||
}
|
||
rmState.Movement.HitGround();
|
||
// DR bookkeeping only (partner of the jump-start
|
||
// `State |= Gravity`).
|
||
rmState.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||
return;
|
||
}
|
||
|
||
// ── GROUNDED ROUTING (CPhysicsObj::MoveOrTeleport) ────────────
|
||
const float MaxPhysicsDistance = 96f;
|
||
var localPlayerPos = _playerController?.Position ?? System.Numerics.Vector3.Zero;
|
||
float dist = System.Numerics.Vector3.Distance(worldPos, localPlayerPos);
|
||
|
||
if (dist > MaxPhysicsDistance)
|
||
{
|
||
// Beyond view bubble: SetPositionSimple slide-snap. Clear queue.
|
||
rmState.Interp.Clear();
|
||
rmState.Body.Position = worldPos;
|
||
}
|
||
else
|
||
{
|
||
// Within view bubble: enqueue waypoint for adjust_offset to walk to.
|
||
// The per-frame TickAnimations player-remote path drives the
|
||
// actual body advancement via InterpolationManager.AdjustOffset.
|
||
// Pass body's current position so the InterpolationManager can
|
||
// detect a far-distance enqueue (>100 m from body) and pre-arm
|
||
// an immediate blip — avoids body drifting visibly toward a
|
||
// far waypoint instead of teleporting to it.
|
||
float headingFromQuat = ExtractYawFromQuaternion(rot);
|
||
rmState.Interp.Enqueue(
|
||
worldPos,
|
||
headingFromQuat,
|
||
isMovingTo: false,
|
||
currentBodyPosition: rmState.Body.Position);
|
||
}
|
||
// Track the UP-derived synth velocity for diagnostics
|
||
// ([VEL_DIAG] pace comparison). L.2g S5 (2026-07-02): the
|
||
// #39-era cycle-refinement call that used to live here is
|
||
// DELETED — retail never adapts a remote's animation from
|
||
// observed pace (deviation map DEV-2; premise refuted at
|
||
// decomp + ACE source + the S0 live capture, which shows
|
||
// explicit 0x0005↔0x0007 UMs on every Shift toggle).
|
||
// Player-remote cycles are UM-driven only.
|
||
if (rmState.PrevServerPosTime > 0.0)
|
||
{
|
||
double nowSecVel = rmState.LastServerPosTime;
|
||
double dtPos = nowSecVel - rmState.PrevServerPosTime;
|
||
if (dtPos > 0.001)
|
||
{
|
||
var synthVel = (worldPos - rmState.PrevServerPos) / (float)dtPos;
|
||
rmState.ServerVelocity = synthVel;
|
||
rmState.HasServerVelocity = true;
|
||
}
|
||
}
|
||
|
||
// Sync the visible entity to the body — overrides the unconditional
|
||
// entity.SetPosition(worldPos) snap at the top of this function.
|
||
// For the far-snap branch this is a no-op (body == worldPos); for
|
||
// the near-enqueue branch this prevents a 1-frame teleport-then-
|
||
// yank-back rubber-band as TickAnimations chases worldPos via the
|
||
// queue.
|
||
entity.SetPosition(rmState.Body.Position);
|
||
return;
|
||
}
|
||
|
||
double nowSec = (now - System.DateTime.UnixEpoch).TotalSeconds;
|
||
System.Numerics.Vector3? serverVelocity = update.Velocity;
|
||
if (serverVelocity is null
|
||
&& !IsPlayerGuid(update.Guid)
|
||
&& rmState.LastServerPosTime > 0.0)
|
||
{
|
||
double elapsed = nowSec - rmState.LastServerPosTime;
|
||
if (elapsed > 0.001)
|
||
serverVelocity = (worldPos - rmState.LastServerPos) / (float)elapsed;
|
||
}
|
||
if (serverVelocity is { } authoritativeVelocity)
|
||
{
|
||
rmState.ServerVelocity = authoritativeVelocity;
|
||
rmState.HasServerVelocity = true;
|
||
}
|
||
else if (!IsPlayerGuid(update.Guid))
|
||
{
|
||
rmState.ServerVelocity = System.Numerics.Vector3.Zero;
|
||
rmState.HasServerVelocity = false;
|
||
}
|
||
// R5-V3 #171 residual (2026-07-04 gate: "flashing/flapping",
|
||
// stale facing, pushed-into-player): while an entity is STUCK,
|
||
// the sticky steer owns its frame — retail's UP corrections flow
|
||
// through the InterpolationManager into the SAME adjust_offset
|
||
// chain where StickyManager OVERWRITES them while armed
|
||
// (PositionManager::adjust_offset 0x00555190 order; sticky
|
||
// assigns m_fOrigin 0x00555430), so a server correction can
|
||
// never fight an armed stick frame-by-frame. This legacy NPC
|
||
// path hard-snaps OUTSIDE that chain, producing a visible
|
||
// snap-out/steer-back oscillation at UP cadence (position) and
|
||
// a stale-facing stomp (orientation). Faithful translation to
|
||
// the snap architecture: suppress the position/orientation/
|
||
// velocity snaps while stuck. LastServerPos/Time bookkeeping
|
||
// still records below — server truth reasserts on the first UP
|
||
// after unstick (bounded by the 1 s sticky lease). Register
|
||
// row with TS-41/TS-44.
|
||
bool snapSuppressedByStick =
|
||
(rmState.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||
if (snapSuppressedByStick
|
||
&& AcDream.Core.Physics.PhysicsDiagnostics.ProbeStickyEnabled)
|
||
{
|
||
float snapDist = System.Numerics.Vector3.Distance(
|
||
worldPos, rmState.Body.Position);
|
||
Console.WriteLine(FormattableString.Invariant(
|
||
$"[sticky-snap-skip] guid=0x{update.Guid:X8} d={snapDist:F3} srv=({worldPos.X:F2},{worldPos.Y:F2}) body=({rmState.Body.Position.X:F2},{rmState.Body.Position.Y:F2})"));
|
||
}
|
||
if (!snapSuppressedByStick)
|
||
rmState.Body.Position = worldPos;
|
||
// K-fix15 (2026-04-26): DON'T auto-clear airborne on UP.
|
||
// ACE broadcasts UPs during the arc (peak / mid-fall / land)
|
||
// at ~5-10 Hz. The previous K-fix9 logic cleared Airborne on
|
||
// the FIRST UP after the jump, which:
|
||
// * restored Contact + OnWalkable,
|
||
// * removed the Gravity flag,
|
||
// * caused the next per-tick to stomp Velocity via
|
||
// apply_current_movement (reading InterpretedState =
|
||
// Ready, so Velocity.Z went to 0),
|
||
// …so the body got stuck at the server-broadcast apex Z,
|
||
// visibly hovering. The fix: leave Airborne true; the
|
||
// per-tick post-resolve logic detects an actual landing
|
||
// (resolveResult.IsOnGround && Velocity.Z <= 0) and clears
|
||
// it then. Mirrors how PlayerMovementController re-grounds
|
||
// the local player at the bottom of its arc.
|
||
//
|
||
// The position-snap above is still authoritative — if ACE
|
||
// says the body is at Z=68 mid-arc, we render Z=68. But we
|
||
// continue integrating gravity from there, so the body
|
||
// proceeds along the parabolic path between UPs.
|
||
// Adopt the server's cell ID as the transition starting cell.
|
||
// Retail authoritatively hard-snaps cell membership here too; our
|
||
// per-tick ResolveWithTransition sweep then advances CheckCellId
|
||
// as the sphere crosses cells and writes the new cell back into
|
||
// rmState.CellId so the NEXT frame starts in the correct cell.
|
||
rmState.CellId = p.LandblockId;
|
||
|
||
// Retail hard-snaps orientation on UpdatePosition (set_frame,
|
||
// FUN_00514b90 @ chunk_00510000.c:5637 — direct assignment).
|
||
// Rotation rate between UPs comes from the formula-based
|
||
// omega seed on UpdateMotion (π/2 × turnSpeed). We tried
|
||
// deriving omega from UP deltas, but the first UP after a
|
||
// turn starts incorporates the pre-turn interval and produces
|
||
// a halved "observed" rate → visible slow-start. Formula-only
|
||
// is stable and simple; hard-snap fixes any drift.
|
||
// R5-V3 #171 residual: gated on the stick (see the position-snap
|
||
// comment above) — ACE's server-side facing lags the strafing
|
||
// target; stomping it over the sticky's per-tick face-tracking
|
||
// was the "monster attacking while facing a different direction"
|
||
// gate report.
|
||
if (!snapSuppressedByStick)
|
||
rmState.Body.Orientation = rot;
|
||
rmState.LastServerPos = worldPos;
|
||
rmState.LastServerPosTime = nowSec;
|
||
// Align the body's physics clock with our clock so update_object
|
||
// doesn't sub-step a huge initial gap.
|
||
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
|
||
|
||
// ACE broadcasts UpdatePosition WITHOUT HasVelocity for player
|
||
// remote motion — even while actively running. Per packet
|
||
// captures: UPs always arrive with velocity null. So we can't
|
||
// use UP-absent-velocity as a stop signal (was previously a
|
||
// bug that fired StopCompletely every UP → intermittent run).
|
||
//
|
||
// Stop is signaled by UpdateMotion(ForwardCommand = Ready =
|
||
// 0x41000003), handled in OnLiveMotionUpdated. UP's role here
|
||
// is just to hard-snap position and adopt velocity IF the
|
||
// packet happens to carry one (rare for players, common for
|
||
// scripted-path NPCs / missiles).
|
||
if (update.Velocity is { } svel)
|
||
{
|
||
rmState.Body.Velocity = svel;
|
||
// Only use the < 0.2 m/s stop signal when velocity was
|
||
// explicitly provided (i.e. server sent HasVelocity + tiny
|
||
// value = "I'm definitely stopped"). Absent velocity field
|
||
// carries no stop information for our ACE.
|
||
if (svel.LengthSquared() < 0.04f)
|
||
{
|
||
rmState.Motion.StopCompletely();
|
||
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
|
||
&& aeForStop.Sequencer is not null)
|
||
{
|
||
uint curStyle = aeForStop.Sequencer.CurrentStyle;
|
||
uint readyCmd = (curStyle & 0xFF000000u) != 0
|
||
? ((curStyle & 0xFF000000u) | 0x01000003u)
|
||
: 0x41000003u;
|
||
aeForStop.Sequencer.SetCycle(curStyle, readyCmd);
|
||
}
|
||
}
|
||
}
|
||
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
|
||
&& !snapSuppressedByStick)
|
||
{
|
||
rmState.Body.Velocity = rmState.ServerVelocity;
|
||
}
|
||
|
||
if (rmState.HasServerVelocity
|
||
&& !snapSuppressedByStick
|
||
&& _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity))
|
||
{
|
||
// NPC/monster remotes: PlanFromVelocity cycle selection from
|
||
// UP-derived velocity (ACE broadcasts NPC motion patterns the
|
||
// UM stream alone doesn't cover). Player remotes return early
|
||
// inside — their cycles are UM-driven only per retail (L.2g
|
||
// S5; DEV-2 deleted). Unification of NPCs onto the
|
||
// CMotionInterp funnel is S6.
|
||
//
|
||
// D2 (Commit A 2026-05-03): tag whether the velocity feeding
|
||
// ApplyServerControlledVelocityCycle is wire-explicit or
|
||
// synthesized from position deltas (the common case).
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
string velSrc = update.Velocity is null ? "synth" : "wire";
|
||
System.Console.WriteLine(
|
||
$"[UPCYCLE_SRC] guid={update.Guid:X8} src={velSrc}");
|
||
}
|
||
ApplyServerControlledVelocityCycle(
|
||
update.Guid,
|
||
aeForVelocity,
|
||
rmState,
|
||
rmState.ServerVelocity);
|
||
}
|
||
|
||
entity.SetPosition(rmState.Body.Position);
|
||
entity.Rotation = rmState.Body.Orientation;
|
||
}
|
||
|
||
// Phase B.3 / G.3a (#133): portal-space arrival detection.
|
||
// Only runs for our own player character while in PortalSpace.
|
||
if (_playerController is not null
|
||
&& _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
|
||
&& update.Guid == _playerServerGuid)
|
||
{
|
||
// Compute old landblock coords from controller position (using the
|
||
// current streaming origin as the reference center).
|
||
var oldPos = _playerController.Position;
|
||
int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
|
||
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
|
||
|
||
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
|
||
|
||
Console.WriteLine(
|
||
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
|
||
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
|
||
|
||
System.Numerics.Vector3 newWorldPos;
|
||
if (differentLandblock)
|
||
{
|
||
// #145: drop the stale SOURCE 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 (AdjustPosition / Resolve, iterating _landblocks) resolves the
|
||
// player into the STALE source landblock (a dungeon's frame). That happens
|
||
// for the arrival snap AND every per-frame resolve until the source finally
|
||
// unloads, so outbound movement is sent in the dungeon's frame and the server
|
||
// rejects every move as a failed transition (#145 / #138). Removing the stale
|
||
// center here makes both resolves fall through to the server-authoritative
|
||
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
|
||
// destination streams in. Only the offset-(0,0) center can collide with the
|
||
// destination-local position, so removing it alone is sufficient.
|
||
uint staleCenterId = AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
|
||
_liveCenterX, _liveCenterY);
|
||
_physicsEngine.RemoveLandblock(staleCenterId);
|
||
|
||
// Recenter the streaming controller on the new landblock NOW (kick
|
||
// off the dungeon load). After recentering, the destination is
|
||
// (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin.
|
||
_liveCenterX = lbX;
|
||
_liveCenterY = lbY;
|
||
newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
|
||
|
||
// #135: pre-collapse on teleport into a sealed dungeon too — same
|
||
// race as login. The destination isn't placed until it hydrates, so
|
||
// without this NormalTick loads the full neighbor window during the
|
||
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
|
||
// observer pinned to _liveCenterX/Y while held, so the stale frozen
|
||
// player position can't drift the observer off the dungeon and re-expand.
|
||
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
|
||
_streamingController.PreCollapseToDungeon(lbX, lbY);
|
||
else
|
||
// Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL
|
||
// resident terrain so overlapping blocks on a NEARBY jump re-bake at the
|
||
// new origin instead of rendering shifted — the confirmed "terrain in the
|
||
// sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22.
|
||
_streamingController?.ForceReloadWindow();
|
||
}
|
||
else
|
||
{
|
||
newWorldPos = worldPos;
|
||
}
|
||
|
||
// Retail "pink-bubble" transit: do NOT snap here. Record the destination and
|
||
// PRIORITIZE its landblock in streaming so it applies ahead of the per-frame
|
||
// budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame
|
||
// in OnUpdate — holds the player in PortalSpace behind the fade until the
|
||
// destination terrain is resident (TeleportWorldReady), then fires Place. While
|
||
// held, no movement resolve runs, so the outbound cell frame can't corrupt.
|
||
_pendingTeleportRot = rot;
|
||
_pendingTeleportPos = newWorldPos;
|
||
_pendingTeleportCell = p.LandblockId;
|
||
_teleportHoldSeconds = 0f;
|
||
_teleportForced = false;
|
||
if (_streamingController is not null)
|
||
{
|
||
_streamingController.PriorityLandblockId =
|
||
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
|
||
// Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not
|
||
// just the one landblock the player stands on — so they arrive in a loaded,
|
||
// collidable world instead of a single landblock in the void.
|
||
_streamingController.PriorityRadius = TeleportNearRingRadius;
|
||
}
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||
"AIM", p.LandblockId,
|
||
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
|
||
}
|
||
}
|
||
|
||
// Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the
|
||
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
|
||
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
|
||
// Replaces the old TeleportArrivalController hold/place machine.
|
||
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
|
||
private bool _teleportInProgress;
|
||
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 float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay
|
||
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 the fade 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 the fade
|
||
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
|
||
private const int TeleportNearRingRadius = 1;
|
||
|
||
// #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 resident so we
|
||
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
|
||
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
|
||
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
|
||
/// single destination landblock — so the fade only lifts onto a loaded, collidable world
|
||
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
|
||
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
|
||
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
|
||
/// 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)
|
||
{
|
||
if (IsSpawnClaimUnhydratable(destCell)) return true;
|
||
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
|
||
return indoor
|
||
? _physicsEngine.IsSpawnCellReady(destCell)
|
||
: _physicsEngine.IsNeighborhoodTerrainResident(destCell, TeleportNearRingRadius);
|
||
}
|
||
|
||
// 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.Yaw = AcQuaternionToYaw(_pendingTeleportRot);
|
||
|
||
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
|
||
_retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
|
||
playerVelocity: System.Numerics.Vector3.Zero,
|
||
isOnGround: true,
|
||
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
|
||
dt: 1f / 60f);
|
||
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||
"PLACED", resolved.CellId, $"forced={forced}");
|
||
// Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND
|
||
// the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in
|
||
// and fires FireLoginComplete (which regains control + acks the server). 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 the retail fade transit. The per-frame TAS tick holds
|
||
/// the player behind the fade until the destination is resident, then materializes them
|
||
/// (Place) and, after the world fades back in, regains control + acks the server
|
||
/// (FireLoginComplete).
|
||
/// </summary>
|
||
private void OnTeleportStarted(uint sequence)
|
||
{
|
||
if (_playerController is not null)
|
||
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
|
||
_teleportInProgress = true;
|
||
_teleportHoldSeconds = 0f;
|
||
_teleportForced = false;
|
||
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
|
||
Console.WriteLine($"live: teleport started (seq={sequence})");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
|
||
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
|
||
/// with the CAMERA's current world position as the anchor. For
|
||
/// scene-wide storm effects (lightning) the camera is the right
|
||
/// reference frame since the flash is meant to be "around the
|
||
/// player." For per-entity effects the runner's dedupe by
|
||
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays
|
||
/// working on different guids.
|
||
///
|
||
/// <para>
|
||
/// Improvements for follow-up: look up the guid's actual last-
|
||
/// known world position from <c>_worldState</c> so per-entity
|
||
/// spell casts and emote gestures anchor correctly. For Phase 6
|
||
/// scope (lightning, which is Dereth-wide) the camera anchor is
|
||
/// sufficient.
|
||
/// </para>
|
||
/// </summary>
|
||
private void OnPlayScriptReceived(uint guid, uint scriptId)
|
||
{
|
||
if (_scriptRunner is null) return;
|
||
|
||
var camWorldPos = System.Numerics.Vector3.Zero;
|
||
if (_cameraController is not null)
|
||
{
|
||
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
|
||
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
|
||
}
|
||
|
||
_scriptRunner.Play(scriptId, guid, camWorldPos);
|
||
}
|
||
|
||
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);
|
||
|
||
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
|
||
continue;
|
||
|
||
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);
|
||
// Refresh anchor + rotation every frame so AttachLocal
|
||
// (is_parent_local=1) particles track the camera. Retail
|
||
// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the
|
||
// live parent frame each tick; for sky-PES the parent IS
|
||
// the camera. UpdateEntityAnchor is a no-op when no
|
||
// emitters yet exist (script just spawned this frame).
|
||
_particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation);
|
||
|
||
if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key))
|
||
continue;
|
||
|
||
if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor))
|
||
{
|
||
_activeSkyPes.Add(key);
|
||
}
|
||
else
|
||
{
|
||
_missingSkyPes.Add(key);
|
||
_particleSink.ClearEntityRenderPass(skyEntityId);
|
||
}
|
||
}
|
||
}
|
||
|
||
foreach (var key in _activeSkyPes.ToArray())
|
||
{
|
||
if (seen.Contains(key))
|
||
continue;
|
||
|
||
uint skyEntityId = SkyPesEntityId(key);
|
||
_scriptRunner.Stop(key.PesObjectId, skyEntityId);
|
||
_particleSink.StopAllForEntity(skyEntityId, fadeOut: true);
|
||
_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);
|
||
}
|
||
|
||
private static uint ParticleEntityKey(AcDream.Core.World.WorldEntity entity)
|
||
=> entity.ServerGuid != 0 ? entity.ServerGuid : 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: streaming load delegate, runs on the worker thread.
|
||
/// Reads the landblock from the dats, hydrates its stab entities (same
|
||
/// path as the old preload), and returns a fully-populated LoadedLandblock.
|
||
/// Thread-safe: uses only DatCollection reads (documented thread-safe by
|
||
/// DatReaderWriter) and pure CPU work. No GL calls here.
|
||
///
|
||
/// MVP scope: stabs only. Scenery + interior added in Task 8.
|
||
///
|
||
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
|
||
/// LandBlockInfo + scenery + interior hydration. They return only the
|
||
/// LandBlock heightmap dat record + an empty entity list — enough for
|
||
/// terrain-mesh build on the next phase. Near-tier loads run the full
|
||
/// path. This replaces Bug A's post-load entity strip in
|
||
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
|
||
/// early-out at the source.
|
||
/// </summary>
|
||
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(
|
||
uint landblockId,
|
||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
||
{
|
||
if (_dats is null) return null;
|
||
|
||
// Phase A.1 hotfix: hold the dat lock for the entire load. The worker
|
||
// thread mustn't read dats concurrently with the render thread's
|
||
// ApplyLoadedTerrain / live-spawn handlers. Hold time is bounded by
|
||
// the size of a single landblock's CPU-side build (tens of ms worst
|
||
// case), which blocks the render thread for at most that duration.
|
||
// This is the minimum correct behavior; a future pass can reduce
|
||
// contention by pre-building render-thread work on the worker.
|
||
// tp-probe (2026-06-22, REMOVABLE): measure lock-WAIT (the _datLock
|
||
// contention signal — large only when the render thread is hammering the
|
||
// lock during a CreateObject flood) AND lock-HOLD (the intrinsic build
|
||
// cost). Identical work in both branches; the probe branch only adds the
|
||
// stopwatch + log. No behavior change when ProbeTeleportEnabled is false.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeTeleportEnabled)
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
lock (_datLock)
|
||
{
|
||
long waitedMs = sw.ElapsedMilliseconds;
|
||
sw.Restart();
|
||
var built = BuildLandblockForStreamingLocked(landblockId, kind);
|
||
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
|
||
"BUILD", landblockId,
|
||
$"waited={waitedMs}ms held={sw.ElapsedMilliseconds}ms kind={kind}");
|
||
return built;
|
||
}
|
||
}
|
||
lock (_datLock)
|
||
{
|
||
return BuildLandblockForStreamingLocked(landblockId, kind);
|
||
}
|
||
}
|
||
|
||
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(
|
||
uint landblockId,
|
||
AcDream.App.Streaming.LandblockStreamJobKind kind)
|
||
{
|
||
if (_dats is null) return null;
|
||
|
||
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
|
||
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
|
||
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
|
||
// worker-thread cost per far-tier LB from ~tens of ms to a single
|
||
// dat read.
|
||
if (kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
|
||
{
|
||
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
|
||
if (heightmapOnly is null) return null;
|
||
return new AcDream.Core.World.LoadedLandblock(
|
||
landblockId,
|
||
heightmapOnly,
|
||
System.Array.Empty<AcDream.Core.World.WorldEntity>(),
|
||
AcDream.Core.World.PhysicsDatBundle.Empty); // far tier: no cells/buildings/entities
|
||
}
|
||
|
||
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
|
||
if (baseLoaded is null) return null;
|
||
|
||
int lbX = (int)((landblockId >> 24) & 0xFFu);
|
||
int lbY = (int)((landblockId >> 16) & 0xFFu);
|
||
var worldOffset = new System.Numerics.Vector3(
|
||
(lbX - _liveCenterX) * 192f,
|
||
(lbY - _liveCenterY) * 192f,
|
||
0f);
|
||
|
||
// Hydrate the stabs: same logic as the old OnLoad preload. Each stab
|
||
// entity from LandblockLoader carries a SourceGfxObjOrSetupId that we
|
||
// expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build.
|
||
// GPU upload (EnsureUploaded) happens on the render thread in
|
||
// ApplyLoadedTerrain — NOT here.
|
||
var hydrated = new List<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
|
||
foreach (var e in baseLoaded.Entities)
|
||
{
|
||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||
var stabBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||
|
||
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
||
{
|
||
// Single GfxObj stab — identity part transform.
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
||
if (gfx is not null)
|
||
{
|
||
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) stabBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
||
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
||
}
|
||
}
|
||
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
// Multi-part Setup — flatten to per-part GfxObj refs.
|
||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
|
||
if (setup is not null)
|
||
{
|
||
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
|
||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||
foreach (var mr in flat)
|
||
{
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||
if (gfx is null) continue;
|
||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) stabBounds.Add(mr.PartTransform, pb.Value);
|
||
meshRefs.Add(mr);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (meshRefs.Count == 0) continue;
|
||
|
||
var entity = new AcDream.Core.World.WorldEntity
|
||
{
|
||
Id = e.Id,
|
||
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
||
Position = e.Position + worldOffset,
|
||
Rotation = e.Rotation,
|
||
MeshRefs = meshRefs,
|
||
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
|
||
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
|
||
};
|
||
if (stabBounds.TryGet(out var sbMin, out var sbMax))
|
||
entity.SetLocalBounds(sbMin, sbMax);
|
||
hydrated.Add(entity);
|
||
}
|
||
|
||
// Task 8: merge stabs + scenery + interior into one entity list.
|
||
var merged = new List<AcDream.Core.World.WorldEntity>(hydrated);
|
||
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
|
||
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
|
||
|
||
// datLock fix: pre-read (under the worker's _datLock) every dat the apply
|
||
// consumes, so ApplyLoadedTerrainLocked can run lock-free. Built AFTER
|
||
// `merged` so entity GfxObj/Setup ids are known.
|
||
var physicsDats = BuildPhysicsDatBundle(landblockId, merged);
|
||
return new AcDream.Core.World.LoadedLandblock(
|
||
baseLoaded.LandblockId,
|
||
baseLoaded.Heightmap,
|
||
merged,
|
||
physicsDats);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pre-reads (under the worker's <c>_datLock</c>) every dat object
|
||
/// <see cref="ApplyLoadedTerrainLocked"/> consumes, so the apply makes zero
|
||
/// <c>DatCollection</c> calls and the update thread never blocks on the
|
||
/// worker's lock. MIRRORS the apply's six read sites — gather and apply must
|
||
/// enumerate the same ids. Reads only; no build / registration.
|
||
/// </summary>
|
||
private AcDream.Core.World.PhysicsDatBundle BuildPhysicsDatBundle(
|
||
uint landblockId,
|
||
System.Collections.Generic.IReadOnlyList<AcDream.Core.World.WorldEntity> entities)
|
||
{
|
||
var envCells = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.EnvCell>();
|
||
var environments = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Environment>();
|
||
var setups = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.Setup>();
|
||
var gfxObjs = new System.Collections.Generic.Dictionary<uint, DatReaderWriter.DBObjs.GfxObj>();
|
||
|
||
// (1) LandBlockInfo
|
||
var lbInfo = _dats!.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||
(landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||
|
||
if (lbInfo is not null)
|
||
{
|
||
// (2)+(3) EnvCell + Environment, per cell
|
||
if (lbInfo.NumCells > 0)
|
||
{
|
||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||
{
|
||
uint envCellId = firstCellId + offset;
|
||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||
if (envCell is null) continue;
|
||
envCells[envCellId] = envCell;
|
||
if (envCell.EnvironmentId == 0) continue;
|
||
uint envId = 0x0D000000u | envCell.EnvironmentId;
|
||
if (!environments.ContainsKey(envId))
|
||
{
|
||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(envId);
|
||
if (environment is not null) environments[envId] = environment;
|
||
}
|
||
}
|
||
}
|
||
|
||
// (4) building shell Setup, per building
|
||
foreach (var building in lbInfo.Buildings)
|
||
{
|
||
uint modelId = building.ModelId;
|
||
if ((modelId & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(modelId))
|
||
{
|
||
var bldSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(modelId);
|
||
if (bldSetup is not null) setups[modelId] = bldSetup;
|
||
}
|
||
}
|
||
}
|
||
|
||
// (5)+(6) entity GfxObj (BSP cache) + entity Setup (ShadowObjects parts/lights)
|
||
foreach (var entity in entities)
|
||
{
|
||
uint src = entity.SourceGfxObjOrSetupId;
|
||
if ((src & 0xFF000000u) == 0x02000000u && !setups.ContainsKey(src))
|
||
{
|
||
var s = _dats.Get<DatReaderWriter.DBObjs.Setup>(src);
|
||
if (s is not null) setups[src] = s;
|
||
}
|
||
foreach (var meshRef in entity.MeshRefs)
|
||
{
|
||
uint gid = meshRef.GfxObjId;
|
||
if ((gid & 0xFF000000u) != 0x01000000u) continue;
|
||
if (gfxObjs.ContainsKey(gid)) continue;
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(gid);
|
||
if (gfx is not null) gfxObjs[gid] = gfx;
|
||
}
|
||
}
|
||
|
||
return new AcDream.Core.World.PhysicsDatBundle(lbInfo, envCells, environments, setups, gfxObjs);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
|
||
/// landblock on the worker thread. Pure CPU — no GL calls.
|
||
///
|
||
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
|
||
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
|
||
/// LoadedLandblock instead of iterating worldView.Landblocks.
|
||
/// </summary>
|
||
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
|
||
AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY)
|
||
{
|
||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||
if (_dats is null || _heightTable is null) return result;
|
||
|
||
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
|
||
if (region is null) return result;
|
||
|
||
// Build a set of terrain vertex indices that have buildings on them,
|
||
// so the scenery generator can skip those cells (ACME conformance fix 4d).
|
||
HashSet<int>? buildingCells = null;
|
||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
|
||
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
||
if (lbInfo is not null)
|
||
{
|
||
// Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
|
||
// static scenery placeholders themselves (rocks, tree clusters) that
|
||
// retail does NOT use to suppress scenery generation. Including them
|
||
// here over-suppressed scenery in town landblocks.
|
||
buildingCells = new HashSet<int>();
|
||
foreach (var bldg in lbInfo.Buildings)
|
||
{
|
||
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
|
||
int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8);
|
||
buildingCells.Add(cx * 9 + cy);
|
||
}
|
||
}
|
||
|
||
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
|
||
_dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable);
|
||
if (spawns.Count == 0) return result;
|
||
|
||
var lbOffset = new System.Numerics.Vector3(
|
||
(lbX - _liveCenterX) * 192f,
|
||
(lbY - _liveCenterY) * 192f,
|
||
0f);
|
||
|
||
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
|
||
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
|
||
// Both must go into our ID so landblocks don't collide.
|
||
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
|
||
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
|
||
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
|
||
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
|
||
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
|
||
uint localIndex = 0;
|
||
|
||
foreach (var spawn in spawns)
|
||
{
|
||
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
|
||
// Scale is baked into the root transform by wrapping each part's
|
||
// transform with a scale matrix.
|
||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||
var sceneryBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
|
||
|
||
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
|
||
{
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
|
||
if (gfx is not null)
|
||
{
|
||
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
|
||
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
|
||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) sceneryBounds.Add(scaleMat, pb.Value);
|
||
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
|
||
}
|
||
}
|
||
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
|
||
if (setup is not null)
|
||
{
|
||
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
|
||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||
foreach (var mr in flat)
|
||
{
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||
if (gfx is null) continue;
|
||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||
// Compose: part's own transform, then the spawn's scale.
|
||
var partXf = mr.PartTransform * scaleMat;
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) sceneryBounds.Add(partXf, pb.Value);
|
||
meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, partXf));
|
||
}
|
||
}
|
||
}
|
||
|
||
if (meshRefs.Count == 0) continue;
|
||
|
||
// Sample terrain Z at (localX, localY) to lift scenery onto the
|
||
// ground. Add BaseLoc.Z from the scenery ObjectDesc (passed in as
|
||
// spawn.LocalPosition.Z) so meshes that specify a vertical offset
|
||
// from the ground (e.g., flowers at -0.1m, roots below terrain)
|
||
// settle properly.
|
||
float localX = spawn.LocalPosition.X;
|
||
float localY = spawn.LocalPosition.Y;
|
||
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
|
||
// — it uses the same AC2D render split-direction formula the
|
||
// TerrainModernRenderer uses for the visible terrain mesh. This
|
||
// guarantees trees are placed on the SAME Z height the player
|
||
// walks on. If physics hasn't registered this landblock yet,
|
||
// fall back to the local bilinear sample.
|
||
var worldPx = localX + lbOffset.X;
|
||
var worldPy = localY + lbOffset.Y;
|
||
// FIX (trees-in-sky, 2026-06-22): scenery ground-Z comes from THIS
|
||
// landblock's OWN heightmap — the same triangle-aware Z the player walks on
|
||
// (TerrainSurface.SampleZFromHeightmap, lock-step with physics per #48),
|
||
// scoped to the landblock being built. The former global
|
||
// _physicsEngine.SampleTerrainZ(worldPx) query was structurally racy: at
|
||
// build time this landblock is NOT registered in physics yet, so that query
|
||
// could only return null (→ this same own-heightmap) or a STALE neighbor's
|
||
// height — the previous location's terrain, still registered after a teleport
|
||
// recenter (which drops only the single stale CENTER landblock, GameWindow
|
||
// :5444) until streaming unloads it — planting scenery at the old location's
|
||
// altitude (trees-in-sky, deltaZ up to +500m; confirmed via the
|
||
// [scenery-z-stale] probe 2026-06-22). Own-heightmap is correct in every
|
||
// case, so the global query is removed (also drops its per-spawn cost).
|
||
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
|
||
float finalZ = groundZ + spawn.LocalPosition.Z;
|
||
|
||
// Issue #48 diagnostic. One log line per (spawn, rendered-mesh)
|
||
// disambiguates H1 (BaseLoc.Z / mesh-zMin per-species), H2
|
||
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
|
||
// User identifies a floating tree visually, finds the matching
|
||
// line by world coords + gfx id, the data picks the hypothesis.
|
||
if (_options.DumpSceneryZ)
|
||
{
|
||
// groundZ now always comes from THIS landblock's own heightmap (the
|
||
// global physics query was removed — see the trees-in-sky fix above).
|
||
string source = "heightmap";
|
||
foreach (var mr in meshRefs)
|
||
{
|
||
var dgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||
if (dgfx is null) continue;
|
||
|
||
float zMin = float.PositiveInfinity, zMax = float.NegativeInfinity;
|
||
foreach (var v in dgfx.VertexArray.Vertices.Values)
|
||
{
|
||
if (v.Origin.Z < zMin) zMin = v.Origin.Z;
|
||
if (v.Origin.Z > zMax) zMax = v.Origin.Z;
|
||
}
|
||
if (float.IsPositiveInfinity(zMin)) { zMin = 0f; zMax = 0f; }
|
||
|
||
// Per-part transform offset inside the setup (post-spawn-scale).
|
||
// For setup spawns this is Setup.PlacementFrames[Default].Frames[i] *
|
||
// spawn.Scale. For single-GfxObj spawns it's identity * spawn.Scale.
|
||
var partT = mr.PartTransform.Translation;
|
||
|
||
bool hasDD = dgfx.Flags.HasFlag(DatReaderWriter.Enums.GfxObjFlags.HasDIDDegrade);
|
||
string ddInfo = string.Empty;
|
||
if (hasDD && dgfx.DIDDegrade != 0)
|
||
{
|
||
var ddi = _dats.Get<DatReaderWriter.DBObjs.GfxObjDegradeInfo>(dgfx.DIDDegrade);
|
||
if (ddi is not null && ddi.Degrades.Count > 0)
|
||
{
|
||
uint slot0Id = (uint)ddi.Degrades[0].Id;
|
||
float slot0Min = 0f;
|
||
var slot0Gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(slot0Id);
|
||
if (slot0Gfx is not null && slot0Gfx.VertexArray.Vertices.Count > 0)
|
||
{
|
||
slot0Min = float.PositiveInfinity;
|
||
foreach (var v in slot0Gfx.VertexArray.Vertices.Values)
|
||
if (v.Origin.Z < slot0Min) slot0Min = v.Origin.Z;
|
||
if (float.IsPositiveInfinity(slot0Min)) slot0Min = 0f;
|
||
}
|
||
ddInfo = $" deg[0]=0x{slot0Id:X8} deg[0]ZMin={slot0Min:F3}";
|
||
}
|
||
}
|
||
|
||
// partWorldZMin = the lowest vertex of this part in world space.
|
||
// = finalZ (setup origin in world Z) + partT.Z (part offset) + zMin (mesh-local lowest vertex)
|
||
// If everything is right and the lowest part of the tree should
|
||
// touch the ground, we expect partWorldZMin <= groundZ for at
|
||
// least one part of a multi-part setup.
|
||
float partWorldZMin = finalZ + partT.Z + zMin;
|
||
|
||
Console.WriteLine(
|
||
$"[scenery-z] lb=0x{lb.LandblockId:X8} root=0x{spawn.ObjectId:X8} gfx=0x{mr.GfxObjId:X8}" +
|
||
$" source={source}" +
|
||
$" world=({worldPx:F2},{worldPy:F2}) localXY=({localX:F2},{localY:F2})" +
|
||
$" groundZ={groundZ:F3} BaseLoc.Z={spawn.LocalPosition.Z:F3} finalZ={finalZ:F3}" +
|
||
$" partT=({partT.X:F2},{partT.Y:F2},{partT.Z:F3}) spawnScale={spawn.Scale:F3}" +
|
||
$" zRange=[{zMin:F3}..{zMax:F3}] partWorldZMin={partWorldZMin:F3} delta={partWorldZMin - groundZ:F3}" +
|
||
$" hasDIDDegrade={hasDD}{ddInfo}");
|
||
}
|
||
}
|
||
|
||
var hydrated = new AcDream.Core.World.WorldEntity
|
||
{
|
||
Id = sceneryIdBase + localIndex++,
|
||
SourceGfxObjOrSetupId = spawn.ObjectId,
|
||
Position = new System.Numerics.Vector3(localX, localY, finalZ) + lbOffset,
|
||
Rotation = spawn.Rotation,
|
||
MeshRefs = meshRefs,
|
||
Scale = spawn.Scale,
|
||
};
|
||
if (sceneryBounds.TryGet(out var scbMin, out var scbMax))
|
||
hydrated.SetLocalBounds(scbMin, scbMax);
|
||
result.Add(hydrated);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
|
||
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
|
||
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
|
||
///
|
||
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
|
||
/// so ApplyLoadedTerrain can drain + upload them on the render thread.
|
||
///
|
||
/// Ported from pre-streaming preload lines 407-565.
|
||
/// </summary>
|
||
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
|
||
uint landblockId, int lbX, int lbY)
|
||
{
|
||
var result = new List<AcDream.Core.World.WorldEntity>();
|
||
if (_dats is null) return result;
|
||
|
||
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
|
||
if (lbInfo is null || lbInfo.NumCells == 0) return result;
|
||
|
||
var lbOffset = new System.Numerics.Vector3(
|
||
(lbX - _liveCenterX) * 192f,
|
||
(lbY - _liveCenterY) * 192f,
|
||
0f);
|
||
|
||
// Per-landblock id namespace: 0x40000000 | (lbX << 16) | (lbY << 8) | local_counter —
|
||
// the same 0xNNXXYY## scheme scenery uses (0x80XXYY##). Distinct from scenery
|
||
// (0x80000000+) and stabs (ids from LandblockLoader).
|
||
//
|
||
// #119 ROOT-CAUSE FIX (2026-06-11): this used to be
|
||
// `0x40000000 | (landblockId & 0x00FFFF00)`, which for landblock keys 0xXXYYFFFF
|
||
// resolves to 0x40YYFF00 — the landblock X byte DISCARDED. Every landblock in a
|
||
// map Y-row produced the same id base, so interior statics collided across
|
||
// landblocks (Holtburg town A9B3's 9th stab == the AAB3 tower's 43-part spiral
|
||
// staircase, both 0x40B3FF09). The Tier-1 classification cache then served one
|
||
// entity's batches to the other (the cache hint at bucket-draw time was the
|
||
// player's landblock, identical for both) — the session-sticky "broken stairs +
|
||
// water barrel". Counter overflow past 0xFF still bleeds into the lbY byte (the
|
||
// documented #53 scenery-namespace caveat); the cache's (EntityId, owner-derived
|
||
// LandblockHint) tuple key disambiguates that residual case.
|
||
uint interiorLbX = (landblockId >> 24) & 0xFFu;
|
||
uint interiorLbY = (landblockId >> 16) & 0xFFu;
|
||
uint interiorIdBase = 0x40000000u | (interiorLbX << 16) | (interiorLbY << 8);
|
||
uint localCounter = 0;
|
||
|
||
uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u;
|
||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||
{
|
||
uint envCellId = firstCellId + offset;
|
||
var envCell = _dats.Get<DatReaderWriter.DBObjs.EnvCell>(envCellId);
|
||
if (envCell is null)
|
||
{
|
||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||
// every id in [0x0100, 0x0100+NumCells) is derived from LandBlockInfo and
|
||
// MUST exist in the cell dat — a null here is always a read anomaly.
|
||
Console.WriteLine($"[cell-miss] EnvCell 0x{envCellId:X8} null during interior hydration (NumCells={lbInfo.NumCells})");
|
||
continue;
|
||
}
|
||
|
||
// Phase 7.1: build and register room geometry for this EnvCell.
|
||
DatReaderWriter.Types.CellStruct? cellStruct = null;
|
||
if (envCell.EnvironmentId != 0)
|
||
{
|
||
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(0x0D000000u | envCell.EnvironmentId);
|
||
if (environment is null)
|
||
{
|
||
// TEMP diagnostic (dat-race investigation 2026-06-09, strip with fix):
|
||
// a null Environment means this cell's WALLS are silently never
|
||
// registered while its static objects still draw — the exact
|
||
// white-walls geometry signature.
|
||
Console.WriteLine($"[cell-miss] Environment 0x{0x0D000000u | envCell.EnvironmentId:X8} null for EnvCell 0x{envCellId:X8} -> walls not registered");
|
||
}
|
||
if (environment is not null
|
||
&& environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct))
|
||
{
|
||
// Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as
|
||
// WorldEntities with fake MeshRefs. The CellMesh.Build call stays for
|
||
// _pendingCellMeshes (still populated for any consumer) and the physics
|
||
// data cache (CacheCellStruct uses cellStruct directly, not the sub-meshes).
|
||
// Static objects inside the cell continue to flow through the dispatcher
|
||
// as WorldEntity records below — they have real GfxObj MeshRefs that work
|
||
// fine; EnvCellRenderer.RegisterCell receives an empty staticObjects list.
|
||
// Transforms — needed by the portal-visibility cell (unlifted) AND the
|
||
// render/physics path. Computed for EVERY cell with a valid cellStruct,
|
||
// not just drawable ones. Keep the small render lift out of physics; retail
|
||
// BSP contact planes use the EnvCell origin verbatim. The lift constant is
|
||
// shared with every draw-space consumer of portal polygons (OutsideView
|
||
// gate, seal/punch fans) — PortalVisibilityBuilder.ShellDrawLiftZ (#130).
|
||
var physicsCellOrigin = envCell.Position.Origin + lbOffset;
|
||
var cellOrigin = physicsCellOrigin + new System.Numerics.Vector3(
|
||
0f, 0f, AcDream.App.Rendering.PortalVisibilityBuilder.ShellDrawLiftZ);
|
||
var cellTransform =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||
System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
|
||
var physicsCellTransform =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
||
System.Numerics.Matrix4x4.CreateTranslation(physicsCellOrigin);
|
||
|
||
// PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless
|
||
// of whether CellMesh.Build produced drawable sub-meshes. A portals-only
|
||
// pass-through connector (a ramp / stair / cellar mouth) yields 0 render
|
||
// sub-meshes but MUST be in the visibility graph so the flood can traverse it
|
||
// to the cells beyond — otherwise the flood lookup-misses the unregistered
|
||
// neighbour and the grey clear shows through the opening (#133: ramp
|
||
// neighbour 0x0007014D had 0 sub-meshes → unregistered → vis=1 grey barrier
|
||
// at the ramp; confirmed via [cellreg] registered=204/205 + [pv-trace]
|
||
// skip=lookup-miss). Retail keeps the whole landblock cell array resident
|
||
// before the flood runs; BuildLoadedCell reads the cellStruct portals, NOT
|
||
// the render sub-meshes. The +0.02 m render lift is a DRAW concern only and
|
||
// is intentionally NOT fed into the visibility transform (#119-residual: the
|
||
// lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells).
|
||
BuildLoadedCell(envCellId, envCell, cellStruct, physicsCellOrigin, physicsCellTransform);
|
||
|
||
// PHYSICS cell graph: cache EVERY cell with a valid cellStruct, regardless of
|
||
// drawable sub-meshes. The camera-collision sweep (SmartBox::update_viewer →
|
||
// sphere_path.curr_cell, pc:92870) and the player cell-transit must be able to
|
||
// TRANSIT THROUGH a portals-only connector — otherwise the viewer/curr cell can
|
||
// never reach it and lags one cell behind the eye (#133 residual: the camera sat
|
||
// 1.32 m past the ramp portal's plane while the viewer cell stalled in
|
||
// 0x00070103 — the sweep transited every cached neighbour but NEVER the
|
||
// un-cached connector 0x014D — so the side test culled the on-screen connector
|
||
// portal and the grey clear showed through). Retail keeps the whole landblock
|
||
// cell array resident for the sweep; a portals-only connector has an empty
|
||
// collision BSP but its portals drive the transit. CacheCellStruct reads the
|
||
// cellStruct directly, not the render sub-meshes.
|
||
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, physicsCellTransform);
|
||
|
||
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
|
||
if (cellSubMeshes.Count > 0)
|
||
{
|
||
_pendingCellMeshes[envCellId] = cellSubMeshes;
|
||
|
||
// Phase A8: register the cell with EnvCellRenderer for rendering.
|
||
// staticObjects is empty — cell stabs continue as separate WorldEntity
|
||
// records via the dispatcher (see lines below for the unchanged stab path).
|
||
_envCellRenderer?.RegisterCell(
|
||
landblockId: landblockId,
|
||
envCellId: envCellId,
|
||
envCell: envCell,
|
||
cellStruct: cellStruct,
|
||
cellTransform: cellTransform,
|
||
cellWorldPosition: cellOrigin,
|
||
cellRotation: envCell.Position.Orientation,
|
||
staticObjects: System.Array.Empty<(uint, System.Numerics.Vector3, System.Numerics.Quaternion, bool, System.Numerics.Matrix4x4)>());
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 2d: static objects inside the EnvCell.
|
||
foreach (var stab in envCell.StaticObjects)
|
||
{
|
||
// #119 decisive probe: HYDRATE-side dump for ACDREAM_DUMP_ENTITY-
|
||
// targeted stabs. This is the MOMENT MeshRefs are constructed —
|
||
// a degraded dat read here (setup null / placement frames short /
|
||
// part GfxObj null) permanently corrupts the entity (H-A), and
|
||
// nothing downstream ever rebuilds it. Inert when the set is empty.
|
||
bool dumpStab = AcDream.Core.Rendering.RenderingDiagnostics
|
||
.DumpEntitySourceIds.Contains(stab.Id);
|
||
int dumpSetupParts = -1, dumpPlacementFrames = -1, dumpFlattened = -1, dumpDropped = 0;
|
||
|
||
// #136: skip an EDITOR-ONLY placement marker. Such a dat object degrades to
|
||
// nothing (GfxObj id 0) at any runtime distance, so retail's distance-based
|
||
// degrade (CPhysicsPart::UpdateViewerDistance) never draws it — only the
|
||
// WorldBuilder editor shows it at the origin. acdream's render path came from
|
||
// WB (no distance LOD), so without this skip it draws the marker forever (the
|
||
// red/green dungeon "cone"). Bare-GfxObj stabs are checked here; Setup stabs
|
||
// skip per-part below (a Setup that is ALL markers drops via meshRefs.Count==0).
|
||
if ((stab.Id & 0xFF000000u) == 0x01000000u
|
||
&& AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, stab.Id))
|
||
continue;
|
||
|
||
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
||
var interiorBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator();
|
||
if ((stab.Id & 0xFF000000u) == 0x01000000u)
|
||
{
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
|
||
if (gfx is not null)
|
||
{
|
||
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
|
||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) interiorBounds.Add(System.Numerics.Matrix4x4.Identity, pb.Value);
|
||
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
|
||
}
|
||
else if (dumpStab)
|
||
{
|
||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} GFXOBJ-NULL -> entity dropped");
|
||
}
|
||
}
|
||
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
|
||
if (setup is not null)
|
||
{
|
||
_physicsDataCache.CacheSetup(stab.Id, setup);
|
||
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
|
||
if (dumpStab)
|
||
{
|
||
dumpSetupParts = setup.Parts.Count;
|
||
dumpPlacementFrames = setup.PlacementFrames.Count;
|
||
dumpFlattened = flat.Count;
|
||
}
|
||
foreach (var mr in flat)
|
||
{
|
||
// #136: skip an editor-only marker PART (retail hides it at runtime
|
||
// distance). The #136 dungeon "cone" is Setup 0x02000C39 whose sole
|
||
// part GfxObj 0x010028CA is such a marker — skipping it empties
|
||
// meshRefs and the whole stab drops below.
|
||
if (AcDream.Core.Meshing.GfxObjDegradeResolver.IsRuntimeHiddenMarker(_dats, mr.GfxObjId))
|
||
continue;
|
||
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
|
||
if (gfx is null)
|
||
{
|
||
dumpDropped++;
|
||
if (dumpStab)
|
||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} part gfx=0x{mr.GfxObjId:X8} GFXOBJ-NULL -> part dropped");
|
||
continue;
|
||
}
|
||
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
|
||
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
||
var pb = AcDream.Core.Meshing.GfxObjBounds.Get(gfx);
|
||
if (pb is not null) interiorBounds.Add(mr.PartTransform, pb.Value);
|
||
meshRefs.Add(mr);
|
||
}
|
||
}
|
||
else if (dumpStab)
|
||
{
|
||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} SETUP-NULL -> entity dropped");
|
||
}
|
||
}
|
||
|
||
if (meshRefs.Count == 0)
|
||
{
|
||
if (dumpStab)
|
||
Console.WriteLine($"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} meshRefs=0 -> entity dropped");
|
||
continue;
|
||
}
|
||
|
||
// Stabs inside EnvCells are already in landblock-local coordinates
|
||
// (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would
|
||
// be wrong — see Phase 2d comment in the pre-streaming preload.
|
||
var worldPos = stab.Frame.Origin + lbOffset;
|
||
var worldRot = stab.Frame.Orientation;
|
||
|
||
var hydrated = new AcDream.Core.World.WorldEntity
|
||
{
|
||
Id = interiorIdBase + localCounter++,
|
||
SourceGfxObjOrSetupId = stab.Id,
|
||
Position = worldPos,
|
||
Rotation = worldRot,
|
||
MeshRefs = meshRefs,
|
||
ParentCellId = envCellId,
|
||
};
|
||
if (interiorBounds.TryGet(out var ibMin, out var ibMax))
|
||
hydrated.SetLocalBounds(ibMin, ibMax);
|
||
|
||
if (dumpStab)
|
||
{
|
||
Console.WriteLine(
|
||
$"[dump-entity] HYDRATE src=0x{stab.Id:X8} cell=0x{envCellId:X8} entId=0x{hydrated.Id:X8} " +
|
||
$"setupParts={dumpSetupParts} placementFrames={dumpPlacementFrames} flattened={dumpFlattened} " +
|
||
$"built={meshRefs.Count} dropped={dumpDropped} " +
|
||
$"pos=({worldPos.X:F2},{worldPos.Y:F2},{worldPos.Z:F2})");
|
||
for (int i = 0; i < meshRefs.Count; i++)
|
||
{
|
||
var t = meshRefs[i].PartTransform.Translation;
|
||
Console.WriteLine($"[dump-entity] hyd-part[{i:D2}] gfx=0x{meshRefs[i].GfxObjId:X8} t=({t.X:F3},{t.Y:F3},{t.Z:F3})");
|
||
}
|
||
}
|
||
|
||
result.Add(hydrated);
|
||
}
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/// <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.Core.World.LoadedLandblock lb,
|
||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||
{
|
||
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;
|
||
ApplyLoadedTerrainLocked(lb, meshData);
|
||
if (_frameDiag)
|
||
{
|
||
_applyAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdT0;
|
||
_appliesThisUpdate++;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
|
||
/// for render-thread registration. Called from the worker thread during
|
||
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
|
||
/// </summary>
|
||
private void BuildLoadedCell(
|
||
uint envCellId,
|
||
DatReaderWriter.DBObjs.EnvCell envCell,
|
||
DatReaderWriter.Types.CellStruct cellStruct,
|
||
System.Numerics.Vector3 cellOrigin,
|
||
System.Numerics.Matrix4x4 cellTransform)
|
||
{
|
||
System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse);
|
||
|
||
// Compute local AABB from CellStruct vertices.
|
||
var boundsMin = new System.Numerics.Vector3(float.MaxValue);
|
||
var boundsMax = new System.Numerics.Vector3(float.MinValue);
|
||
foreach (var kvp in cellStruct.VertexArray.Vertices)
|
||
{
|
||
var v = kvp.Value;
|
||
var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
|
||
boundsMin = System.Numerics.Vector3.Min(boundsMin, pos);
|
||
boundsMax = System.Numerics.Vector3.Max(boundsMax, pos);
|
||
}
|
||
if (boundsMin.X == float.MaxValue)
|
||
{
|
||
boundsMin = System.Numerics.Vector3.Zero;
|
||
boundsMax = System.Numerics.Vector3.Zero;
|
||
}
|
||
|
||
// Build portal list and clip planes from CellPortals.
|
||
var portals = new List<CellPortalInfo>();
|
||
var clipPlanes = new List<PortalClipPlane>();
|
||
var portalPolygons = new List<System.Numerics.Vector3[]>();
|
||
|
||
// Compute cell centroid in local space for InsideSide determination.
|
||
var centroid = (boundsMin + boundsMax) * 0.5f;
|
||
|
||
foreach (var portal in envCell.CellPortals)
|
||
{
|
||
portals.Add(new CellPortalInfo(
|
||
portal.OtherCellId,
|
||
portal.PolygonId,
|
||
(ushort)portal.Flags,
|
||
portal.OtherPortalId)); // Phase U.2b: dat back-link → reciprocal portal index (retail 433557)
|
||
|
||
// Build clip plane from the portal polygon.
|
||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)
|
||
&& poly.VertexIds.Count >= 3)
|
||
{
|
||
// Get first 3 vertices in local space for the plane.
|
||
System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero,
|
||
p1 = System.Numerics.Vector3.Zero,
|
||
p2 = System.Numerics.Vector3.Zero;
|
||
bool found = true;
|
||
if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0))
|
||
p0 = new System.Numerics.Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z);
|
||
else found = false;
|
||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1))
|
||
p1 = new System.Numerics.Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z);
|
||
else found = false;
|
||
if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2))
|
||
p2 = new System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z);
|
||
else found = false;
|
||
|
||
if (found)
|
||
{
|
||
var normal = System.Numerics.Vector3.Normalize(
|
||
System.Numerics.Vector3.Cross(p1 - p0, p2 - p0));
|
||
float d = -System.Numerics.Vector3.Dot(normal, p0);
|
||
|
||
// Determine InsideSide: which side of the plane the cell centroid is on.
|
||
// If centroid dot > 0 → inside is positive half-space (InsideSide=0).
|
||
float centroidDot = System.Numerics.Vector3.Dot(normal, centroid) + d;
|
||
int insideSide = centroidDot >= 0 ? 0 : 1;
|
||
|
||
clipPlanes.Add(new PortalClipPlane
|
||
{
|
||
Normal = normal,
|
||
D = d,
|
||
InsideSide = insideSide,
|
||
});
|
||
}
|
||
else
|
||
{
|
||
clipPlanes.Add(default);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
clipPlanes.Add(default);
|
||
}
|
||
|
||
// Phase A8: capture the full polygon vertices in cell-local space
|
||
// for the indoor stencil pipeline. Reads the same source as the
|
||
// ClipPlane block above (polygon.VertexIds → cellStruct vertices).
|
||
System.Numerics.Vector3[] polyVerts = System.Array.Empty<System.Numerics.Vector3>();
|
||
if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var portalPoly)
|
||
&& portalPoly.VertexIds.Count >= 3)
|
||
{
|
||
polyVerts = new System.Numerics.Vector3[portalPoly.VertexIds.Count];
|
||
bool allResolved = true;
|
||
for (int vi = 0; vi < portalPoly.VertexIds.Count; vi++)
|
||
{
|
||
if (cellStruct.VertexArray.Vertices.TryGetValue(
|
||
(ushort)portalPoly.VertexIds[vi], out var pv))
|
||
{
|
||
polyVerts[vi] = new System.Numerics.Vector3(
|
||
pv.Origin.X, pv.Origin.Y, pv.Origin.Z);
|
||
}
|
||
else
|
||
{
|
||
allResolved = false;
|
||
break;
|
||
}
|
||
}
|
||
if (!allResolved)
|
||
polyVerts = System.Array.Empty<System.Numerics.Vector3>();
|
||
}
|
||
portalPolygons.Add(polyVerts);
|
||
}
|
||
|
||
// Phase U.4c: surface the stable PVS + seen-outside flag onto the render cell.
|
||
// Both come straight off the dat EnvCell — no new parsing (PhysicsDataCache
|
||
// already reads VisibleCells the same way; A8CellAudit reads the flag).
|
||
uint lbPrefix = envCellId & 0xFFFF0000u;
|
||
var visibleCells = new List<uint>();
|
||
if (envCell.VisibleCells is not null)
|
||
foreach (var lowId in envCell.VisibleCells)
|
||
visibleCells.Add(lbPrefix | lowId);
|
||
bool seenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside);
|
||
|
||
var loaded = new LoadedCell
|
||
{
|
||
CellId = envCellId,
|
||
WorldPosition = cellOrigin,
|
||
WorldTransform = cellTransform,
|
||
InverseWorldTransform = inverse,
|
||
LocalBoundsMin = boundsMin,
|
||
LocalBoundsMax = boundsMax,
|
||
Portals = portals,
|
||
ClipPlanes = clipPlanes,
|
||
PortalPolygons = portalPolygons, // Phase A8
|
||
VisibleCells = visibleCells, // Phase U.4c
|
||
SeenOutside = seenOutside, // Phase U.4c
|
||
};
|
||
_pendingCells.Add(loaded);
|
||
}
|
||
|
||
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb,
|
||
AcDream.Core.Terrain.LandblockMeshData meshData)
|
||
{
|
||
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
|
||
// _heightTable still needed for physics TerrainSurface below.
|
||
if (_terrain is null || _dats is null || _heightTable is null) return;
|
||
|
||
// datLock fix: every dat the apply needs was pre-read by the worker into
|
||
// lb.PhysicsDats. Read from the bundle — NO _dats.Get here, so this method
|
||
// (and its caller) need no _datLock.
|
||
var datBundle = lb.PhysicsDats ?? AcDream.Core.World.PhysicsDatBundle.Empty;
|
||
|
||
uint lbXu = (lb.LandblockId >> 24) & 0xFFu;
|
||
uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
|
||
int lbX = (int)lbXu;
|
||
int lbY = (int)lbYu;
|
||
var origin = new System.Numerics.Vector3(
|
||
(lbX - _liveCenterX) * 192f,
|
||
(lbY - _liveCenterY) * 192f,
|
||
0f);
|
||
|
||
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
|
||
// two-tier entry point. Delegates to AddLandblock internally; both
|
||
// paths share one GPU upload path.
|
||
// [FRAME-DIAG]: bracket the terrain glBufferSubData sub-span so we can tell
|
||
// the (tiny ~17KB) GPU upload apart from the dat-read/registration tail.
|
||
long fdU0 = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
|
||
if (_frameDiag)
|
||
_applyUploadAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdU0;
|
||
|
||
// [FRAME-DIAG]: split the post-upload apply CPU into three sub-spans via
|
||
// method-scope checkpoints — cell-build → gfxobj-BSP → ShadowObjects+lights.
|
||
long fdCheck = _frameDiag ? System.Diagnostics.Stopwatch.GetTimestamp() : 0L;
|
||
|
||
// Step 4: drain pending LoadedCells from the worker thread.
|
||
// Also collect into a local dict for the BuildingLoader stamping pass below.
|
||
var drainedCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>();
|
||
while (_pendingCells.TryTake(out var cell))
|
||
{
|
||
_cellVisibility.AddCell(cell);
|
||
drainedCells[cell.CellId] = cell;
|
||
}
|
||
|
||
// Compute the per-landblock AABB for frustum culling. XY from the
|
||
// landblock's world origin + 192 footprint. Z from the terrain vertex
|
||
// range padded +50 above (for trees/buildings) and -10 below (for
|
||
// basements). TerrainRenderer already scans vertices internally; we
|
||
// replicate here so GpuWorldState has the same bounds for the static
|
||
// mesh renderer's culling pass.
|
||
{
|
||
float zMin = float.MaxValue, zMax = float.MinValue;
|
||
foreach (var v in meshData.Vertices)
|
||
{
|
||
float z = v.Position.Z;
|
||
if (z < zMin) zMin = z;
|
||
if (z > zMax) zMax = z;
|
||
}
|
||
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
|
||
zMax += 50f; // generous pad for trees and buildings
|
||
zMin -= 10f; // below-ground buffer for basements/cellars
|
||
var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin);
|
||
var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax);
|
||
_worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax);
|
||
}
|
||
|
||
// Phase B.3: populate the physics engine with terrain + indoor cell
|
||
// surfaces for this landblock. Runs under _datLock (same lock as the
|
||
// rest of ApplyLoadedTerrainLocked) so dat reads are safe.
|
||
{
|
||
uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu;
|
||
uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu;
|
||
// Extract per-vertex terrain-type bytes so TerrainSurface can
|
||
// classify water cells for ValidateWalkable's water-depth
|
||
// adjustment. Each TerrainInfo is a ushort with Type in bits
|
||
// 2-6; taking the low byte preserves those bits (+ Road in 0-1,
|
||
// which the classifier masks off).
|
||
var terrainBytes = new byte[81];
|
||
for (int i = 0; i < 81; i++)
|
||
terrainBytes[i] = (byte)(ushort)lb.Heightmap.Terrain[i];
|
||
|
||
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
|
||
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY, terrainBytes);
|
||
|
||
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
|
||
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();
|
||
var lbInfo = datBundle.Info;
|
||
if (lbInfo is not null && lbInfo.NumCells > 0)
|
||
{
|
||
uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u;
|
||
for (uint offset = 0; offset < lbInfo.NumCells; offset++)
|
||
{
|
||
uint envCellId = firstCellId + offset;
|
||
if (!datBundle.EnvCells.TryGetValue(envCellId, out var envCell)) continue;
|
||
if (envCell.EnvironmentId == 0) continue;
|
||
|
||
if (!datBundle.Environments.TryGetValue(
|
||
0x0D000000u | envCell.EnvironmentId, out var environment)) continue;
|
||
if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue;
|
||
|
||
// Transform CellStruct vertices from cell-local to world space.
|
||
var rot = envCell.Position.Orientation;
|
||
var cellOriginWorld = envCell.Position.Origin + origin;
|
||
var worldVerts = new Dictionary<ushort, System.Numerics.Vector3>(
|
||
cellStruct.VertexArray.Vertices.Count);
|
||
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
|
||
{
|
||
var localPos = vtx.Origin;
|
||
var worldPos = System.Numerics.Vector3.Transform(localPos, rot) + cellOriginWorld;
|
||
worldVerts[(ushort)vid] = worldPos;
|
||
}
|
||
|
||
// Extract polygon vertex-id lists from PhysicsPolygons.
|
||
// PhysicsPolygons is Dictionary<ushort, Polygon>; iterate Values.
|
||
var polyVids = new List<List<short>>(cellStruct.PhysicsPolygons.Count);
|
||
foreach (var poly in cellStruct.PhysicsPolygons.Values)
|
||
{
|
||
var vids = new List<short>(poly.VertexIds.Count);
|
||
foreach (var vid in poly.VertexIds)
|
||
vids.Add(vid);
|
||
polyVids.Add(vids);
|
||
}
|
||
|
||
cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids));
|
||
|
||
// Extract portal planes from this EnvCell's CellPortals.
|
||
// CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons),
|
||
// NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells.
|
||
foreach (var portal in envCell.CellPortals)
|
||
{
|
||
if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly))
|
||
continue;
|
||
if (poly.VertexIds.Count < 3)
|
||
continue;
|
||
|
||
// Collect ALL polygon vertices for accurate centroid + radius.
|
||
var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count];
|
||
bool allFound = true;
|
||
for (int pv = 0; pv < poly.VertexIds.Count; pv++)
|
||
{
|
||
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv]))
|
||
{ allFound = false; break; }
|
||
}
|
||
if (!allFound) continue;
|
||
|
||
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
|
||
portalVerts.AsSpan(),
|
||
portal.OtherCellId, // target cell (0xFFFF = outdoor)
|
||
envCellId & 0xFFFFu, // owner cell (low 16 bits)
|
||
(ushort)portal.Flags));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit.
|
||
// Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world-
|
||
// relative origin + orientation) and a Portals list. The landcell id is
|
||
// derived from the building's frame origin using retail's row-major grid
|
||
// formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock.
|
||
if (lbInfo is not null && lbInfo.Buildings.Count > 0)
|
||
{
|
||
// #146 (2026-06-24): clear this landblock's prior building cache so
|
||
// the loop re-bases each building's STREAMING-RELATIVE WorldTransform
|
||
// with the CURRENT _liveCenter (origin, recomputed above per apply).
|
||
// CacheBuilding's per-cell first-wins then applies fresh within this
|
||
// pass — mirrors terrain's per-apply WorldOffset re-base (AddLandblock).
|
||
// Without it, CacheBuilding's idempotent guard locks the transform at
|
||
// the frame it was first cached with, so a teleport recenter strands
|
||
// the shell BSP at a stale world offset and house walls stop colliding
|
||
// (login at Arwic → portal to Holtburg → walls clip; bldOrigin ~5.5km off).
|
||
_physicsDataCache.RemoveBuildingsForLandblock(lb.LandblockId);
|
||
|
||
uint lbPrefix = lb.LandblockId & 0xFFFF0000u;
|
||
foreach (var building in lbInfo.Buildings)
|
||
{
|
||
// #147 (2026-06-24): do NOT skip portal-less buildings. A town's
|
||
// PERIMETER WALL is a building with NO portals (you don't enter a
|
||
// wall segment through a door) — but it still needs its shell BSP
|
||
// registered for COLLISION. The original skip existed for the
|
||
// transit/entry feature (CellTransit.CheckBuildingTransit); dropping
|
||
// the whole building also dropped its collision shell, so a far
|
||
// town's city walls had ZERO collision and the player walked through
|
||
// them (Arwic: 16 of 30 buildings are portal-less wall segments
|
||
// ringing the town at 24 m intervals). Retail collides with a
|
||
// building's shell regardless of portals — find_building_collisions
|
||
// (0x006b5300) tests parts[0], independent of the portal list. So a
|
||
// portal-less building is now cached with an EMPTY portal list (no
|
||
// transit) but its collision shell (ModelId) intact.
|
||
|
||
var bldPortals = new System.Collections.Generic.List<AcDream.Core.Physics.BldPortalInfo>(
|
||
building.Portals.Count);
|
||
foreach (var bp in building.Portals)
|
||
{
|
||
bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
|
||
otherCellId: lbPrefix | (uint)bp.OtherCellId,
|
||
// DatReaderWriter parses the dat's 16-bit field as
|
||
// ushort; retail sign-extends it to a SIGNED int
|
||
// (CBldPortal.other_portal_id, acclient.h:32098) and
|
||
// check_building_transit gates on `>= 0`
|
||
// (Ghidra 0x0052c5dc). 0xFFFF → -1 = no reciprocal.
|
||
otherPortalId: unchecked((short)bp.OtherPortalId),
|
||
flags: (ushort)bp.Flags));
|
||
}
|
||
|
||
// Build a world transform for the building. Frame.Origin is
|
||
// landblock-relative; add the landblock world origin to get
|
||
// world space.
|
||
var bldOriginWorld = building.Frame.Origin + origin;
|
||
var buildingTransform =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
|
||
* System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld);
|
||
|
||
// Derive the outdoor landcell id containing this building.
|
||
// Reuse TerrainSurface.ComputeOutdoorCellId rather than
|
||
// re-deriving the row-major (gridX * 8 + gridY + 1) formula here.
|
||
// Frame.Origin is landblock-relative, same coordinate space as
|
||
// ComputeOutdoorCellId expects (local X/Y within the 192m block).
|
||
uint landcellLow = terrainSurface.ComputeOutdoorCellId(
|
||
building.Frame.Origin.X, building.Frame.Origin.Y);
|
||
uint landcellId = lbPrefix | landcellLow;
|
||
|
||
// BR-7 / A6.P4 (2026-06-11): the shell part-0 GfxObj id
|
||
// arms the retail building collision channel
|
||
// (CBuildingObj::find_building_collisions, Ghidra
|
||
// 0x006b5300 — one BSP test on part_array->parts[0]).
|
||
// 0x01 model ids are the GfxObj; 0x02 Setups resolve to
|
||
// their first part here, where the dat is at hand.
|
||
uint shellPart0 = building.ModelId;
|
||
if ((shellPart0 & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
// _dats is non-null on every streaming-drain path
|
||
// (the GfxObj physics cache loop below dereferences
|
||
// it unconditionally).
|
||
datBundle.Setups.TryGetValue(building.ModelId, out var bldSetup);
|
||
shellPart0 = bldSetup is not null && bldSetup.Parts.Count > 0
|
||
? bldSetup.Parts[0]
|
||
: 0u;
|
||
}
|
||
_physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform,
|
||
modelId: shellPart0);
|
||
}
|
||
}
|
||
|
||
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
|
||
portalPlanes, origin.X, origin.Y);
|
||
|
||
// Phase A8 (2026-05-26 / fix 2026-05-28): build per-landblock
|
||
// BuildingRegistry from LandBlockInfo.Buildings, stamping
|
||
// LoadedCell.BuildingId for each cell in a building's cell set.
|
||
//
|
||
// FIX 2026-05-28: previously passed `drainedCells` only — that's the
|
||
// dict of cells drained THIS frame. Cells loaded on prior frames
|
||
// were missed, leaving their BuildingId null and the
|
||
// `cameraInsideBuilding` gate FALSE even when the player was inside
|
||
// a tagged cottage. (First visual-gate launch showed 8737 [vis]
|
||
// lines with inside=True really=True but ZERO [buildings] probe
|
||
// emissions — same root cause as the RR7.1 / RR7.2 saga.) The fix:
|
||
// merge `drainedCells` with every cell currently registered with
|
||
// _cellVisibility for this landblock prefix. BuildingLoader's BFS
|
||
// + stamping pass then sees the complete cell set.
|
||
//
|
||
// Cells without a building stay at BuildingId == null (outdoor
|
||
// surface cells; dungeon cells not enumerated in LandBlockInfo.Buildings).
|
||
if (lbInfo is not null)
|
||
{
|
||
// Merge: previously-loaded cells + freshly-drained cells.
|
||
// drainedCells wins on key collision (it has the same instance
|
||
// anyway — both dicts reference the same LoadedCell objects).
|
||
var lbStampCells = new System.Collections.Generic.Dictionary<uint, LoadedCell>(drainedCells);
|
||
uint lbPrefix = (lb.LandblockId >> 16) & 0xFFFFu;
|
||
foreach (var c in _cellVisibility.GetCellsForLandblock(lbPrefix))
|
||
{
|
||
if (!lbStampCells.ContainsKey(c.CellId))
|
||
lbStampCells[c.CellId] = c;
|
||
}
|
||
// FIX 2026-05-28: normalize storage key to the cell-prefix
|
||
// convention (`landblockId & 0xFFFF0000u`). The lb.LandblockId
|
||
// field encodes 0xXXYY_FFFF (low 16 bits = 0xFFFF for the
|
||
// landblock's own LandBlockInfo dat id), but the runtime
|
||
// lookup at line ~7110 derives the key from a cell id via
|
||
// `cellId & 0xFFFF0000u` which yields 0xXXYY_0000. Storage
|
||
// and lookup must agree. Mask both sides to the upper-16
|
||
// form so the registry resolves correctly. This is the same
|
||
// bug the RR7.2 commit (`efe3520`, reverted by `9aaae02`)
|
||
// tried to fix; landing it here in the data-flow layer.
|
||
uint regKey = lb.LandblockId & 0xFFFF0000u;
|
||
_buildingRegistries[regKey] =
|
||
AcDream.App.Rendering.Wb.BuildingLoader.Build(
|
||
lbInfo, lb.LandblockId, lbStampCells);
|
||
}
|
||
|
||
// Phase A8: finalize EnvCellRenderer's per-landblock instance store.
|
||
// Atomically swaps PendingInstances -> committed, computes TotalEnvCellBounds,
|
||
// populates StaticPartGroups/BuildingPartGroups via PopulateRecursive.
|
||
_envCellRenderer?.FinalizeLandblock(lb.LandblockId);
|
||
}
|
||
|
||
// N.5: WbMeshAdapter.Tick() handles GPU upload for all GfxObj meshes via
|
||
// ObjectMeshManager.PrepareMeshDataAsync. The legacy EnsureUploaded loop
|
||
// (and _pendingCellMeshes drain) are retired with InstancedMeshRenderer.
|
||
// Cache GfxObj physics data (BSP trees) for the physics engine — this
|
||
// loop is physics-only, not renderer-side.
|
||
// [FRAME-DIAG] checkpoint: end cell-build, begin gfxobj-BSP.
|
||
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyCellAccumTicks += t - fdCheck; fdCheck = t; }
|
||
foreach (var entity in lb.Entities)
|
||
{
|
||
foreach (var meshRef in entity.MeshRefs)
|
||
{
|
||
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
|
||
if (!datBundle.GfxObjs.TryGetValue(meshRef.GfxObjId, out var gfx)) continue;
|
||
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
|
||
}
|
||
}
|
||
// Drain _pendingCellMeshes to prevent unbounded accumulation.
|
||
// The data is no longer consumed (WB handles EnvCell geometry through
|
||
// its own pipeline), but the worker thread still populates this dict.
|
||
_pendingCellMeshes.Clear();
|
||
// [FRAME-DIAG] checkpoint: end gfxobj-BSP, begin ShadowObjects+lights.
|
||
if (_frameDiag) { long t = System.Diagnostics.Stopwatch.GetTimestamp(); _applyBspAccumTicks += t - fdCheck; fdCheck = t; }
|
||
|
||
// Task 7: register static entities into the ShadowObjectRegistry so the
|
||
// Transition system can find and collide against them during movement.
|
||
// Only entities backed by a GfxObj with a physics BSP are registered —
|
||
// entities with no BSP (pure visual, no physics) are skipped.
|
||
//
|
||
// Radius source priority:
|
||
// 1. GfxObj: use the BSP root bounding sphere radius if available.
|
||
// 2. Setup: use Setup.Radius (the capsule radius) if available.
|
||
// 3. Fallback: 1.0m (conservative default for trees / small objects).
|
||
int lbBspCount = 0, lbCylCount = 0, lbNoneCount = 0;
|
||
int scTried = 0;
|
||
foreach (var entity in lb.Entities)
|
||
{
|
||
// Phase G.2: if the entity's Setup has baked-in LightInfos,
|
||
// register them with the LightManager so torches, braziers,
|
||
// and lifestones cast real light on nearby geometry. Hooked
|
||
// via the LightingHookSink so per-entity owner tracking +
|
||
// SetLightHook IsLit toggles all go through one codepath.
|
||
// Only applies to Setup-sourced entities (0x02xxxxxx) — raw
|
||
// GfxObjs don't carry Lights dictionaries.
|
||
if (_lightingSink is not null && _dats is not null)
|
||
{
|
||
uint src = entity.SourceGfxObjOrSetupId;
|
||
if ((src & 0xFF000000u) == 0x02000000u)
|
||
{
|
||
datBundle.Setups.TryGetValue(src, out var datSetup);
|
||
if (datSetup is not null && datSetup.Lights.Count > 0)
|
||
{
|
||
var loaded = AcDream.Core.Lighting.LightInfoLoader.Load(
|
||
datSetup,
|
||
ownerId: entity.Id,
|
||
entityPosition: entity.Position,
|
||
entityRotation: entity.Rotation);
|
||
foreach (var ls in loaded)
|
||
_lightingSink.RegisterOwnedLight(ls);
|
||
}
|
||
}
|
||
}
|
||
|
||
int entityBsp = 0, entityCyl = 0;
|
||
// Treat both procedural scenery (0x80000000+) AND LandBlockInfo
|
||
// stabs (IDs < 0x40000000 with 0x01/0x02 source) as outdoor-entities
|
||
// that should use visual-mesh-AABB collision. Stabs include landscape
|
||
// trees placed by Turbine (not procedural scenery) that otherwise
|
||
// have no collision shape registered.
|
||
uint _srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
|
||
bool _isOutdoorMesh = ((entity.Id & 0x80000000u) != 0) // scenery
|
||
|| ((entity.Id < 0x40000000u) // stab
|
||
&& (_srcPrefix == 0x01000000u || _srcPrefix == 0x02000000u));
|
||
bool _isScenery = _isOutdoorMesh;
|
||
// ISSUES #83 / Phase A1 (2026-05-21): landblock stabs
|
||
// (LandBlockInfo.Objects + Buildings) use entity.Id with the
|
||
// 0xC0XXYY00+n layout per LandblockLoader.cs:55. Their BSP
|
||
// collision covers the whole structure; the mesh-AABB-fallback
|
||
// path below is for canopy-only-BSP procedural scenery
|
||
// (0x80XXYY00+n) and produces a redundant 1.5m-clamped
|
||
// invisible disc at the stab's mesh origin — the user-reported
|
||
// "thin air" collision inside cottages. Gate the fallback to
|
||
// exclude stabs. Spec:
|
||
// docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md.
|
||
bool _isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u;
|
||
if (_isScenery) scTried++;
|
||
// Register EACH physics-enabled part so multi-part Setups
|
||
// (buildings, trees) have all their collision geometry registered.
|
||
// Each part gets its own ShadowEntry with its world-space transform.
|
||
var entityRoot =
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
|
||
System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
|
||
|
||
uint partIndex = 0;
|
||
foreach (var meshRef in entity.MeshRefs)
|
||
{
|
||
// BR-7 / A6.P4 (2026-06-11): building SHELLS are not shadow
|
||
// objects in retail — their collision is the per-LandCell
|
||
// building channel (CSortCell::find_collisions, Ghidra
|
||
// 0x005340a0, dispatched off the CacheBuilding entry at the
|
||
// building's origin landcell). Registering them here is what
|
||
// put the cottage GfxObj into the radial sweep's reach from
|
||
// the cellar (#98). Interior statics + outdoor stab objects
|
||
// (LandBlockInfo.Objects) remain shadow objects.
|
||
if (entity.IsBuildingShell) break;
|
||
|
||
var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
|
||
if (partCached?.BSP?.Root is null) { partIndex++; continue; }
|
||
|
||
// Compute the part's world-space position from its transform.
|
||
var partWorld = meshRef.PartTransform * entityRoot;
|
||
|
||
// Decompose to extract scale (scenery objects have it baked
|
||
// into PartTransform), rotation, and translation.
|
||
System.Numerics.Vector3 partScale3;
|
||
System.Numerics.Quaternion partRot;
|
||
System.Numerics.Vector3 partPos;
|
||
if (System.Numerics.Matrix4x4.Decompose(partWorld,
|
||
out partScale3, out partRot, out partPos))
|
||
{ /* decompose succeeded */ }
|
||
else
|
||
{
|
||
partScale3 = System.Numerics.Vector3.One;
|
||
partRot = entity.Rotation;
|
||
partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
|
||
}
|
||
|
||
// Use uniform scale (X component) — AC objects are uniformly scaled.
|
||
float partScale = partScale3.X;
|
||
if (partScale <= 0f) partScale = 1f;
|
||
|
||
// Local bounding sphere radius × world scale = world-space radius
|
||
// for the broad phase. The BSPQuery will also use `partScale` to
|
||
// transform player spheres into the unscaled BSP coordinate space.
|
||
float localRadius = partCached.BoundingSphere?.Radius ?? 1f;
|
||
float worldRadius = localRadius * partScale;
|
||
|
||
// Use a unique sub-ID per part: entity.Id * 256 + partIndex.
|
||
uint partId = entity.Id * 256u + partIndex;
|
||
_physicsEngine.ShadowObjects.Register(
|
||
partId, meshRef.GfxObjId,
|
||
partPos, partRot, worldRadius,
|
||
origin.X, origin.Y, lb.LandblockId,
|
||
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
|
||
partScale,
|
||
seedCellId: entity.ParentCellId ?? 0u);
|
||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||
// partCached?.BSP?.Root non-null was checked above (else `continue`),
|
||
// so hasPhys=true on this path.
|
||
// state/flags literals: landblock-baked scenery has no server PhysicsState
|
||
// broadcast and no PWD bitfield; defaults match static-solid semantics.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{partId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{meshRef.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=partIdx={partIndex} hasPhys=true state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
|
||
|
||
entityBsp++;
|
||
partIndex++;
|
||
}
|
||
|
||
// Register collision shapes from the Setup (if this entity has one).
|
||
// Retail uses CylSpheres for trunks/pillars, Spheres for blob-shaped
|
||
// collision volumes. We register both as "cylinder" shadow entries
|
||
// because our collision system only has BSP and Cylinder types; a
|
||
// Sphere is handled as a short cylinder.
|
||
//
|
||
// SCALE + ROTATION handling:
|
||
// - Radius, Height, and the local Origin offset are ALL scaled by
|
||
// entity.Scale so they match the visually-scaled mesh.
|
||
// - The Origin offset is ROTATED by entity.Rotation so rotated
|
||
// scenery has its collision cylinder in the correct world spot.
|
||
//
|
||
// Keying:
|
||
// entity.Id → the primary CylSphere (if any)
|
||
// entity.Id + K*0x10000000u → additional CylSpheres/Spheres
|
||
// This ensures uniqueness per shape so ShadowObjectRegistry doesn't
|
||
// clobber entries via Deregister.
|
||
{
|
||
var setup = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId);
|
||
// Retail binary collision dispatch (CPhysicsObj::FindObjCollisions
|
||
// @0x0050f050, see feedback_retail_binary_dispatch): an object uses
|
||
// its physics BSP if it HAS one (HAS_PHYSICS_BSP_PS 0x10000), ELSE
|
||
// its CSetup CylSpheres/Spheres — never both. The per-part BSP loop
|
||
// above already registered every BSP-bearing part (entityBsp counts
|
||
// them). So register the Setup's CylSpheres/Spheres ONLY when no BSP
|
||
// claimed this object (entityBsp == 0).
|
||
//
|
||
// This supersedes the ISSUES #83 / A1.6 (2026-05-21) gate that
|
||
// skipped Setup cyl/sphere for ALL landblock stabs to stop a
|
||
// BSP+cylinder doubled-collision. That gate over-broadened: it also
|
||
// killed collision for BSP-LESS stabs whose ONLY shape is a Setup
|
||
// CylSphere — e.g. the Holtburg town torch (stab Setup 0x020005D8,
|
||
// part 0x01001774 HasPhysics=False/BSP=null, cylSphere r=0.2 h=2.2).
|
||
// Retail collides with it via that cylsphere (live cdb 2026-06-25:
|
||
// FindObjCollisions target ncyl=1, cyl h=2.2 at world (105.99,17.17));
|
||
// acdream walked straight through. Gating on entityBsp==0 keeps #83's
|
||
// anti-doubling (stab WITH a BSP → BSP-only) while restoring cyl
|
||
// collision for BSP-less stabs. Other landblock entities on this path
|
||
// (scenery — tree-trunk cylspheres etc.) are unaffected: with no BSP
|
||
// they still register cyl/sphere exactly as before.
|
||
if (setup is not null && entityBsp == 0)
|
||
{
|
||
float entScale = entity.Scale > 0f ? entity.Scale : 1f;
|
||
uint shapeIndex = 0;
|
||
|
||
// Register every CylSphere the Setup defines.
|
||
for (int ci = 0; ci < setup.CylSpheres.Count; ci++)
|
||
{
|
||
var cyl = setup.CylSpheres[ci];
|
||
float cylRadius = cyl.Radius * entScale;
|
||
float baseHeight = cyl.Height > 0 ? cyl.Height : cyl.Radius * 4f;
|
||
float cylHeight = baseHeight * entScale;
|
||
if (cylRadius <= 0f) continue;
|
||
|
||
// Rotate the local origin offset by entity rotation,
|
||
// then scale it before adding to entity.Position.
|
||
var localOffset = new System.Numerics.Vector3(
|
||
cyl.Origin.X, cyl.Origin.Y, cyl.Origin.Z) * entScale;
|
||
var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
|
||
|
||
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
|
||
_physicsEngine.ShadowObjects.Register(
|
||
shapeId, entity.SourceGfxObjOrSetupId,
|
||
entity.Position + worldOffset,
|
||
entity.Rotation, cylRadius,
|
||
origin.X, origin.Y, lb.LandblockId,
|
||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight,
|
||
seedCellId: entity.ParentCellId ?? 0u);
|
||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
|
||
entityCyl++;
|
||
}
|
||
|
||
// Register every Sphere as a short cylinder when no
|
||
// CylSphere claimed the object.
|
||
if (setup.CylSpheres.Count == 0)
|
||
{
|
||
for (int si = 0; si < setup.Spheres.Count; si++)
|
||
{
|
||
var sph = setup.Spheres[si];
|
||
if (sph.Radius <= 0f) continue;
|
||
|
||
float sphRadius = sph.Radius * entScale;
|
||
float sphHeight = sphRadius * 2f;
|
||
|
||
// Rotate + scale the local origin, then offset the
|
||
// cylinder base down by the scaled radius so the
|
||
// short cylinder is centered on the sphere.
|
||
var localOffset = new System.Numerics.Vector3(
|
||
sph.Origin.X, sph.Origin.Y, sph.Origin.Z) * entScale;
|
||
var worldOffset = System.Numerics.Vector3.Transform(localOffset, entity.Rotation);
|
||
worldOffset.Z -= sphRadius;
|
||
|
||
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
|
||
_physicsEngine.ShadowObjects.Register(
|
||
shapeId, entity.SourceGfxObjOrSetupId,
|
||
entity.Position + worldOffset,
|
||
entity.Rotation, sphRadius,
|
||
origin.X, origin.Y, lb.LandblockId,
|
||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight,
|
||
seedCellId: entity.ParentCellId ?? 0u);
|
||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si} state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
|
||
entityCyl++;
|
||
}
|
||
}
|
||
|
||
// Setup.Radius fallback: the Setup has NO CylSpheres and NO
|
||
// Spheres but has a positive Radius/Height. Use the overall
|
||
// bounding cylinder scaled by entity.Scale.
|
||
if (setup.CylSpheres.Count == 0 && setup.Spheres.Count == 0
|
||
&& setup.Radius > 0f && entityBsp == 0)
|
||
{
|
||
float fr = setup.Radius * entScale;
|
||
float fh = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale;
|
||
uint shapeId = entity.Id + (shapeIndex++) * 0x10000000u;
|
||
_physicsEngine.ShadowObjects.Register(
|
||
shapeId, entity.SourceGfxObjOrSetupId,
|
||
entity.Position, entity.Rotation, fr,
|
||
origin.X, origin.Y, lb.LandblockId,
|
||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh,
|
||
seedCellId: entity.ParentCellId ?? 0u);
|
||
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
|
||
// state/flags literals: landblock-baked scenery; no server PhysicsState.
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback state=0x{0u:X8} flags={AcDream.Core.Physics.EntityCollisionFlags.None}"));
|
||
entityCyl++;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Tally per-entity collision presence (debug counter — optional).
|
||
if (entityBsp > 0) lbBspCount++;
|
||
if (entityCyl > 0) lbCylCount++;
|
||
if (entityBsp == 0 && entityCyl == 0)
|
||
{
|
||
// Only count as "none" if it's an OUTDOOR entity (0x01/0x02 source).
|
||
// EnvCell entities (src = cell ID like 0xAABBxxxx) use BSP collision
|
||
// via CellPhysics and don't need cylinder registration.
|
||
uint srcPrefix = entity.SourceGfxObjOrSetupId & 0xFF000000u;
|
||
if (srcPrefix == 0x01000000u || srcPrefix == 0x02000000u)
|
||
lbNoneCount++;
|
||
}
|
||
}
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && scTried > 0)
|
||
Console.WriteLine(
|
||
$"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} (outdoorNone={lbNoneCount})");
|
||
|
||
// Find scenery WITHOUT any cached visual bounds at all
|
||
int sceneryNoCache = 0;
|
||
var sampleMissing = new List<uint>();
|
||
foreach (var entity in lb.Entities)
|
||
{
|
||
if ((entity.Id & 0x80000000u) == 0) continue; // not scenery
|
||
bool anyHaveBounds = false;
|
||
foreach (var mr in entity.MeshRefs)
|
||
{
|
||
var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
|
||
if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; }
|
||
}
|
||
if (!anyHaveBounds)
|
||
{
|
||
sceneryNoCache++;
|
||
if (sampleMissing.Count < 3)
|
||
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
|
||
}
|
||
}
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled && sceneryNoCache > 0)
|
||
{
|
||
string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}"));
|
||
Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}");
|
||
}
|
||
|
||
|
||
// BR-7 / A6.P4 (2026-06-11): this landblock's cells + buildings are
|
||
// now hydrated — re-run the registration flood for entities whose
|
||
// shadow cell set touches it. Covers the streaming races in both
|
||
// directions (server spawn before the landblock hydrated → flood
|
||
// couldn't traverse; cells hydrated after the spawn). Retail
|
||
// equivalent: CObjCell::init_objects → recalc_cross_cells on cell
|
||
// load (Ghidra 0x0052b420 / 0x00515a30).
|
||
_physicsEngine.ShadowObjects.RefloodLandblock(lb.LandblockId);
|
||
|
||
// Register each stab as a plugin snapshot so the plugin host has
|
||
// visibility into the streaming world state.
|
||
foreach (var entity in lb.Entities)
|
||
{
|
||
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);
|
||
}
|
||
// [FRAME-DIAG] checkpoint: end ShadowObjects+lights registration (apply tail).
|
||
if (_frameDiag) _applyShadowAccumTicks += System.Diagnostics.Stopwatch.GetTimestamp() - fdCheck;
|
||
}
|
||
|
||
private void OnUpdate(double dt)
|
||
{
|
||
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
|
||
|
||
// [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; AppendLiveEntity
|
||
// 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 = _liveSession is not null
|
||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld;
|
||
if (_streamingController is not null && _cameraController is not null
|
||
&& (!IsLiveModeWaitingForLogin || liveInWorld))
|
||
{
|
||
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 (_liveSession is not null
|
||
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
|
||
{
|
||
// 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
|
||
// _lastLivePlayerLandblockId was 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 (_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);
|
||
}
|
||
// Consume the login-spawn far-recenter flag (network thread → render
|
||
// thread): drop the stale startup window + null the region so this
|
||
// Tick re-bootstraps the whole window fresh around the spawn origin.
|
||
// Same mechanism the outdoor-teleport path uses (line ~5725). Fixes
|
||
// the cold-spawn streaming hole (resident-but-never-loaded landblocks
|
||
// the RecenterTo diff skipped as already-resident).
|
||
if (_pendingForceReloadWindow)
|
||
{
|
||
_pendingForceReloadWindow = false;
|
||
_streamingController.ForceReloadWindow();
|
||
}
|
||
_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)
|
||
_worldState.AppendLiveEntity(centerLb, entity);
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
_liveSessionController?.Tick();
|
||
|
||
// 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 + fades fires FireLoginComplete (regain control + ack). The fade overlay
|
||
// is opaque black during the tunnel states (we render a fade, not the 3D swirl) and
|
||
// ramps the world back in on WorldFadeIn.
|
||
if (_teleportInProgress)
|
||
{
|
||
bool haveDest = _pendingTeleportCell != 0u;
|
||
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
|
||
if (haveDest && !ready)
|
||
{
|
||
_teleportHoldSeconds += (float)dt;
|
||
if (_teleportHoldSeconds >= TeleportMaxHoldSeconds)
|
||
{
|
||
ready = true;
|
||
_teleportForced = true;
|
||
}
|
||
}
|
||
|
||
var (snap, evts) = _teleportAnim.Tick((float)dt, ready);
|
||
_teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha;
|
||
|
||
foreach (var e in evts)
|
||
{
|
||
switch (e)
|
||
{
|
||
case AcDream.Core.World.TeleportAnimEvent.Place:
|
||
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
|
||
if (_streamingController is not null)
|
||
{
|
||
_streamingController.PriorityLandblockId = 0u;
|
||
_streamingController.PriorityRadius = 0;
|
||
}
|
||
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());
|
||
_teleportInProgress = false;
|
||
_pendingTeleportCell = 0u;
|
||
_teleportFadeAlpha = 0f;
|
||
break;
|
||
default:
|
||
// PlayEnterSound / EnterTunnel / PlayExitSound — audio polish deferred.
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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.
|
||
_inputDispatcher?.Tick();
|
||
|
||
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
|
||
// on selection, then revert). No-op when nothing is flashing.
|
||
_selectedObjectController?.Tick(dt);
|
||
|
||
// Phase K.2 — re-evaluate WantCaptureMouse for the MMB
|
||
// mouse-look state machine. Detect rising/falling edges so the
|
||
// state suspends correctly when ImGui claims the cursor while
|
||
// MMB is held (e.g. a tooltip pop-up over the cursor). When the
|
||
// suspend deactivates an active session, restore the cursor so
|
||
// it doesn't get stuck hidden under a panel.
|
||
if (_mouseLook is not null)
|
||
{
|
||
bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
|
||
if (wcm != _lastWantCaptureMouse)
|
||
{
|
||
bool wasActive = _mouseLook.Active;
|
||
_mouseLook.OnWantCaptureMouseChanged(wcm);
|
||
if (wasActive && !_mouseLook.Active) RestoreCursorAfterMouseLook();
|
||
_lastWantCaptureMouse = wcm;
|
||
}
|
||
}
|
||
|
||
// 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 _liveSession → 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(
|
||
dt,
|
||
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 * (float)dt;
|
||
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).
|
||
bool walking = _inputDispatcher.IsActionHeld(
|
||
AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode);
|
||
bool wHeld = _inputDispatcher.IsActionHeld(
|
||
AcDream.UI.Abstractions.Input.InputAction.MovementForward);
|
||
var input = new AcDream.App.Input.MovementInput(
|
||
Forward: wHeld || _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, // default = run; Shift held = walk
|
||
MouseDeltaX: 0f,
|
||
Jump: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump));
|
||
|
||
// Fix #42 (2026-05-05): keep PlayerMovementController's
|
||
// LocalEntityId in sync with the live local player entity so
|
||
// FindObjCollisions skips its own ShadowEntry. Re-fetched per
|
||
// tick so re-spawns / character switches don't leave a stale
|
||
// id on the controller. Pre-spawn or between-character it
|
||
// stays 0 (no filter), which is harmless because there's no
|
||
// ShadowEntry registered yet.
|
||
_playerController.LocalEntityId =
|
||
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var localEnt)
|
||
? localEnt.Id
|
||
: 0u;
|
||
|
||
// R5-V2: tick the player's TargetManager BEFORE Update ticks
|
||
// MoveToManager.UseTime (retail UpdateObjectInternal order). This is
|
||
// the load-bearing call for creature-chase: the player, as a watched
|
||
// target, pushes its position to its voyeurs (the NPCs moving-to it)
|
||
// here — that push lands in each NPC's HandleUpdateTarget the same
|
||
// frame, ahead of the NPCs' own UseTime in the per-remote loop
|
||
// (which runs after this block). Replaces the AP-79 player poll.
|
||
_playerHost?.HandleTargetting();
|
||
|
||
var result = _playerController.Update((float)dt, input);
|
||
|
||
// Update the player entity's position + rotation so it renders at
|
||
// the physics-resolved location each frame.
|
||
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
|
||
{
|
||
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
|
||
pe.ParentCellId = result.CellId;
|
||
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
|
||
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
|
||
|
||
// Move the player entity to its current landblock in GpuWorldState
|
||
// so it doesn't get frustum-culled when the player walks away from
|
||
// the spawn landblock. Without this, the entity stays in the spawn
|
||
// landblock's entity list and disappears when that landblock is culled.
|
||
uint currentLb;
|
||
if (result.CellId != 0 && (result.CellId & 0xFFFFu) >= 0x0100u)
|
||
{
|
||
// Indoor cell (dungeon/building EnvCell): the entity's landblock is
|
||
// the CELL's landblock. Dungeon EnvCells sit at arbitrary "ocean"
|
||
// world coords with negative local-Y, so floor(pp.Y/192) lands one
|
||
// landblock off (the Bug-A class) — relocating the player into the
|
||
// landblock the dungeon collapse unloaded, making the avatar
|
||
// invisible. The cell id is authoritative.
|
||
currentLb = (result.CellId & 0xFFFF0000u) | 0xFFFFu;
|
||
}
|
||
else
|
||
{
|
||
var pp = _playerController.Position;
|
||
int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f);
|
||
int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f);
|
||
currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF);
|
||
}
|
||
// #138-B (2026-06-24): do NOT relocate the avatar while a teleport
|
||
// is in transit (PortalSpace). The controller's cell is still the
|
||
// FROZEN SOURCE until PlaceTeleportArrival materializes the
|
||
// destination, so relocating here drags the avatar — which the
|
||
// teleport's rescue/re-inject (GpuWorldState.DrainRescued) already
|
||
// placed at the destination center — back into the now-UNLOADED
|
||
// SOURCE landblock's pending bucket, where nothing recovers it
|
||
// (RelocateEntity only scans _loaded). Net: the avatar vanishes
|
||
// after teleporting out. The teleport machinery owns the avatar's
|
||
// landblock during transit; per-frame relocation resumes at
|
||
// FireLoginComplete (InWorld). Probe-confirmed: launch4 line 561
|
||
// APPEND guid=player lb=0x0007FFFF(source dungeon) -> PENDING ->
|
||
// DRAWSET ABSENT, never recovered.
|
||
if (_playerController.State != AcDream.App.Input.PlayerState.PortalSpace)
|
||
_worldState.RelocateEntity(pe, currentLb);
|
||
}
|
||
|
||
// 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: (float)dt);
|
||
// 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).
|
||
_retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
|
||
playerVelocity: _playerController.BodyVelocity,
|
||
isOnGround: result.IsOnGround,
|
||
contactPlaneNormal: _playerController.ContactPlane.Normal,
|
||
dt: (float)dt,
|
||
cellId: _playerController.CellId,
|
||
selfEntityId: _playerController.LocalEntityId);
|
||
|
||
// Send outbound movement messages to the live server.
|
||
if (_liveSession is not null)
|
||
{
|
||
// Convert world position back to AC wire coordinates.
|
||
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
|
||
//
|
||
// #107 (2026-06-10): the wire (cell, position) pair must be
|
||
// SELF-CONSISTENT — derive the landblock frame from the
|
||
// resolver's FULL cell id (always prefixed post-#106) instead
|
||
// of recomputing it from the position. The old composition
|
||
// welded a position-derived landblock onto result.CellId's low
|
||
// word; any tick where the two disagreed (landblock crossing,
|
||
// indoor cells near a boundary) sent ACE an id naming a
|
||
// DIFFERENT landblock's cell — and ACE persists the pair
|
||
// verbatim into the character save, which is the poisoned
|
||
// (cell-from-one-building, position-in-another) restore shape
|
||
// behind the #107 login wedge.
|
||
uint wireCellId = result.CellId;
|
||
int lbX = (int)(wireCellId >> 24);
|
||
int lbY = (int)((wireCellId >> 16) & 0xFFu);
|
||
if ((wireCellId & 0xFFFF0000u) == 0u)
|
||
{
|
||
// Defensive: bare low-word id (should not occur post-#106) —
|
||
// fall back to the position-derived landblock.
|
||
lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
|
||
lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
|
||
wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (wireCellId & 0xFFFFu);
|
||
}
|
||
float localX = result.Position.X - (lbX - _liveCenterX) * 192f;
|
||
float localY = result.Position.Y - (lbY - _liveCenterY) * 192f;
|
||
var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z);
|
||
var wireRot = YawToAcQuaternion(_playerController.Yaw);
|
||
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
|
||
|
||
// 2026-05-16 (issue #75) / R4-V5: user-MoveToState packets
|
||
// are ONLY for user-initiated motion intent — retail's
|
||
// architectural split between user-input motion and
|
||
// server-driven motion. Post-V5 the split holds BY
|
||
// CONSTRUCTION: MotionStateChanged derives exclusively from
|
||
// input edges (the MoveToManager's dispatches never touch
|
||
// the controller's edge detector), so a manager-driven
|
||
// moveto produces no outbound MoveToState; the moment the
|
||
// user presses a key, the edge's CancelMoveTo chain kills
|
||
// the moveto and THAT state change legitimately goes on
|
||
// the wire ("user took control" — which is now true). The
|
||
// former !IsServerAutoWalking guard is redundant and gone
|
||
// with B.6.
|
||
if (result.MotionStateChanged)
|
||
{
|
||
// HoldKey axis values — retail enum (acclient.h enum
|
||
// HoldKey): Invalid = 0, None = 1, Run = 2.
|
||
// Retail always sends CURRENT_HOLD_KEY (and uses the same
|
||
// value for every active per-axis hold key — see
|
||
// holtburger's build_motion_state_raw_motion_state).
|
||
// When the player is running forward, Run; otherwise None.
|
||
var axisHoldKey = result.IsRunning
|
||
? AcDream.Core.Physics.HoldKey.Run
|
||
: AcDream.Core.Physics.HoldKey.None;
|
||
|
||
// D1/L.2b (2026-06-30): build the COMPLETE RawMotionState
|
||
// snapshot (matching retail's CPhysicsObj::InqRawMotionState())
|
||
// and let RawMotionStatePacker's default-difference comparison
|
||
// decide what actually goes on the wire — see
|
||
// RawMotionStatePacker (ports RawMotionState::Pack, 0x0051ed10).
|
||
// This slice changes ONLY the packing; the state values
|
||
// below are exactly what the pre-slice code passed in.
|
||
var rawMotionState = new AcDream.Core.Physics.RawMotionState
|
||
{
|
||
CurrentHoldKey = axisHoldKey,
|
||
// CurrentStyle: not tracked by acdream today — leave at
|
||
// the retail default (0x8000003D) so the packer omits it.
|
||
ForwardCommand = result.ForwardCommand ?? AcDream.Core.Physics.RawMotionState.Default.ForwardCommand,
|
||
ForwardHoldKey = result.ForwardCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||
// D6.2b: the wire carries the RAW forward_speed (1.0 for run/walk;
|
||
// the controller now sets outForwardSpeed=1.0). ACE recomputes the
|
||
// broadcast run speed from run skill (echo-confirmed 2026-07-01), so
|
||
// this is retail-faithful; 1.0 is omitted by default-difference packing.
|
||
ForwardSpeed = result.ForwardSpeed ?? AcDream.Core.Physics.RawMotionState.Default.ForwardSpeed,
|
||
SidestepCommand = result.SidestepCommand ?? AcDream.Core.Physics.RawMotionState.Default.SidestepCommand,
|
||
SidestepHoldKey = result.SidestepCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||
SidestepSpeed = result.SidestepSpeed ?? AcDream.Core.Physics.RawMotionState.Default.SidestepSpeed,
|
||
TurnCommand = result.TurnCommand ?? AcDream.Core.Physics.RawMotionState.Default.TurnCommand,
|
||
TurnHoldKey = result.TurnCommand.HasValue ? axisHoldKey : AcDream.Core.Physics.HoldKey.Invalid,
|
||
TurnSpeed = result.TurnSpeed ?? AcDream.Core.Physics.RawMotionState.Default.TurnSpeed,
|
||
// Actions: acdream does not yet emit discrete motion
|
||
// events on this path (D2, deferred to Phase 2+).
|
||
};
|
||
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var body = AcDream.Core.Net.Messages.MoveToState.Build(
|
||
gameActionSequence: seq,
|
||
rawMotionState: rawMotionState,
|
||
cellId: wireCellId,
|
||
position: wirePos,
|
||
rotation: wireRot,
|
||
instanceSequence: _liveSession.InstanceSequence,
|
||
serverControlSequence: _liveSession.ServerControlSequence,
|
||
teleportSequence: _liveSession.TeleportSequence,
|
||
forcePositionSequence: _liveSession.ForcePositionSequence,
|
||
contact: contactByte != 0,
|
||
// Standing longjump is not implemented yet — honest
|
||
// current value, not a guess (D3).
|
||
standingLongjump: false);
|
||
DumpMovementTruthOutbound(
|
||
"MTS", seq, result, wirePos, wireCellId, contactByte);
|
||
_liveSession.SendGameAction(body);
|
||
// D5 audit (2026-06-30, this slice): retail's
|
||
// SendMovementEvent (acclient_2013_pseudo_c.txt, 0x006b4680)
|
||
// stamps ONLY last_sent_position_time after an MTS send — it
|
||
// does NOT update last_sent_position or
|
||
// last_sent_contact_plane (confirmed via Ghidra
|
||
// decompile-by-address during this slice). Only
|
||
// SendPositionEvent (the AP path, below) stamps all three.
|
||
// acdream's NotePositionSent call here stamps all three on
|
||
// BOTH paths — this is a real, audit-confirmed divergence
|
||
// from retail. Per this slice's scope (D5 = audit only,
|
||
// change only with explicit sign-off), left UNCHANGED;
|
||
// reported to the lead engineer instead of fixed here.
|
||
_playerController.NotePositionSent(
|
||
worldPos: _playerController.Position,
|
||
cellId: _playerController.CellId,
|
||
contactPlane: _playerController.ContactPlane,
|
||
nowSeconds: _playerController.SimTimeSeconds);
|
||
}
|
||
|
||
if (_playerController.HeartbeatDue)
|
||
{
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
|
||
gameActionSequence: seq,
|
||
cellId: wireCellId,
|
||
position: wirePos,
|
||
rotation: wireRot,
|
||
instanceSequence: _liveSession.InstanceSequence,
|
||
serverControlSequence: _liveSession.ServerControlSequence,
|
||
teleportSequence: _liveSession.TeleportSequence,
|
||
forcePositionSequence: _liveSession.ForcePositionSequence,
|
||
lastContact: contactByte);
|
||
DumpMovementTruthOutbound(
|
||
"AP", seq, result, wirePos, wireCellId, contactByte);
|
||
_liveSession.SendGameAction(body);
|
||
// B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
|
||
// HeartbeatDue resets its interval from THIS send — mirrors retail's
|
||
// SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348,
|
||
// decomp address 0x006b4770, re-confirmed via the Ghidra
|
||
// decompile-by-address bridge during the L.2b slice 2026-06-30)
|
||
// writing last_sent_position_time + last_sent_position +
|
||
// last_sent_contact_plane after each AP. Unlike the MTS path
|
||
// above (SendMovementEvent, 0x006b4680), which retail stamps
|
||
// ONLY last_sent_position_time, this AP path is correct.
|
||
_playerController.NotePositionSent(
|
||
worldPos: _playerController.Position,
|
||
cellId: _playerController.CellId,
|
||
contactPlane: _playerController.ContactPlane,
|
||
nowSeconds: _playerController.SimTimeSeconds);
|
||
}
|
||
|
||
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
|
||
{
|
||
// D4/L.2b (2026-06-30): JumpPack::Pack (0x00516d10) packs the
|
||
// full Position, not an objectGuid/spellId — pass the same
|
||
// wireCellId/wirePos/wireRot the MoveToState send above uses.
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build(
|
||
gameActionSequence: seq,
|
||
extent: result.JumpExtent.Value,
|
||
velocity: result.JumpVelocity.Value,
|
||
cellId: wireCellId,
|
||
position: wirePos,
|
||
rotation: wireRot,
|
||
instanceSequence: _liveSession.InstanceSequence,
|
||
serverControlSequence: _liveSession.ServerControlSequence,
|
||
teleportSequence: _liveSession.TeleportSequence,
|
||
forcePositionSequence: _liveSession.ForcePositionSequence);
|
||
_liveSession.SendGameAction(jumpBody);
|
||
}
|
||
}
|
||
|
||
// Update the player entity's animation cycle to match current motion.
|
||
}
|
||
}
|
||
|
||
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}")
|
||
: "-";
|
||
|
||
/// <summary>
|
||
/// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North)
|
||
/// to AC's quaternion heading convention.
|
||
/// AC heading: 0=West, 90=North, 180=East, 270=South.
|
||
/// Formula from holtburger Quaternion::from_heading.
|
||
/// </summary>
|
||
private static System.Numerics.Quaternion YawToAcQuaternion(float yaw)
|
||
{
|
||
// Our yaw → AC heading in degrees:
|
||
// yaw=0 → East → AC 180°, yaw=PI/2 → North → AC 90°
|
||
// heading_deg = 180 - yaw_degrees
|
||
float yawDeg = yaw * (180f / MathF.PI);
|
||
float headingDeg = 180f - yawDeg;
|
||
if (headingDeg < 0f) headingDeg += 360f;
|
||
if (headingDeg >= 360f) headingDeg -= 360f;
|
||
|
||
// holtburger from_heading: theta = (450 - heading_deg) in radians
|
||
float theta = (450f - headingDeg) * (MathF.PI / 180f);
|
||
float halfTheta = theta * 0.5f;
|
||
float w = MathF.Cos(halfTheta);
|
||
float z = MathF.Sin(halfTheta);
|
||
|
||
// Canonicalize: w must be non-negative
|
||
if (w < 0f) { w = -w; z = -z; }
|
||
|
||
return new System.Numerics.Quaternion(0f, 0f, z, w);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Inverse of <see cref="YawToAcQuaternion"/>: extracts the local yaw (rotation
|
||
/// about the Z axis, in radians) from an AC wire quaternion.
|
||
/// Yaw=0 faces +X (East). Used by the L.3.1 InterpolationManager routing to
|
||
/// convert server orientation into the heading expected by <c>InterpolationManager.Enqueue</c>.
|
||
/// Standard formula: atan2( 2(wz + xy), 1 − 2(y² + z²) ).
|
||
/// </summary>
|
||
private static float ExtractYawFromQuaternion(System.Numerics.Quaternion q)
|
||
{
|
||
return MathF.Atan2(
|
||
2f * (q.W * q.Z + q.X * q.Y),
|
||
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Exact inverse of <see cref="YawToAcQuaternion"/>: AC wire orientation → our internal
|
||
/// yaw (radians, 0=+X East). NOT the same as <see cref="ExtractYawFromQuaternion"/>, which
|
||
/// returns the raw quaternion Z-angle (AC's <c>theta = 450 - heading</c>) — that carries a
|
||
/// 270° offset vs our yaw. Inverting the documented chain: theta = 450 - heading and
|
||
/// heading = 180 - yaw ⇒ yaw = thetaDeg - 270. Used to face the player the server-specified
|
||
/// way on a teleport arrival (retail drops you facing the portal's destination heading).
|
||
/// </summary>
|
||
private static float AcQuaternionToYaw(System.Numerics.Quaternion q)
|
||
{
|
||
float thetaDeg = ExtractYawFromQuaternion(q) * (180f / MathF.PI);
|
||
float yawDeg = thetaDeg - 270f; // 180 - (450 - thetaDeg)
|
||
float yaw = yawDeg * (MathF.PI / 180f);
|
||
return MathF.Atan2(MathF.Sin(yaw), MathF.Cos(yaw)); // normalize to (-PI, PI]
|
||
}
|
||
|
||
private void OnCameraModeChanged(bool _modeBool)
|
||
{
|
||
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)
|
||
{
|
||
_frameProfiler.FrameBoundary(_gl!);
|
||
|
||
// 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).
|
||
_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();
|
||
}
|
||
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 6.4: advance per-entity animation playback before drawing
|
||
// so the renderer always sees the up-to-date per-part transforms.
|
||
if (_animatedEntities.Count > 0)
|
||
TickAnimations((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;
|
||
|
||
if (_cameraController is not null)
|
||
{
|
||
var camera = _cameraController.Active;
|
||
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection);
|
||
|
||
// 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);
|
||
|
||
// 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;
|
||
if (_window is not null && _window.VSync != d.VSync)
|
||
_window.VSync = 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);
|
||
_scriptRunner?.Tick((float)deltaSeconds);
|
||
_particleSystem?.Tick((float)deltaSeconds);
|
||
|
||
// 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.
|
||
Lighting.BuildPointLightSnapshot(camPos);
|
||
_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 * camera.Projection;
|
||
_envCellFrustum?.Update(envCellViewProj);
|
||
|
||
HashSet<uint>? animatedIds = null;
|
||
if (_animatedEntities.Count > 0)
|
||
{
|
||
animatedIds = new HashSet<uint>(_animatedEntities.Count);
|
||
foreach (var k in _animatedEntities.Keys)
|
||
animatedIds.Add(k);
|
||
}
|
||
|
||
// 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
|
||
// (ClipFrameAssembler.Assemble → ClipFrame.Reset) so its SSBO/UBO ids are
|
||
// reused — no per-frame GL buffer churn. UploadShared binds binding=2
|
||
// (mesh SSBO) + binding=2 (terrain UBO); each renderer re-binds its
|
||
// binding=2 defensively from the ids we hand it.
|
||
_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 _buildingRegistries.Values)
|
||
{
|
||
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;
|
||
}
|
||
|
||
_clipFrame.UploadShared(_gl);
|
||
_wbDrawDispatcher?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||
_envCellRenderer?.SetClipRegionSsbo(_clipFrame.RegionSsbo);
|
||
_terrain?.SetClipUbo(_clipFrame.TerrainUbo);
|
||
|
||
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)
|
||
{
|
||
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||
ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo);
|
||
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(_particleSystem, 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,
|
||
Frustum = frustum,
|
||
PlayerLandblockId = playerLb,
|
||
AnimatedEntityIds = animatedIds,
|
||
RenderCenterLbX = renderCenterLbX,
|
||
RenderCenterLbY = renderCenterLbY,
|
||
RenderRadius = _nearRadius,
|
||
LandblockEntries = _worldState.LandblockEntries,
|
||
SetTerrainClipUbo = uboId => _terrain?.SetClipUbo(uboId),
|
||
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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId == 0);
|
||
},
|
||
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;
|
||
|
||
// 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);
|
||
}
|
||
|
||
// 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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId == 0
|
||
|| (!_outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId)
|
||
&& _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId)));
|
||
}
|
||
else
|
||
{
|
||
sigSceneParticles = sigSceneParticles == "none" ? "global" : sigSceneParticles + "+global";
|
||
_particleRenderer.Draw(
|
||
_particleSystem,
|
||
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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId == 0
|
||
|| _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||
}
|
||
|
||
// 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;
|
||
_gl.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||
ClipFrame.TerrainClipUboBinding, _clipFrame.TerrainUbo);
|
||
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(_particleSystem, 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, camera.Projection);
|
||
}
|
||
|
||
// 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: ;
|
||
}
|
||
|
||
// 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
|
||
&& _paperdollViewportWidget is { Visible: true } dollWidget
|
||
&& _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 && _uiHost is not null)
|
||
{
|
||
_uiHost.Tick(deltaSeconds);
|
||
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
|
||
}
|
||
|
||
// Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so
|
||
// it covers them during a transit; the ImGui devtools below composite on top so they
|
||
// stay visible for debugging. Alpha is 0 outside a teleport → Draw is a no-op.
|
||
_fadeOverlay?.Draw(_teleportFadeAlpha);
|
||
|
||
// 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 =
|
||
_commandBus ?? (AcDream.UI.Abstractions.ICommandBus)
|
||
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);
|
||
// B.7 Vivid Target Indicator: draws corner triangles to the
|
||
// ImGui background draw list so it appears behind any docked
|
||
// panels but still over the 3D scene. Cheap when no
|
||
// selection — internal early-return on null guid.
|
||
_targetIndicator?.Render();
|
||
using (var _imguiStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.ImGui))
|
||
{
|
||
_imguiBootstrap.Render();
|
||
}
|
||
}
|
||
|
||
// 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;
|
||
|
||
// L.0 Display tab: ShowFps gates the perf string in the
|
||
// title bar. Default is true (matches pre-L.0 behaviour);
|
||
// unchecking the toggle in Display tab collapses the title
|
||
// to just "acdream" for a cleaner alt-tab experience.
|
||
//
|
||
// When perf is shown, 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.
|
||
bool showFps = _settingsVm?.DisplayDraft.ShowFps ?? true;
|
||
if (showFps)
|
||
{
|
||
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})";
|
||
}
|
||
else
|
||
{
|
||
_window!.Title = "acdream";
|
||
}
|
||
_lastFps = fps;
|
||
_lastFrameMs = avgFrameTime;
|
||
_perfAccum = 0;
|
||
_perfFrameCount = 0;
|
||
}
|
||
}
|
||
|
||
/// <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 AnimatedEntity record) are untouched. The static
|
||
/// renderer reads the new MeshRefs on the next Draw call.
|
||
/// </summary>
|
||
private void TickAnimations(float dt)
|
||
{
|
||
// Retail has NO stop-detection heuristic — it relies on the
|
||
// server sending explicit UpdateMotion(Ready). The server-side
|
||
// stop signal flows the same way as any other motion change:
|
||
// alt releases W → MoveToState(Ready) → ACE broadcasts
|
||
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
|
||
// On our side, both are handled: UpdateMotion routes through
|
||
// MotionInterpreter.DoInterpretedMotion which zeroes the
|
||
// interpreter's state, and UpdatePosition's HasVelocity~0 hits
|
||
// MotionInterpreter.StopCompletely. Anything else is server
|
||
// buggery (packet loss, ACE bug) — don't guess client-side.
|
||
var now = System.DateTime.UtcNow;
|
||
|
||
foreach (var kv in _animatedEntities)
|
||
{
|
||
var ae = kv.Value;
|
||
|
||
// The server guid is carried on the entity itself
|
||
// (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at
|
||
// construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid`
|
||
// and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly —
|
||
// O(1) — instead of the former reverse ReferenceEquals scan over ALL of
|
||
// _entitiesByServerGuid, which made this loop O(animated × all-entities).
|
||
// That super-linear cost is the FPS-drops-per-teleport sink: world
|
||
// entities accumulate without bound (pruned only on DeleteObject, and ACE
|
||
// does not re-send / clear KnownObjects across a teleport), so N climbs
|
||
// every hop. ServerGuid defaults to 0 for entities not server-tracked,
|
||
// exactly matching the old scan's miss case.
|
||
uint serverGuid = ae.Entity.ServerGuid;
|
||
|
||
// ── Dead-reckoning: smooth position between UpdatePosition bursts.
|
||
// The server broadcasts UpdatePosition at ~5-10Hz for distant
|
||
// entities; without integration, remote chars jitter-hop between
|
||
// samples. Each tick we advance entity.Position by the
|
||
// sequencer's current velocity (rotated into world space by the
|
||
// entity's facing) — matching the retail client's
|
||
// apply_current_movement (chunk_00520000.c L7132-L7189) and
|
||
// holtburger's project_pose_by_velocity.
|
||
//
|
||
// The cap on predict-distance from the last server pos prevents
|
||
// runaway when the sequencer's velocity and the server's reality
|
||
// disagree (e.g. server is rubber-banding the entity). Retail
|
||
// uses a similar clamp at PhysicsObj::IsInterpolationComplete.
|
||
if (ae.Sequencer is not null
|
||
&& serverGuid != 0
|
||
&& serverGuid != _playerServerGuid
|
||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rm)
|
||
// R4-V5 door fix companion: rm entries now also exist for
|
||
// static animated objects (doors/levers — created on first
|
||
// UM so the funnel can play their On/Off cycles). Those
|
||
// must NOT enter this dead-reckoning block: it force-
|
||
// asserts ground contact, zeroes/integrates velocity, and
|
||
// ground-resolves the body — position machinery for
|
||
// entities the server MOVES. "Has ever received an
|
||
// UpdatePosition" is exactly that distinction (there is
|
||
// nothing to dead-reckon between UPs that don't exist);
|
||
// their animation still plays via the sequencer's own
|
||
// TickAnimations advance.
|
||
&& rm.LastServerPosTime > 0)
|
||
{
|
||
// R5-V2: retail UpdateObjectInternal ticks TargetManager::
|
||
// HandleTargetting UNCONDITIONALLY per entity, BEFORE the
|
||
// movement managers' UseTime. This is where this entity, as a
|
||
// watched target, pushes its position to its voyeurs (any entity
|
||
// moving-to it), and where its own target-info staleness times
|
||
// out. Runs for every remote regardless of the grounded/airborne
|
||
// branch below (which drive MoveToManager.UseTime via
|
||
// TickRemoteMoveTo). No-op for entities with no target + no voyeurs.
|
||
rm.Host?.HandleTargetting();
|
||
|
||
if (IsPlayerGuid(serverGuid) && !rm.Airborne)
|
||
{
|
||
// ── L.3 M2/M3 (2026-05-05): queue + anim chase for grounded player remotes ──
|
||
//
|
||
// Per retail spec (docs/research/2026-05-04-l3-port/01-per-tick.md +
|
||
// 04-interp-manager.md +
|
||
// 05-position-manager-and-partarray.md):
|
||
//
|
||
// - For a grounded REMOTE player, m_velocityVector stays at 0.
|
||
// - apply_current_movement is NEVER called per tick on remotes
|
||
// (it's the local-player-only velocity feed).
|
||
// - UpdatePhysicsInternal's translation step is gated on
|
||
// velocity² > 0, so it's a no-op when body.Velocity = 0.
|
||
// - ResolveWithTransition is NOT called — the server already
|
||
// collision-resolved the broadcast position.
|
||
// - Per-tick body translation per retail UpdatePositionInternal:
|
||
// 1. CPartArray::Update writes anim root motion (body-local
|
||
// seqVel × dt) into the local frame.
|
||
// 2. PositionManager::adjust_offset OVERWRITES the local
|
||
// frame's origin with the queue catch-up vector when
|
||
// the queue is active and the head is not yet reached
|
||
// — REPLACE, not additive.
|
||
// 3. Frame::combine composes the local frame with the
|
||
// body's world pose.
|
||
// Net: catch-up replaces anim during the chase phase, anim
|
||
// stands when the queue is empty / head reached. PositionManager.
|
||
// ComputeOffset implements this exact REPLACE dichotomy.
|
||
//
|
||
// Airborne player remotes (rm.Airborne) and NPCs fall through to
|
||
// the legacy path below — unchanged from main per the M2 plan.
|
||
System.Numerics.Vector3 seqVel = ae.Sequencer?.CurrentVelocity
|
||
?? System.Numerics.Vector3.Zero;
|
||
System.Numerics.Vector3 seqOmega = ae.Sequencer?.CurrentOmega
|
||
?? System.Numerics.Vector3.Zero;
|
||
|
||
// Step 1: transient flags (Contact + OnWalkable for grounded;
|
||
// Active always so UpdatePhysicsInternal doesn't early-return).
|
||
if (!rm.Airborne)
|
||
{
|
||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||
|
||
// For grounded remotes the body should not be carrying
|
||
// velocity — retail's m_velocityVector for a remote is
|
||
// 0 unless the server explicitly pushed one. Clear any
|
||
// stale velocity from a prior airborne arc so
|
||
// UpdatePhysicsInternal doesn't double-apply it on top
|
||
// of the seqVel-driven ComputeOffset translation below.
|
||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||
}
|
||
else
|
||
{
|
||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||
}
|
||
|
||
// R4-V5 glide fix (2026-07-03 user report: a retail
|
||
// player using an object GLIDES to it — position moves
|
||
// via the UP queue but no walk/run legs): remote
|
||
// PLAYERS' MoveToManagers were armed by mt-6 but never
|
||
// TICKED — the V4 UseTime slot lived only in the
|
||
// NPC/legacy branch below, gated !IsPlayerGuid
|
||
// (inherited from the deleted RemoteMoveToDriver's
|
||
// NPC-only scope). Retail ticks every entity's
|
||
// MovementManager (UpdateObjectInternal has no entity-
|
||
// class fork). The manager's dispatches produce the
|
||
// locomotion cycle through the funnel sink (the LEGS);
|
||
// position stays queue-chased per the L.3 M2 spec, so
|
||
// the two compose exactly like an NPC's tick.
|
||
TickRemoteMoveTo(rm);
|
||
|
||
// Step 2 (M3): queue + anim translation via PositionManager.
|
||
// ComputeOffset returns:
|
||
// - Vector3.Zero when queue is empty AND seqVel is zero
|
||
// (idle remote between UPs after head reached) — body
|
||
// stays still.
|
||
// - Direction × min(catchUpSpeed × dt, dist) when the
|
||
// queue is active and head is not reached — body chases
|
||
// the head waypoint at up to 2× motion-table max speed
|
||
// (REPLACES anim for this frame).
|
||
// - Anim root motion (seqVel × dt rotated into world) when
|
||
// the queue is empty OR head is within DesiredDistance —
|
||
// body advances with the locomotion cycle's baked
|
||
// velocity, keeping legs and body pace synchronized.
|
||
// - Blip-to-tail (tail − body) when fail_count > 3.
|
||
float maxSpeed = rm.Motion.GetMaxSpeed();
|
||
// Slope-staircase fix (2026-05-05): sample terrain normal
|
||
// at the body's current XY so PositionManager can project
|
||
// the seqVel-only fallback onto the local slope. Without
|
||
// this, the queue-empty interval between UPs left Z flat
|
||
// (anim cycles bake Z=0 body-local) — visible ~5 Hz
|
||
// staircase when a remote runs up/down hills. The
|
||
// projection is a no-op on flat ground.
|
||
System.Numerics.Vector3? terrainNormal = _physicsEngine.SampleTerrainNormal(
|
||
rm.Body.Position.X, rm.Body.Position.Y);
|
||
|
||
System.Numerics.Vector3 bodyPosBefore = rm.Body.Position;
|
||
System.Numerics.Vector3 offset = rm.Position.ComputeOffset(
|
||
dt: (double)dt,
|
||
currentBodyPosition: rm.Body.Position,
|
||
seqVel: seqVel,
|
||
ori: rm.Body.Orientation,
|
||
interp: rm.Interp,
|
||
maxSpeed: maxSpeed,
|
||
terrainNormal: terrainNormal);
|
||
// R5-V3 (#171): retail chains Interpolation → Sticky over
|
||
// ONE shared delta frame (PositionManager::adjust_offset
|
||
// 0x00555190). The combiner's catch-up IS acdream's
|
||
// interpolation stage, so its offset SEEDS the frame
|
||
// (converted to mover-local) and StickyManager::
|
||
// adjust_offset OVERWRITES it when armed+initialized
|
||
// (0x00555430 assigns m_fOrigin rather than accumulating).
|
||
// With no stick armed the frame comes back untouched and
|
||
// this reduces to the pre-V3 `Position += offset`.
|
||
if (rm.Host is { } plHost)
|
||
{
|
||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||
{
|
||
Origin = AcDream.Core.Physics.Motion.MoveToMath.GlobalToLocalVec(
|
||
rm.Body.Orientation, offset),
|
||
};
|
||
plHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||
}
|
||
else
|
||
{
|
||
rm.Body.Position += offset;
|
||
}
|
||
|
||
// Slope-staircase diagnostic — gated on ACDREAM_SLOPE_DIAG=1.
|
||
// Prints per-tick body Z trajectory + queue state + projected
|
||
// offset.Z so we can grep before/after the fix and confirm Z
|
||
// changes continuously between UPs on slopes (no flat
|
||
// intervals followed by snaps).
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_SLOPE_DIAG") == "1")
|
||
{
|
||
bool queueActive = rm.Interp.IsActive;
|
||
float nz = terrainNormal?.Z ?? 1.0f;
|
||
System.Console.WriteLine(
|
||
$"[SLOPE] guid={serverGuid:X8} bodyZ={bodyPosBefore.Z:F3}->{rm.Body.Position.Z:F3} "
|
||
+ $"offset=({offset.X:F3},{offset.Y:F3},{offset.Z:F3}) "
|
||
+ $"queue={queueActive} cpN.Z={nz:F3}");
|
||
}
|
||
|
||
// Step 2.5: angular velocity → body orientation. Prefer
|
||
// ObservedOmega (set explicitly in OnLiveMotionUpdated from
|
||
// the wire's TurnCommand + signed TurnSpeed) over the
|
||
// sequencer's synthesized omega: when the player runs in
|
||
// a circle ACE broadcasts ForwardCommand=RunForward AND
|
||
// TurnCommand=TurnLeft on the same UpdateMotion. The
|
||
// sequencer's animCycle picker chooses RunForward (legs
|
||
// running), whose synthesized CurrentOmega is zero. Body
|
||
// would not rotate between UPs and body.Velocity stays in
|
||
// an out-of-date world direction, producing the
|
||
// user-reported "rectangle when running circles" effect.
|
||
// ObservedOmega has the correct turn rate even when the
|
||
// visible cycle is RunForward.
|
||
System.Numerics.Vector3 omegaToApply =
|
||
rm.ObservedOmega.LengthSquared() > 1e-9f
|
||
? rm.ObservedOmega
|
||
: seqOmega;
|
||
if (omegaToApply.LengthSquared() > 1e-9f)
|
||
{
|
||
float angleDelta = omegaToApply.Length() * (float)dt;
|
||
System.Numerics.Vector3 axis = System.Numerics.Vector3.Normalize(omegaToApply);
|
||
var rot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angleDelta);
|
||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||
System.Numerics.Quaternion.Concatenate(rm.Body.Orientation, rot));
|
||
|
||
// Diagnostic (ACDREAM_REMOTE_VEL_DIAG=1): print seqOmega direction
|
||
// once per remote per ~1 second so we can confirm whether the omega
|
||
// sign actually being applied matches the retail-observed turn
|
||
// direction. Z>0 = CCW (TurnLeft); Z<0 = CW (TurnRight).
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
if (nowSec - rm.LastOmegaDiagLogTime > 0.5)
|
||
{
|
||
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
|
||
System.Console.WriteLine(
|
||
$"[OMEGA_DIAG] guid={serverGuid:X8} motion=0x{seqMotion:X8} "
|
||
+ $"omegaApplied.Z={omegaToApply.Z:F3} "
|
||
+ $"(seq.Z={seqOmega.Z:F3} obs.Z={rm.ObservedOmega.Z:F3}) "
|
||
+ $"(Z>0=CCW=TurnLeft, Z<0=CW=TurnRight)");
|
||
rm.LastOmegaDiagLogTime = nowSec;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Step 3: calc_acceleration sets body.Acceleration from the Gravity flag
|
||
// (mirrors retail CPhysicsObj::calc_acceleration @ FUN_00511420, called
|
||
// per-frame in update_object). Without this, body.Acceleration stays stale
|
||
// or zero → gravity never decays jump velocity → endless rise on jumps.
|
||
rm.Body.calc_acceleration();
|
||
|
||
// Step 4: physics integration (Euler: pos += vel*dt + 0.5*accel*dt²).
|
||
rm.Body.UpdatePhysicsInternal(dt);
|
||
|
||
// Step 4b INTENTIONALLY OMITTED in M2:
|
||
// ResolveWithTransition is NOT called — the server has
|
||
// already collision-resolved the broadcast position, and
|
||
// running our sweep on tiny per-frame queue catch-up deltas
|
||
// amplifies micro-bounces into visible position blips
|
||
// (issue #40 staircase + flat-ground blips). Per retail
|
||
// spec the per-tick body advance for a remote is purely
|
||
// the queue catch-up; collision is the sender's problem.
|
||
//
|
||
// Step 5 (landing fallback) is unreachable in this branch —
|
||
// we're gated on !rm.Airborne. Airborne player remotes fall
|
||
// through to the legacy path below where K-fix15 still fires.
|
||
|
||
// Step 6: speed-overshoot diagnostic (ACDREAM_REMOTE_VEL_DIAG=1).
|
||
// Track the maximum sequencer velocity magnitude seen since
|
||
// the last UpdatePosition arrival (carried on the
|
||
// RemoteMotion struct), then on each UP arrival the
|
||
// OnLivePositionUpdated path prints the comparison against
|
||
// the server's actual broadcast pace
|
||
// ((LastServerPos - PrevServerPos) / Δt). This guarantees
|
||
// both sides are sampled during the same window and the
|
||
// ratio reflects the real overshoot.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1")
|
||
{
|
||
// body.Velocity is now the source of bulk translation
|
||
// (set above by apply_current_movement). Track its
|
||
// magnitude so VEL_DIAG can compare against the actual
|
||
// server broadcast pace.
|
||
float seqSpeedNow = rm.Body.Velocity.Length();
|
||
if (seqSpeedNow > rm.MaxSeqSpeedSinceLastUP)
|
||
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
|
||
}
|
||
|
||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||
if (rm.CellId != 0)
|
||
ae.Entity.ParentCellId = rm.CellId;
|
||
ae.Entity.Rotation = rm.Body.Orientation;
|
||
}
|
||
else
|
||
{
|
||
// ── LEGACY PATH (UNCHANGED — kept until Task 8 cleanup) ──
|
||
//
|
||
// Stop detection is handled explicitly on packet receipt:
|
||
// - UpdateMotion with ForwardCommand flag CLEARED → Ready.
|
||
// - UpdatePosition with HasVelocity flag CLEARED → StopCompletely.
|
||
// Both map to retail's "flag-absent = Invalid = reset to
|
||
// default" semantics (FUN_0051F260 bulk-copy). No timer-based
|
||
// inference needed — the server sends the right signal every
|
||
// time a remote stops.
|
||
|
||
// Retail per-tick motion pipeline applied to every remote.
|
||
// Mirrors retail FUN_00515020 update_object → FUN_00513730
|
||
// UpdatePositionInternal → FUN_005111D0 UpdatePhysicsInternal:
|
||
//
|
||
// 1. apply_current_movement (FUN_00529210) — recomputes
|
||
// body.Velocity from InterpretedState via get_state_velocity.
|
||
// 2. Pull omega from the sequencer (baked MotionData.Omega
|
||
// for TurnRight / TurnLeft cycles, scaled by speedMod).
|
||
// 3. body.update_object(now) — Euler-integrates
|
||
// position += Velocity × dt + 0.5 × Accel × dt² AND
|
||
// orientation += omega × dt.
|
||
//
|
||
// On UpdatePosition receipt we hard-snap body.Position and
|
||
// body.Orientation — if integration matched server physics,
|
||
// each snap is small/invisible.
|
||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
|
||
// Step 1: re-apply current motion commands → body.Velocity.
|
||
// Forces OnWalkable + Contact so the gate in apply_current_movement
|
||
// always succeeds (remotes are server-authoritative; we don't
|
||
// simulate airborne physics for them).
|
||
//
|
||
// K-fix9 (2026-04-26): SKIP this when the remote is airborne.
|
||
// Otherwise the force-OnWalkable + apply_current_movement
|
||
// path stomps the +Z velocity we set in OnLiveVectorUpdated,
|
||
// and gravity never gets to integrate the arc. The airborne
|
||
// body keeps the launch velocity from the VectorUpdate;
|
||
// UpdatePhysicsInternal below applies gravity each tick;
|
||
// the next UpdatePosition snaps to the new ground location
|
||
// and re-grounds.
|
||
if (!rm.Airborne)
|
||
{
|
||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
|
||
| AcDream.Core.Physics.TransientStateFlags.Active;
|
||
// #170 residual fix (2026-07-04): an ARMED moveto always
|
||
// takes the MOVETO branch. The old arbitration routed the
|
||
// tick to SERVERVEL whenever UPs flowed (position-delta
|
||
// synthesis sets HasServerVelocity=true for any moving
|
||
// NPC), which SKIPPED MoveToManager.UseTime for exactly
|
||
// the duration of the server-side chase — the armed
|
||
// manager only ever ticked in UP-silent gaps (creature
|
||
// stopped server-side), turned toward a stale heading,
|
||
// and was interrupted by the next UM before reaching
|
||
// BeginMoveForward. Live funnel (launch-drainq.log,
|
||
// corrected per-guid attribution): 16 arms → 11 turns
|
||
// dispatched → 1 run install; [npc-tick] shows
|
||
// branch=SERVERVEL (skips UseTime) mtState=MoveToObject
|
||
// for chasing scamps. The legs stayed in Ready while the
|
||
// body glided on synthesized velocity — the #170 slide.
|
||
// Retail runs MovementManager::UseTime UNCONDITIONALLY
|
||
// per tick (CPhysicsObj::UpdateObjectInternal 0x005156b0,
|
||
// call at 0x00515998) and has NO wire-velocity leg-driver;
|
||
// between UPs a moveto-driven body translates from the
|
||
// motion state (get_state_velocity) with UP hard-snaps
|
||
// correcting drift. The SERVERVEL leg remains ONLY as the
|
||
// legacy fallback for entities WITHOUT an armed moveto
|
||
// (scripted-path NPCs / missiles carrying wire velocity).
|
||
// Full-stack conformance: RemoteChaseEndToEndHarnessTests
|
||
// (turn→run→drain sustained when the manager is ticked).
|
||
bool moveToArmed = rm.MoveTo is
|
||
{ MovementTypeState: not AcDream.Core.Physics.MovementType.Invalid };
|
||
// R5-V3 (#171): a STUCK entity is also client-side-
|
||
// driven — after the sticky arrival the moveto is
|
||
// cleaned (Invalid) but StickyManager::adjust_offset
|
||
// owns the between-snap translation exactly like an
|
||
// armed moveto. Routing it to SERVERVEL would glide
|
||
// the body on synthesized wire velocity AGAINST the
|
||
// sticky steer (the same starvation class as the #170
|
||
// SERVERVEL fix — register TS-41). Retail has no
|
||
// SERVERVEL leg at all; it stays the fallback for
|
||
// entities with NO client-side movement driver.
|
||
bool stickyArmed =
|
||
(rm.Host?.PositionManager.GetStickyObjectId() ?? 0u) != 0u;
|
||
if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity
|
||
&& !moveToArmed && !stickyArmed)
|
||
{
|
||
double velocityAge = nowSec - rm.LastServerPosTime;
|
||
if (velocityAge > ServerControlledVelocityStaleSeconds)
|
||
{
|
||
rm.ServerVelocity = System.Numerics.Vector3.Zero;
|
||
rm.HasServerVelocity = false;
|
||
rm.Body.Velocity = System.Numerics.Vector3.Zero;
|
||
ApplyServerControlledVelocityCycle(
|
||
serverGuid,
|
||
ae,
|
||
rm,
|
||
System.Numerics.Vector3.Zero);
|
||
}
|
||
else
|
||
{
|
||
rm.Body.Velocity = rm.ServerVelocity;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// R4-V4: the retail MoveToManager drives
|
||
// server-directed movement per tick — UseTime runs
|
||
// HandleMoveToPosition/HandleTurnToHeading (steering
|
||
// + arrival + fail-distance), dispatching its OWN
|
||
// per-node locomotion (turn / RunForward) through the
|
||
// sink. That per-node dispatch is the only motion the
|
||
// per-tick update should issue for a remote.
|
||
TickRemoteMoveTo(rm);
|
||
|
||
// #170 ROOT FIX (2026-07-04): the per-frame
|
||
// rm.Motion.apply_current_movement(...) call that used
|
||
// to be here is DELETED. For a remote (which has a
|
||
// DefaultSink) it re-ran ApplyInterpretedMovement EVERY
|
||
// FRAME, re-dispatching the whole interpreted state —
|
||
// stance (0x8000003C), forward=attack, sidestep/turn
|
||
// stops — and each successful DoInterpretedMotion
|
||
// appends a CMotionInterp.pending_motions node. Those
|
||
// nodes barely drain for a remote, so pending_motions
|
||
// EXPLODED to ~1.3M entries (live capture: add=1.37M vs
|
||
// done=5.7K; ~671K of them the unchanged stance).
|
||
// MotionsPending() then stayed permanently true, so
|
||
// MoveToManager.BeginTurnToHeading (0x00529b90,
|
||
// `if (motions_pending) return`) could never start the
|
||
// chase turn → the chase never reached BeginMoveForward
|
||
// → RunForward, so the creature SLID in an idle+attack
|
||
// pose instead of running. Retail dispatches per MOTION
|
||
// EVENT (per UM), never per frame — a live cdb drain
|
||
// trace showed retail add_to_queue == MotionDone (queue
|
||
// stays shallow). The motion is already dispatched per
|
||
// UM by the funnel (MoveToInterpretedState) and by the
|
||
// MoveToManager per node above; the only thing the
|
||
// deleted call still provided was body velocity, which
|
||
// the get_state_velocity refresh below performs
|
||
// directly. get_state_velocity (0x00527d50, verbatim)
|
||
// returns WalkForward→3.12×spd, RunForward→4.0×spd, 0
|
||
// otherwise; grounded-only (this branch already asserted
|
||
// Contact+OnWalkable), airborne velocity owns the jump.
|
||
if (rm.Body.OnWalkable)
|
||
rm.Body.set_local_velocity(
|
||
rm.Motion.get_state_velocity(),
|
||
rm.Body.LastMoveWasAutonomous);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Airborne — keep Active flag (so UpdatePhysicsInternal
|
||
// doesn't early-return) but DON'T set Contact / OnWalkable.
|
||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Active;
|
||
}
|
||
|
||
// Step 2: integrate rotation manually per tick. We can't
|
||
// rely on PhysicsBody.update_object here — its MinQuantum
|
||
// gate (1/30 s) causes it to SKIP integration when our
|
||
// 60fps render dt (~0.016s) is below the quantum, meaning
|
||
// rotation never advances. Measured snap per UP was ~129°
|
||
// = the full expected 1s × 2.24 rad/s, confirming zero
|
||
// between-tick rotation.
|
||
//
|
||
// Manual integration matches retail's FUN_005256b0
|
||
// apply_physics (Orientation *= quat(ω × dt)). Use
|
||
// ObservedOmega derived from server UP rotation deltas so
|
||
// the rate exactly matches server physics — hard-snap on
|
||
// next UP becomes invisible by construction.
|
||
rm.Body.Omega = System.Numerics.Vector3.Zero; // don't double-integrate in update_object
|
||
if (rm.ObservedOmega.LengthSquared() > 1e-8f)
|
||
{
|
||
float omegaMag = rm.ObservedOmega.Length();
|
||
var axis = rm.ObservedOmega / omegaMag;
|
||
float angle = omegaMag * dt;
|
||
var deltaRot = System.Numerics.Quaternion.CreateFromAxisAngle(axis, angle);
|
||
rm.Body.Orientation = System.Numerics.Quaternion.Normalize(
|
||
System.Numerics.Quaternion.Multiply(rm.Body.Orientation, deltaRot));
|
||
}
|
||
|
||
// Step 3: integrate physics — retail FUN_005111D0
|
||
// UpdatePhysicsInternal. Pure Euler:
|
||
// position += velocity × dt + 0.5 × accel × dt²
|
||
//
|
||
// Call UpdatePhysicsInternal DIRECTLY rather than via
|
||
// PhysicsBody.update_object (FUN_00515020). update_object gates
|
||
// on MinQuantum = 1/30s: at our 60fps render tick (~16ms),
|
||
// deltaTime < MinQuantum → early return AND LastUpdateTime is
|
||
// NOT advanced. Net effect: position never integrates between
|
||
// UpdatePositions and the only Body.Position changes come
|
||
// from the UP hard-snap, producing a visible teleport-stride
|
||
// on slopes (the "staircase" the user reported).
|
||
//
|
||
// PlayerMovementController.cs:358 calls UpdatePhysicsInternal
|
||
// directly for the same reason. Remote motion mirrors that.
|
||
// Omega is already integrated manually above, so we zero it
|
||
// here to prevent UpdatePhysicsInternal's own omega pass from
|
||
// double-integrating.
|
||
var preIntegratePos = rm.Body.Position;
|
||
// R5-V3 (#171): the sticky steer — retail
|
||
// UpdatePositionInternal's PositionManager::adjust_offset
|
||
// slot (0x00512c30, call @0x00512d0e): the delta composes
|
||
// into this tick's motion BEFORE UpdatePhysicsInternal +
|
||
// the transition sweep below, so collision resolves the
|
||
// sticky movement like any other motion (preIntegratePos
|
||
// is captured first — the sweep covers the steer). No-op
|
||
// when nothing is stuck (untouched delta frame).
|
||
if (rm.Host is { } npcHost)
|
||
{
|
||
var pmDelta = new AcDream.Core.Physics.Motion.MotionDeltaFrame();
|
||
npcHost.PositionManager.AdjustOffset(pmDelta, dt);
|
||
ApplyPositionManagerDelta(rm.Body, pmDelta);
|
||
}
|
||
rm.Body.calc_acceleration();
|
||
rm.Body.UpdatePhysicsInternal(dt);
|
||
var postIntegratePos = rm.Body.Position;
|
||
|
||
// Step 4: collision sweep — retail FUN_00514B90 →
|
||
// FUN_005148A0 → Transition::FindTransitionalPosition.
|
||
// Projects the sphere from preIntegratePos to postIntegratePos
|
||
// through the BSP + terrain, resolving:
|
||
// - terrain Z snap along the slope (fixes the "staircase" where
|
||
// horizontal Euler motion up a slope sinks into rising ground
|
||
// until the next UP pops it up)
|
||
// - indoor BSP walls (via the 6-path dispatcher in BSPQuery)
|
||
// - object collisions via ShadowObjectRegistry
|
||
// - step-up / step-down against walkable ledges
|
||
// ResolveWithTransition is the same call PlayerMovementController
|
||
// uses for the local player; remotes now get the full retail
|
||
// treatment between UpdatePositions instead of pure kinematics.
|
||
//
|
||
// Skipped when rm.CellId == 0 (no UP landed yet — can't build
|
||
// a SpherePath without a starting cell). One-frame grace until
|
||
// the first UP arrives; harmless because the entity is
|
||
// server-freshly-spawned at a valid Z anyway.
|
||
if (rm.CellId != 0 && _physicsEngine.LandblockCount > 0)
|
||
{
|
||
// Sphere dims match local-player defaults (human Setup
|
||
// bounds — ~0.48m radius, ~1.2m height). Good enough for
|
||
// grounded humanoid remotes; can be setup-derived later
|
||
// if creatures of wildly different sizes need different
|
||
// collision profiles.
|
||
var resolveResult = _physicsEngine.ResolveWithTransition(
|
||
preIntegratePos, postIntegratePos, rm.CellId,
|
||
sphereRadius: 0.48f,
|
||
sphereHeight: 1.2f,
|
||
stepUpHeight: 0.4f, // L.2.3a: retail human-scale, was 2.0f
|
||
stepDownHeight: 0.4f, // L.2.3a: retail human-scale, was 0.04f
|
||
// K-fix9 (2026-04-26): mirror the K-fix7 gate —
|
||
// airborne remotes must NOT pre-seed the
|
||
// ContactPlane, otherwise AdjustOffset's snap-to-plane
|
||
// branch zeroes the +Z offset every step (same bug
|
||
// we hit on the local jump).
|
||
isOnGround: !rm.Airborne,
|
||
body: rm.Body, // persist ContactPlane across frames for slope tracking
|
||
// Retail default physics state includes EdgeSlide.
|
||
// Remote dead-reckoning should exercise the same
|
||
// edge/cliff branch as local movement.
|
||
moverFlags: AcDream.Core.Physics.ObjectInfoState.EdgeSlide,
|
||
// Fix #42 (2026-05-05): skip the moving remote's
|
||
// own ShadowEntry. _animatedEntities is keyed by
|
||
// entity.Id so kv.Key matches the EntityId the
|
||
// ShadowObjectRegistry has for this remote.
|
||
// Without this, the airborne sweep collides with
|
||
// the remote's own cylinder and produces ~1m of
|
||
// horizontal drift on the first jump frame
|
||
// (validated by [SWEEP-OBJ] traces).
|
||
movingEntityId: kv.Key);
|
||
|
||
rm.Body.Position = resolveResult.Position;
|
||
if (resolveResult.CellId != 0)
|
||
rm.CellId = resolveResult.CellId;
|
||
|
||
// K-fix15 (2026-04-26): post-resolve landing
|
||
// detection for airborne remotes. Mirrors
|
||
// PlayerMovementController's local-player landing
|
||
// path: when the resolver says we're on ground AND
|
||
// velocity is no longer pointing up, transition
|
||
// back to grounded — clear Airborne, restore
|
||
// Contact + OnWalkable, remove Gravity, zero any
|
||
// residual downward velocity, and trigger
|
||
// HitGround so the sequencer can swap from
|
||
// Falling → idle/locomotion. Without this, an
|
||
// airborne remote falls through the floor (gravity
|
||
// keeps building Velocity.Z negative until the
|
||
// sphere-sweep clamps each frame, but Airborne
|
||
// stays true forever).
|
||
if (rm.Airborne
|
||
&& resolveResult.IsOnGround
|
||
&& rm.Body.Velocity.Z <= 0f)
|
||
{
|
||
rm.Airborne = false;
|
||
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
|
||
| AcDream.Core.Physics.TransientStateFlags.OnWalkable;
|
||
rm.Body.Velocity = new System.Numerics.Vector3(
|
||
rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f);
|
||
// #161: HitGround MUST run with the Gravity state
|
||
// bit still set — CMotionInterp::HitGround
|
||
// (0x00528ac0) gates on state&0x400 (retail never
|
||
// clears GRAVITY on landing; it's a persistent
|
||
// object property). Clearing it first made this
|
||
// re-apply a silent no-op, which is why the
|
||
// falling pose never exited. The re-apply
|
||
// dispatches the PRESERVED pre-fall forward
|
||
// command through the funnel → the motion table
|
||
// plays the Falling→X landing link. (The old
|
||
// K-fix17 forced SetCycle is deleted: it read the
|
||
// then-clobbered InterpretedState.ForwardCommand
|
||
// — 0x40000015 — and re-set the very Falling
|
||
// cycle it meant to clear.)
|
||
// R4-V5 (closes the V4 wiring-contract gap the
|
||
// adversarial review caught): retail order —
|
||
// minterp first, then moveto (MovementManager::
|
||
// HitGround 0x00524300, §2d — the R5-V5 facade
|
||
// relay). Re-arms a moveto suspended by the
|
||
// airborne UseTime contact gate; without it a
|
||
// chasing NPC that lands stalls until ACE's
|
||
// ~1 Hz re-emit.
|
||
rm.Movement.HitGround();
|
||
// DR bookkeeping only (partner of the jump-start
|
||
// `State |= Gravity`): stops the per-tick gravity
|
||
// integration for the grounded body.
|
||
rm.Body.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity;
|
||
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||
Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}");
|
||
}
|
||
}
|
||
|
||
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
|
||
if (rm.CellId != 0)
|
||
ae.Entity.ParentCellId = rm.CellId;
|
||
ae.Entity.Rotation = rm.Body.Orientation;
|
||
}
|
||
|
||
// R5-V3 (#171): retail UpdateObjectInternal tail —
|
||
// PositionManager::UseTime (0x005156b0, call @0x005159b3,
|
||
// right after CPartArray::HandleMovement, UNCONDITIONAL for
|
||
// every entity in both grounded and airborne branches): the
|
||
// sticky 1 s lease watchdog (StickyManager::UseTime
|
||
// 0x00555610 — a stick not re-issued by a fresh server arm
|
||
// within 1 s tears itself down). No-op while nothing is stuck.
|
||
rm.Host?.PositionManager.UseTime();
|
||
}
|
||
|
||
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
|
||
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
|
||
if (ae.Sequencer is not null)
|
||
{
|
||
// Per-tick sequencer-state diag: prove whether the sequencer
|
||
// for the observed retail char actually holds the latest
|
||
// motion (= SetCycle landed) OR is stuck on an old motion
|
||
// (= something elsewhere is reverting). Throttled to once
|
||
// per second per remote.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||
&& serverGuid != 0
|
||
&& serverGuid != _playerServerGuid)
|
||
{
|
||
double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag)
|
||
&& nowSec - rmDiag.LastSeqStateLogTime > 1.0)
|
||
{
|
||
// D1 (2026-05-03): SEQSTATE has its own throttle clock
|
||
// (LastSeqStateLogTime) so it isn't silently swallowed by
|
||
// OMEGA_DIAG resetting LastOmegaDiagLogTime when the
|
||
// observed remote happens to be turning.
|
||
System.Console.WriteLine(
|
||
$"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} "
|
||
+ $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}");
|
||
|
||
// #39 fix-3 evidence (2026-05-06): CURRNODE proves
|
||
// whether _currNode is actually on the cycle (anim
|
||
// ref hash matches FirstCyclic) or stuck somewhere
|
||
// else. SCFULL captures _currNode==_firstCyclic only
|
||
// at SetCycle return; this captures it per render
|
||
// tick so we can see if something resets it later.
|
||
var d = ae.Sequencer.CurrentNodeDiag;
|
||
int firstHash = ae.Sequencer.FirstCyclicAnimRefHash;
|
||
System.Console.WriteLine(
|
||
$"[CURRNODE] guid={serverGuid:X8} "
|
||
+ $"animRef=0x{d.AnimRefHash:X8} firstCyclicAnimRef=0x{firstHash:X8} "
|
||
+ $"isOnCyclic={d.AnimRefHash == firstHash && firstHash != 0} "
|
||
+ $"isLooping={d.IsLooping} fr={d.Framerate:F2} "
|
||
+ $"frame=[{d.StartFrame}..{d.EndFrame}] "
|
||
+ $"pos={d.FramePosition:F2} qCount={d.QueueCount}");
|
||
|
||
rmDiag.LastSeqStateLogTime = nowSec;
|
||
}
|
||
}
|
||
seqFrames = ae.Sequencer.Advance(dt);
|
||
|
||
// R3-W2: bind the MotionDone seam once per sequencer to the
|
||
// entity's REAL consumer — the MotionInterpreter's
|
||
// pending_motions pop (retail CPhysicsObj::MotionDone
|
||
// 0x0050fdb0 → MovementManager → CMotionInterp::MotionDone
|
||
// 0x00527ec0; r3-port-plan.md §4). Remotes bind their
|
||
// RemoteMotion interp; the local player binds the
|
||
// controller's; interp-less entities (doors, statics) keep
|
||
// the diagnostic recorder only.
|
||
if (ae.Sequencer.MotionDoneTarget is null)
|
||
{
|
||
uint mdGuid = serverGuid;
|
||
// Resolve the interp AT FIRE TIME, not bind time: a
|
||
// remote's RemoteMotion is created on its first UM/UP,
|
||
// which can arrive AFTER the first anim tick — an
|
||
// eagerly-captured null would silently drop completions
|
||
// forever. MotionDone fires per completed motion (rare),
|
||
// so the dictionary lookup is negligible.
|
||
ae.Sequencer.MotionDoneTarget = (m, ok) =>
|
||
{
|
||
AcDream.Core.Physics.MotionInterpreter? interp = null;
|
||
if (mdGuid != 0 && mdGuid == _playerServerGuid)
|
||
interp = _playerController?.Motion;
|
||
else if (mdGuid != 0
|
||
&& _remoteDeadReckon.TryGetValue(mdGuid, out var rmFire))
|
||
interp = rmFire.Motion;
|
||
|
||
interp?.MotionDone(m, ok);
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1")
|
||
System.Console.WriteLine(
|
||
$"[MOTIONDONE] guid={mdGuid:X8} motion=0x{m:X8} success={ok} "
|
||
+ $"pending={(interp?.MotionsPending() ?? false)}");
|
||
};
|
||
}
|
||
|
||
// Phase E.1: drain animation hooks (footstep sounds, attack
|
||
// damage frames, particle spawns, part swaps, etc.) and fan
|
||
// them out to registered subsystems via the hook router.
|
||
// Mirrors ACE's PhysicsObj.add_anim_hook dispatch path.
|
||
//
|
||
// R2-Q4 queue-drain wiring (r2-port-plan.md §4, the G6 seam;
|
||
// per-tick PLACEMENT provisional until R6 installs retail's
|
||
// UpdateObjectInternal order): each drained AnimDone hook
|
||
// advances the entity's MotionTableManager countdown
|
||
// (retail AnimDoneHook::Execute 0x00526c20 → Hook_AnimDone
|
||
// 0x0050fda0 → CPartArray::AnimationDone(1)), and UseTime
|
||
// runs once per tick to sweep zero-tick queue entries
|
||
// (retail call sites 0x00517d57/0x00517d67).
|
||
var hooks = ae.Sequencer.ConsumePendingHooks();
|
||
if (hooks.Count > 0)
|
||
{
|
||
System.Numerics.Vector3 worldPos = ae.Entity.Position;
|
||
for (int hi = 0; hi < hooks.Count; hi++)
|
||
{
|
||
var hook = hooks[hi];
|
||
if (hook is null) continue;
|
||
_hookRouter.OnHook(ae.Entity.Id, worldPos, hook);
|
||
if (hook is DatReaderWriter.Types.AnimationDoneHook)
|
||
ae.Sequencer.Manager.AnimationDone(success: true);
|
||
}
|
||
}
|
||
ae.Sequencer.Manager.UseTime();
|
||
}
|
||
else
|
||
{
|
||
// Legacy path (entities without a MotionTable / sequencer).
|
||
int span = ae.HighFrame - ae.LowFrame;
|
||
if (span <= 0) continue;
|
||
ae.CurrFrame += dt * ae.Framerate;
|
||
if (ae.CurrFrame > ae.HighFrame)
|
||
{
|
||
float over = ae.CurrFrame - ae.LowFrame;
|
||
ae.CurrFrame = ae.LowFrame + (over % (span + 1));
|
||
}
|
||
else if (ae.CurrFrame < ae.LowFrame)
|
||
ae.CurrFrame = ae.LowFrame;
|
||
}
|
||
|
||
int partCount = ae.PartTemplate.Count;
|
||
|
||
// D5 (Commit A 2026-05-03): PARTSDIAG — proves whether
|
||
// PartTemplate.Count diverges from seqFrames.Count (silent
|
||
// identity-quat fallback freezes parts → H5) and whether the
|
||
// per-part frames returned by Advance actually change between
|
||
// Walk and Run cycles. The seqFrames hash is a sum-of-components
|
||
// proxy: cheap, unitless, monotonically distinct between cycles
|
||
// for any non-degenerate animation. If [PARTSDIAG] shows the
|
||
// hash unchanged across a Walk→Run transition while [SEQSTATE]
|
||
// shows CurrentMotion flipping, the sequencer is serving stale
|
||
// frames despite the cycle being correct.
|
||
if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1"
|
||
&& serverGuid != 0
|
||
&& serverGuid != _playerServerGuid
|
||
&& _remoteDeadReckon.TryGetValue(serverGuid, out var rmParts))
|
||
{
|
||
double nowSecParts = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds;
|
||
if (nowSecParts - rmParts.LastPartsDiagLogTime > 1.0)
|
||
{
|
||
int seqCount = seqFrames?.Count ?? -1;
|
||
int setupParts = ae.Setup.Parts.Count;
|
||
// #62: B.4c introduced `Animation = null!` for sequencer-driven
|
||
// door entities (parallel branch sites at line 2867 + 7892).
|
||
// Doors don't currently enter _remoteDeadReckon, so this
|
||
// path isn't reachable for them today — but the read was
|
||
// unguarded and would NRE the moment something flips.
|
||
// Local-var + `is not null` keeps the guard scoped: the
|
||
// legacy slerp branch below treats ae.Animation as non-
|
||
// null (per its non-nullable declared type) unchanged.
|
||
var animMaybeNull = ae.Animation;
|
||
int animFrame0Parts =
|
||
animMaybeNull is not null && animMaybeNull.PartFrames.Count > 0
|
||
? animMaybeNull.PartFrames[0].Frames.Count
|
||
: -1;
|
||
double seqHash = 0.0;
|
||
if (seqFrames is not null)
|
||
{
|
||
for (int hi = 0; hi < seqFrames.Count; hi++)
|
||
{
|
||
var f = seqFrames[hi];
|
||
seqHash += f.Origin.X + f.Origin.Y + f.Origin.Z
|
||
+ f.Orientation.X + f.Orientation.Y
|
||
+ f.Orientation.Z + f.Orientation.W;
|
||
}
|
||
}
|
||
System.Console.WriteLine(
|
||
$"[PARTSDIAG] guid={serverGuid:X8} "
|
||
+ $"pt.Count={partCount} seqFrames.Count={seqCount} "
|
||
+ $"setup.Parts.Count={setupParts} "
|
||
+ $"anim.PartFrames[0].Frames.Count={animFrame0Parts} "
|
||
+ $"seqHash={seqHash:F4}");
|
||
rmParts.LastPartsDiagLogTime = nowSecParts;
|
||
}
|
||
}
|
||
|
||
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
|
||
var scaleMat = ae.Scale == 1.0f
|
||
? System.Numerics.Matrix4x4.Identity
|
||
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
|
||
|
||
for (int i = 0; i < partCount; i++)
|
||
{
|
||
System.Numerics.Vector3 origin;
|
||
System.Numerics.Quaternion orientation;
|
||
|
||
if (seqFrames is not null)
|
||
{
|
||
// Sequencer path.
|
||
if (i < seqFrames.Count)
|
||
{
|
||
origin = seqFrames[i].Origin;
|
||
orientation = seqFrames[i].Orientation;
|
||
}
|
||
else
|
||
{
|
||
origin = System.Numerics.Vector3.Zero;
|
||
orientation = System.Numerics.Quaternion.Identity;
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Legacy slerp path.
|
||
int frameIdx = (int)Math.Floor(ae.CurrFrame);
|
||
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|
||
|| frameIdx >= ae.Animation.PartFrames.Count)
|
||
frameIdx = ae.LowFrame;
|
||
int nextIdx = frameIdx + 1;
|
||
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
|
||
nextIdx = ae.LowFrame;
|
||
float t = ae.CurrFrame - frameIdx;
|
||
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
|
||
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
|
||
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
|
||
if (i < partFrames.Count)
|
||
{
|
||
var f0 = partFrames[i];
|
||
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
|
||
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
|
||
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
|
||
}
|
||
else
|
||
{
|
||
origin = System.Numerics.Vector3.Zero;
|
||
orientation = System.Numerics.Quaternion.Identity;
|
||
}
|
||
}
|
||
|
||
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
|
||
// composition order: scale → rotate → translate.
|
||
var defaultScale = i < ae.Setup.DefaultScale.Count
|
||
? ae.Setup.DefaultScale[i]
|
||
: System.Numerics.Vector3.One;
|
||
|
||
var partTransform =
|
||
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
|
||
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
|
||
System.Numerics.Matrix4x4.CreateTranslation(origin);
|
||
|
||
// Bake the entity's ObjScale on top, matching the hydration
|
||
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
|
||
if (ae.Scale != 1.0f)
|
||
partTransform = partTransform * scaleMat;
|
||
|
||
var template = ae.PartTemplate[i];
|
||
if (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)
|
||
{
|
||
continue;
|
||
}
|
||
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
|
||
{
|
||
SurfaceOverrides = template.SurfaceOverrides,
|
||
});
|
||
}
|
||
|
||
ae.Entity.MeshRefs = newMeshRefs;
|
||
}
|
||
}
|
||
|
||
// 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);
|
||
|
||
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||
ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo);
|
||
|
||
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(_particleSystem, camera, camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene);
|
||
|
||
EnableClipDistances();
|
||
_terrainCpuStopwatch.Restart();
|
||
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
|
||
_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);
|
||
|
||
_gl!.BindBufferBase(BufferTargetARB.UniformBuffer,
|
||
ClipFrame.TerrainClipUboBinding, _clipFrame!.TerrainUbo);
|
||
|
||
// 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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId != 0
|
||
&& _outdoorSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||
}
|
||
|
||
// 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(_particleSystem, 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;
|
||
}
|
||
|
||
_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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId != 0
|
||
&& _visibleSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||
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.Draw(
|
||
_particleSystem,
|
||
camera,
|
||
camPos,
|
||
AcDream.Core.Vfx.ParticleRenderPass.Scene,
|
||
emitter => emitter.AttachedObjectId != 0
|
||
&& _dynamicsSceneParticleEntityIds.Contains(emitter.AttachedObjectId));
|
||
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 BeginLiveSessionAsync 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";
|
||
// 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
|
||
// branch in BeginLiveSessionAsync 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 goes via the
|
||
// window property; resolution + fullscreen go through
|
||
// ApplyDisplayWindowState which is shared with the on-Save path.
|
||
if (_window is not null)
|
||
{
|
||
if (_window.VSync != _persistedDisplay.VSync)
|
||
_window.VSync = _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: disposes the old
|
||
/// <see cref="_streamingController"/> + <see cref="_streamer"/>
|
||
/// and constructs new ones with the new radii.</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);
|
||
|
||
// Streaming radii — requires tearing down + rebuilding the controller
|
||
// (radii are constructor-time on StreamingController, not live-mutable).
|
||
// The ~1-2s hitch while the worker drains is acceptable for a settings change.
|
||
if (_streamer is not null && _streamingController is not null)
|
||
{
|
||
_nearRadius = newResolved.NearRadius;
|
||
_farRadius = newResolved.FarRadius;
|
||
|
||
// StreamingController is stateless (no Dispose needed); dispose
|
||
// only the LandblockStreamer worker thread.
|
||
_streamer.Dispose();
|
||
|
||
_streamer = new AcDream.App.Streaming.LandblockStreamer(
|
||
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
|
||
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;
|
||
return AcDream.Core.Terrain.LandblockMesh.Build(
|
||
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
|
||
});
|
||
_streamer.Start();
|
||
|
||
_streamingController = new AcDream.App.Streaming.StreamingController(
|
||
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
|
||
enqueueUnload: _streamer.EnqueueUnload,
|
||
drainCompletions: _streamer.DrainCompletions,
|
||
applyTerrain: ApplyLoadedTerrain,
|
||
state: _worldState,
|
||
nearRadius: _nearRadius,
|
||
farRadius: _farRadius,
|
||
clearPendingLoads: _streamer.ClearPendingLoads,
|
||
removeTerrain: id =>
|
||
{
|
||
if (_lightingSink is not null &&
|
||
_worldState.TryGetLandblock(id, out var lb))
|
||
{
|
||
foreach (var ent in lb!.Entities)
|
||
_lightingSink.UnregisterOwner(ent.Id);
|
||
}
|
||
_terrain?.RemoveLandblock(id);
|
||
_physicsEngine.RemoveLandblock(id);
|
||
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
|
||
_buildingRegistries.Remove(id & 0xFFFF0000u); // Phase A8 (key normalization fix 2026-05-28)
|
||
_envCellRenderer?.RemoveLandblock(id); // Phase A8
|
||
});
|
||
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
|
||
|
||
Console.WriteLine(
|
||
$"[QUALITY] Streaming restarted: 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 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 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)
|
||
{
|
||
bool wcm = DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
|
||
_mouseLook.Press(_lastMouseX, _lastMouseY, wcm);
|
||
if (_mouseLook.Active) HideCursorForMouseLook();
|
||
}
|
||
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
|
||
{
|
||
bool wasActive = _mouseLook.Active;
|
||
_mouseLook.Release();
|
||
if (wasActive) RestoreCursorAfterMouseLook();
|
||
}
|
||
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;
|
||
}
|
||
|
||
// 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;
|
||
|
||
// 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.
|
||
_uiHost?.ToggleWindow(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.SelectionClosestMonster:
|
||
SelectClosestCombatTarget(showToast: true);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
|
||
ToggleLiveCombatMode();
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.CombatLowAttack:
|
||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Low);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.CombatMediumAttack:
|
||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.Medium);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.CombatHighAttack:
|
||
SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction.High);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.SelectLeft:
|
||
PickAndStoreSelection(useImmediately: false);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.SelectDblLeft:
|
||
PickAndStoreSelection(useImmediately: true);
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.UseSelected:
|
||
UseCurrentSelection();
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.SelectionPickUp:
|
||
if (_selectedGuid is uint pickupTarget)
|
||
SendPickUp(pickupTarget);
|
||
else
|
||
_debugVm?.AddToast("Nothing selected");
|
||
break;
|
||
|
||
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||
if (_cameraController?.IsFlyMode == true)
|
||
_cameraController.ToggleFly(); // exit fly, release cursor
|
||
else if (_playerMode)
|
||
{
|
||
_playerMode = false;
|
||
_cameraController?.ExitChaseMode();
|
||
_playerController = null;
|
||
_chaseCamera = null;
|
||
_retailChaseCamera = null;
|
||
}
|
||
else
|
||
_window!.Close();
|
||
break;
|
||
}
|
||
}
|
||
|
||
private void ToggleLiveCombatMode()
|
||
{
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
return;
|
||
|
||
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(Combat.CurrentMode);
|
||
_liveSession.SendChangeCombatMode(nextMode);
|
||
Combat.SetCombatMode(nextMode);
|
||
string text = $"Combat mode {nextMode}";
|
||
Console.WriteLine($"combat: {text}");
|
||
_debugVm?.AddToast(text);
|
||
}
|
||
|
||
private void SendLiveCombatAttack(AcDream.Core.Combat.CombatAttackAction action)
|
||
{
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
return;
|
||
|
||
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;
|
||
}
|
||
|
||
uint? target = GetSelectedOrClosestCombatTarget();
|
||
if (target is null)
|
||
{
|
||
_debugVm?.AddToast("No monster target");
|
||
Console.WriteLine("combat: attack ignored; no creature target found");
|
||
return;
|
||
}
|
||
|
||
var height = AcDream.Core.Combat.CombatInputPlanner.HeightFor(action);
|
||
const float FullBar = 1.0f;
|
||
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
|
||
{
|
||
_liveSession.SendMissileAttack(target.Value, height, FullBar);
|
||
Console.WriteLine($"combat: missile attack target=0x{target.Value:X8} height={height} accuracy={FullBar:F2}");
|
||
}
|
||
else
|
||
{
|
||
_liveSession.SendMeleeAttack(target.Value, height, FullBar);
|
||
Console.WriteLine($"combat: melee attack target=0x{target.Value:X8} height={height} power={FullBar:F2}");
|
||
}
|
||
}
|
||
|
||
private uint? GetSelectedOrClosestCombatTarget()
|
||
{
|
||
if (_selectedGuid is { } selected && IsLiveCreatureTarget(selected))
|
||
return selected;
|
||
|
||
return SelectClosestCombatTarget(showToast: false);
|
||
}
|
||
|
||
// ============================================================
|
||
// Phase B.4b — outbound Use handler. Wires three input actions
|
||
// (LMB click select, LMB-double-click select+use, R hotkey
|
||
// use-selected) through WorldPicker into InteractRequests.BuildUse.
|
||
// The inbound reply (SetState 0xF74B) lands via L.2g slice 1.
|
||
// ============================================================
|
||
|
||
private void PickAndStoreSelection(bool useImmediately)
|
||
{
|
||
if (_cameraController is null || _window is null) return;
|
||
|
||
// 2026-05-16 — retail-faithful screen-rect picker. The hit area
|
||
// is the same screen-space rect the target indicator draws
|
||
// (computed via the shared AcDream.Core.Selection.ScreenProjection
|
||
// helper). Per user feedback 2026-05-16: clicking the indicator
|
||
// brackets — including the rect corners — must select the entity.
|
||
// The per-type radius/offset heuristics retired here (1.0/1.6/2.0
|
||
// m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
|
||
// existed to make a 3D ray-sphere picker approximate the visible
|
||
// rect; the new picker doesn't need them.
|
||
var camera = _cameraController.Active;
|
||
var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
|
||
|
||
// Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
|
||
// cached EnvCell physics so the picker can occlude entities behind
|
||
// walls. Snapshot is per-pick (one click), iteration is bounded
|
||
// by the streaming radius (~80 cells at radius 4).
|
||
var loadedCellPhysics = new List<AcDream.Core.Physics.CellPhysics>();
|
||
foreach (var cellId in _physicsDataCache.CellStructIds)
|
||
{
|
||
var cp = _physicsDataCache.GetCellStruct(cellId);
|
||
if (cp is not null) loadedCellPhysics.Add(cp);
|
||
}
|
||
|
||
var picked = AcDream.Core.Selection.WorldPicker.Pick(
|
||
mouseX: _lastMouseX, mouseY: _lastMouseY,
|
||
view: camera.View, projection: camera.Projection,
|
||
viewport: viewport,
|
||
candidates: _entitiesByServerGuid.Values,
|
||
skipServerGuid: _playerServerGuid,
|
||
// Resolver: Setup's SelectionSphere is the ONLY input. If the
|
||
// entity's Setup didn't bake a SelectionSphere, return null —
|
||
// the picker skips it, which matches retail behaviour
|
||
// (Render::GfxObjUnderSelectionRay at 0x0054c740 skips
|
||
// candidates with no drawing_sphere data). Earlier defensive
|
||
// 1.5 m × scale synth was removed 2026-05-16 — it made
|
||
// dat-incomplete entities click as phantom hitboxes the size
|
||
// of an NPC, diverging from retail and masking real Setup-
|
||
// loading bugs.
|
||
sphereForEntity: e =>
|
||
TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
|
||
? ((System.Numerics.Vector3, float)?)(c, r)
|
||
: null,
|
||
// Match the indicator's TriangleSize (8 px) so the click area
|
||
// extends out to the bracket corners — what the user perceives
|
||
// as "selectable extent."
|
||
inflatePixels: 8f,
|
||
cellOccluder: loadedCellPhysics.Count > 0
|
||
? (origin, direction) =>
|
||
AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
|
||
: null);
|
||
|
||
if (picked is uint guid)
|
||
{
|
||
SelectedGuid = guid;
|
||
string label = DescribeLiveEntity(guid);
|
||
Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}");
|
||
// B.7 (2026-05-15): one-shot per-pick diagnostic so we can
|
||
// see exactly which ItemType + PWD bitfield bits + resolved
|
||
// RadarBlipColor are produced for the just-picked entity.
|
||
// Helps verify whether a "green NPC" really is flagged as
|
||
// Vendor server-side or whether our lookup is wrong.
|
||
uint rawItemType = (uint)LiveItemType(guid);
|
||
uint pwdBits = 0;
|
||
uint? pickUseability = null;
|
||
float? pickUseRadius = null;
|
||
float pickScale = 1f;
|
||
uint? pickSetupId = null;
|
||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||
{
|
||
if (spawn.ObjectDescriptionFlags is { } odf) pwdBits = odf;
|
||
pickUseability = spawn.Useability;
|
||
pickUseRadius = spawn.UseRadius;
|
||
pickScale = spawn.ObjScale ?? 1f;
|
||
pickSetupId = spawn.SetupTableId;
|
||
}
|
||
var col = AcDream.Core.Ui.RadarBlipColors.For(rawItemType, pwdBits);
|
||
string useStr = pickUseability.HasValue ? $"0x{pickUseability.Value:X4}" : "null";
|
||
string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null";
|
||
string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null";
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})"));
|
||
_debugVm?.AddToast($"Selected: {label}");
|
||
if (useImmediately) SendUse(guid);
|
||
}
|
||
else
|
||
{
|
||
_debugVm?.AddToast("Nothing to select");
|
||
}
|
||
}
|
||
|
||
private void UseCurrentSelection()
|
||
{
|
||
if (_selectedGuid is not uint sel)
|
||
{
|
||
_debugVm?.AddToast("Nothing selected");
|
||
return;
|
||
}
|
||
|
||
// 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
|
||
// key. Retail dispatches by TARGET TYPE first; the useability gate
|
||
// is enforced by each individual action handler (SendUse checks
|
||
// IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
|
||
// a top-level block. Previously the IsUseableTarget gate at the
|
||
// entry point rejected USEABLE_NO=1 items (spell components,
|
||
// gems) which retail accepts as pickupable — they just aren't
|
||
// "useable" in the activate-from-world sense.
|
||
//
|
||
// Dispatch order:
|
||
// 1. Creature → SendUse (talk to NPC, attack monster)
|
||
// 2. Pickupable → SendPickUp (small items, corpses)
|
||
// 3. Useable → SendUse (doors, portals, lifestones,
|
||
// potions / scrolls activated from world)
|
||
// 4. Else → toast "X cannot be used" (signs, banners,
|
||
// decorative scenery)
|
||
//
|
||
// Retail string at acclient_2013_pseudo_c.txt:1033115
|
||
// (data_7e2a70): "The %s cannot be used".
|
||
|
||
bool isCreature = (LiveItemType(sel) & AcDream.Core.Items.ItemType.Creature) != 0;
|
||
|
||
if (isCreature)
|
||
{
|
||
SendUse(sel);
|
||
return;
|
||
}
|
||
|
||
if (IsPickupableTarget(sel))
|
||
{
|
||
SendPickUp(sel);
|
||
return;
|
||
}
|
||
|
||
if (IsUseableTarget(sel))
|
||
{
|
||
SendUse(sel);
|
||
return;
|
||
}
|
||
|
||
string label = DescribeLiveEntity(sel);
|
||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
|
||
}
|
||
|
||
private void SendUse(uint guid)
|
||
{
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
{
|
||
_debugVm?.AddToast("Not in world");
|
||
return;
|
||
}
|
||
|
||
// Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455
|
||
// ItemUses::IsUseable). Signs / banners with useability=0 silently
|
||
// ignore Use.
|
||
if (!IsUseableTarget(guid))
|
||
{
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
|
||
return;
|
||
}
|
||
|
||
// B.6/R4-V5: install a speculative local TurnToObject/MoveToObject
|
||
// through the player's MoveToManager so close-range Use rotates the
|
||
// body to face before the action fires. For FAR targets, ACE's
|
||
// CreateMoveToChain (Player_Move.cs:37-179) takes over via inbound
|
||
// MovementType=6, whose PerformMovement re-targets with ACE's
|
||
// wire-supplied radius.
|
||
//
|
||
// Close-range deferral fires the wire packet ONCE on
|
||
// MoveToComplete(None) (turn-first done), not a retry of an
|
||
// earlier failed send. No re-send path.
|
||
bool closeRange = IsCloseRangeTarget(guid);
|
||
InstallSpeculativeTurnToTarget(guid);
|
||
|
||
if (closeRange)
|
||
{
|
||
// Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
|
||
// will fire it after rotation completes.
|
||
_pendingPostArrivalAction = (guid, false);
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
|
||
return;
|
||
}
|
||
|
||
// Far range: send Use ONCE. ACE's CreateMoveToChain
|
||
// (Player_Use.cs:205) holds a callback (TryUseItem) and fires
|
||
// it server-side when WithinUseRadius passes during the
|
||
// MoveToChain poll (Player_Move.cs:150). No client-side retry
|
||
// needed — the Phase B.6 MoveToState-suppression fix
|
||
// (GameWindow.cs:6410) keeps ACE's chain alive during the
|
||
// walk.
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||
_liveSession.SendGameAction(body);
|
||
Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
string label = DescribeLiveEntity(guid);
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}"));
|
||
}
|
||
}
|
||
|
||
// Phase D.5.1 — direct use-by-guid for toolbar shortcut clicks.
|
||
// Mirrors the B.4b far-range send path; no proximity / auto-walk needed
|
||
// for items already in the player's inventory.
|
||
private void UseItemByGuid(uint guid)
|
||
{
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
return;
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||
_liveSession.SendGameAction(body);
|
||
Console.WriteLine($"[D.5.1] toolbar use-item guid=0x{guid:X8} seq={seq}");
|
||
}
|
||
|
||
private void SendPickUp(uint itemGuid)
|
||
{
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
{
|
||
_debugVm?.AddToast("Not in world");
|
||
return;
|
||
}
|
||
|
||
// Creature-pickup block (with retail toast). Comes BEFORE the
|
||
// generic IsPickupableTarget gate so creatures get the specific
|
||
// "cannot pick up creatures!" message instead of the generic
|
||
// "can't be picked up!".
|
||
// Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4).
|
||
if (IsLiveCreatureTarget(itemGuid))
|
||
{
|
||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures);
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.5] SendPickUp ignored — creature item=0x{itemGuid:X8}");
|
||
return;
|
||
}
|
||
|
||
// Generic non-pickupable gate (signs, banners, decorative scenery).
|
||
// Retail string acclient_2013_pseudo_c.txt:401589 (sprintf
|
||
// "The %s can't be picked up!").
|
||
if (!IsPickupableTarget(itemGuid))
|
||
{
|
||
string label = DescribeLiveEntity(itemGuid);
|
||
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label));
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}");
|
||
return;
|
||
}
|
||
|
||
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
|
||
// SendUse — close-range pickup rotates locally to face the
|
||
// item first, then the wire packet fires when the local
|
||
// overlay reports arrival.
|
||
//
|
||
// 2026-05-16: simplified — FIRST send on arrival, not a retry.
|
||
bool closeRange = IsCloseRangeTarget(itemGuid);
|
||
InstallSpeculativeTurnToTarget(itemGuid);
|
||
|
||
if (closeRange)
|
||
{
|
||
_pendingPostArrivalAction = (itemGuid, true);
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
|
||
return;
|
||
}
|
||
|
||
// Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
|
||
// callback pattern as SendUse — ACE's chain fires
|
||
// PutItemInContainer/Move server-side when in range. No
|
||
// client-side retry; Phase B.6 MoveToState suppression keeps
|
||
// ACE's chain alive.
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||
seq, itemGuid, _playerServerGuid, placement: 0);
|
||
_liveSession.SendGameAction(body);
|
||
Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
|
||
{
|
||
string label = DescribeLiveEntity(itemGuid);
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}"));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-05-16 / R4-V5. Fires the deferred close-range Use/PickUp action
|
||
/// once the player's moveto completes naturally (the MoveToManager's
|
||
/// <c>MoveToComplete(None)</c> client seam — the body has finished
|
||
/// rotating to face / walking to the target; a user-input cancel never
|
||
/// fires it). This is a FIRST send — not a retry of an earlier failed
|
||
/// send. Far-range Use/PickUp paths fire the wire packet immediately at
|
||
/// <see cref="SendUse"/>/<see cref="SendPickUp"/> time and never touch
|
||
/// <c>_pendingPostArrivalAction</c>.
|
||
/// </summary>
|
||
private void OnAutoWalkArrivedSendDeferredAction()
|
||
{
|
||
if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending)
|
||
return;
|
||
_pendingPostArrivalAction = null;
|
||
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
return;
|
||
|
||
var seq = _liveSession.NextGameActionSequence();
|
||
if (isPickup)
|
||
{
|
||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
|
||
seq, guid, _playerServerGuid, placement: 0);
|
||
_liveSession.SendGameAction(body);
|
||
Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
|
||
}
|
||
else
|
||
{
|
||
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
|
||
_liveSession.SendGameAction(body);
|
||
Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// B.6 (2026-05-15). Install a local auto-walk overlay against the
|
||
/// given target entity at Use / PickUp send time. ACE's response
|
||
/// branches by distance:
|
||
/// <list type="bullet">
|
||
/// <item>Far target → ACE broadcasts a <c>MovementType=6</c>
|
||
/// MoveToObject which arrives shortly after and overwrites
|
||
/// our speculative state with ACE's wire-supplied use-radius
|
||
/// and origin. No conflict; same target either way.</item>
|
||
/// <item>Close target → ACE skips MoveToChain (WithinUseRadius
|
||
/// shortcut at <c>Player_Move.cs:66</c>) and rotates the body
|
||
/// server-side via <c>Rotate(target)</c>. ACE doesn't broadcast
|
||
/// anything actionable to us, so our pre-installed overlay
|
||
/// handles the local rotation — body turns to face the target
|
||
/// in place, then ends.</item>
|
||
/// </list>
|
||
/// <para>
|
||
/// Per-type use radius mirrors the picker's radius heuristic:
|
||
/// 3 m for creatures, 2 m for doors / lifestones / portals,
|
||
/// 0.6 m for everything else (ground items).
|
||
/// </para>
|
||
/// </summary>
|
||
/// <summary>
|
||
/// B.6 (2026-05-15). True if the local player is already inside the
|
||
/// target's use radius right now — i.e. ACE will NOT auto-walk us
|
||
/// when we send the action. Used to gate the close-range deferral
|
||
/// in SendUse / SendPickUp: when close, we hold the wire packet
|
||
/// until our local rotation overlay reports alignment, then fire.
|
||
/// </summary>
|
||
private bool IsCloseRangeTarget(uint targetGuid)
|
||
{
|
||
if (_playerController is null) return false;
|
||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
||
return false;
|
||
|
||
// Mirror InstallSpeculativeTurnToTarget's per-type radius heuristic.
|
||
float useRadius = 0.6f;
|
||
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
||
{
|
||
useRadius = 3.0f;
|
||
}
|
||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||
{
|
||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
|
||
}
|
||
|
||
var bodyPos = _playerController.Position;
|
||
float dx = entity.Position.X - bodyPos.X;
|
||
float dy = entity.Position.Y - bodyPos.Y;
|
||
float distSq = dx * dx + dy * dy;
|
||
return distSq <= useRadius * useRadius;
|
||
}
|
||
|
||
private void InstallSpeculativeTurnToTarget(uint targetGuid)
|
||
{
|
||
if (_playerController is not { } pc || pc.MoveTo is null) return;
|
||
if (!_entitiesByServerGuid.TryGetValue(targetGuid, out var entity))
|
||
return;
|
||
|
||
// Per-type use radius — same heuristic as the picker's
|
||
// radiusForGuid callback (register AP-23, re-anchored here by
|
||
// R4-V5; survives because ACE's close-branch broadcasts nothing
|
||
// actionable).
|
||
float useRadius = 0.6f;
|
||
if ((LiveItemType(targetGuid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
||
{
|
||
useRadius = 3.0f;
|
||
}
|
||
else if (_lastSpawnByGuid.TryGetValue(targetGuid, out var spawn)
|
||
&& spawn.ObjectDescriptionFlags is { } odf)
|
||
{
|
||
// BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE
|
||
const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
|
||
}
|
||
|
||
// Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
|
||
// from local distance so the speculative moveto uses the
|
||
// same walk/run as the wire-triggered overwrite that arrives
|
||
// moments later. ACE's Creature.SetWalkRunThreshold sets
|
||
// CanCharge when player→target distance >= WalkRunThreshold /
|
||
// 2 = 7.5 m (the 15 m wire default halved). Match exactly so
|
||
// the speculative install doesn't flip walk↔run when ACE's
|
||
// MoveToObject broadcast overwrites it (PerformMovement
|
||
// cancels + restarts — retail-consistent re-target).
|
||
const float AceCanChargeDistance = 7.5f;
|
||
var bodyPos = _playerController.Position;
|
||
float ddx = entity.Position.X - bodyPos.X;
|
||
float ddy = entity.Position.Y - bodyPos.Y;
|
||
float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
|
||
bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
|
||
|
||
// R4-V5: retail's client-initiated use flow issues TurnToObject /
|
||
// MoveToObject through the SAME manager the wire path uses (decomp
|
||
// §9a/§9b callers) — in-range targets get a pure turn-to-face;
|
||
// out-of-range targets get the local moveto that ACE's mt-6
|
||
// broadcast will re-target moments later. MovementParameters ctor
|
||
// defaults (0x1EE0F: MoveTowards, UseSpheres, threshold 15,
|
||
// MinDistance 0) match the old BeginServerAutoWalk install's
|
||
// semantics; only the AP-23 radius + the #77 CanCharge prediction
|
||
// are non-default.
|
||
var p = new AcDream.Core.Physics.Motion.MovementParameters
|
||
{
|
||
DistanceToObject = useRadius,
|
||
CanCharge = speculativeCanCharge,
|
||
};
|
||
var ms = new AcDream.Core.Physics.MovementStruct
|
||
{
|
||
ObjectId = targetGuid,
|
||
TopLevelId = targetGuid,
|
||
Pos = new AcDream.Core.Physics.Position(
|
||
_playerController.CellId, entity.Position,
|
||
System.Numerics.Quaternion.Identity),
|
||
Params = p,
|
||
Type = IsCloseRangeTarget(targetGuid)
|
||
? AcDream.Core.Physics.MovementType.TurnToObject
|
||
: AcDream.Core.Physics.MovementType.MoveToObject,
|
||
};
|
||
// R5-V3 (#171, diff-review find): retail resolves the TARGET's
|
||
// PartArray radius/height at EVERY MoveToObject call site — the
|
||
// client-initiated use flow included (CPhysicsObj::MoveToObject
|
||
// 0x005128e9/0x00512903), not just the wire mt-6 route. Without
|
||
// this, the player's now-real own radius made the UseSpheres
|
||
// arrival hybrid (centerDist − playerRadius ≤ useRadius) — the
|
||
// auto-walk stopped ~one player-radius farther out than the AP-23
|
||
// constants intended, and the two MoveToObject sites disagreed.
|
||
(ms.Radius, ms.Height) = GetSetupCylinder(targetGuid, entity);
|
||
// Part of this install's AP-23 adaptation: store the autonomy flag
|
||
// exactly as the wire mt-6 this install anticipates would (the P1
|
||
// unpack store, IsAutonomous=false) — without it the per-tick
|
||
// pump's A3 dispatch stays on the raw branch (the user's last input
|
||
// set it autonomous) and clobbers this moveto's dispatched motions
|
||
// with the idle raw state.
|
||
_playerController.SetLastMoveWasAutonomous(false);
|
||
// R5-V5: through the facade (MovementManager::PerformMovement
|
||
// 0x005240d0) — retail's client-initiated use flow reaches the same
|
||
// manager the wire path uses.
|
||
pc.Movement.PerformMovement(ms);
|
||
}
|
||
|
||
private uint? SelectClosestCombatTarget(bool showToast)
|
||
{
|
||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
|
||
return null;
|
||
|
||
uint? bestGuid = null;
|
||
float bestDistanceSq = float.PositiveInfinity;
|
||
foreach (var (guid, entity) in _entitiesByServerGuid)
|
||
{
|
||
if (!IsLiveCreatureTarget(guid))
|
||
continue;
|
||
|
||
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
||
entity.Position,
|
||
playerEntity.Position);
|
||
if (distanceSq >= bestDistanceSq)
|
||
continue;
|
||
|
||
bestDistanceSq = distanceSq;
|
||
bestGuid = guid;
|
||
}
|
||
|
||
SelectedGuid = bestGuid;
|
||
if (bestGuid is { } selected)
|
||
{
|
||
string label = DescribeLiveEntity(selected);
|
||
float distance = MathF.Sqrt(bestDistanceSq);
|
||
Console.WriteLine($"combat: selected target 0x{selected:X8} {label} dist={distance:F1}");
|
||
if (showToast)
|
||
_debugVm?.AddToast($"Target {label}");
|
||
}
|
||
else if (showToast)
|
||
{
|
||
_debugVm?.AddToast("No monster target");
|
||
Console.WriteLine("combat: no creature target found");
|
||
}
|
||
|
||
return bestGuid;
|
||
}
|
||
|
||
private bool IsLiveCreatureTarget(uint guid)
|
||
{
|
||
if (guid == _playerServerGuid)
|
||
return false;
|
||
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||
return false;
|
||
|
||
return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
||
}
|
||
|
||
// PublicWeenieDesc _bitfield flags (acclient.h:6431-6463) — same bitfield RadarBlipColors reads.
|
||
private const uint BfPlayer = 0x8u; // BF_PLAYER (acclient.h:6434)
|
||
private const uint BfAttackable = 0x10u; // BF_ATTACKABLE (acclient.h:6437)
|
||
|
||
/// <summary>
|
||
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
||
/// Approximates retail's <c>IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()</c>
|
||
/// gate (gmToolbarUI::HandleSelectionChanged :198754) using the server-provided PWD flags:
|
||
/// the <c>BF_ATTACKABLE</c> bit (monsters) or the <c>BF_PLAYER</c> bit (other players).
|
||
/// A friendly NPC (e.g. a vendor) has neither bit set → name-only, matching retail.
|
||
/// The full PK/faction logic of ObjectIsAttackable + the pet case are not ported (divergence AP-46).
|
||
/// </summary>
|
||
private bool IsHealthBarTarget(uint guid)
|
||
{
|
||
if (guid == _playerServerGuid)
|
||
return false;
|
||
if (!_entitiesByServerGuid.ContainsKey(guid))
|
||
return false;
|
||
|
||
uint pwd = _lastSpawnByGuid.TryGetValue(guid, out var spawn)
|
||
&& spawn.ObjectDescriptionFlags is { } odf ? odf : 0u;
|
||
|
||
// Another player → health bar (retail IsPlayer branch).
|
||
if ((pwd & BfPlayer) != 0)
|
||
return true;
|
||
|
||
// Attackable branch: retail ObjectIsAttackable requires the object to be a CREATURE
|
||
// first (InqType() & 0x10, acclient_2013_pseudo_c.txt:375406), THEN attackable. A Door
|
||
// carries the BF_ATTACKABLE bit but is ItemType Misc, so it is never a health-bar target —
|
||
// require the Creature flag here too (matches retail; excludes attackable doors/objects).
|
||
bool isCreature = (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
||
return isCreature && (pwd & BfAttackable) != 0;
|
||
}
|
||
|
||
|
||
/// <summary>
|
||
/// 2026-05-16 — retail-faithful port of
|
||
/// <c>SmartBox::GetObjectBoundingBox</c> (decomp <c>0x00452e20</c>)
|
||
/// using <c>CPhysicsObj::GetSelectionSphere</c> (<c>0x0050ea40</c>)
|
||
/// → <c>CPartArray::GetSelectionSphere</c> (<c>0x00518b80</c>).
|
||
///
|
||
/// <para>
|
||
/// Retail's VividTargetIndicator does NOT use a per-mesh AABB —
|
||
/// it uses the Setup's <c>selection_sphere</c> field (a single
|
||
/// sphere encompassing the entire entity, baked at Setup-creation
|
||
/// time). The sphere is scaled by the part-array scale
|
||
/// (component-wise on center, Z-scale on radius — retail's exact
|
||
/// formula at <c>0x00518ba6–0x00518be3</c>) and rotated by entity
|
||
/// orientation. The screen indicator rect is the projection of
|
||
/// the camera-aligned BBox of this sphere — i.e. a screen circle
|
||
/// of radius <c>worldRadius * focalLength / depth</c>.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Result: the indicator rect MATCHES the Setup's intended
|
||
/// "selectable extent" — which is typically larger than the mesh
|
||
/// AABB by design (Setups bake a slightly oversized selection
|
||
/// sphere so far targets still get pickable indicators). That's
|
||
/// why our previous mesh-AABB indicator was smaller than retail's.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool TryGetEntitySelectionSphere(uint guid,
|
||
out System.Numerics.Vector3 worldCenter,
|
||
out float worldRadius)
|
||
{
|
||
worldCenter = default;
|
||
worldRadius = 0f;
|
||
|
||
if (!_entitiesByServerGuid.TryGetValue(guid, out var entity)) return false;
|
||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn)) return false;
|
||
if (spawn.SetupTableId is not uint setupId) return false;
|
||
if (_dats is null) return false;
|
||
if (!_dats.TryGet<DatReaderWriter.DBObjs.Setup>(setupId, out var setup)) return false;
|
||
|
||
// DAT Setup carries `SelectionSphere` (Origin + Radius). A zero
|
||
// radius means the Setup didn't bake one — fall back to the
|
||
// caller's other path.
|
||
var sel = setup.SelectionSphere;
|
||
if (sel is null || sel.Radius <= 1e-4f) return false;
|
||
|
||
// Retail GetSelectionSphere applies part-array scale to the
|
||
// sphere center (component-wise) and to the radius (Z-scale
|
||
// only). For uniform entity scale these coincide.
|
||
float scale = entity.Scale > 0f ? entity.Scale : 1f;
|
||
var localCenter = new System.Numerics.Vector3(
|
||
sel.Origin.X * scale,
|
||
sel.Origin.Y * scale,
|
||
sel.Origin.Z * scale);
|
||
|
||
// Setup-local center → world. Entity rotation applies; entity
|
||
// position is the world origin of the setup.
|
||
var rot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation);
|
||
var rotated = System.Numerics.Vector3.Transform(localCenter, rot);
|
||
worldCenter = entity.Position + rotated;
|
||
worldRadius = sel.Radius * scale;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-05-15. Retail-faithful gate for R-key Use / F-key PickUp.
|
||
/// Returns true when the entity is interactable from the world per
|
||
/// the server-supplied <c>ITEM_USEABLE _useability</c> field
|
||
/// (acclient.h:6478) — specifically when the <c>USEABLE_REMOTE
|
||
/// (0x20)</c> bit is set OR a composite form containing it
|
||
/// (<c>USEABLE_REMOTE_NEVER_WALK = 0x60</c>, the
|
||
/// <c>SOURCE_X_TARGET_REMOTE</c> variants in the 0x200000+ range).
|
||
///
|
||
/// <para>
|
||
/// Retail behaviour for non-useable entities (signs, banners,
|
||
/// decorative scenery): the R-key does nothing — no walk, no Use
|
||
/// packet, no toast. The retail client checks useability before
|
||
/// any action and silently ignores the press. We honor that with
|
||
/// a silent early return at the call site.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// <b>Fallback when useability is unknown.</b> The wire's
|
||
/// <c>weenieFlags & 0x10</c> bit gates whether ACE serializes
|
||
/// useability at all. If absent, <see cref="CreateObject.Parsed.Useability"/>
|
||
/// is null. Conservatively we permit Use for entities we've
|
||
/// historically been able to interact with — creatures, doors,
|
||
/// lifestones, portals, corpses — to avoid regressing the existing
|
||
/// M1 flows. Pure-scenery untyped entities (the sign case) fall
|
||
/// through to "blocked".
|
||
/// </para>
|
||
/// </summary>
|
||
private bool IsUseableTarget(uint guid)
|
||
{
|
||
if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||
{
|
||
// Authoritative path: server published Useability.
|
||
// 2026-05-16 — retail-faithful gate per ItemUses::IsUseable
|
||
// at acclient_2013_pseudo_c.txt:256455 (4 call-site cross-
|
||
// checks confirm: ItemHolder::UseObject 0x00588a80,
|
||
// DetermineUseResult 0x402697, UsingItem 0x367638,
|
||
// disable-button state 0x198826 — all key off non-zero).
|
||
// BN's `!(x) & 1` rendering is a mis-decompile of the
|
||
// setne+and test-flag inliner. Real semantic:
|
||
//
|
||
// IsUseable(_useability) := (_useability != USEABLE_UNDEF)
|
||
//
|
||
// ANY non-zero value passes (including USEABLE_NO=1,
|
||
// USEABLE_CONTAINED=8, etc.). Retail trusts the server to
|
||
// have only set non-zero on entities where Use is sensible.
|
||
//
|
||
// Previous implementation (B.8) checked
|
||
// `(useability & USEABLE_REMOTE_BIT) != 0` which is STRICTER
|
||
// than retail — a USEABLE_NO door would be blocked locally
|
||
// but pass retail's gate. Now matches retail bit-for-bit.
|
||
if (spawn.Useability is uint useability)
|
||
{
|
||
// Retail-faithful Use gate per acclient_2013_pseudo_c.txt:256455
|
||
// ItemUses::IsUseable: non-zero useability passes. But two
|
||
// values produce "cannot be used" client-side without a
|
||
// wire send in retail's observable behaviour:
|
||
// USEABLE_UNDEF (0): server's Use handler would reject;
|
||
// retail UseObject path shows "cannot be used" toast.
|
||
// USEABLE_NO (1): explicitly not useable — same outcome.
|
||
// Both come from acclient.h:6478 ITEM_USEABLE enum.
|
||
//
|
||
// Retail technically sends the packet for USEABLE_NO (the
|
||
// audit's `IsUseable != 0` reading is correct), but ACE
|
||
// never broadcasts MovementType=6 for it, so retail
|
||
// doesn't visibly approach. Our client installs a
|
||
// speculative auto-walk overlay BEFORE the server
|
||
// response — so the only way to avoid "approach then fail"
|
||
// is to gate USEABLE_NO client-side. Net result matches
|
||
// user-observed retail behaviour.
|
||
const uint USEABLE_UNDEF = 0u;
|
||
const uint USEABLE_NO = 1u;
|
||
if (useability == USEABLE_UNDEF || useability == USEABLE_NO)
|
||
return false;
|
||
return true;
|
||
}
|
||
|
||
// Useability NOT in PWD — fall back to known-useable types.
|
||
// ObjectDescriptionFlags BF_DOOR|BF_LIFESTONE|BF_PORTAL|BF_CORPSE
|
||
// historically work with Use; allow them through.
|
||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||
{
|
||
const uint UseableFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
|
||
if ((odf & UseableFlatMask) != 0)
|
||
{
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[useability-fallback] flat-class guid=0x{guid:X8} odf=0x{odf:X8} (ACE sent no useability bit)"));
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Creatures (NPCs / players) are always Use targets in our
|
||
// fallback even when ACE didn't publish useability. Retail
|
||
// would have blocked here (null → USEABLE_UNDEF → 0 → block),
|
||
// but ACE's seed DB has many talk-only NPC weenies with
|
||
// `ItemUseable = null`; without the fallback the M1 "click NPC"
|
||
// flow regresses. The diagnostic line below lets us measure
|
||
// how often this branch fires in real play.
|
||
if ((LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0)
|
||
{
|
||
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
|
||
Console.WriteLine(System.FormattableString.Invariant(
|
||
$"[useability-fallback] creature guid=0x{guid:X8} (ACE sent no useability bit)"));
|
||
return true;
|
||
}
|
||
|
||
// Default: not useable. Signs, banners, untyped scenery with no
|
||
// server-supplied useability and no creature/door PWD bits land
|
||
// here — exactly the retail "nothing happens" case.
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 2026-05-16. Retail-faithful gate for F-key PickUp / right-click
|
||
/// "Pick Up." Distinct from <see cref="IsUseableTarget"/> because
|
||
/// pickup is more restrictive than Use: the entity must be useable
|
||
/// FROM THE WORLD (USEABLE_REMOTE bit, 0x20). Signs / banners with
|
||
/// USEABLE_NO (0x1) lack the REMOTE bit so pickup is blocked
|
||
/// client-side without a wire packet — matches retail's "The X can't
|
||
/// be picked up!" client-side toast.
|
||
///
|
||
/// <para>
|
||
/// Useable values that include USEABLE_REMOTE (0x20):
|
||
/// USEABLE_REMOTE (0x20), USEABLE_REMOTE_NEVER_WALK (0x60),
|
||
/// USEABLE_VIEWED_REMOTE (0x30), and the SOURCE_*_TARGET_REMOTE
|
||
/// composites in the 0x200000+ range.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Null-useability fallback: same as <see cref="IsUseableTarget"/>
|
||
/// — permit pickup for entities with BF_CORPSE bit set, and for
|
||
/// items with small-item ItemType. This preserves M1 ground-item
|
||
/// pickup flow for entities where ACE didn't publish useability.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <summary>
|
||
/// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
|
||
///
|
||
/// <para>
|
||
/// The earlier <c>(useability & USEABLE_REMOTE) != 0u</c> check
|
||
/// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
|
||
/// action ("can the player activate this item from the world");
|
||
/// PICKUP is a separate action governed by retail's
|
||
/// PutItemInContainer handler, which accepts any small-item-class
|
||
/// entity from the world regardless of useability bits. A spell
|
||
/// component with useability=USEABLE_NO=1 is still pickupable in
|
||
/// retail — USEABLE_NO blocks using the component (you can't
|
||
/// "activate" it standalone), not picking it up.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// Now matches retail: small-item ItemType class OR BF_CORPSE bit
|
||
/// → pickupable. Server validates the request server-side
|
||
/// (in-range, target-still-exists, container-has-room).
|
||
/// </para>
|
||
/// </summary>
|
||
private bool IsPickupableTarget(uint guid)
|
||
{
|
||
if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
|
||
return false;
|
||
|
||
// 2026-05-16 — primary discriminator is the BF_STUCK
|
||
// ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
|
||
// ACE mark immovable world objects (signs, banners, doors,
|
||
// benches) as Stuck server-side. ACE's PutItemInContainer
|
||
// handler (Player_Inventory.cs:831-836) responds with
|
||
// WeenieError.Stuck (0x29) when the client attempts a pickup
|
||
// on an item with the Stuck flag — so the client should gate
|
||
// out signs etc. before sending the wire packet.
|
||
//
|
||
// Discriminates same-ItemType ambiguity that useability can't:
|
||
// Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
|
||
// Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
|
||
// Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
|
||
if (spawn.ObjectDescriptionFlags is { } odf)
|
||
{
|
||
const uint BF_STUCK = 0x0004u;
|
||
const uint BF_CORPSE = 0x2000u;
|
||
// Corpses are pickupable (loot) — BF_CORPSE wins over
|
||
// any BF_STUCK that might be coincidentally set.
|
||
if ((odf & BF_CORPSE) != 0u) return true;
|
||
// Anything else with BF_STUCK is immovable scenery.
|
||
if ((odf & BF_STUCK) != 0u) return false;
|
||
}
|
||
|
||
// Small-item ItemType class: dropped weapons, armor, food,
|
||
// jewelry, money, misc, gems, spell components, etc.
|
||
uint it = spawn.ItemType ?? 0u;
|
||
const uint SmallItemMask =
|
||
(uint)(AcDream.Core.Items.ItemType.MeleeWeapon
|
||
| AcDream.Core.Items.ItemType.Armor
|
||
| AcDream.Core.Items.ItemType.Clothing
|
||
| AcDream.Core.Items.ItemType.Jewelry
|
||
| AcDream.Core.Items.ItemType.Food
|
||
| AcDream.Core.Items.ItemType.Money
|
||
| AcDream.Core.Items.ItemType.Misc
|
||
| AcDream.Core.Items.ItemType.MissileWeapon
|
||
| AcDream.Core.Items.ItemType.Container
|
||
| AcDream.Core.Items.ItemType.Gem
|
||
| AcDream.Core.Items.ItemType.SpellComponents
|
||
| AcDream.Core.Items.ItemType.Writable
|
||
| AcDream.Core.Items.ItemType.Key
|
||
| AcDream.Core.Items.ItemType.Caster);
|
||
return (it & SmallItemMask) != 0u;
|
||
}
|
||
|
||
private AcDream.Core.Items.ItemType LiveItemType(uint guid) =>
|
||
Objects.Get(guid)?.Type ?? AcDream.Core.Items.ItemType.None;
|
||
|
||
private string? LiveName(uint guid) => Objects.Get(guid)?.Name;
|
||
|
||
private string DescribeLiveEntity(uint guid)
|
||
{
|
||
var name = LiveName(guid);
|
||
if (!string.IsNullOrWhiteSpace(name)) return name!;
|
||
return $"0x{guid:X8}";
|
||
}
|
||
|
||
/// <summary>
|
||
/// K.1b: Tab handler extracted into a method so the dispatcher
|
||
/// subscriber can call it. Same body as the previous Tab branch in
|
||
/// the legacy direct keyboard handler — toggle player↔fly mode, set
|
||
/// up <see cref="PlayerMovementController"/> + <see cref="ChaseCamera"/>
|
||
/// when entering player mode, tear them down on exit.
|
||
/// K.2: also disarms the auto-entry trigger when the user toggles
|
||
/// manually (their choice wins).
|
||
/// </summary>
|
||
private void TogglePlayerMode()
|
||
{
|
||
// Phase B.2 guard: only active when a live session is in-world.
|
||
if (_liveSession is null
|
||
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
|
||
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
|
||
{
|
||
_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 (BuildLoadedCell, ~line 5999) 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;
|
||
}
|
||
|
||
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
|
||
|
||
// R4-V5: the local player's verbatim MoveToManager — same seam
|
||
// wiring shape as EnsureRemoteMotionBindings, with three
|
||
// player-specific differences: (a) heading reads/writes go through
|
||
// the controller's Yaw (the authoritative facing the body
|
||
// quaternion is re-derived from every Update; a quaternion-only
|
||
// set_heading would be overwritten next frame) via the P5-pinned
|
||
// yaw↔heading bridge; (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). setHeading's
|
||
// `send` flag is currently UNCONSUMED (register TS-33): the AP
|
||
// heartbeat diffs position/plane/cell but not orientation (retail's
|
||
// Frame::is_equal compares the full frame), so a stationary heading
|
||
// snap doesn't reach the wire — masked against ACE, which rotates
|
||
// server-side on its own turn paths; the full-frame diff lands with
|
||
// the R7 outbound-cadence port.
|
||
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.Motion.StopCompletely(),
|
||
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: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
|
||
getOwnHeight: () => 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();
|
||
};
|
||
|
||
// 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. Registered in _physicsHosts so those NPCs' GetObjectA
|
||
// resolves the player; HandleTargetting is ticked in the player
|
||
// pre-Update block.
|
||
playerHost = new EntityPhysicsHost(
|
||
_playerServerGuid,
|
||
getPosition: () => new AcDream.Core.Physics.Position(
|
||
pcMoveTo.CellId,
|
||
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pSelf)
|
||
? pSelf.Position : pcMoveTo.Position,
|
||
pcMoveTo.BodyOrientation),
|
||
getVelocity: () => pcMoveTo.BodyVelocity,
|
||
getRadius: () => GetSetupCylinder(_playerServerGuid, playerEntity).Radius,
|
||
inContact: () => pcMoveTo.BodyInContact,
|
||
minterpMaxSpeed: () => pcMoveTo.Motion.GetMaxSpeed(),
|
||
curTime: () => pcMoveTo.SimTimeSeconds,
|
||
physicsTimerTime: () => pcMoveTo.SimTimeSeconds,
|
||
getObjectA: ResolvePhysicsHost,
|
||
// Retail CPhysicsObj::HandleUpdateTarget (0x00512bc0) fan head:
|
||
// MovementManager::HandleUpdateTarget (@0x00512bf0 — the facade
|
||
// relay); the host chains the PositionManager leg after it.
|
||
handleUpdateTarget: info => _playerController?.Movement.HandleUpdateTarget(info),
|
||
interruptCurrentMovement: () => _playerController?.Movement.CancelMoveTo(
|
||
AcDream.Core.Physics.WeenieError.ActionCancelled));
|
||
_playerHost = playerHost;
|
||
_physicsHosts[_playerServerGuid] = 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
|
||
// _lastSpawnByGuid entry was written by OnLiveEntitySpawnedLocked
|
||
// before EnterPlayerModeNow could possibly be reached).
|
||
uint pinitCellId;
|
||
if (_lastSpawnByGuid.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);
|
||
// R3-W4: bind the player interp's retail seams to the player
|
||
// sequencer — LeaveGround/HitGround strip transition links here
|
||
// (the K-fix18 replacement).
|
||
_playerController.Motion.RemoveLinkAnimations = playerSeq.RemoveAllLinkAnimations;
|
||
_playerController.Motion.InitializeMotionTables =
|
||
() => playerSeq.Manager.InitializeState();
|
||
_playerController.Motion.CheckForCompletedMotions =
|
||
playerSeq.Manager.CheckForCompletedMotions;
|
||
// R3-W6: the player's cycles are now driven through the SAME
|
||
// dispatch sink remotes use (TurnApplied/TurnStopped omitted —
|
||
// ObservedOmega is a remote-DR-only concept; local rotation is
|
||
// the controller's Yaw integration).
|
||
_playerController.Motion.DefaultSink =
|
||
new AcDream.Core.Physics.Motion.MotionTableDispatchSink(playerSeq);
|
||
}
|
||
|
||
var initResult = _physicsEngine.Resolve(
|
||
playerEntity.Position, pinitCellId,
|
||
System.Numerics.Vector3.Zero, 100f);
|
||
_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;
|
||
|
||
var q = playerEntity.Rotation;
|
||
float rawYaw = MathF.Atan2(
|
||
2f * (q.W * q.Z + q.X * q.Y),
|
||
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
|
||
_playerController.Yaw = rawYaw + MathF.PI / 2f;
|
||
|
||
_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>
|
||
/// 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={_lastSpawnByGuid.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()
|
||
{
|
||
// Phase A.1: join the streamer worker thread before tearing down GL
|
||
// state. The worker may still be processing a load job that references
|
||
// _dats; Dispose cancels the token and waits up to 2s for the thread.
|
||
_streamer?.Dispose();
|
||
// Phase I.7: unsubscribe combat → chat translator before the
|
||
// session it depends on goes away.
|
||
_combatChatTranslator?.Dispose();
|
||
_liveSessionController?.Dispose();
|
||
_liveSession = null;
|
||
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
|
||
_wbDrawDispatcher?.Dispose();
|
||
_envCellRenderer?.Dispose(); // Phase A8
|
||
_portalDepthMask?.Dispose(); // T1
|
||
_fadeOverlay?.Dispose();
|
||
_clipFrame?.Dispose(); // Phase U.3
|
||
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
|
||
_samplerCache?.Dispose();
|
||
_textureCache?.Dispose();
|
||
_wbMeshAdapter?.Dispose(); // Phase N.4+N.5 WB foundation (mandatory modern path)
|
||
|
||
_meshShader?.Dispose();
|
||
_terrain?.Dispose();
|
||
_terrainModernShader?.Dispose();
|
||
_sceneLightingUbo?.Dispose();
|
||
_paperdollViewportRenderer?.Dispose(); // Slice 2: the doll's off-screen FBO + textures
|
||
_particleRenderer?.Dispose();
|
||
_debugLines?.Dispose();
|
||
_uiHost?.Dispose();
|
||
_textRenderer?.Dispose();
|
||
_debugFont?.Dispose();
|
||
_frameProfiler.Dispose(); // MP0: releases the GpuFrameTimer query ring
|
||
_dats?.Dispose();
|
||
_input?.Dispose();
|
||
_gl?.Dispose();
|
||
}
|
||
|
||
public void Dispose() => _window?.Dispose();
|
||
|
||
// ── Phase I.6 — TurbineChat outbound helpers ──────────────────
|
||
|
||
/// <summary>
|
||
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
|
||
/// to a runtime Turbine room. Returned by
|
||
/// <see cref="ResolveTurbineForKind"/> when the player has access
|
||
/// to that Turbine channel; null otherwise.
|
||
/// </summary>
|
||
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
|
||
|
||
/// <summary>
|
||
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
|
||
/// runtime Turbine room id + chat-type. Returns null when
|
||
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
|
||
/// or the channel has no assigned room (e.g. player not in a society).
|
||
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
|
||
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
|
||
/// </summary>
|
||
private static TurbineResolution? ResolveTurbineForKind(
|
||
AcDream.UI.Abstractions.ChatChannelKind kind,
|
||
AcDream.Core.Chat.TurbineChatState state)
|
||
{
|
||
if (!state.Enabled) return null;
|
||
|
||
var (room, chatType, name) = kind switch
|
||
{
|
||
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
|
||
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.General =>
|
||
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
|
||
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
|
||
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
|
||
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.Society =>
|
||
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
|
||
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
|
||
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
|
||
_ => (0u, 0u, string.Empty),
|
||
};
|
||
|
||
if (room == 0u) return null;
|
||
return new TurbineResolution(room, chatType, name);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pick a human-readable label for a Turbine room broadcast. Uses
|
||
/// the chat-type when known (semantic name), falls back to the
|
||
/// numeric room id for unknown rooms.
|
||
/// </summary>
|
||
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
|
||
{
|
||
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
|
||
{
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
|
||
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
|
||
_ => $"Room 0x{roomId:X8}",
|
||
};
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|