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 string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; private IWindow? _window; private GL? _gl; private IInputContext? _input; private TerrainModernRenderer? _terrain; private Shader? _shader; /// Phase N.5b: terrain_modern.vert/.frag program. Owned by /// at draw time but allocated + disposed here. Lives /// in parallel with (legacy terrain.vert/.frag) until /// Task 9 deletes the legacy renderer. private Shader? _terrainModernShader; private CameraController? _cameraController; private IMouse? _capturedMouse; private DatCollection? _dats; private float _lastMouseX; private float _lastMouseY; private Shader? _meshShader; private TextureCache? _textureCache; /// Phase N.4+: WB-backed rendering pipeline adapter. Always non-null /// after OnLoad completes (modern path is mandatory as of N.5). private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter; private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter; private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher; /// Phase N.5: ARB_bindless_texture + ARB_shader_draw_parameters /// support. Required at startup — missing bindless throws /// in OnLoad. private AcDream.App.Rendering.Wb.BindlessSupport? _bindlessSupport; private SamplerCache? _samplerCache; private DebugLineRenderer? _debugLines; // K-fix4 (2026-04-26): default OFF. The orange BSP / green cylinder // wireframes are noisy outdoors and confuse first-time users into // thinking they're a rendering bug. Ctrl+F2 toggles, the DebugPanel // → Diagnostics → "Toggle collision wires" button toggles too. private bool _debugCollisionVisible = false; private int _debugDrawLogOnce = 0; // Phase I.2: the old StbTrueTypeSharp DebugOverlay was deleted in // favor of the ImGui-backed DebugPanel (see _debugVm below). The // TextRenderer + BitmapFont fields stay alive because they're shared // with UiHost and reserved for the future world-space HUD (D.6 — // damage floaters, name plates) where ImGui can't reach into the 3D // scene. They are no longer used for any debug overlay. private TextRenderer? _textRenderer; private BitmapFont? _debugFont; // 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; // 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 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: DatCollection is NOT thread-safe. The streaming worker // thread and the render thread both read dats (BuildLandblockForStreaming // on the worker; ApplyLoadedTerrain + live-spawn handlers on the render // thread). Concurrent reads corrupt internal caches and produce // half-populated LandBlock.Height[] arrays, which caused terrain to render // as "a giant ball with spikes" before this lock was added. All _dats.Get // calls that can race with the worker thread MUST acquire this lock. 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; private Dictionary? _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> _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 _pendingCells = new(); /// /// Phase 6.4: per-entity animation playback state for entities whose /// MotionTable resolved to a real cycle. The render loop ticks each /// of these every frame, advances the current frame number, then /// rebuilds the entity's MeshRefs by re-flattening the Setup against /// the new . /// Static decorations and entities with no motion table never /// appear in this map. /// private readonly Dictionary _animatedEntities = 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 /// /// 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. /// public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? 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. private readonly bool _enableSkyPesDebug = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal); // Diagnostic: hide a specific humanoid part (>=10 parts) at render. private static readonly int s_hidePartIndex = int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1; // 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. private static readonly bool s_retailCloseDegrades = !string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal); // 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. private static readonly bool s_dumpSceneryZ = string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_SCENERY_Z"), "1", StringComparison.Ordinal); /// /// Issue #47 humanoid-setup detector. Matches Aluvian Male /// (0x02000001) 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 0x010001EC. Non-humanoid /// 34-part setups (rare) won't have the sentinel pattern. /// 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 _activeSkyPes = new(); private readonly HashSet _missingSkyPes = new(); // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when // position has stalled for >StopIdleMs — retail observer pattern per // ACE Player_Tick.cs line 368: the client never sends "released forward" // MoveToState, so the server never broadcasts an explicit stop. Observer // must infer it from position deltas. private readonly Dictionary _remoteLastMove = new(); /// /// 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 CMotionInterp-surfaced /// velocity forward each tick — see chunk_00520000.c /// apply_current_movement L7132-L7189 and holtburger's /// spatial/physics.rs::project_pose_by_velocity). /// /// /// 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 CurrentVelocity (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). /// /// private readonly Dictionary _remoteDeadReckon = new(); /// /// Per-remote-entity physics + motion stack — verbatim application of /// retail's client-side motion pipeline to every remote. Mirrors /// retail FUN_00515020 update_objectFUN_00513730 /// UpdatePositionInternalFUN_005111D0 /// UpdatePhysicsInternal, and ACE's PhysicsObj.cs port. /// /// /// 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 with its /// so each /// remote gets the same treatment as the local player. /// /// private sealed class RemoteMotion { public AcDream.Core.Physics.PhysicsBody Body; public AcDream.Core.Physics.MotionInterpreter Motion; /// Last UpdatePosition timestamp — drives body.update_object sub-stepping. public double LastServerPosTime; /// Last known server position — kept for diagnostics / HUD. public System.Numerics.Vector3 LastServerPos; /// /// 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. /// public System.Numerics.Vector3 ServerVelocity; public bool HasServerVelocity; /// /// 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 /// instead of /// the InterpretedMotionState path. /// public bool ServerMoveToActive; /// /// True once a MoveTo packet's full path payload (Origin + thresholds) /// has been parsed and the world-converted destination is stored on /// . Cleared on arrival or when /// the next non-MoveTo UpdateMotion replaces the locomotion source. /// Phase L.1c (2026-04-28). /// public bool HasMoveToDestination; /// /// World-space destination from the most recent MoveTo packet's /// Origin field, converted via the same landblock-grid /// arithmetic OnLivePositionUpdated uses. /// public System.Numerics.Vector3 MoveToDestinationWorld; /// /// min_distance from the MoveTo packet's MovementParameters. /// Used by as /// the chase-arrival threshold per retail /// MoveToManager::HandleMoveToPosition. /// public float MoveToMinDistance; /// /// distance_to_object from the MoveTo packet. Reserved for /// the flee branch (move_away); chase uses /// . /// public float MoveToDistanceToObject; /// /// True if MovementParameters bit 9 (move_towards, mask /// 0x200) is set on the active packet — i.e. this is a /// chase. False = flee (move_away) or static target. /// public bool MoveToMoveTowards; /// /// 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 /// /// — typically because the entity left our streaming view and /// the server stopped broadcasting its MoveTo updates. /// public double LastMoveToPacketTime; /// /// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed /// (π/2 × turnSpeed, signed). Applied per tick to body orientation /// via manual integration (bypassing PhysicsBody.update_object's /// MinQuantum 30fps gate that would otherwise skip most ticks). /// Zeroed on UM with TurnCommand absent. /// public System.Numerics.Vector3 ObservedOmega; /// /// 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 /// /// 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). /// public uint CellId; /// /// 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. /// public bool Airborne; /// /// 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 . /// public AcDream.Core.Physics.InterpolationManager Interp { get; } = new AcDream.Core.Physics.InterpolationManager(); /// /// 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. /// public AcDream.Core.Physics.PositionManager Position { get; } = new AcDream.Core.Physics.PositionManager(); /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): the /// previous UpdatePosition's world position + timestamp. The per-tick /// path computes (serverPos - prevServerPos) / dt and compares /// it to the sequencer's CurrentVelocity. 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. /// public System.Numerics.Vector3 PrevServerPos; public double PrevServerPosTime; public double LastOmegaDiagLogTime; /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own /// throttle clock for the SEQSTATE log line in TickAnimations. /// Previously SEQSTATE shared 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). /// public double LastSeqStateLogTime; /// /// Diagnostic-only (gated on ACDREAM_REMOTE_VEL_DIAG=1): own /// throttle clock for the PARTSDIAG log line in TickAnimations /// (D5). One log per remote per ~1s. /// public double LastPartsDiagLogTime; /// /// 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. /// public float MaxSeqSpeedSinceLastUP; /// /// Seconds-since-epoch timestamp of the most recent UpdateMotion (UM) /// for this remote. Used by the player-remote velocity-fallback cycle /// refinement to skip refinement while a fresh UM is authoritative — /// retail's outbound MoveToState gives us direction-explicit cycles /// on direction-key changes (W press, W release, W↔S flip), and we /// only want UP-derived velocity to refine the speed bucket within /// a direction when no UM has arrived recently. Defaults to 0 /// (epoch) so the first UP after spawn is allowed to refine /// immediately if velocity already differs from the spawn cycle. /// public double LastUMTime; 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, }; Motion = new AcDream.Core.Physics.MotionInterpreter(Body); } } /// Soft-snap decay rate (1/sec). At this rate the residual /// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a /// 2m residual fades within ~300ms without visible snap. private const float SnapResidualDecayRate = 8.0f; /// /// When the prediction error exceeds this many meters, we treat the /// update as a teleport / rubber-band and hard-snap (no soft lerp). /// Prevents the soft-snap logic from trying to smooth a genuine portal /// or force-move event. /// /// /// Matches retail's GetAutonomyBlipDistance (ACE /// PhysicsObj.cs:545): 20m for creatures, 25m for players. /// We use 20m as a conservative default — any delta larger than this /// must be a teleport (portal, recall, spawn). A running character /// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas /// of ~9.5m, well below this threshold, so normal movement flows /// through the interpolation queue instead of hard-snapping. /// /// private const float SnapHardSnapThreshold = 20.0f; /// /// Soft-snap window in seconds: after an UpdatePosition arrives for a /// remote entity, dead-reckoning continues but the "origin" for /// predicted position is the server pos. This matches retail's snap /// behavior — the server is authoritative, we just interpolate between /// authoritative samples. /// private const float DeadReckonMaxPredictSeconds = 1.0f; // Phase F.1-H.1 — client-side state classes fed by GameEventWiring. // Exposed publicly so plugins + UI panels can bind directly. public readonly AcDream.Core.Chat.ChatLog Chat = new(); // Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global // chat rooms. Empty/disabled until the server fires // SetTurbineChatChannels (0x0295) shortly after EnterWorld. public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new(); public readonly AcDream.Core.Combat.CombatState Combat = new(); // 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//net10.0/data/ // by the csproj 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.ItemRepository Items = new(); // Issue #5 — caches CreatureProfile.{Stamina, Mana, *Max} from // PlayerDescription so the Vitals HUD can render those bars. // Issue #6 — wired to SpellBook so GetMaxApprox folds enchantment // buffs into the max formula via Spellbook.GetVitalMod. public readonly AcDream.Core.Player.LocalPlayerState LocalPlayer = null!; // Phase D.2a — ImGui devtools UI overlay. Null unless ACDREAM_DEVTOOLS=1. // See docs/plans/2026-04-24-ui-framework.md for the staged UI strategy. private AcDream.UI.ImGui.ImGuiBootstrapper? _imguiBootstrap; private AcDream.UI.ImGui.ImGuiPanelHost? _panelHost; private AcDream.UI.Abstractions.Panels.Vitals.VitalsVM? _vitalsVm; // 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; private static readonly bool DevToolsEnabled = Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1"; private static readonly bool DumpMoveTruthEnabled = Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOVE_TRUTH") == "1"; // 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. long.MinValue sentinel means // "no day rolled yet" so the first RefreshSkyForCurrentDay call // unconditionally installs a provider. See r12 §11 for the roller // semantics. private long _loadedSkyDayIndex = long.MinValue; private AcDream.Core.World.DayGroupData? _activeDayGroup; 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 bool _playerMode; private uint _playerServerGuid; private uint? _playerCurrentAnimCommand; private float _playerCurrentAnimSpeed = 1f; 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. private AcDream.Core.Net.WorldSession? _liveSession; private int _liveCenterX; private int _liveCenterY; 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. private static readonly bool LiveModeEnabled = Environment.GetEnvironmentVariable("ACDREAM_LIVE") == "1"; /// /// 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 . /// Offline (LiveModeEnabled false) returns false → unchanged /// orbit-camera Holtburg view stays the foreground. Once chase mode /// is active the property latches false for the rest of the /// session, even if the user later toggles into fly mode (we don't /// want to re-blank the world after they've seen it). /// private bool IsLiveModeWaitingForLogin => LiveModeEnabled && !_chaseModeEverEntered; // Latches true the first time chase mode becomes active. Used by // IsLiveModeWaitingForLogin to suppress the pre-login render gate // for subsequent fly-mode toggles. private bool _chaseModeEverEntered; /// /// 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 /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _entitiesByServerGuid = new(); private readonly Dictionary _liveEntityInfoByGuid = new(); /// /// Latest for each /// guid. Captured at the end of so /// can reuse the position/setup/motion /// fields when a 0xF625 ObjDescEvent arrives carrying only updated visuals. /// private readonly Dictionary _lastSpawnByGuid = new(); private uint? _selectedTargetGuid; private readonly record struct LiveEntityInfo( string? Name, AcDream.Core.Items.ItemType ItemType); 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(string datDir, WorldGameState worldGameState, WorldEvents worldEvents) { _datDir = datDir; _worldGameState = worldGameState; _worldEvents = worldEvents; SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); } /// /// Issue #11 — load data/spells.csv from the bin output (copied /// there by the csproj). Returns SpellTable.Empty + logs a /// warning if the file is missing (e.g. when GameWindow is instantiated /// from tooling contexts that don't include the data folder). /// 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() { var options = WindowOptions.Default with { Size = new Vector2D(1280, 720), Title = "acdream — phase 1", API = new GraphicsAPI( ContextAPI.OpenGL, ContextProfile.Core, ContextFlags.ForwardCompatible, new APIVersion(4, 3)), VSync = false, // off during development so the perf overlay shows true framerate }; _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, wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard); _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, 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) { _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); _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. _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); string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); _shader = new Shader(_gl, Path.Combine(shadersDir, "terrain.vert"), Path.Combine(shadersDir, "terrain.frag")); // Phase N.5b: terrain_modern shader pair — bindless texture handles + // glMultiDrawElementsIndirect dispatch path. Loaded in parallel with // the legacy `_shader`; Task 9 will retire the legacy 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 (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1") { 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(); // Phase D.2a — ImGui devtools overlay. Zero cost when the env var // isn't set: no context creation, no per-frame branches hit. // See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md. if (DevToolsEnabled) { try { _imguiBootstrap = new AcDream.UI.ImGui.ImGuiBootstrapper(_gl!, _window!, _input!); _panelHost = new AcDream.UI.ImGui.ImGuiPanelHost(); // VitalsVM: GUID=0 at construction; set later at EnterWorld // (see the _playerServerGuid assignment path). Pre-login the // HP bar just reads 1.0 (safe default) — harmless. Stam/Mana // bars surface only after the first PlayerDescription has // populated LocalPlayer (Issue #5). _vitalsVm = new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer); _vitalsPanel = new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm); _panelHost.Register(_vitalsPanel); // ChatPanel: reads the tail of the shared ChatLog. No GUID // dependency — works pre-login (empty) and post-login (live // tail of received speech/tells/channels/system msgs). // FpsProvider + PositionProvider plumb the runtime state // the client-side /framerate and /loc commands need; the // panel asks the VM, the VM asks GameWindow via these // delegates, no panel-vs-renderer-vs-state coupling. var chatVm = new AcDream.UI.Abstractions.Panels.Chat.ChatVM(Chat) { FpsProvider = () => (float)_lastFps, PositionProvider = () => GetDebugPlayerPosition(), }; _chatPanel = new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm); _panelHost.Register(_chatPanel); // Phase I.2: DebugPanel — replaces the deleted custom // DebugOverlay (six floating panels + hint bar + toast). // The VM closes over every data source the old snapshot // record exposed; reads are live (no per-frame snapshot // build). Action hooks tie the panel's cycle/toggle // buttons back to the same routines the F2/F7/F10 // keybinds use. _debugVm = new AcDream.UI.Abstractions.Panels.Debug.DebugVM( getPlayerPosition: () => GetDebugPlayerPosition(), getPlayerHeadingDeg: () => GetDebugPlayerHeadingDeg(), getPlayerCellId: () => GetDebugPlayerCellId(), getPlayerOnGround: () => GetDebugPlayerOnGround(), getInPlayerMode: () => _playerMode, getInFlyMode: () => _cameraController?.IsFlyMode ?? false, getVerticalVelocity: () => _playerController?.VerticalVelocity ?? 0f, getEntityCount: () => _worldState.Entities.Count, getAnimatedCount: () => _animatedEntities.Count, getLandblocksVisible: () => _lastVisibleLandblocks, getLandblocksTotal: () => _lastTotalLandblocks, getShadowObjectCount: () => _physicsEngine.ShadowObjects.TotalRegistered, getNearestObjDist: () => _lastNearestObjDist, getNearestObjLabel: () => _lastNearestObjLabel, getColliding: () => _lastColliding, getDebugWireframes: () => _debugCollisionVisible, getStreamingRadius: () => _streamingRadius, 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); } 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(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); _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(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 Dictionary(); // (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 N.4+N.5 — WB rendering pipeline foundation. The modern path is // mandatory as of N.5 ship amendment: WbMeshAdapter + WbDrawDispatcher // always construct. WbMeshAdapter owns ObjectMeshManager and opens its // own file handles for the dat files (independent of our DatCollection). { var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; _wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _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(e.SourceGfxObjOrSetupId); if (setup is not null) { uint mtableId = (uint)setup.DefaultMotionTable; if (mtableId != 0) { var mtable = capturedDats.Get(mtableId); if (mtable is not null) return new AcDream.Core.Physics.AnimationSequencer(setup, mtable, capturedAnimLoader); } // Setup exists but no motion table — no-op sequencer. return new AcDream.Core.Physics.AnimationSequencer( setup, new DatReaderWriter.DBObjs.MotionTable(), capturedAnimLoader); } } // Complete fallback: empty setup + empty motion table + null loader. return new AcDream.Core.Physics.AnimationSequencer( new DatReaderWriter.DBObjs.Setup(), new DatReaderWriter.DBObjs.MotionTable(), new NullAnimLoader()); } var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter( _textureCache!, SequencerFactory, _wbMeshAdapter!); _wbEntitySpawnAdapter = wbEntitySpawnAdapter; _worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter); _wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher( _gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!); } // 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); // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). // Values outside [0, 8] fall back to the field default of 2. var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8) _streamingRadius = r; Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); // The streamer's load delegate wraps LandblockLoader.Load + stab // hydration. Scenery + interior will land in Task 8. _streamer = new AcDream.App.Streaming.LandblockStreamer( loadLandblock: id => BuildLandblockForStreaming(id)); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: _streamer.EnqueueLoad, enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, radius: _streamingRadius, 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); }); // 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() { if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1") return; var host = Environment.GetEnvironmentVariable("ACDREAM_TEST_HOST") ?? "127.0.0.1"; var portStr = Environment.GetEnvironmentVariable("ACDREAM_TEST_PORT") ?? "9000"; var user = Environment.GetEnvironmentVariable("ACDREAM_TEST_USER"); var pass = Environment.GetEnvironmentVariable("ACDREAM_TEST_PASS"); if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass)) { Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping"); return; } try { // Resolve DNS names (e.g. play.coldeve.ac) as well as literal // IP addresses. `IPAddress.Parse` throws on hostnames; fall // back to `Dns.GetHostAddresses` and prefer IPv4 (ACE + retail // use IPv4 UDP exclusively). System.Net.IPAddress ip; if (!System.Net.IPAddress.TryParse(host, out ip!)) { var addrs = System.Net.Dns.GetHostAddresses(host); ip = System.Array.Find(addrs, a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) ?? (addrs.Length > 0 ? addrs[0] : throw new System.Exception( $"DNS resolved no addresses for '{host}'")); Console.WriteLine($"live: resolved {host} → {ip}"); } var endpoint = new System.Net.IPEndPoint(ip, int.Parse(portStr)); Console.WriteLine($"live: connecting to {endpoint} as {user}"); _liveSession = new AcDream.Core.Net.WorldSession(endpoint); _liveSession.EntitySpawned += OnLiveEntitySpawned; _liveSession.EntityDeleted += OnLiveEntityDeleted; _liveSession.MotionUpdated += OnLiveMotionUpdated; _liveSession.PositionUpdated += OnLivePositionUpdated; _liveSession.VectorUpdated += OnLiveVectorUpdated; _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(0x0E000004u); AcDream.Core.Net.GameEventWiring.WireAll( _liveSession.GameEvents, Items, 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}"); } }); // 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. var liveSession = _liveSession; var chat = Chat; _commandBus = new AcDream.UI.Abstractions.LiveCommandBus(); var turbineChat = TurbineChat; uint playerGuid = _playerServerGuid; _commandBus.Register(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); Chat.OnSystemMessage($"connecting to {host}:{portStr} 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"); _liveSession.Dispose(); _liveSession = null; return; } var chosen = _liveSession.Characters.Characters[0]; _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}"); _liveSession.EnterWorld(user, characterIndex: 0); // L.0 Character tab: swap the SettingsVM's character bag // from the "default" pre-login bag to the actual chosen // toon's bag. Every Save from now on writes under the // chosen toon's name. LoadCharacterContext rebinds BOTH // persisted + draft so HasUnsavedChanges doesn't flag the // swap as a pending edit. _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"); } // Phase K.2: arm auto-entry. The guard's predicates won't // pass yet — the entity stream hasn't started — but the // OnUpdate tick re-checks every frame and fires once // everything converges (typically 100-300 ms after EnterWorld // returns). User can pre-empt via DebugPanel "Toggle // Free-Fly Mode" or Tab; both call Cancel() first. _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; _liveSession?.Dispose(); _liveSession = null; } } /// /// 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). /// 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); } } private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn) { _liveSpawnReceived++; // 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, logDelete: false); // 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. 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}"); _liveEntityInfoByGuid[spawn.Guid] = new LiveEntityInfo( spawn.Name, spawn.ItemType is { } rawItemType ? (AcDream.Core.Items.ItemType)rawItemType : AcDream.Core.Items.ItemType.None); // 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 = 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(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(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); 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(spawn.SetupTableId.Value); if (setup is not null) _physicsDataCache.CacheSetup(spawn.SetupTableId.Value, setup); if (setup is null) { _liveDropReasonSetupDatMissing++; 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(flat); var animPartChanges = spawn.AnimPartChanges ?? Array.Empty(); // 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(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 // 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 (s_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(); 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>(); foreach (var tc in textureChanges) { if (!tcByPart.TryGetValue(tc.PartIndex, out var set)) { set = new HashSet(); tcByPart[tc.PartIndex] = set; } set.Add(tc.OldTexture); } for (int pi = 0; pi < parts.Count; pi++) { var pgfx = _dats.Get(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(); foreach (var surfQid in pgfx.Surfaces) { uint surfId = (uint)surfQid; var surf = _dats.Get(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>? resolvedOverridesByPart = null; if (textureChanges.Count > 0) { // First pass: group (oldOrigTex → newOrigTex) per part. var perPartOldToNew = new Dictionary>(); foreach (var tc in textureChanges) { if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict)) { dict = new Dictionary(); 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 = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); resolvedOverridesByPart = new Dictionary>(); for (int pi = 0; pi < parts.Count; pi++) { if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue; var partGfx = _dats.Get(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? resolved = null; foreach (var surfQid in partGfx.Surfaces) { uint surfId = (uint)surfQid; var surfDat = _dats.Get(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(); 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(); int dumpClothingTotalTris = 0; for (int partIdx = 0; partIdx < parts.Count; partIdx++) { var mr = parts[partIdx]; var gfx = _dats.Get(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? 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; meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, transform) { SurfaceOverrides = surfaceOverrides, }); } if (meshRefs.Count == 0) { _liveDropReasonNoMeshRefs++; 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(); } 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, }; 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?)[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(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; } // Phase L.1c followup (2026-04-28): apply the same // missing-cycle fallback the OnLiveMotionUpdated path // uses. Without this, a monster spawned in combat // stance with the wire's seqMotion absent from its // MotionTable hits ClearCyclicTail() with no // replacement enqueue, every body part snaps to its // setup-default offset, and the visual collapses to // "torso on the ground" — visible to acdream // observers when another client is in combat with a // monster, until the first OnLiveMotionUpdated UM // applies the same fallback there. uint spawnCycle = seqMotion; if (!sequencer.HasCycle(seqStyle, spawnCycle)) { uint origCycle = spawnCycle; // RunForward → WalkForward → Ready if ((spawnCycle & 0xFFu) == 0x07 && sequencer.HasCycle(seqStyle, 0x45000005u)) { spawnCycle = 0x45000005u; } else if (sequencer.HasCycle(seqStyle, 0x41000003u)) { spawnCycle = 0x41000003u; } else { spawnCycle = 0; } if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") { Console.WriteLine( $"spawn cycle missing for guid=0x{spawn.Guid:X8} mtable=0x{mtableId:X8} " + $"style=0x{seqStyle:X8} requested=0x{origCycle:X8} " + $"→ fallback=0x{spawnCycle:X8}"); } } if (spawnCycle != 0) sequencer.SetCycle(seqStyle, spawnCycle); } } } _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); } } // Dump a summary periodically so we can see drop breakdowns without // waiting for a graceful shutdown. if (_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}"); } } /// /// 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 TerrainSurface for the landblock yet /// (streaming race). Delegates to /// /// so this fallback and the player-physics path stay in lock-step on /// sloped cells. /// /// /// 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. /// /// 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) { if (RemoveLiveEntityByServerGuid(delete.Guid, logDelete: true) && Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") { Console.WriteLine( $"live: delete guid=0x{delete.Guid:X8} instSeq={delete.InstanceSequence}"); } } /// /// Server broadcast a 0xF625 ObjDescEvent — 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 /// . The dedup at the start of /// tears down the previous /// rendering state (GpuWorldState entry, animated entity, collision /// registration) before rebuilding. /// 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); } /// /// Commit B 2026-04-29 — register a live (server-spawned) entity into /// the as a single collision body. /// One entry per entity (in contrast to static scenery's per-CylSphere /// registration) so RemoveLiveEntityByServerGuid's single /// Deregister(entity.Id) cleans it up without leaks. /// /// /// Geometry-priority order matches retail /// (acclient_2013_pseudo_c.txt:276858-276987): CylSpheres > /// Sphere fallback > Setup.Radius. Phantom Setups (no shape) are /// rejected — retail's FindObjCollisions falls through to /// OK_TS in that case. /// /// /// /// Carries derived from the PWD /// bitfield (acclient.h:6431-6463) plus IsCreature /// derived from the inbound ItemType. Commit C consumes these in /// the PvP exemption block. /// /// 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; // Retail-faithful phantom skip (acclient_2013_pseudo_c.txt:276917). if (!hasCyl && !hasSphere && !hasRadius) return; float entScale = spawn.ObjScale ?? 1.0f; float radius; float height; if (hasCyl) { // Pick the largest CylSphere as the body cylinder. Retail // tests every CylSphere in turn (276891) but for collision // BLOCKING the largest is sufficient — the player will stop // at the body's outer radius. var sph = setup.CylSpheres[0]; for (int i = 1; i < setup.CylSpheres.Count; i++) { if (setup.CylSpheres[i].Radius > sph.Radius) sph = setup.CylSpheres[i]; } radius = sph.Radius * entScale; height = (sph.Height > 0 ? sph.Height : sph.Radius * 4f) * entScale; } else if (hasRadius) { radius = setup.Radius * entScale; height = (setup.Height > 0 ? setup.Height : setup.Radius * 2f) * entScale; } else { // Sphere-only: largest sphere as a Cylinder approximation. var sph = setup.Spheres[0]; for (int i = 1; i < setup.Spheres.Count; i++) { if (setup.Spheres[i].Radius > sph.Radius) sph = setup.Spheres[i]; } radius = sph.Radius * entScale; height = sph.Radius * 2f * entScale; } if (radius <= 0f) 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.Register( entity.Id, entity.SourceGfxObjOrSetupId, entity.Position, entity.Rotation, radius, origin.X, origin.Y, spawn.Position.Value.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight: height, scale: 1.0f, state: state, flags: flags); } private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete) { if (!_entitiesByServerGuid.TryGetValue(serverGuid, out var existingEntity)) return false; _worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(existingEntity.Id); _animatedEntities.Remove(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); _liveEntityInfoByGuid.Remove(serverGuid); _entitiesByServerGuid.Remove(serverGuid); _lastSpawnByGuid.Remove(serverGuid); if (_selectedTargetGuid == serverGuid) _selectedTargetGuid = null; if (logDelete) _lightingSink?.UnregisterOwner(existingEntity.Id); return true; } /// /// 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. /// 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; // #39 (2026-05-06): stamp the per-remote LastUMTime so the // UP-velocity fallback path in ApplyServerControlledVelocityCycle // can skip refinement while a UM is fresh. UMs are authoritative // for direction-key changes (W press / release / W↔S flip); // velocity refinement only helps for HoldKey-only changes (Shift // toggle while a direction key is held — retail does NOT broadcast // a fresh MoveToState in that case). if (_remoteDeadReckon.TryGetValue(update.Guid, out var rmStateForUm)) { rmStateForUm.LastUMTime = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; } // 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}"); } // Wire server-echoed RunRate first — used for the player's own // locomotion tuning regardless of whether a cycle resolves. if (_playerController is not null && update.Guid == _playerServerGuid && update.MotionState.ForwardSpeed.HasValue && update.MotionState.ForwardSpeed.Value > 0f) { _playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value); } // ── 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; if ((!command.HasValue || command.Value == 0) && update.MotionState.IsServerControlledMoveTo) { // Retail MoveToManager::BeginMoveForward calls // MovementParameters::get_command (0x0052AA00), then // _DoMotion -> adjust_motion. With CanRun and enough // distance, WalkForward + HoldKey_Run becomes RunForward, // and CMotionInterp::apply_run_to_command (0x00527BE0) // multiplies speed by the packet's runRate. var seed = AcDream.Core.Physics.ServerControlledLocomotion .PlanMoveToStart( update.MotionState.MoveToSpeed ?? 1f, update.MotionState.MoveToRunRate ?? 1f, update.MotionState.MoveToCanRun); fullMotion = seed.Motion; speedMod = seed.SpeedMod; } else 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. } else { var forwardRoute = AcDream.Core.Physics.AnimationCommandRouter.Classify(fullMotion); bool forwardIsOverlay = forwardRoute is AcDream.Core.Physics.AnimationCommandRouteKind.Action or AcDream.Core.Physics.AnimationCommandRouteKind.Modifier or AcDream.Core.Physics.AnimationCommandRouteKind.ChatEmote; bool remoteIsAirborne = _remoteDeadReckon.TryGetValue(update.Guid, out var rmCheck) && rmCheck.Airborne; // Retail MotionTable::GetObjectSequence routes action-class // ForwardCommand values (creature attacks, chat-emotes) through // the Action branch, where the swing is appended before the // current cyclic tail and currState.Substate remains Ready. // Treating 0x10000051/52/53 as SetCycle commands made the // immediate follow-up Ready packet abort the swing. // Phase L.1c followup (2026-04-28): the next two state-update // blocks are LIFTED out of the substate-only `else` branch so // they run for BOTH overlay (Action/Modifier/ChatEmote) and // substate (Walk/Run/Ready/etc) packets. Two separate research // agents converged on the same root cause for the user- // observed "creature just runs instead of attacking" symptom: // // 1. Attack swings arrive as mt=0 with // ForwardCommand=AttackHigh1 (Action class). Retail's // CMotionInterp::move_to_interpreted_state // (acclient_2013_pseudo_c.txt:305936-305992) bulk-copies // forward_command from the wire into the body's // InterpretedState UNCONDITIONALLY. With // forward_command=AttackHigh1, get_state_velocity // returns 0 because its gate is RunForward||WalkForward // — body stops moving forward. // // 2. The acdream overlay branch was routing through // PlayAction (animation overlay) but skipping ALL of: // - ServerMoveToActive flag update // - MoveToPath capture // - InterpretedState.ForwardCommand assignment // So during a swing UM, the body's InterpretedState // stayed at RunForward from the prior MoveTo packet, // ServerMoveToActive stayed true, and the per-tick // remote driver kept steering + applying RunForward // velocity through every frame. // // Note: we bypass DoInterpretedMotion / ApplyMotionToInterpretedState // here because the latter is a heuristic that ONLY handles // WalkForward / RunForward / WalkBackward / SideStep / Turn // / Ready (MotionInterpreter.cs:941-970). For an Action // command (e.g. AttackHigh1 = 0x10000062) the switch falls // through and InterpretedState is silently NOT updated — // exactly the bug we are fixing. Direct field assignment // matches retail's copy_movement_from bulk-copy // (acclient_2013_pseudo_c.txt:293301-293311). if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot)) { remoteMot.ServerMoveToActive = update.MotionState.IsServerControlledMoveTo; // Bulk-copy the wire's resolved ForwardCommand + speed // into InterpretedState UNCONDITIONALLY (overlay, // substate, AND MoveTo packets). Matches retail's // copy_movement_from semantics // (acclient_2013_pseudo_c.txt:293301-293311) which does // not filter by MovementType. // // For MoveTo packets, fullMotion is the RunForward seed // from PlanMoveToStart, so this populates // ForwardCommand=RunForward + ForwardSpeed=speed*runRate // — what the OLD substate-only DoInterpretedMotion call // (commit f794832 removed) used to set. Without it, // apply_current_movement reads the default // ForwardCommand=Ready and produces zero velocity, so // chasing creatures only translate via UpdatePosition // hard-snaps and at spawn appear posed at default // (visible as "torso on the ground" until the first UP // snap hits). // // For overlay (Action) packets this sets ForwardCommand // to the Attack/Twitch/etc command, and // get_state_velocity returns 0 because the gate is // RunForward||WalkForward — body stops moving forward. 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}"); } // Motion command changed — invalidate observed-velocity // history so the per-tick scaling in TickAnimations // doesn't reuse a stale ratio derived from the OLD // motion (e.g. carrying run-pace serverSpeed into the // first walk frame, which would briefly accelerate // walk to run pace before settling). remoteMot.PrevServerPosTime = 0.0; } remoteMot.Motion.InterpretedState.ForwardCommand = fullMotion; // Pass speedMod through verbatim — preserve sign so retail's // adjust_motion'd backward walk (cmd=WalkForward, spd<0) // produces backward velocity in get_state_velocity, NOT // forward. Pre-fix used `<=0 ? 1 : speedMod` which clamped // negative to 1.0 and made the dead-reckoned body translate // forward despite the reverse-playback animation — visually // "still walking forward" from the observer's POV. remoteMot.Motion.InterpretedState.ForwardSpeed = speedMod; if (update.MotionState.IsServerControlledMoveTo && update.MotionState.MoveToPath is { } path) { remoteMot.MoveToDestinationWorld = AcDream.Core.Physics.RemoteMoveToDriver .OriginToWorld( path.OriginCellId, path.OriginX, path.OriginY, path.OriginZ, _liveCenterX, _liveCenterY); remoteMot.MoveToMinDistance = path.MinDistance; remoteMot.MoveToDistanceToObject = path.DistanceToObject; remoteMot.MoveToMoveTowards = update.MotionState.MoveTowards; remoteMot.HasMoveToDestination = true; remoteMot.LastMoveToPacketTime = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; } else if (!update.MotionState.IsServerControlledMoveTo) { // Off MoveTo — clear stale destination so the per-tick // driver doesn't keep steering. remoteMot.HasMoveToDestination = false; } } if (forwardIsOverlay) { if (!remoteIsAirborne) { AcDream.Core.Physics.AnimationCommandRouter.RouteFullCommand( ae.Sequencer, fullStyle, fullMotion, speedMod <= 0f ? 1f : speedMod); } } else { // Pick which cycle to play on the sequencer. Priority: // 1. Forward cmd if active (RunForward / WalkForward) — legs run/walk. // 2. Else sidestep cmd if active — legs strafe. // 3. Else turn cmd if active — legs pivot. // 4. Else Ready — idle. // // For forward+sidestep or forward+turn, the forward cycle // wins at the anim layer; the sidestep/turn contribute via // MotionInterpreter velocity/omega writes. uint animCycle = fullMotion; float animSpeed = speedMod; uint fwdLow = fullMotion & 0xFFu; bool fwdIsRunWalk = fwdLow == 0x05 /* Walk */ || fwdLow == 0x06 /* WalkBack */ || fwdLow == 0x07 /* Run */; if (!fwdIsRunWalk) { // Forward is Ready (or absent). Prefer sidestep cycle if present, // else turn cycle, else Ready. if (update.MotionState.SideStepCommand is { } sideForAnim && sideForAnim != 0) { uint sideFullForAnim = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(sideForAnim); if (sideFullForAnim == 0) sideFullForAnim = 0x65000000u | sideForAnim; animCycle = sideFullForAnim; animSpeed = update.MotionState.SideStepSpeed ?? 1f; } else if (update.MotionState.TurnCommand is { } turnForAnim && turnForAnim != 0) { uint turnFullForAnim = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(turnForAnim); if (turnFullForAnim == 0) turnFullForAnim = 0x65000000u | turnForAnim; animCycle = turnFullForAnim; // SIGNED — do NOT MathF.Abs. ACE encodes TurnLeft on the // wire as (TurnCommand=TurnRight, TurnSpeed=NEGATIVE), // mirroring retail's adjust_motion convention. The // sequencer's negative-speed path (EnqueueMotionData // multiplies MotionData.Omega by speedMod, the // synthesize-omega fallback flips zomega via // -(pi/2)*adjustedSpeed) only produces the correct // CCW rotation when the sign is preserved here. // Confirmed by live wire trace 2026-05-03: TurnLeft // input arrives as turnCmd16=0x000D, speed=-1.500. animSpeed = update.MotionState.TurnSpeed ?? 1f; } } // K-fix17 (2026-04-26): preserve the Falling cycle while // the remote is airborne. ACE broadcasts UpdateMotion // mid-arc when the player turns / runs — the previous // SetCycle here swapped Falling → RunForward, breaking // the visible jump animation. The arc still played out // physics-wise (body went up/down), but the legs ran // instead of folded. Skip the cycle swap when airborne; // the InterpretedState updates below still fire so the // body's velocity matches the new motion command, AND // the post-resolve landing path restores the cycle to // whatever the interpreted state says when the body // lands. if (!remoteIsAirborne) { // Fallback chain for missing cycles in the MotionTable. // SetCycle unconditionally calls ClearCyclicTail() before // looking up the cycle; if the cycle is absent, the body // ends up with no cyclic tail at all and every part snaps // to its setup-default offset — visible as "torso on the // ground" because most creatures' setup-default puts all // limbs at the torso origin. // // This is specifically a regression from commit 186a584 // (Phase L.1c port): pre-fix, MoveTo packets fell through // to fullMotion=Ready (which always exists in every // MotionTable). Post-fix, MoveTo packets seed // fullMotion=RunForward, but some creatures (especially // when stance=HandCombat) lack a (combat, RunForward) // cycle. Fall through RunForward → WalkForward → Ready // until we find one the table actually contains. // // Note: this fallback is for the SEQUENCER (visible // animation) only. InterpretedState.ForwardCommand still // gets the wire's (or seeded) ForwardCommand verbatim // so apply_current_movement produces correct velocity. uint cycleToPlay = animCycle; if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" && (animCycle & 0xFFu) is 0x05u or 0x07u) { bool hc = ae.Sequencer.HasCycle(fullStyle, cycleToPlay); System.Console.WriteLine( $"[HASCYCLE] guid={update.Guid:X8} style=0x{fullStyle:X8} " + $"requestedCycle=0x{cycleToPlay:X8} HasCycle={hc}"); } if (!ae.Sequencer.HasCycle(fullStyle, cycleToPlay)) { uint requested = cycleToPlay; // RunForward (0x44000007) → WalkForward (0x45000005) if ((cycleToPlay & 0xFFu) == 0x07 && ae.Sequencer.HasCycle(fullStyle, 0x45000005u)) { cycleToPlay = 0x45000005u; } // WalkForward → Ready (0x41000003) else if (ae.Sequencer.HasCycle(fullStyle, 0x41000003u)) { cycleToPlay = 0x41000003u; } // Ready missing too — leave the existing cycle alone // by not calling SetCycle at all (avoids the // ClearCyclicTail wipe). else { cycleToPlay = 0; } if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") { Console.WriteLine( $"UM cycle missing for guid=0x{update.Guid:X8} " + $"style=0x{fullStyle:X8} requested=0x{requested:X8} " + $"→ fallback=0x{cycleToPlay:X8}"); } } if (cycleToPlay != 0) { if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" && (ae.Sequencer.CurrentMotion != cycleToPlay || MathF.Abs(ae.Sequencer.CurrentSpeedMod - animSpeed) > 1e-3f)) { System.Console.WriteLine( $"[SETCYCLE] guid={update.Guid:X8} " + $"old=(motion=0x{ae.Sequencer.CurrentMotion:X8} speed={ae.Sequencer.CurrentSpeedMod:F3}) " + $"new=(motion=0x{cycleToPlay:X8} speed={animSpeed:F3})"); } ae.Sequencer.SetCycle(fullStyle, cycleToPlay, animSpeed); } } // Retail runs the full MotionInterp state machine on every // remote. Route each wire command (forward, sidestep, turn) // through DoInterpretedMotion so apply_current_movement → // get_state_velocity → PhysicsBody.set_local_velocity fires // on a subsequent tick exactly as retail's FUN_00529210 // (apply_current_movement) does. // // Decompile refs: // FUN_00529930 DoMotion // FUN_00528f70 DoInterpretedMotion // FUN_00528960 get_state_velocity // FUN_00529210 apply_current_movement // ServerMoveToActive flag, MoveToPath capture, and the // InterpretedState.ForwardCommand bulk-copy are already // handled by the LIFTED block above (so overlay-class swings // also clear stale MoveTo state and update the body's // forward command). This branch only handles sidestep / // turn axes plus the ObservedOmega seed — none of which // appear on overlay packets, so the existing logic is // correct unchanged. (`remoteMot` is the same dictionary // entry obtained at the top of the lifted block.) if (remoteMot is not null) { // Sidestep axis. if (update.MotionState.SideStepCommand is { } sideCmd16 && sideCmd16 != 0) { uint sideFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(sideCmd16); if (sideFull == 0) sideFull = 0x65000000u | sideCmd16; float sideSpd = update.MotionState.SideStepSpeed ?? 1f; remoteMot.Motion.DoInterpretedMotion( sideFull, sideSpd, modifyInterpretedState: true); } else { // No sidestep — clear any leftover strafing motion. remoteMot.Motion.StopInterpretedMotion( AcDream.Core.Physics.MotionCommand.SideStepRight, modifyInterpretedState: true); remoteMot.Motion.StopInterpretedMotion( AcDream.Core.Physics.MotionCommand.SideStepLeft, modifyInterpretedState: true); } // Turn axis — and use as the on/off switch for ObservedOmega. // On turn start: seed ObservedOmega from the formula // (π/2 × turnSpeed) so rotation begins THIS tick without // waiting for the next UP to observe a delta. // On turn end: zero ObservedOmega so rotation stops // immediately instead of coasting at the last observed // rate until the next UP shows zero delta. // UpdatePosition still REFINES the rate from actual // server deltas (more accurate than the formula), but // this ensures instant on/off response. if (update.MotionState.TurnCommand is { } turnCmd16 && turnCmd16 != 0) { uint turnFull = AcDream.Core.Physics.MotionCommandResolver .ReconstructFullCommand(turnCmd16); if (turnFull == 0) turnFull = 0x65000000u | turnCmd16; float turnSpd = update.MotionState.TurnSpeed ?? 1f; if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[TURN_WIRE] guid={update.Guid:X8} turnCmd16=0x{turnCmd16:X4} " + $"turnFull=0x{turnFull:X8} low=0x{turnFull & 0xFFu:X2} " + $"({(((turnFull & 0xFFu) == 0x0D) ? "TurnRight" : ((turnFull & 0xFFu) == 0x0E) ? "TurnLeft" : "OTHER")}) " + $"speed={turnSpd:F3}"); } remoteMot.Motion.DoInterpretedMotion( turnFull, turnSpd, modifyInterpretedState: true); // Seed ObservedOmega with formula so rotation starts // immediately; UP deltas will refine the rate. uint turnLow = turnFull & 0xFFu; if (turnLow == 0x0D /* TurnRight */) remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, -(MathF.PI / 2f) * turnSpd); else if (turnLow == 0x0E /* TurnLeft */) remoteMot.ObservedOmega = new System.Numerics.Vector3(0, 0, (MathF.PI / 2f) * turnSpd); } else { remoteMot.Motion.StopInterpretedMotion( AcDream.Core.Physics.MotionCommand.TurnRight, modifyInterpretedState: true); remoteMot.Motion.StopInterpretedMotion( AcDream.Core.Physics.MotionCommand.TurnLeft, modifyInterpretedState: true); // Zero ObservedOmega immediately — don't coast. remoteMot.ObservedOmega = System.Numerics.Vector3.Zero; } } } } // 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.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, 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; } /// /// 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. /// /// /// 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. /// 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; // K-fix10 (2026-04-26): force the Falling animation cycle on // the remote so the legs match the arc. Mirrors the local // player's UpdatePlayerAnimation path which sets // animCommand = Falling whenever !IsOnGround. // // K-fix18 (2026-04-26): pass skipTransitionLink:true so the // RunForward→Falling transition frames don't play first. // Without that flag the remote stood still for ~100 ms at // the start of the jump while the link drained, then // folded into Falling. Skipping the link makes the pose // engage immediately on jump start. if (_entitiesByServerGuid.TryGetValue(update.Guid, out var ent) && _animatedEntities.TryGetValue(ent.Id, out var ae) && ae.Sequencer is not null) { uint style = ae.Sequencer.CurrentStyle != 0 ? ae.Sequencer.CurrentStyle : 0x8000003Du; // NonCombat default ae.Sequencer.SetCycle(style, AcDream.Core.Physics.MotionCommand.Falling, 1.0f, skipTransitionLink: true); } } 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}"); } } private static bool IsRemoteLocomotion(uint motion) { uint low = motion & 0xFFu; return low is 0x05 or 0x06 or 0x07 or 0x0F or 0x10; } /// /// Grace window in seconds after a UM arrives during which UP-derived /// velocity refinement is suppressed for a player remote. UMs are /// authoritative; the velocity fallback only fills the gap when retail /// does not send a fresh MoveToState (Shift toggle while direction key /// held). 200 ms covers the worst-case UM/UP race — UMs arrive on /// direction-key events and UPs at 5–10 Hz, so the first UP after a /// fresh UM lands ~100–200 ms behind. Tightened from 500 ms (commit /// 8fa04af original) per user observation that the Shift-toggle /// transition was visibly slower than retail; with 0.2 s the worst-case /// added latency is just the UP cadence below it. /// private const double UmGraceSeconds = 0.2; /// /// Speed (m/s) above which a player-remote currently in WalkForward /// is promoted to RunForward by velocity refinement. Tuned to player /// speeds: walk ≈ 3.12 m/s (WalkAnimSpeed × 1.0), run ≈ 8–12 m/s /// (RunAnimSpeed × runRate ≈ 4.0 × 2.0–3.0). Hysteresis with /// avoids thrashing at the boundary. /// private const float PlayerRunPromoteSpeed = 5.5f; /// /// Speed (m/s) below which a player-remote currently in RunForward /// is demoted to WalkForward by velocity refinement. /// private const float PlayerRunDemoteSpeed = 4.5f; private void ApplyServerControlledVelocityCycle( uint serverGuid, AnimatedEntity ae, RemoteMotion rm, System.Numerics.Vector3 velocity) { if (rm.Airborne) return; if (ae.Sequencer is null) return; // MoveTo packets already seeded the retail speed/runRate cycle. // Keep UpdatePosition-derived velocity for render position only; // using it to choose the cycle reverts fast chases back to slow // velocity-estimated animation. if (rm.ServerMoveToActive) return; if (IsPlayerGuid(serverGuid)) { // #39 (2026-05-06): player-remote forward-direction speed-bucket // refinement. The bug case: actor toggles Shift while holding W // (or releases Shift). Retail's outbound apparently does NOT // broadcast a fresh MoveToState for HoldKey-only changes // (verified via static analysis of CommandInterpreter::SendMovementEvent // call sites; needs cdb confirmation). ACE has nothing to // broadcast → no UM arrives at the observer → cycle stays at // whichever direction-bucket was last set. Velocity DOES change // (UP carries new pace), so this code path uses UP-derived // velocity to refine the speed bucket within the same direction. // // Conservative scope: // - Forward direction only (low byte 0x05 or 0x07). Sidestep // and backward HoldKey toggles are deferred until the TTD // trace described in // docs/research/2026-05-06-locomotion-cycle-transitions/ // confirms retail's exact algorithm. // - Hysteresis (4.5 m/s demote / 5.5 m/s promote) prevents // thrashing at the boundary. // - 500 ms UM grace window — a fresh UM is always authoritative. ApplyPlayerLocomotionRefinement(serverGuid, ae, rm, velocity); 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.ServerMoveToActive}"); } ae.Sequencer.SetCycle(style, plan.Motion, plan.SpeedMod); } private void ApplyPlayerLocomotionRefinement( uint serverGuid, AnimatedEntity ae, RemoteMotion rm, System.Numerics.Vector3 velocity) { // UM grace: a fresh UM is authoritative. double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; double sinceUm = nowSec - rm.LastUMTime; if (sinceUm < UmGraceSeconds) return; uint currentMotion = ae.Sequencer!.CurrentMotion; uint lowByte = currentMotion & 0xFFu; float currentSign = MathF.Sign(ae.Sequencer.CurrentSpeedMod); if (currentSign == 0f) currentSign = 1f; // Recognised locomotion directions: // 0x05 (WalkForward) — also encodes WalkBackward via negative speed // (ACE convention: SidestepCommand= cancel, ForwardCommand= // WalkForward, ForwardSpeed *= -0.65) // 0x07 (RunForward) // 0x0F (SideStepRight) // 0x10 (SideStepLeft) // Other motions (Ready, Turn, emotes, attacks) are left to UM-driven SetCycle. const uint LowWalkForward = 0x05u; const uint LowRunForward = 0x07u; const uint LowSideStepRight = 0x0Fu; const uint LowSideStepLeft = 0x10u; bool isForwardClass = lowByte == LowWalkForward || lowByte == LowRunForward; bool isSidestep = lowByte == LowSideStepRight || lowByte == LowSideStepLeft; if (!isForwardClass && !isSidestep) return; float horizSpeed = MathF.Sqrt(velocity.X * velocity.X + velocity.Y * velocity.Y); // Hysteresis: stay in current bucket unless we cross the appropriate // threshold. Below StopSpeed → don't refine (let UM Ready stop signal // handle the stop transition; we don't want UP momentary 0-velocity // to drop the cycle to Ready while the actor is mid-stride). if (horizSpeed < AcDream.Core.Physics.ServerControlledLocomotion.StopSpeed) return; uint targetMotion; float speedMod; if (isSidestep) { // Sidestep: motion ID stays the same (SideStepLeft / SideStepRight). // Retail's wire encoding for sidestep speed buckets uses the same // motion ID with different SidestepSpeed (slow ≈ 1.25 multiplier, // fast ≈ 3.0 clamp per ACE MovementData.cs:124-131). On Shift // toggle while a strafe key is held, retail does NOT broadcast a // fresh MoveToState (same wire-silence rule as the forward case), // so observer-side cycle refinement must come from UP-derived // velocity here. Preserve the sign — SideStepLeft is sometimes // emitted with negative speedMod by the adjust_motion path. // // Magnitude: horizSpeed / SidestepAnimSpeed maps the observed // speed back to a SideStepSpeed the sequencer can apply as a // framerate multiplier. Retail's get_state_velocity for // sidestep cycles is `velocity.X = SidestepAnimSpeed * // SideStepSpeed` (MotionInterpreter.cs:592 — constant 1.25 // m/s). Dividing by WalkAnimSpeed (3.12) here was wrong by // 2.5× and made slow strafe play visibly slower than retail. float sideMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed; sideMag = MathF.Min(MathF.Max( sideMag, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); targetMotion = currentMotion; speedMod = sideMag * currentSign; } else if (currentSign < 0f) { // BACKWARD walk: ACE encodes WalkBackward as `WalkForward` motion // with NEGATIVE speedMod (MovementData.cs:115 `interpState.ForwardSpeed *= -0.65f`). // No "RunBackward" motion exists — Shift toggle on backward // changes only the magnitude of speedMod (slow back ≈ -0.65, // fast back ≈ -1.91 = -runRate × 0.65). Keep WalkForward motion, // refine magnitude, preserve negative sign. // // Without this branch (the original fix #1), backward refinement // computed a positive speedMod from horizSpeed and overwrote the // negative sign, making the legs play forward-walk while the body // continued moving backward (the rubber-banding the user reported). float backMag = horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed; backMag = MathF.Min(MathF.Max( backMag, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; speedMod = -backMag; } else if (lowByte == LowRunForward) { if (horizSpeed < PlayerRunDemoteSpeed) { targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; speedMod = MathF.Min(MathF.Max( horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); } else { targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; speedMod = MathF.Min(MathF.Max( horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); } } else { // currently WalkForward (0x05) with positive speedMod = walking forward. if (horizSpeed > PlayerRunPromoteSpeed) { targetMotion = AcDream.Core.Physics.MotionCommand.RunForward; speedMod = MathF.Min(MathF.Max( horizSpeed / AcDream.Core.Physics.MotionInterpreter.RunAnimSpeed, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); } else { targetMotion = AcDream.Core.Physics.MotionCommand.WalkForward; speedMod = MathF.Min(MathF.Max( horizSpeed / AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed, AcDream.Core.Physics.ServerControlledLocomotion.MinSpeedMod), AcDream.Core.Physics.ServerControlledLocomotion.MaxSpeedMod); } } // Skip the SetCycle if neither motion nor speedMod changed // meaningfully — avoids replaying transition links every UP. bool motionChanged = currentMotion != targetMotion && (currentMotion & 0xFFu) != (targetMotion & 0xFFu); bool speedChanged = MathF.Abs(ae.Sequencer.CurrentSpeedMod - speedMod) > 0.05f; if (!motionChanged && !speedChanged) return; if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[UPCYCLE_PLAYER] guid={serverGuid:X8} " + $"|v|={horizSpeed:F2} cur=0x{currentMotion:X8} " + $"-> motion=0x{targetMotion:X8} speedMod={speedMod:F2} " + $"sinceUM={sinceUm:F2}s " + $"motionChg={motionChanged} speedChg={speedChanged}"); } uint style = ae.Sequencer.CurrentStyle != 0 ? ae.Sequencer.CurrentStyle : 0x8000003Du; ae.Sequencer.SetCycle(style, targetMotion, speedMod); } private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { // Phase A.1: track the most recently updated entity's landblock so the // streaming controller can follow the player. TODO: filter by our own // character guid once we reliably know it from CharacterList. _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; 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.Position = worldPos; 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) { _physicsEngine.ShadowObjects.UpdatePosition( entity.Id, worldPos, rot, origin.X, origin.Y, 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.Position = 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.Position = 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.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rmState.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact | AcDream.Core.Physics.TransientStateFlags.OnWalkable; rmState.Interp.Clear(); rmState.Body.Position = worldPos; // Reset the sequencer out of Falling — see matching block in // TickAnimations Step 5 (env-var path) for rationale. if (_animatedEntities.TryGetValue(entity.Id, out var aeForLand) && aeForLand.Sequencer is not null) { uint style = aeForLand.Sequencer.CurrentStyle != 0 ? aeForLand.Sequencer.CurrentStyle : 0x8000003Du; uint landingCmd = rmState.Motion.InterpretedState.ForwardCommand; if (landingCmd == 0) landingCmd = AcDream.Core.Physics.MotionCommand.Ready; float landingSpeed = rmState.Motion.InterpretedState.ForwardSpeed; if (landingSpeed <= 0f) landingSpeed = 1f; aeForLand.Sequencer.SetCycle(style, landingCmd, landingSpeed); } 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); } // #39 fix-3 (2026-05-06): velocity-fallback cycle refinement // for player remotes. Wire-level evidence (`launch-39-diag2.log`): // when retail's actor toggles Shift while a direction key // is held, retail's outbound MoveToState logic does NOT // emit a fresh packet (only Ready ↔ Run UMs visible in // `[FWD_WIRE]`, despite a clear walk-pace ↔ run-pace // velocity transition in `[VEL_DIAG]`). ACE has nothing // to broadcast → no UM arrives at the observer → cycle // sticks at whatever the last UM set. Compute the // synth-velocity here in the player-remote path AND // call into ApplyServerControlledVelocityCycle, which // routes through the direction-preserving + UM-grace // ApplyPlayerLocomotionRefinement helper (added in // commit 8fa04af). // // The legacy non-player block below (3759+) covers NPCs // and is gated `!IsPlayerGuid`; this block fills the // matching gap for players. 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; if (_animatedEntities.TryGetValue(entity.Id, out var aeForVel) && aeForVel.Sequencer is not null) { if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1") { System.Console.WriteLine( $"[UPCYCLE_SRC] guid={update.Guid:X8} src=synth-player"); } ApplyServerControlledVelocityCycle( update.Guid, aeForVel, rmState, synthVel); } } } // Sync the visible entity to the body — overrides the unconditional // entity.Position = 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.Position = 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; } 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. 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.ServerMoveToActive = false; 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) { rmState.Body.Velocity = rmState.ServerVelocity; } if (rmState.HasServerVelocity && _animatedEntities.TryGetValue(entity.Id, out var aeForVelocity)) { // #39 (2026-05-06): un-gated for player remotes — the // function itself routes player remotes into the dedicated // ApplyPlayerLocomotionRefinement path (forward-direction // speed bucket only, with UM grace + hysteresis). Non-player // remotes use the existing PlanFromVelocity path. // // D2 (Commit A 2026-05-03): tag whether the velocity feeding // ApplyServerControlledVelocityCycle is wire-explicit (rare for // player remotes — ACE almost never sets HasVelocity on player // UPs) or synthesized from position deltas (the common case). // Pairs with the [UPCYCLE]/[UPCYCLE_PLAYER] line printed inside. 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.Position = rmState.Body.Position; entity.Rotation = rmState.Body.Orientation; } // Phase B.3: 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); bool farAway = System.Numerics.Vector3.Distance(worldPos, oldPos) > 100f; if (differentLandblock || farAway) { Console.WriteLine( $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); // 1. Recenter the streaming controller on the new landblock. _liveCenterX = lbX; _liveCenterY = lbY; // Recompute worldPos with new center (it becomes local-to-center). // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ) // relative to the new origin — which maps to world-space (0,0,0) + local offset. // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically. var newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); // (after recentering, origin is (0,0,0) since lb == center) // 2. Resolve through physics for the correct ground Z. uint newCellId = p.LandblockId; var resolved = _physicsEngine.Resolve( newWorldPos, newCellId, System.Numerics.Vector3.Zero, _playerController.StepUpHeight); var snappedPos = new System.Numerics.Vector3( resolved.Position.X, resolved.Position.Y, resolved.Position.Z); // 3. Snap player entity + controller. entity.Position = snappedPos; entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); // 4. Recenter chase camera on the new position. _chaseCamera?.Update(snappedPos, _playerController.Yaw); // 5. Return to InWorld. _playerController.State = AcDream.App.Input.PlayerState.InWorld; Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); // 5. Send LoginComplete to tell the server the client finished loading. // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440), // retail clients call send_login_complete() after each portal transition. // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path // doesn't also send one. We send directly here instead. _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); } } } /// /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). /// Freeze movement input by setting the player controller to PortalSpace. /// The controller's Update() will return a zero-movement result until the /// destination UpdatePosition arrives and OnLivePositionUpdated resets the /// state to InWorld. /// private void OnTeleportStarted(uint sequence) { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; Console.WriteLine($"live: teleport started (seq={sequence})"); } /// /// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the /// (guid, scriptId) pair into /// 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 /// (scriptId, entityId) keeps multiple simultaneous plays /// working on different guids. /// /// /// Improvements for follow-up: look up the guid's actual last- /// known world position from _worldState so per-entity /// spell casts and emote gestures anchor correctly. For Phase 6 /// scope (lightning, which is Dereth-wide) the camera anchor is /// sufficient. /// /// 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(); 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 System.Numerics.Vector3 SkyPesAnchor( AcDream.Core.World.SkyObjectData obj, System.Numerics.Vector3 cameraWorldPos) { if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); return cameraWorldPos; } private static System.Numerics.Quaternion SkyPesRotation( AcDream.Core.World.SkyObjectData obj, float dayFraction) { float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); return System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitY, -rotationRad); } /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override /// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc) /// for now — actual sound playback needs a lookup table from /// EnvironChangeType → wave asset, which we don't yet /// have dat-indexed; follow-up will wire the thunder wave ids. /// private void OnEnvironChanged(uint environChangeType) { // Fog presets — values match AcDream.Core.World.EnvironOverride // byte-for-byte (we deliberately mirrored retail's enum). if (environChangeType <= 0x06u) { Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType; Console.WriteLine( $"live: AdminEnvirons fog override = " + $"{(AcDream.Core.World.EnvironOverride)environChangeType}"); return; } // Sound cues 0x65..0x7B. Log by retail name for now; audio // binding is a separate follow-up (needs sound-table indexing // plus a PlaySound API on OpenAlAudioEngine that takes a // retail sound enum → wave-id mapping). string name = environChangeType switch { 0x65u => "RoarSound", 0x66u => "BellSound", 0x67u => "Chant1Sound", 0x68u => "Chant2Sound", 0x69u => "DarkWhispers1Sound", 0x6Au => "DarkWhispers2Sound", 0x6Bu => "DarkLaughSound", 0x6Cu => "DarkWindSound", 0x6Du => "DarkSpeechSound", 0x6Eu => "DrumsSound", 0x6Fu => "GhostSpeakSound", 0x70u => "BreathingSound", 0x71u => "HowlSound", 0x72u => "LostSoulsSound", 0x75u => "SquealSound", 0x76u => "Thunder1Sound", 0x77u => "Thunder2Sound", 0x78u => "Thunder3Sound", 0x79u => "Thunder4Sound", 0x7Au => "Thunder5Sound", 0x7Bu => "Thunder6Sound", _ => $"Unknown(0x{environChangeType:X2})", }; Console.WriteLine( $"live: AdminEnvirons sound cue = {name} " + $"(0x{environChangeType:X2}) — audio binding pending"); } /// /// 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. /// private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId) { 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. lock (_datLock) { return BuildLandblockForStreamingLocked(landblockId); } } private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(uint landblockId) { if (_dats is null) return null; 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(baseLoaded.Entities.Count); foreach (var e in baseLoaded.Entities) { var meshRefs = new List(); if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) { // Single GfxObj stab — identity part transform. var gfx = _dats.Get(e.SourceGfxObjOrSetupId); if (gfx is not null) { _physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx); 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(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(mr.GfxObjId); if (gfx is null) continue; _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); 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, }; hydrated.Add(entity); } // Task 8: merge stabs + scenery + interior into one entity list. var merged = new List(hydrated); merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY)); return new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, baseLoaded.Heightmap, merged); } /// /// 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. /// private List BuildSceneryEntitiesForStreaming( AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY) { var result = new List(); if (_dats is null || _heightTable is null) return result; var region = _dats.Get(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? buildingCells = null; var lbInfo = _dats.Get( (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(); 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(); var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(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); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); } } else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u) { var setup = _dats.Get(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(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. meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); } } } 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 // TerrainChunkRenderer 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; float? maybePhysicsZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy); float groundZ = maybePhysicsZ ?? 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 (s_dumpSceneryZ) { string source = maybePhysicsZ.HasValue ? "physics" : "bilinear"; foreach (var mr in meshRefs) { var dgfx = _dats.Get(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(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(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, }; result.Add(hydrated); } return result; } /// /// 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. /// private List BuildInteriorEntitiesForStreaming( uint landblockId, int lbX, int lbY) { var result = new List(); if (_dats is null) return result; var lbInfo = _dats.Get((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 | (lbId & 0x00FFFF00) | local_counter. // Distinct from scenery (0x80000000+) and stabs (ids from LandblockLoader). uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u); uint localCounter = 0; uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; for (uint offset = 0; offset < lbInfo.NumCells; offset++) { uint envCellId = firstCellId + offset; var envCell = _dats.Get(envCellId); if (envCell is null) 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(0x0D000000u | envCell.EnvironmentId); if (environment is not null && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) { var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; var cellOrigin = envCell.Position.Origin + lbOffset + new System.Numerics.Vector3(0f, 0f, 0.02f); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform); var cellEntity = new AcDream.Core.World.WorldEntity { Id = interiorIdBase + localCounter++, SourceGfxObjOrSetupId = envCellId, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = new[] { cellMeshRef }, ParentCellId = envCellId, }; result.Add(cellEntity); // Step 4: build LoadedCell for portal visibility. BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); // Cache CellStruct physics BSP for indoor collision. _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform); } } } // Phase 2d: static objects inside the EnvCell. foreach (var stab in envCell.StaticObjects) { var meshRefs = new List(); if ((stab.Id & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(stab.Id); if (gfx is not null) { _physicsDataCache.CacheGfxObj(stab.Id, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } } else if ((stab.Id & 0xFF000000u) == 0x02000000u) { var setup = _dats.Get(stab.Id); if (setup is not null) { _physicsDataCache.CacheSetup(stab.Id, setup); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; _physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx); _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(mr); } } } if (meshRefs.Count == 0) 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, }; result.Add(hydrated); } } return result; } /// /// Phase A.1: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. /// Mirrors the terrain-build + entity-upload part of the old preload. /// Must only be called from the render thread. /// private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb) { if (_terrain is null || _dats is null || _blendCtx is null || _heightTable is null || _surfaceCache is null) return; // Phase A.1 hotfix: render-thread path also takes the dat lock so it // doesn't race with BuildLandblockForStreaming on the worker thread. // Hold the lock across the entire apply because we read dats below // (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from // LandblockMesh.Build. lock (_datLock) { ApplyLoadedTerrainLocked(lb); } } /// /// Step 4: build a for portal visibility and queue it /// for render-thread registration. Called from the worker thread during /// . /// 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(); var clipPlanes = new List(); // 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)); // 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); } } var loaded = new LoadedCell { CellId = envCellId, WorldPosition = cellOrigin, WorldTransform = cellTransform, InverseWorldTransform = inverse, LocalBoundsMin = boundsMin, LocalBoundsMax = boundsMax, Portals = portals, ClipPlanes = clipPlanes, }; _pendingCells.Add(loaded); } private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb) { if (_terrain is null || _dats is null || _blendCtx is null || _heightTable is null || _surfaceCache is null) return; 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); // Build terrain mesh data on the render thread (pure CPU; acceptable // for the MVP; a future pass can move it to the worker thread). var meshData = AcDream.Core.Terrain.LandblockMesh.Build( lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); _terrain.AddLandblock(lb.LandblockId, meshData, origin); // Step 4: drain pending LoadedCells from the worker thread. while (_pendingCells.TryTake(out var cell)) _cellVisibility.AddCell(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(); var portalPlanes = new List(); var lbInfo = _dats.Get( (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); 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; var envCell = _dats.Get(envCellId); if (envCell is null) continue; if (envCell.EnvironmentId == 0) continue; var environment = _dats.Get( 0x0D000000u | envCell.EnvironmentId); if (environment is null) 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( 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; iterate Values. var polyVids = new List>(cellStruct.PhysicsPolygons.Count); foreach (var poly in cellStruct.PhysicsPolygons.Values) { var vids = new List(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)); } } } _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, portalPlanes, origin.X, origin.Y); } // 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. foreach (var entity in lb.Entities) { foreach (var meshRef in entity.MeshRefs) { if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; var gfx = _dats.Get(meshRef.GfxObjId); if (gfx is null) 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(); // 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, scHaveBounds = 0, scRegistered = 0, scTooThin = 0, scNoBounds = 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) { var datSetup = _dats.Get(src); 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; 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) { 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); 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); if (setup is not null) { 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); 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); 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); entityCyl++; } } } // L-fix3 (2026-04-28): retail "decorative / phantom" detection. // A Setup is phantom (no collision) when it has NO CylSpheres, // NO Spheres, AND zero overall Radius. Small plants, grass // tufts, flowers, ground-cover bushes all match — retail // ships them with empty collision arrays so the player walks // through them. Without this gate the mesh-bounds fallback // below assigns every plant a 0.3 m+ collision cylinder // (line ~4409 clamp) and they block the player. // // The gate is layered AFTER the Setup CylSphere / BSP // registrations above (which are no-ops for phantom Setups // anyway), so non-phantom scenery (trees with real // CylSpheres or canopy-only BSPs) still gets the // mesh-bounds fallback. The check is on entity.SourceGfxObjOrSetupId // to look up the cached Setup; if it's a raw GfxObj // (0x010xxxxx, no Setup metadata) we keep the old fallback // behaviour because GfxObjs don't expose phantom intent. bool isPhantomSetup = false; if ((entity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { var setupInfoForPhantom = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); if (setupInfoForPhantom is not null && setupInfoForPhantom.CylSpheres.Count == 0 && setupInfoForPhantom.Spheres.Count == 0 && setupInfoForPhantom.Radius <= 0.0001f) { isPhantomSetup = true; } } // VISUAL mesh-bounds collision: for SCENERY entities (IDs with // 0x80000000 bit set, indicating procedurally-placed scenery), // ALWAYS compute a cylinder from the world-space mesh AABB. // This catches trees whose BSP is only on the canopy (player // walks under) AND corrects CylSphere positioning issues caused // by mesh files having vertices offset from the mesh origin. // // For stabs (low IDs) and live entities, keep the existing Setup // CylSphere / BSP registrations — those are placed with precise // frame data and don't have the scenery offset issue. // // L-fix3: skip entirely when the Setup is phantom — retail // decorative meshes have no collision data on purpose. if (!isPhantomSetup && (_isOutdoorMesh || (entityBsp == 0 && entityCyl == 0)) && entity.MeshRefs.Count > 0) { float entScale = entity.Scale > 0f ? entity.Scale : 1f; bool haveBounds = false; var worldMin = new System.Numerics.Vector3(float.MaxValue); var worldMax = new System.Numerics.Vector3(float.MinValue); var entRoot = System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) * System.Numerics.Matrix4x4.CreateTranslation(entity.Position); // First pass: compute overall vertical extent in world Z. float overallMinZ = float.MaxValue; float overallMaxZ = float.MinValue; foreach (var mr in entity.MeshRefs) { var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); if (vb is null || vb.Radius <= 0f) continue; var partWorld = mr.PartTransform * entRoot; for (int bi = 0; bi < 8; bi++) { var corner = new System.Numerics.Vector3( (bi & 1) != 0 ? vb.Max.X : vb.Min.X, (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); var w = System.Numerics.Vector3.Transform(corner, partWorld); if (w.Z < overallMinZ) overallMinZ = w.Z; if (w.Z > overallMaxZ) overallMaxZ = w.Z; } } // Second pass: use TRUNK HEIGHT ONLY (bottom 25% of the mesh // or first 2.5m, whichever is smaller) for horizontal radius. // This gives us the trunk thickness — not the canopy spread. // The Z extent still uses the full mesh (so tall trees have // tall collision cylinders). float trunkHeight = MathF.Min(2.5f, (overallMaxZ - overallMinZ) * 0.25f); if (trunkHeight < 0.5f) trunkHeight = 0.5f; float trunkTopZ = overallMinZ + trunkHeight; foreach (var mr in entity.MeshRefs) { var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId); if (vb is null || vb.Radius <= 0f) continue; var partWorld = mr.PartTransform * entRoot; // Only accumulate horizontal extents from corners within the // trunk height range. Pass the full vertical extent through. for (int bi = 0; bi < 8; bi++) { var corner = new System.Numerics.Vector3( (bi & 1) != 0 ? vb.Max.X : vb.Min.X, (bi & 2) != 0 ? vb.Max.Y : vb.Min.Y, (bi & 4) != 0 ? vb.Max.Z : vb.Min.Z); var w = System.Numerics.Vector3.Transform(corner, partWorld); // Always track vertical extent if (w.Z < worldMin.Z) worldMin.Z = w.Z; if (w.Z > worldMax.Z) worldMax.Z = w.Z; // Only track horizontal extent for TRUNK-level vertices if (w.Z <= trunkTopZ) { if (w.X < worldMin.X) worldMin.X = w.X; if (w.Y < worldMin.Y) worldMin.Y = w.Y; if (w.X > worldMax.X) worldMax.X = w.X; if (w.Y > worldMax.Y) worldMax.Y = w.Y; haveBounds = true; } } } if (haveBounds) { if (_isScenery) scHaveBounds++; // RADIUS: prefer the Setup's CylSphere radius (the retail // trunk radius — thin, matches tree trunks). Fall back to // Setup.Radius or mesh AABB if CylSphere is unavailable. // Always scale by entity.Scale. float entScaleLocal = entity.Scale > 0f ? entity.Scale : 1f; float cylRadius = -1f; float cylHeight; var setupInfo = _physicsDataCache.GetSetup(entity.SourceGfxObjOrSetupId); if (setupInfo is not null) { if (setupInfo.CylSpheres.Count > 0 && setupInfo.CylSpheres[0].Radius > 0f) { // Retail CylSphere — the definitive trunk collision. cylRadius = setupInfo.CylSpheres[0].Radius * entScaleLocal; } else if (setupInfo.Radius > 0f) { // Setup.Radius — the overall bounding radius. For // thin trunks this might be the full tree radius // (canopy included) but often it's the trunk. cylRadius = setupInfo.Radius * entScaleLocal; } } // Fall back to mesh AABB trunk-level radius if no Setup data. if (cylRadius < 0f) { float halfX = (worldMax.X - worldMin.X) * 0.5f; float halfY = (worldMax.Y - worldMin.Y) * 0.5f; cylRadius = MathF.Max(halfX, halfY); } // Clamp: retail AC trunks are 0.3-1.0m. Bigger radii (from // the AABB fallback for canopy-heavy meshes) are clearly // wrong; clamp to a reasonable tree-trunk maximum. if (cylRadius < 0.3f) cylRadius = 0.3f; if (cylRadius > 1.5f) cylRadius = 1.5f; // HEIGHT: use Setup.Height scaled, or mesh AABB vertical extent. if (setupInfo is not null && setupInfo.Height > 0f) cylHeight = setupInfo.Height * entScaleLocal; else cylHeight = MathF.Max(worldMax.Z - entity.Position.Z, cylRadius); // CENTER: entity.Position (the rendered root). var baseCenter = new System.Numerics.Vector3( entity.Position.X, entity.Position.Y, entity.Position.Z); _physicsEngine.ShadowObjects.Register( entity.Id, entity.SourceGfxObjOrSetupId, baseCenter, entity.Rotation, cylRadius, origin.X, origin.Y, lb.LandblockId, AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight); entityCyl++; if (_isScenery) scRegistered++; } else if (_isScenery) scNoBounds++; } // 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 (scTried > 0) Console.WriteLine( $"lb 0x{lb.LandblockId:X8}: scenery tried={scTried} registered={scRegistered} " + $"noBounds={scNoBounds} tooThin={scTooThin} (outdoorNone={lbNoneCount})"); // Find scenery WITHOUT any cached visual bounds at all int sceneryNoCache = 0; var sampleMissing = new List(); 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 (sceneryNoCache > 0) { string samples = string.Join(",", sampleMissing.Select(s => $"0x{s:X8}")); Console.WriteLine($" → {sceneryNoCache} scenery entities had no visual bounds cached. Samples: {samples}"); } // 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); } } private void OnUpdate(double dt) { // 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. if (_streamingController is not null && _cameraController is not null && !IsLiveModeWaitingForLogin) { int observerCx = _liveCenterX; int observerCy = _liveCenterY; 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 && _lastLivePlayerLandblockId is { } lid) { // Live mode (fly camera): follow the server's last-known player position. observerCx = (int)((lid >> 24) & 0xFFu); observerCy = (int)((lid >> 16) & 0xFFu); } 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); } _streamingController.Tick(observerCx, observerCy); // 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. _liveSession?.Tick(); // 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 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). 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 // 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; 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.Position = result.RenderPosition; 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. var pp = _playerController.Position; int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); _worldState.RelocateEntity(pe, currentLb); } // Update chase camera. K-fix12 (2026-04-26): pass isOnGround // so the camera pins its Z to last-grounded while the // player is airborne — without this the camera follows // player.Z 1:1 during a jump and the player's screen // position never changes. With the pin: player visibly // rises above the camera, matching retail "you can see // yourself jump" feedback. _chaseCamera.Update(result.RenderPosition, _playerController.Yaw, isOnGround: result.IsOnGround, dt: (float)dt); // 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. int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f); int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f); float localX = result.Position.X - (lbX - _liveCenterX) * 192f; float localY = result.Position.Y - (lbY - _liveCenterY) * 192f; uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu); var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z); var wireRot = YawToAcQuaternion(_playerController.Yaw); byte contactByte = result.IsOnGround ? (byte)1 : (byte)0; if (result.MotionStateChanged) { // HoldKey axis values — retail enum (holtburger types.rs 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, 2=Run; otherwise 1=None. const uint HoldKeyNone = 1u; const uint HoldKeyRun = 2u; uint axisHoldKey = result.IsRunning ? HoldKeyRun : HoldKeyNone; var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.MoveToState.Build( gameActionSequence: seq, forwardCommand: result.ForwardCommand, forwardSpeed: result.ForwardSpeed, sidestepCommand: result.SidestepCommand, sidestepSpeed: result.SidestepSpeed, turnCommand: result.TurnCommand, turnSpeed: result.TurnSpeed, holdKey: axisHoldKey, // always present forwardHoldKey: result.ForwardCommand.HasValue ? axisHoldKey : (uint?)null, sidestepHoldKey: result.SidestepCommand.HasValue ? axisHoldKey : (uint?)null, turnHoldKey: result.TurnCommand.HasValue ? axisHoldKey : (uint?)null, cellId: wireCellId, position: wirePos, rotation: wireRot, instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, forcePositionSequence: _liveSession.ForcePositionSequence, contactLongJump: contactByte); DumpMovementTruthOutbound( "MTS", seq, result, wirePos, wireCellId, contactByte); _liveSession.SendGameAction(body); } 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); } if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue) { var seq = _liveSession.NextGameActionSequence(); var jumpBody = AcDream.Core.Net.Messages.JumpAction.Build( gameActionSequence: seq, extent: result.JumpExtent.Value, velocity: result.JumpVelocity.Value, 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. UpdatePlayerAnimation(result); } } 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}") : "-"; /// /// 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. /// 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); } /// /// Inverse of : 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 InterpolationManager.Enqueue. /// Standard formula: atan2( 2(wz + xy), 1 − 2(y² + z²) ). /// 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)); } 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) { // 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); _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); // 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). _wbMeshAdapter?.Tick(); // 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). var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; // 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 (_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, cameraInsideCell); Lighting.Tick(camPos); var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build( Lighting, in atmo, camPos, (float)WorldTime.DayFraction); _sceneLightingUbo?.Upload(ubo); // 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); } // 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). Skipped // when indoors; dungeons fully block sky visibility. // // 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. if (!cameraInsideCell) { _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); 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 — pairs with the streaming-Tick gate in // OnUpdate so absolutely nothing of the world (Holtburg or // otherwise) renders pre-login. The sky still draws above so // the user sees a live, time-of-day-correct sky during the // brief connection + character-list + EnterWorld handshake. if (IsLiveModeWaitingForLogin) { goto SkipWorldGeometry; } // Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup // (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch // is cheap; only the periodic Console.WriteLine is gated. _terrainCpuStopwatch.Restart(); _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); _terrainCpuStopwatch.Stop(); _terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds); _terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length; MaybeFlushTerrainDiag(); // Conditional depth clear: when camera is inside a building, clear // depth (not color) so interior geometry writes fresh Z values on top // of the terrain color buffer. Exit portals show outdoor terrain color // because we kept the color buffer. Matching ACME GameScene.cs pattern. if (cameraInsideCell) _gl!.Clear(ClearBufferMask.DepthBufferBit); // L-fix1 (2026-04-28): pass the set of animated-entity ids so // the renderer keeps remote players / NPCs / monsters // visible even when their landblock rotates out of the // frustum. Without this, other characters wink in/out as // the camera turns. The set is rebuilt per-frame from // _animatedEntities — it's small (<100 entities typically) // so HashSet allocation is cheap. Static scenery still // respects landblock-level cull. HashSet? animatedIds = null; if (_animatedEntities.Count > 0) { animatedIds = new HashSet(_animatedEntities.Count); foreach (var k in _animatedEntities.Keys) animatedIds.Add(k); } // N.5: WbDrawDispatcher is always non-null (modern path mandatory). _wbDrawDispatcher!.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds, animatedEntityIds: animatedIds); // 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 (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(_particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.Scene); // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // meshes (Properties & 0x04, e.g. the 815m-tall rain // cylinder 0x01004C42/0x01004C44) render AFTER the scene so // the additive rain streaks overlay terrain and entities // instead of being painted over by them. This is the second // half of retail's LScape::draw split — GameSky::Draw(1) // fires after the DrawBlock loop. Same indoor gate as the // sky pass: weather is suppressed inside cells. if (!cameraInsideCell) { _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, _activeDayGroup, kf, environOverrideActive); if (_particleSystem is not null && _particleRenderer is not null) _particleRenderer.Draw(_particleSystem, camera, camPos, AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } // 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.LandblockEntries) { 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.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(); } ImGuiNET.ImGui.EndMenu(); } ImGuiNET.ImGui.EndMainMenuBar(); } _panelHost.RenderAll(ctx); _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: , // PY Time: "). 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; } } /// /// Phase 6.4: advance every animated entity's frame counter by /// * 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. /// 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; // Locate the server guid for this entity once per tick — needed // for dead-reckoning. O(N) reverse lookup; for player populations // < 100 the cost is negligible. uint serverGuid = 0; foreach (var esg in _entitiesByServerGuid) { if (ReferenceEquals(esg.Value, ae.Entity)) { serverGuid = esg.Key; break; } } // ── 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)) { 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; } // 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); 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.Position = rm.Body.Position; 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; if (!IsPlayerGuid(serverGuid) && rm.HasServerVelocity) { 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 if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive && rm.HasMoveToDestination) { // Phase L.1c port of retail MoveToManager per-tick // steering (HandleMoveToPosition @ 0x00529d80). // Steer body orientation toward the latest // server-supplied destination, then let // apply_current_movement set Velocity from the // RunForward cycle through the now-correct heading. // Stale-destination guard (2026-04-28): if no // MoveTo packet has refreshed the destination // recently, the entity has likely left our // streaming view or the server cancelled the // move without us seeing the cancel UM. Continuing // to steer toward a stale point produces the // "monster runs in place after popping back into // view" symptom. Clear and stand down. double moveToAge = nowSec - rm.LastMoveToPacketTime; if (moveToAge > AcDream.Core.Physics.RemoteMoveToDriver.StaleDestinationSeconds) { rm.HasMoveToDestination = false; rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { var driveResult = AcDream.Core.Physics.RemoteMoveToDriver .Drive( rm.Body.Position, rm.Body.Orientation, rm.MoveToDestinationWorld, rm.MoveToMinDistance, rm.MoveToDistanceToObject, (float)dt, rm.MoveToMoveTowards, out var steeredOrientation); rm.Body.Orientation = steeredOrientation; if (driveResult == AcDream.Core.Physics.RemoteMoveToDriver .DriveResult.Arrived) { // Within arrival window — zero velocity until the // next MoveTo packet refreshes the destination // (or the server explicitly stops us with an // interpreted-motion UM cmd=Ready). rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { // Steering active — apply_current_movement reads // InterpretedState.ForwardCommand=RunForward (set // when the MoveTo packet arrived) and emits // velocity along +Y in body local space. Our // updated orientation rotates that into the right // world direction toward the target. rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); // Clamp horizontal velocity so we don't overshoot // the arrival threshold during the final tick of // approach. Without this, a 4 m/s body advances // ~6 cm/tick and visibly runs slightly through // the target before the swing UM lands. float arrivalThreshold = rm.MoveToMoveTowards ? rm.MoveToDistanceToObject : rm.MoveToMinDistance; rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver .ClampApproachVelocity( rm.Body.Position, rm.Body.Velocity, rm.MoveToDestinationWorld, arrivalThreshold, (float)dt, rm.MoveToMoveTowards); } } } else if (!IsPlayerGuid(serverGuid) && rm.ServerMoveToActive) { // MoveTo flag set but we haven't seen a path payload // yet (e.g. truncated packet, or a brand-new entity // whose first cycle UM is still in flight). Hold // velocity at zero — same conservative stance as the // 882a07c stabilizer for incomplete state. rm.Body.Velocity = System.Numerics.Vector3.Zero; } else { rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); } } 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; 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.State &= ~AcDream.Core.Physics.PhysicsStateFlags.Gravity; rm.Body.Velocity = new System.Numerics.Vector3( rm.Body.Velocity.X, rm.Body.Velocity.Y, 0f); rm.Motion.HitGround(); // K-fix17 (2026-04-26): reset the sequencer cycle // from Falling back to whatever the interpreted // motion state says they should be doing now. // Without this, the remote stays in the Falling // pose forever (legs folded) until the next // server-sent UpdateMotion arrives. Use the // sequencer's current style (preserved across // jump) and pick the cycle from // InterpretedState.ForwardCommand: Ready // (idle), WalkForward, RunForward, WalkBackward. // SideStep / Turn aren't strict locomotion // priorities — the next UM the server sends will // refine the cycle if the player is mid-strafe // when they land; this just gets the legs out // of Falling immediately. if (ae.Sequencer is not null) { uint style = ae.Sequencer.CurrentStyle != 0 ? ae.Sequencer.CurrentStyle : 0x8000003Du; uint landingCmd = rm.Motion.InterpretedState.ForwardCommand; if (landingCmd == 0) landingCmd = AcDream.Core.Physics.MotionCommand.Ready; float landingSpeed = rm.Motion.InterpretedState.ForwardSpeed; if (landingSpeed <= 0f) landingSpeed = 1f; ae.Sequencer.SetCycle(style, landingCmd, landingSpeed); } if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") Console.WriteLine($"VU.land guid=0x{serverGuid:X8} Z={rm.Body.Position.Z:F2}"); } } ae.Entity.Position = rm.Body.Position; ae.Entity.Rotation = rm.Body.Orientation; } } // ── Get per-part (origin, orientation) from either sequencer or legacy ── IReadOnlyList? 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); // 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. 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); } } } 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; int animFrame0Parts = ae.Animation.PartFrames.Count > 0 ? ae.Animation.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(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 (s_hidePartIndex >= 0 && i == s_hidePartIndex && partCount >= 10) { continue; } newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform) { SurfaceOverrides = template.SurfaceOverrides, }); } ae.Entity.MeshRefs = newMeshRefs; } } /// /// Phase B.2: switch the locally-controlled player entity's animation cycle /// to match the current motion command. Only re-resolves when the command /// actually changes (forward → run, idle → walk, etc.) to avoid re-building /// the animation entry every frame. /// /// /// Action motions (Jump, FallDown, emotes, attacks) are routed through /// — they /// live in the motion table's Modifiers dict, not the Cycles dict, and /// are inserted into the queue on top of the current cycle instead of /// replacing it. /// /// private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result) { if (_dats is null) return; // ── Airborne SubState (Falling) ──────────────────────────────────── // // Retail models the jump-animation as a SubState swap to // MotionCommand.Falling (0x40000015) while airborne, NOT as an // Action overlay. Empirically verified: Links[(NonCombat,RunForward)] // has 3 transitions including 0x40000015 Falling. The SubState cycle // for Falling lives in Cycles[(style, Falling)] and loops while // airborne. On land, we transition back to whatever SubState the // motion input implies (Ready / WalkForward / RunForward). // // Implementation: force animCommand = Falling when airborne; the // existing SetCycle pathway resolves the link + cycle correctly and // the transition back happens naturally when airborne becomes false. // Determine the animation command: airborne takes priority (Falling // SubState), then forward, sidestep, turn, then idle (Ready 0x41000003). // // Airborne → Falling (retail behavior; see airborne note above). // Otherwise: LocalAnimationCommand (RunForward when running) preferred, // falling back to wire ForwardCommand (WalkForward / WalkBackward). uint animCommand; if (!result.IsOnGround) animCommand = AcDream.Core.Physics.MotionCommand.Falling; else if (result.LocalAnimationCommand is { } localCmd) animCommand = localCmd; else if (result.ForwardCommand is { } fwd) animCommand = fwd; else if (result.SidestepCommand is { } ss) animCommand = ss; else if (result.TurnCommand is { } tc) animCommand = tc; else animCommand = 0x41000003u; // Ready (idle) // Fast path: no command change AND speed delta is negligible. If // command is unchanged but speed changed, we must still propagate // so the sequencer can MultiplyCyclicFramerate — keeping the run // loop smooth without restart. // K-fix5 (2026-04-26): use LocalAnimationSpeed (cycle pace) NOT // ForwardSpeed (wire field) — backward+run + strafe+run keep // ForwardSpeed/SidestepSpeed at 1.0 for ACE compatibility but // need the local cycle to play at runRate × so the animation // matches the actual movement velocity. float newSpeed = result.LocalAnimationSpeed; bool sameCmd = animCommand == _playerCurrentAnimCommand; bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f; if (sameCmd && sameSpeed) return; _playerCurrentAnimCommand = animCommand; _playerCurrentAnimSpeed = newSpeed; if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; // The player entity may not be in _animatedEntities if a post-spawn // UpdateMotion removed it (Phase 6.8 pattern). In that case, load // the Setup and re-register. This is the player's own character so // we always want it animated in player mode. if (!_animatedEntities.TryGetValue(pe.Id, out var ae)) { var setup = _dats.Get(pe.SourceGfxObjOrSetupId); if (setup is null) return; _physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup); // Build a minimal part template from the entity's current MeshRefs. var template = new (uint, IReadOnlyDictionary?)[pe.MeshRefs.Count]; for (int i = 0; i < pe.MeshRefs.Count; i++) template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides); ae = new AnimatedEntity { Entity = pe, Setup = setup, Animation = null!, // filled below LowFrame = 0, HighFrame = 0, Framerate = 30f, Scale = 1f, PartTemplate = template, CurrFrame = 0f, }; _animatedEntities[pe.Id] = ae; } // The motion table cycle key is (style << 16) | (command & 0xFFFFFF). // Without a stance override, the resolver uses the table default // (which always maps to the idle/Ready cycle regardless of command). // Pass the NonCombat stance (0x003D) so the resolver builds the // correct cycle key for walk/run/turn commands. ushort cmdOverride = (ushort)(animCommand & 0xFFFFu); const ushort NonCombatStance = 0x003D; var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( ae.Setup, _dats, motionTableIdOverride: _playerMotionTableId, stanceOverride: NonCombatStance, commandOverride: cmdOverride); // Sequencer path: SetCycle handles adjust_motion internally // (TurnLeft→TurnRight with negative speed, etc.) // // Speed scaling: K-fix5 (2026-04-26) — use LocalAnimationSpeed // (the PlayerMovementController-computed cycle pace) instead of // the wire ForwardSpeed. Forward+Run = runRate; Backward+Run = // runRate (where ForwardSpeed is the ACE-compatible 1.0); // Strafe+Run = runRate (where SidestepSpeed is 1.0). Anything // not in run = 1.0. The animation cycle now visually matches // the movement velocity in every direction. if (ae.Sequencer is not null) { uint fullStyle = 0x80000000u | (uint)NonCombatStance; float animSpeed = result.LocalAnimationSpeed > 0f ? result.LocalAnimationSpeed : 1f; // ACDREAM_ANIM_SPEED_SCALE: optional visual-pacing knob. Retail's // animation framerate scales linearly with speedMod (r03 §8.3), // and our speedMod = runRate. If the visual feel doesn't match // retail, override via env var (default 1.0 = no change). float animScale = 1.0f; if (float.TryParse( Environment.GetEnvironmentVariable("ACDREAM_ANIM_SPEED_SCALE"), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var s) && s > 0f) { animScale = s; } // K-fix18 (2026-04-26): when transitioning into Falling // (jump start), skip the link so the legs engage Falling // immediately. Without this the local player visibly // stood still for ~100 ms at the start of every jump // while the RunForward→Falling transition link drained. // For everything else (Walk → Run, Run → Ready, etc.) we // keep the link so transitions stay smooth. bool skipLink = animCommand == AcDream.Core.Physics.MotionCommand.Falling; // #45 (2026-05-06): scale sidestep speedMod to match ACE's // wire formula. PlayerMovementController hands us a raw // localAnimSpeed (1.0 slow / runRate fast), but ACE's // BroadcastMovement converts SidestepSpeed via // `speed × 3.12 / 1.25 × 0.5` // (Network/Motion/MovementData.cs:124-131). Without the // matching multiplier here, the local sidestep cycle plays // at speedMod = 1.0 while the observer-side cycle plays at // ~1.248 — local strafe visibly slower than retail (user // report at #45 close-out of #39). // Factor = WalkAnimSpeed / SidestepAnimSpeed × 0.5 // = 3.12 / 1.25 × 0.5 = 1.248. uint cmdLow = animCommand & 0xFFu; if (cmdLow == 0x0Fu /* SideStepRight */ || cmdLow == 0x10u /* SideStepLeft */) { animSpeed *= AcDream.Core.Physics.MotionInterpreter.WalkAnimSpeed / AcDream.Core.Physics.MotionInterpreter.SidestepAnimSpeed * 0.5f; } ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale, skipTransitionLink: skipLink); } // Legacy path: update the manual slerp fields (for entities without sequencer) if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; ae.Animation = cycle.Animation; ae.LowFrame = Math.Max(0, cycle.LowFrame); ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); ae.Framerate = cycle.Framerate; ae.CurrFrame = ae.LowFrame; } /// /// Phase 3a — re-roll the active DayGroup whenever the current /// Dereth-day index differs from what we last installed. Idempotent /// within the same server-day. Swaps both the /// feeding /// (for lighting interp) and the cached /// (for the sky-object render loop). /// /// /// Honors ACDREAM_DAY_GROUP=N — when set, every call picks /// group N regardless of day index. Useful for A/B testing each /// weather preset against retail. See /// /// for the roller. /// /// private void RefreshSkyForCurrentDay() { if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0) return; // Retail FUN_00501990 seeds the LCG with the triple stored in // TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear) // // The decompile agent labeled +0x10 "SecondsPerDay (int copy)" // but a live memory probe of retail's acclient.exe (2026-04-23, // tools/RetailTimeProbe) shows the value is actually **360** — // semantically DaysPerYear, not seconds. So the LCG seed is // seed = Year × DaysPerYear + DayOfYear // which is literally "total days since epoch" (a flat day index), // confirmed against retail's Year=116, DayOfYear=47, seed=41807. // // Previously we passed 7620 (DayTicks), producing seed 883967 — // a completely different LCG output → wrong DayGroup pick → // user-observed weather mismatch (acdream clear while retail // stormy, 2026-04-23). The live probe nailed the fix. double ticks = WorldTime.NowTicks; int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks); int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks); int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth * AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360 // Composite day key for change-detection and logging only; the // LCG seed is computed inside SelectDayGroupIndex from (absYear, // secondsPerDay, dayOfYear). long dayIndex = (long)absYear * 360 + dayOfYear; int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear); var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count ? _loadedSkyDesc.DayGroups[idx] : null; bool dayChanged = dayIndex != _loadedSkyDayIndex; bool groupChanged = !ReferenceEquals(grp, _activeDayGroup); if (!dayChanged && !groupChanged) return; _loadedSkyDayIndex = dayIndex; _activeDayGroup = grp; if (grp is not null && grp.SkyTimes.Count > 0) { WorldTime.SetProvider( new AcDream.Core.World.SkyStateProvider( grp.SkyTimes.Select(s => s.Keyframe).ToList())); // Phase 3e: drive the atmospheric weather (rain/snow emitters, // fog-override categories, lightning strobe) from the retail // DayGroup name. Stops the legacy WeatherSystem.RollKind hash // from spawning rain particles on a "Sunny" day (user-observed // rain regression 2026-04-23 after the retail picker landed on // DayGroup[6] "Sunny" but the internal hash picked Rain). Weather.SetKindFromDayGroupName(grp.Name); Console.WriteLine( $"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " + $"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " + $"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})"); } } /// /// Derive the current sun (directional light, slot 0 of the UBO) /// from the interpolated , /// plus the cell ambient. Indoor cells force the sun intensity to /// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient. /// private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) { // 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 (cameraInsideCell) { // Dungeon default per r13 §3 — warm-dark ambient, no sun. 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.10f, 0.09f, 0.08f), SunColor: System.Numerics.Vector3.Zero, SunDirection: sunToWorld); } else { // Outdoor: full keyframe sun + ambient. The SkyKeyframe stores // raw DirColor + DirBright (and AmbColor + AmbBright) for // retail-faithful per-channel keyframe interpolation; the // computed `kf.SunColor` / `kf.AmbientColor` properties return // the post-multiplied product the shader expects. Lighting.Sun = new AcDream.Core.Lighting.LightSource { Kind = AcDream.Core.Lighting.LightKind.Directional, WorldForward = sunToWorld, ColorLinear = kf.SunColor, Intensity = 1f, Range = 1f, }; Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState( AmbientColor: kf.AmbientColor, SunColor: kf.SunColor, SunDirection: sunToWorld); } } // ── Phase I.2 — DebugPanel helpers ──────────────────────────────── // // The ImGui DebugPanel reads through DebugVM closures that ask // GameWindow for live state on every frame. The helper methods below // are the *named* targets of those closures (and of the F-key // shortcuts that share the same actions). Keeping them as methods // (vs ad-hoc lambdas where the VM is constructed) means both the // panel button and the keybind run the *same* code, so behavior // can't drift between the two surfaces. /// Player-mode-aware position source for the DebugPanel. private System.Numerics.Vector3 GetDebugPlayerPosition() { if (_playerMode && _playerController is not null) return _playerController.Position; if (_cameraController?.Active is { } cam) { // Camera world position from inverse of view matrix — same // computation used by the scene-lighting UBO each frame. System.Numerics.Matrix4x4.Invert(cam.View, out var inv); return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43); } return System.Numerics.Vector3.Zero; } /// Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise. private float GetDebugPlayerHeadingDeg() { float deg; if (_playerMode && _playerController is not null) { deg = _playerController.Yaw * (180f / MathF.PI); } else if (_cameraController?.Active is { } cam) { // Camera-relative heading from view matrix forward vector. Use // the same -invView.Mxx convention the snapshot block used. System.Numerics.Matrix4x4.Invert(cam.View, out var inv); var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33); deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI); } else { return 0f; } deg %= 360f; if (deg < 0f) deg += 360f; return deg; } private uint GetDebugPlayerCellId() => _playerMode && _playerController is not null ? _playerController.CellId : 0u; private bool GetDebugPlayerOnGround() => _playerMode && _playerController is not null && !_playerController.IsAirborne; private float GetActiveSensitivity() { if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase; if (_cameraController?.IsFlyMode == true) return _sensFly; return _sensOrbit; } /// /// Cycle the time-of-day debug override. Same body as the old F7 /// keybind handler; called by both the keybind AND the DebugPanel /// "Cycle time of day" button via DebugVM.CycleTimeOfDay. /// private void CycleTimeOfDay() { // none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none _timeDebugStep = (_timeDebugStep + 1) % 5; float? pick = _timeDebugStep switch { 0 => (float?)null, 1 => 0.0f, 2 => 0.25f, 3 => 0.5f, 4 => 0.75f, _ => null, }; if (pick.HasValue) { WorldTime.SetDebugTime(pick.Value); _debugVm?.AddToast($"Time override = {pick.Value:F2}"); } else { WorldTime.ClearDebugTime(); _debugVm?.AddToast("Time override cleared"); } } /// /// Cycle the weather kind. Same body as the old F10 keybind handler. /// private void CycleWeather() { var kinds = new[] { AcDream.Core.World.WeatherKind.Clear, AcDream.Core.World.WeatherKind.Overcast, AcDream.Core.World.WeatherKind.Rain, AcDream.Core.World.WeatherKind.Snow, AcDream.Core.World.WeatherKind.Storm, }; _weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length; Weather.ForceWeather(kinds[_weatherDebugStep]); _debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}"); } /// /// Toggle the collision-wires debug renderer. Same body as the old /// F2 keybind handler. /// private void ToggleCollisionWires() { _debugCollisionVisible = !_debugCollisionVisible; _debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}"); } /// /// Yields the registered DebugPanel(s) so F1 can flip their /// visibility. Returns nothing when devtools are off. /// private IEnumerable 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; /// /// L.0 follow-up: load every section from settings.json + apply the /// runtime-affecting ones (Display window state + Audio engine /// volumes) at startup. Runs unconditionally — settings are runtime /// state, not devtools state. Without this, a user running with /// ACDREAM_DEVTOOLS=0 would silently get WindowOptions /// defaults instead of their saved Display/Audio preferences. /// private void LoadAndApplyPersistedSettings() { _settingsStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore( AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath()); _persistedDisplay = _settingsStore.LoadDisplay(); _persistedAudio = _settingsStore.LoadAudio(); _persistedGameplay = _settingsStore.LoadGameplay(); _persistedChat = _settingsStore.LoadChat(); // _activeToonKey is "default" pre-EnterWorld; the post-login // 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; } } /// /// L.0 Display tab: framebuffer-resize handler — update GL viewport /// + camera aspect when the window is resized (by the user dragging /// the corner OR by ApplyDisplayWindowState applying a saved /// Resolution). Without this, the viewport stays pinned at the /// startup size, producing a small render inside a big window. /// Also force-resets ImGui panel layout so panels that were /// previously off the new viewport snap back to default positions. /// private void OnFramebufferResize(Silk.NET.Maths.Vector2D newSize) { if (newSize.X <= 0 || newSize.Y <= 0) return; _gl?.Viewport(0, 0, (uint)newSize.X, (uint)newSize.Y); _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); } /// /// 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: /// /// OnFramebufferResize (cond=Always — force-reset on resize). /// The View → "Reset window layout" menu item (cond=Always). /// 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). /// /// 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); } /// /// L.0 Display tab: apply the window-state-dependent settings /// (Resolution + Fullscreen) from a /// to the live Silk.NET window. Called at startup (with persisted /// values) and on every Save (with the saved values). Resolution /// parses "WIDTHxHEIGHT" (e.g. "1920x1080"); a malformed /// or unparseable string is silently ignored to avoid crashing the /// client mid-session. /// private void ApplyDisplayWindowState( AcDream.UI.Abstractions.Panels.Settings.DisplaySettings display) { if (_window is null) return; // Resolution: parse and resize if changed. if (TryParseResolution(display.Resolution, out int w, out int h)) { if (_window.Size.X != w || _window.Size.Y != h) _window.Size = new Silk.NET.Maths.Vector2D(w, h); } // Fullscreen: borderless via Silk.NET's WindowState.Fullscreen // (no exclusive-mode DXGI dance needed). var desiredState = display.Fullscreen ? Silk.NET.Windowing.WindowState.Fullscreen : Silk.NET.Windowing.WindowState.Normal; if (_window.WindowState != desiredState) _window.WindowState = desiredState; } private static bool TryParseResolution(string spec, out int width, out int height) { width = height = 0; if (string.IsNullOrWhiteSpace(spec)) return false; var parts = spec.Split('x', 2); if (parts.Length != 2) return false; return int.TryParse(parts[0], out width) && int.TryParse(parts[1], out height) && width > 0 && height > 0; } // 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. /// /// K.1b — multicast subscriber on . /// Handles every game-side reaction to a keyboard/mouse-button chord. /// Per-frame held-state polling (movement WASD/Shift/Space) lives in /// via ; /// this method handles transitional Press/Release events only. /// private void 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. if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) 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.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.EscapeKey: if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { _playerMode = false; _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; _playerCurrentAnimCommand = 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 (_selectedTargetGuid is { } selected && IsLiveCreatureTarget(selected)) return selected; return SelectClosestCombatTarget(showToast: false); } 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; } _selectedTargetGuid = 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; if (!_liveEntityInfoByGuid.TryGetValue(guid, out var info)) return false; return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0; } private string DescribeLiveEntity(uint guid) { if (_liveEntityInfoByGuid.TryGetValue(guid, out var info) && !string.IsNullOrWhiteSpace(info.Name)) return info.Name!; return $"0x{guid:X8}"; } /// /// 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 + /// 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). /// 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; _playerCurrentAnimCommand = null; _playerMouseDeltaX = 0f; } } /// /// K.2: callback the /// guard invokes once login + entity stream + controller readiness /// have all converged. Sets _playerMode = true and runs the /// same construction path the manual Tab handler uses. Predicates on /// the guard already guarantee _entitiesByServerGuid contains /// the player guid, so the inner TryGetValue is a fast-path success. /// 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}"); } } /// /// K-fix3 (2026-04-26): the right "toggle free-fly mode" routine /// when a chase camera is in play. /// 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: /// /// Chase → Fly: cancel auto-entry (user's choice wins) and /// switch to fly camera while keeping _playerMode = true + /// the chase camera alive so we can return. /// Fly → Chase: when _playerMode is still true and the /// chase camera survived, re-enter chase via /// . /// Otherwise (no chase available): the original Fly↔Orbit /// toggle for offline / pre-login flows. /// /// private void ToggleFlyOrChase() { if (_cameraController is null) return; _playerModeAutoEntry?.Cancel(); if (_cameraController.IsFlyMode && _playerMode && _chaseCamera is not null) { _cameraController.EnterChaseMode(_chaseCamera); return; } _cameraController.ToggleFly(); } /// /// K.2: shared "construct controller + chase camera + enter chase /// mode" body extracted from the on-enter branch of /// . Returns false when the player /// entity isn't in _entitiesByServerGuid yet — caller must /// reset _playerMode in that case. /// 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); // 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) { var playerSetup = _dats.Get(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)"); } int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; var initResult = _physicsEngine.Resolve( playerEntity.Position, pinitCellId & 0xFFFFu, System.Numerics.Vector3.Zero, 100f); _playerController.SetPosition(initResult.Position, initResult.CellId); if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE) && playerAE.Sequencer is { } playerSeq) { _playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity); } 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, }; // 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); // 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; } /// /// Phase K.2: hide the system cursor while MMB instant mouse-look is /// held. Saves the previous CursorMode so /// can put it back exactly. Skips when no mouse / no input — tests /// and headless runs stay clean. /// 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; } /// /// 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. /// 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; } } /// /// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies /// the currently-active mode's sensitivity (chase / fly / orbit) by the /// given factor and clamps to [0.005, 3.0]. /// private void AdjustActiveSensitivity(float factor) { string modeLabel; float current; if (_playerMode && _cameraController?.IsChaseMode == true) { modeLabel = "Chase"; current = _sensChase; } else if (_cameraController?.IsFlyMode == true) { modeLabel = "Fly"; current = _sensFly; } else { modeLabel = "Orbit"; current = _sensOrbit; } float next = MathF.Min(3.0f, MathF.Max(0.005f, current * factor)); if (modeLabel == "Chase") _sensChase = next; else if (modeLabel == "Fly") _sensFly = next; else _sensOrbit = next; _debugVm?.AddToast($"{modeLabel} sens {next:F3}x"); } /// /// K.1b: F3 dump handler extracted into a method. Same body as the /// previous in-line F3 branch — prints the player's position + /// nearby visible entities + nearby shadow physics objects. /// private void DumpPlayerAndNearbyEntities() { System.Numerics.Vector3 pos; if (_playerMode && _playerController is not null) pos = _playerController.Position; else { System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv); pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43); } int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f); int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f); Console.WriteLine( $"=== F3 DEBUG DUMP ===\n" + $" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" + $" landblock=0x{(uint)((lbX<<24)|(lbY<<16)|0xFFFF):X8} local=({pos.X - (lbX-_liveCenterX)*192f:F2},{pos.Y - (lbY-_liveCenterY)*192f:F2})\n" + $" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}"); var visibleNearby = new List(); foreach (var e in _worldState.Entities) { float dx = e.Position.X - pos.X; float dy = e.Position.Y - pos.Y; if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e); } Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}"); foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12)) { float d = (e.Position - pos).Length(); Console.WriteLine( $" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " + $"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}"); } var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>(); foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug()) { float dx = o.Position.X - pos.X; float dy = o.Position.Y - pos.Y; float d = MathF.Sqrt(dx * dx + dy * dy); if (d < 15f) sorted.Add((o, d)); } sorted.Sort((a, b) => a.dist.CompareTo(b.dist)); Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}"); foreach (var (o, d) in sorted.Take(12)) { Console.WriteLine( $" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " + $"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}"); } } /// /// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever /// camera distance is current — chase camera distance in player mode, /// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude /// is fixed-step (the previous proportional scroll.Y was lost when we /// moved scroll into the dispatcher, but the discrete step matches /// retail wheel feel). /// private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action) { if (_cameraController is null) return; float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f; if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) { // Chase mode: zoom (closer on ScrollUp). _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); } } /// Phase N.5b: emits [TERRAIN-DIAG] once per ~5s under /// ACDREAM_WB_DIAG=1. Mirrors WbDrawDispatcher.MaybeFlushDiag: /// rolling 256-sample buffer of microseconds, median + p95 reported. /// Sample buffer is NOT cleared on flush — it's a moving window so the /// next 5s window already has 256 frames of recent history. private void MaybeFlushTerrainDiag() { if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal)) return; long now = Environment.TickCount64; if (now - _terrainLastDiagTick <= 5000) return; long cpuMedUs = TerrainDiagMedianMicros(_terrainCpuSamples); long cpuP95Us = TerrainDiagPercentile95Micros(_terrainCpuSamples); Console.WriteLine( $"[TERRAIN-DIAG] cpu_ms={cpuMedUs / 1000.0:F2}/{cpuP95Us / 1000.0:F2} " + $"draws={_terrain?.VisibleSlots ?? 0}/frame " + $"visible={_terrain?.VisibleSlots ?? 0} " + $"loaded={_terrain?.LoadedSlots ?? 0} " + $"capacity={_terrain?.CapacitySlots ?? 0}"); _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; return copy[copy.Length - nz / 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; int idx = copy.Length - 1 - (int)(nz * 0.05); return copy[idx]; } 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(); _liveSession?.Dispose(); _audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context _wbDrawDispatcher?.Dispose(); _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(); _shader?.Dispose(); _terrainModernShader?.Dispose(); _sceneLightingUbo?.Dispose(); _particleRenderer?.Dispose(); _debugLines?.Dispose(); _textRenderer?.Dispose(); _debugFont?.Dispose(); _dats?.Dispose(); _input?.Dispose(); _gl?.Dispose(); } public void Dispose() => _window?.Dispose(); // ── Phase I.6 — TurbineChat outbound helpers ────────────────── /// /// Result of resolving a UI /// to a runtime Turbine room. Returned by /// when the player has access /// to that Turbine channel; null otherwise. /// private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName); /// /// Map a to a /// runtime Turbine room id + chat-type. Returns null when /// isn't /// or the channel has no assigned room (e.g. player not in a society). /// Mirrors holtburger's resolve_turbine_channel /// (references/holtburger/.../client/commands.rs lines 64-98). /// 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); } /// /// 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. /// 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}", }; } /// /// Fallback for the /// sequencer /// factory when neither _dats nor the entity's setup is available. /// Returns null for all animation lookups so the sequencer silently has /// no data (same behaviour as a new empty Setup). /// private sealed class NullAnimLoader : AcDream.Core.Physics.IAnimationLoader { public DatReaderWriter.DBObjs.Animation? LoadAnimation(uint id) => null; } }