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 TerrainChunkRenderer? _terrain;
private Shader? _shader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
private float _lastMouseX;
private float _lastMouseY;
private InstancedMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
/// Phase N.4: WB-backed rendering pipeline adapter. Non-null only
/// when ACDREAM_USE_WB_FOUNDATION=1 is set; null otherwise.
private AcDream.App.Rendering.Wb.WbMeshAdapter? _wbMeshAdapter;
private AcDream.App.Rendering.Wb.EntitySpawnAdapter? _wbEntitySpawnAdapter;
private AcDream.App.Rendering.Wb.WbDrawDispatcher? _wbDrawDispatcher;
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 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_object → FUN_00513730
/// UpdatePositionInternal → FUN_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"));
_meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh_instanced.vert"),
Path.Combine(shadersDir, "mesh_instanced.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)
// Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
_terrain = new TerrainChunkRenderer(_gl, _shader, 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();
_textureCache = new TextureCache(_gl, _dats);
// 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 — WB rendering pipeline foundation. Constructed only when
// ACDREAM_USE_WB_FOUNDATION=1 is set; otherwise the legacy renderer
// path stays in charge. The full ObjectMeshManager bring-up lives in
// WbMeshAdapter (Task 9): OpenGLGraphicsDevice + DefaultDatReaderWriter
// + ObjectMeshManager. WbMeshAdapter opens its own file handles for
// the dat files (independent of our DatCollection).
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled)
{
var wbLogger = Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance;
_wbMeshAdapter = new AcDream.App.Rendering.Wb.WbMeshAdapter(_gl, _datDir, _dats, wbLogger);
Console.WriteLine("[N.4] WbFoundation flag is ENABLED — routing static 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.
{
AcDream.App.Rendering.Wb.LandblockSpawnAdapter? wbSpawnAdapter = null;
AcDream.App.Rendering.Wb.EntitySpawnAdapter? wbEntitySpawnAdapter = null;
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled && _wbMeshAdapter is not null)
{
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 at line 1004; it is non-null here because
// OnLoad wires _dats + _animLoader before this block runs.
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());
}
wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache, SequencerFactory);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
}
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
}
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache, _wbMeshAdapter);
if (AcDream.App.Rendering.Wb.WbFoundationFlag.IsEnabled
&& _wbMeshAdapter is not null && _wbEntitySpawnAdapter is not null)
{
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader, _textureCache, _wbMeshAdapter, _wbEntitySpawnAdapter);
}
// 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 || _staticMesh 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);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
if (dumpClothing)
{
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);
}
// Upload every GfxObj referenced by this landblock's entities.
// EnsureUploaded is idempotent so duplicates across landblocks are free.
if (_staticMesh is not null)
{
// Task 8: drain any pending EnvCell room-mesh sub-meshes first.
// The worker thread pre-built these CPU-side and stored them in
// _pendingCellMeshes. We must upload them here (render thread) before
// the per-MeshRef loop below tries to look them up via GfxObjMesh.Build,
// which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj
// dat ids. EnsureUploaded is idempotent so calling it here then seeing
// the same id again in the loop below is safe.
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes))
_staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes);
}
}
// Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs).
// Skip any ids already uploaded (includes the cell meshes just drained).
foreach (var entity in lb.Entities)
{
foreach (var meshRef in entity.MeshRefs)
{
// Skip EnvCell synthetic ids — already handled above (or already
// uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids
// are 0x02xxxxxx; anything else is not a GfxObj dat record.
if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue;
var gfx = _dats.Get(meshRef.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(meshRef.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
}
}
// 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;
}
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// 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);
}
if (_wbDrawDispatcher is not null)
{
_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds,
animatedEntityIds: animatedIds);
}
else
{
_staticMesh?.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);
}
}
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();
_staticMesh?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_samplerCache?.Dispose();
_textureCache?.Dispose();
_wbMeshAdapter?.Dispose(); // Phase N.4 WB foundation — null when flag off
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.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;
}
}