acdream/src/AcDream.App/Rendering/GameWindow.cs
Erik a316d6359c feat(chat): Phase J Tier 1+2 - @ verb prefix, /retell, /framerate, /loc
Three-tier rollout per the 2026-04-25 retail @help dump showing the
full ACE command surface. Tier 1 + most of Tier 2 in one commit.

TIER 1 - @ as / equivalent

ACE accepts both / and @ as verb prefixes (per its own help text:
"Note: You may substitute a forward slash (/) for the at symbol
(@)."). ChatInputParser now normalises @ to / for the verb-match
phase and re-enters parsing. Critical: for verbs we don't recognise
(@acehelp, @tele, @die, @version, @loc-on-server, @nonsense, ...),
the original @ is kept in the message text so ACE's CommandManager
intercepts the message server-side. If we substituted / there too,
ACE would treat it as plain Talk and broadcast it.

Result: @a hi / @tell Bob hi / @help / @clear / @reply / @retell
all route exactly like their / counterparts. @acehelp / @tele /
@version / @die etc. pass through to the server intact.

TIER 2 - client-only commands

- /retell <msg> (also @retell): resend to the last person you
  tell'd. Mirrors retail @retell. ChatVM tracks
  LastOutgoingTellTarget on each OnSelfSent(Tell, ...) entry —
  SenderGuid==0 distinguishes outgoing echo from inbound whispers,
  same way LastIncomingTellSender already worked. ChatInputParser
  takes a new optional lastOutgoingTellTarget param.

- /framerate (also @framerate): prints "Framerate: 144.2 FPS"
  into chat. Wired via a new ChatVM.FpsProvider Func<float>
  callback set by GameWindow at construction (closes over
  _lastFps). Falls back to "(provider unavailable)" if no
  callback is wired (tests / pre-live).

- /loc (also @loc): prints "Location: (123.4, 567.8, 60.0)" into
  chat. Wired via ChatVM.PositionProvider Func<Vector3> closing
  over GetDebugPlayerPosition() in GameWindow. ACE has a server-
  side @loc too; client wins here (instantaneous + uses the local
  interpolated position).

ChatPanel.TryHandleClientCommand grew @ aliases for /help /clear
/framerate /loc and the new EqAny helper for case-insensitive
multi-string matching. Help text rewritten to reference the
/ <-> @ equivalence and point at @acehelp / @acecommands for ACE's
full command list.

TIER 3 - automatic (no code)

Most retail @-commands (@allegiance motd, @afk, @die, @lifestone,
@corpse, @marketplace, @pkarena, @emote/@emotes, @fillcomps,
@permit, @consent, @squelch, @unsquelch, @messagetypes, @age,
@birth, @day, @endurance, @pklite, @version, @filter, @unfilter,
@loadfile, @log, @marketplace, ...) are server-side ACE commands.
Tier 1's passthrough takes care of them automatically — they
arrive via Talk, ACE recognises the @ and intercepts, replies via
SystemChat (which our 0xF7E0 wiring renders as [System] lines).

DEFERRED

- @saveui / @loadui / @lockui: ImGui layout save/load, ~1 hr
  standalone task. Filed for follow-up.
- @title <text>: rename chat window. ImGui window-id complications.
- Toggle-style @framerate (FPS overlay on/off): print-once is
  simpler and matches retail's most-common usage.

30 new tests:
- ChatInputParserAtPrefixTests: 11 covering @-prefix recognition,
  unknown-@ passthrough, /retell and @retell.
- ChatVMRetellAndProvidersTests: 8 covering LastOutgoingTellTarget
  tracking, FpsProvider/PositionProvider callbacks, no-provider
  fallback.
- ChatPanelInputTests: +3 (/framerate, @loc, @acehelp passthrough).

Solution total: 1063 green (243 Core.Net + 160 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 21:34:13 +02:00

5202 lines
262 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

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

using AcDream.Core.Plugins;
using 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 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;
private DebugLineRenderer? _debugLines;
private bool _debugCollisionVisible = true;
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 readonly 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<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
// this dict and uploads them via EnsureUploaded before the per-MeshRef loop.
// ConcurrentDictionary is required because the worker and render threads
// access this simultaneously without a broader lock.
private readonly System.Collections.Concurrent.ConcurrentDictionary<
uint, System.Collections.Generic.IReadOnlyList<AcDream.Core.Meshing.GfxObjSubMesh>>
_pendingCellMeshes = new();
// Step 4: pending LoadedCell objects built on the worker thread, drained
// to _cellVisibility on the render thread in ApplyLoadedTerrain.
private readonly System.Collections.Concurrent.ConcurrentBag<LoadedCell> _pendingCells = new();
/// <summary>
/// Phase 6.4: per-entity animation playback state for entities whose
/// MotionTable resolved to a real cycle. The render loop ticks each
/// of these every frame, advances the current frame number, then
/// rebuilds the entity's MeshRefs by re-flattening the Setup against
/// the new <see cref="DatReaderWriter.Types.AnimationFrame"/>.
/// Static decorations and entities with no motion table never
/// appear in this map.
/// </summary>
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
private sealed class AnimatedEntity
{
public required AcDream.Core.World.WorldEntity Entity;
public required DatReaderWriter.DBObjs.Setup Setup;
public required DatReaderWriter.DBObjs.Animation Animation;
public required int LowFrame;
public required int HighFrame;
public required float Framerate; // frames per second
public required float Scale; // server ObjScale baked into part transforms each tick
/// <summary>
/// Per-part identity carried over from the hydration pass: the
/// (post-AnimPartChanges) GfxObjId and the (post-resolution)
/// surface override map. The transform is recomputed every tick
/// from the current animation frame; only these two fields are
/// reused unchanged.
/// </summary>
public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary<uint, uint>? SurfaceOverrides)> PartTemplate;
public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame]
public AcDream.Core.Physics.AnimationSequencer? Sequencer;
}
private AcDream.Core.Physics.DatCollectionLoader? _animLoader;
// Phase E.1: central fan-out for animation hooks. Audio (E.2),
// particles (E.3), combat (E.4), and renderer state mutators all
// register sinks at startup. The router is always non-null so the
// per-entity tick loop can just call it unconditionally.
private readonly AcDream.Core.Physics.AnimationHookRouter _hookRouter = new();
// Phase E.2 audio. Null when ACDREAM_NO_AUDIO=1 or the OpenAL driver
// failed to init; all three are set together.
private AcDream.App.Audio.OpenAlAudioEngine? _audioEngine;
private AcDream.Core.Audio.DatSoundCache? _soundCache;
private AcDream.App.Audio.DictionaryEntitySoundTable? _entitySoundTables;
private AcDream.App.Audio.AudioHookSink? _audioSink;
// Phase E.3 particles.
private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new();
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;
// Remote-entity motion inference: tracks when each remote entity last
// moved meaningfully. Used in TickAnimations to swap to Ready when
// position has stalled for >StopIdleMs — retail observer pattern per
// ACE Player_Tick.cs line 368: the client never sends "released forward"
// MoveToState, so the server never broadcasts an explicit stop. Observer
// must infer it from position deltas.
private readonly Dictionary<uint, (System.Numerics.Vector3 Pos, System.DateTime Time)>
_remoteLastMove = new();
/// <summary>
/// Per-remote-entity dead-reckoning state for smoothing between server
/// UpdatePosition broadcasts. Without this, remote characters teleport
/// every ~100200 ms when the server pushes a new position (the retail
/// client hides the gap by integrating <c>CMotionInterp</c>-surfaced
/// velocity forward each tick — see chunk_00520000.c
/// <c>apply_current_movement</c> L7132-L7189 and holtburger's
/// <c>spatial/physics.rs::project_pose_by_velocity</c>).
///
/// <para>
/// Each entry records the last authoritative server position + time + a
/// measured velocity inferred from the delta between consecutive
/// UpdatePositions. The client's per-tick integrator uses the
/// sequencer's <c>CurrentVelocity</c> (rotated into world space by the
/// entity's orientation) as the primary source and falls back to the
/// inferred velocity when the motion table doesn't carry one (e.g. NPC
/// motion tables with HasVelocity=0).
/// </para>
/// </summary>
private readonly Dictionary<uint, RemoteMotion> _remoteDeadReckon = new();
/// <summary>
/// Per-remote-entity physics + motion stack — verbatim application of
/// retail's client-side motion pipeline to every remote. Mirrors
/// retail <c>FUN_00515020</c> <c>update_object</c> → <c>FUN_00513730</c>
/// <c>UpdatePositionInternal</c> → <c>FUN_005111D0</c>
/// <c>UpdatePhysicsInternal</c>, and ACE's <c>PhysicsObj.cs</c> port.
///
/// <para>
/// Retail has NO special "interpolator" for remote entities — it runs
/// the full motion state machine on every entity, local or remote,
/// and reconciles via hard-snap on UpdatePosition. This class simply
/// pairs a <see cref="AcDream.Core.Physics.PhysicsBody"/> with its
/// <see cref="AcDream.Core.Physics.MotionInterpreter"/> so each
/// remote gets the same treatment as the local player.
/// </para>
/// </summary>
private sealed class RemoteMotion
{
public AcDream.Core.Physics.PhysicsBody Body;
public AcDream.Core.Physics.MotionInterpreter Motion;
/// <summary>Last UpdatePosition timestamp — drives body.update_object sub-stepping.</summary>
public double LastServerPosTime;
/// <summary>Last known server position — kept for diagnostics / HUD.</summary>
public System.Numerics.Vector3 LastServerPos;
/// <summary>
/// Legacy field — no longer used for slerp (retail hard-snaps
/// per FUN_00514b90 set_frame). Kept to avoid churn.
/// </summary>
public System.Numerics.Quaternion TargetOrientation = System.Numerics.Quaternion.Identity;
/// <summary>
/// Angular velocity seeded from UpdateMotion TurnCommand/TurnSpeed
/// (π/2 × turnSpeed, signed). Applied per tick to body orientation
/// via manual integration (bypassing <c>PhysicsBody.update_object</c>'s
/// MinQuantum 30fps gate that would otherwise skip most ticks).
/// Zeroed on UM with TurnCommand absent.
/// </summary>
public System.Numerics.Vector3 ObservedOmega;
/// <summary>
/// Full 32-bit cell ID from the latest UpdatePosition. High 16 bits
/// = landblock (LBx,LBy), low 16 bits = cell index (outdoor 0x0001-
/// 0x0040, indoor 0x0100+). Fed into
/// <see cref="AcDream.Core.Physics.PhysicsEngine.ResolveWithTransition"/>
/// so the retail sphere-sweep can look up the right terrain/EnvCell
/// polygons for each remote's per-frame motion. Zero until the first
/// UP lands, which disables the transition step for that frame
/// (Euler alone, matching pre-wire behavior).
/// </summary>
public uint CellId;
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);
}
}
/// <summary>Soft-snap decay rate (1/sec). At this rate the residual
/// halves every 1/rate seconds. 8.0 → ~100ms half-life, so even a
/// 2m residual fades within ~300ms without visible snap.</summary>
private const float SnapResidualDecayRate = 8.0f;
/// <summary>
/// When the prediction error exceeds this many meters, we treat the
/// update as a teleport / rubber-band and hard-snap (no soft lerp).
/// Prevents the soft-snap logic from trying to smooth a genuine portal
/// or force-move event.
///
/// <para>
/// Matches retail's <c>GetAutonomyBlipDistance</c> (ACE
/// <c>PhysicsObj.cs:545</c>): 20m for creatures, 25m for players.
/// We use 20m as a conservative default — any delta larger than this
/// must be a teleport (portal, recall, spawn). A running character
/// with 1-second UpdatePosition cadence at 9.5 m/s produces deltas
/// of ~9.5m, well below this threshold, so normal movement flows
/// through the interpolation queue instead of hard-snapping.
/// </para>
/// </summary>
private const float SnapHardSnapThreshold = 20.0f;
/// <summary>
/// Soft-snap window in seconds: after an UpdatePosition arrives for a
/// remote entity, dead-reckoning continues but the "origin" for
/// predicted position is the server pos. This matches retail's snap
/// behavior — the server is authoritative, we just interpolate between
/// authoritative samples.
/// </summary>
private const float DeadReckonMaxPredictSeconds = 1.0f;
// Phase F.1-H.1 — client-side state classes fed by GameEventWiring.
// Exposed publicly so plugins + UI panels can bind directly.
public readonly AcDream.Core.Chat.ChatLog Chat = new();
// Phase I.6 — runtime state for retail's TurbineChat (0xF7DE) global
// chat rooms. Empty/disabled until the server fires
// SetTurbineChatChannels (0x0295) shortly after EnterWorld.
public readonly AcDream.Core.Chat.TurbineChatState TurbineChat = new();
public readonly AcDream.Core.Combat.CombatState Combat = new();
// Issue #11 — load static spell metadata from data/spells.csv at startup.
// Provides Family for buff stacking (issue #6) + names + icons + tooltips
// for the future Spellbook panel. The CSV is copied to bin/<config>/net10.0/data/
// by the csproj <None Include="...spells.csv"> entry. Loads silently to
// SpellTable.Empty if the file is missing (e.g. tooling contexts).
public readonly AcDream.Core.Spells.SpellTable SpellTable = LoadSpellTable();
public readonly AcDream.Core.Spells.Spellbook SpellBook = null!;
public readonly AcDream.Core.Items.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";
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
// _liveSession + Chat). Null when offline; PanelContext then falls
// back to NullCommandBus.Instance.
private AcDream.UI.Abstractions.LiveCommandBus? _commandBus;
// Phase I.7 — bridges CombatState's typed events into ChatLog as
// retail-faithful "You hit ..." / "... evaded your attack." lines.
// Disposable; lives for the duration of the live session.
private AcDream.Core.Chat.CombatChatTranslator? _combatChatTranslator;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
AcDream.Core.World.SkyStateProvider.Default());
public readonly AcDream.Core.Lighting.LightManager Lighting = new();
public readonly AcDream.Core.World.WeatherSystem Weather = new();
// Wired into the hook router in OnLoad so SetLightHook fires
// from the animation pipeline flip the matching LightSource.IsLit.
private AcDream.Core.Lighting.LightingHookSink? _lightingSink;
// Phase G.1 sky renderer + shared UBO. Created once the GL context
// exists in OnLoad; shared across every other renderer via
// binding = 1 so terrain/mesh/instanced/sky all read the same
// sun / ambient / fog / flash data per frame.
private AcDream.App.Rendering.SceneLightingUboBinding? _sceneLightingUbo;
private AcDream.App.Rendering.Sky.SkyRenderer? _skyRenderer;
private AcDream.Core.World.LoadedSkyDesc? _loadedSkyDesc;
// Phase 3a — retail-faithful per-Dereth-day weather roll. The active
// DayGroup is re-picked deterministically whenever the server clock
// crosses a DayTicks boundary. <c>long.MinValue</c> sentinel means
// "no day rolled yet" so the first RefreshSkyForCurrentDay call
// unconditionally installs a provider. See r12 §11 for the roller
// semantics.
private long _loadedSkyDayIndex = long.MinValue;
private AcDream.Core.World.DayGroupData? _activeDayGroup;
// Current rain/snow emitter handles — spawned on weather-kind change
// and stopped when the kind leaves Rain/Snow. Non-zero == active.
private int _rainEmitterHandle;
private int _snowEmitterHandle;
private AcDream.Core.World.WeatherKind _lastWeatherKind =
AcDream.Core.World.WeatherKind.Clear;
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
// Accumulated mouse X delta for player turning; written in mouse-move
// callback, consumed + reset in OnUpdate each frame.
private float _playerMouseDeltaX;
// 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;
// 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
/// <summary>
/// Phase 6.6/6.7: server-guid → local WorldEntity lookup so
/// UpdateMotion and UpdatePosition handlers can find the entity the
/// server is talking about. The sequential <see cref="_liveEntityIdCounter"/>
/// keys the render list; this parallel dictionary keys by server guid.
/// </summary>
private readonly Dictionary<uint, AcDream.Core.World.WorldEntity> _entitiesByServerGuid = new();
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);
}
/// <summary>
/// Issue #11 — load <c>data/spells.csv</c> from the bin output (copied
/// there by the csproj). Returns <c>SpellTable.Empty</c> + logs a
/// warning if the file is missing (e.g. when GameWindow is instantiated
/// from tooling contexts that don't include the data folder).
/// </summary>
private static AcDream.Core.Spells.SpellTable LoadSpellTable()
{
string path = System.IO.Path.Combine(
System.AppContext.BaseDirectory, "data", "spells.csv");
try
{
if (System.IO.File.Exists(path))
{
var t = AcDream.Core.Spells.SpellTable.LoadFromCsv(path);
Console.WriteLine($"spells: loaded {t.Count} entries from spells.csv");
return t;
}
Console.WriteLine($"spells: data/spells.csv not found at {path}; using empty table");
}
catch (Exception ex)
{
Console.WriteLine($"spells: load failed ({ex.Message}); using empty table");
}
return AcDream.Core.Spells.SpellTable.Empty;
}
public void Run()
{
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream — phase 1",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = false, // off during development so the perf overlay shows true framerate
};
_window = Window.Create(options);
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.Closing += OnClosing;
_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();
foreach (var kb in _input.Keyboards)
kb.KeyDown += (_, key, _) =>
{
if (key == Key.F)
_cameraController?.ToggleFly();
else if (key == Key.F3)
{
// Dump player position + ALL entities (visible rendered) + shadow objs within 15m.
// Lets us see visible-entity-without-collision gaps.
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}");
// Collect VISIBLE entities within 15m (from GpuWorldState)
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
foreach (var e in _worldState.Entities)
{
float dx = e.Position.X - pos.X;
float dy = e.Position.Y - pos.Y;
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
}
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
{
float d = (e.Position - pos).Length();
Console.WriteLine(
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
}
// Collect shadow objects within 15m
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}");
}
}
else if (key == Key.F1)
{
// Phase I.2: F1 now toggles the entire ImGui DebugPanel
// visibility. The old per-section toggles (F4/F5/F6) are
// gone — sections are collapsing headers inside the
// single window now.
foreach (var panel in EnumerateDebugPanel())
{
panel.IsVisible = !panel.IsVisible;
_debugVm?.AddToast($"Debug panel {(panel.IsVisible ? "ON" : "OFF")}");
}
}
else if (key == Key.F2)
{
// Real gameplay toggle — keeps the F2 keybind. Same
// action is wired into the DebugPanel's
// "Toggle collision wires" button via DebugVM.
ToggleCollisionWires();
}
else if (key == Key.F7)
{
// Phase I.2: keep F7 as a hotkey alias for the
// DebugPanel's "Cycle time of day" button.
CycleTimeOfDay();
}
else if (key == Key.F10)
{
// Phase I.2: keep F10 as a hotkey alias for the
// DebugPanel's "Cycle weather" button.
CycleWeather();
}
else if (key == Key.F8 || key == Key.F9)
{
// Adjust whichever mode's sensitivity is currently active.
// Multiplicative step (1.2x / /1.2x) so low values stay fine
// grained and high values move in proportional chunks.
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 = (key == Key.F9) ? current * 1.2f : current / 1.2f;
next = MathF.Min(3.0f, MathF.Max(0.005f, next));
if (modeLabel == "Chase") _sensChase = next;
else if (modeLabel == "Fly") _sensFly = next;
else _sensOrbit = next;
_debugVm?.AddToast($"{modeLabel} sens {next:F3}x");
}
else if (key == Key.Escape)
{
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
{
// Exit player mode on Escape too.
_playerMode = false;
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_playerCurrentAnimCommand = null;
}
else
_window!.Close();
}
// Phase B.2: Tab toggles between fly and player-movement mode.
// Only active when a live session is in-world and we have a
// player entity spawned on screen.
else if (key == Key.Tab && _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld)
{
_playerMode = !_playerMode;
if (_playerMode)
{
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity))
{
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
// Read the real step height from the player's Setup dat.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
if (playerSetup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
? playerSetup.StepUpHeight
: 2f; // default human step height
}
else
{
_playerController.StepUpHeight = 2f; // default human step height
}
// Derive initial cell ID from the entity's world position.
int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f);
int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f);
float plocalX = playerEntity.Position.X - (plbX - _liveCenterX) * 192f;
float plocalY = playerEntity.Position.Y - (plbY - _liveCenterY) * 192f;
uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u;
// Resolve the initial position through the physics engine to
// get the correct terrain Z. The server-sent Z may be stale
// from a previous ACE relocation. With indoor transitions
// disabled, Resolve will always snap to outdoor terrain Z.
var initResult = _physicsEngine.Resolve(
playerEntity.Position, pinitCellId & 0xFFFFu,
System.Numerics.Vector3.Zero, 100f); // huge step height for initial snap
_playerController.SetPosition(initResult.Position, initResult.CellId);
// Option B (r03 §1.3): wire the player's sequencer current
// cycle velocity into MotionInterpreter.get_state_velocity so
// body physics uses MotionData.Velocity * speedMod instead of
// the hardcoded RunAnimSpeed/WalkAnimSpeed. Keeps legs-per-meter
// invariant regardless of which MotionTable drives the player
// (Humanoid RunForward happens to bake Velocity=4.0 so the old
// path looked right there, but the decompiled constant is a
// MotionTable property, not a global).
if (_animatedEntities.TryGetValue(playerEntity.Id, out var playerAE)
&& playerAE.Sequencer is { } playerSeq)
{
_playerController.AttachCycleVelocityAccessor(() => playerSeq.CurrentVelocity);
}
// Derive initial yaw from the entity's rotation.
// The render loop stores rotation as Yaw - PI/2 (to
// compensate for AC models facing +Y at identity), so
// we add PI/2 back when extracting to get the real yaw.
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,
};
_playerMouseDeltaX = 0f;
_cameraController?.EnterChaseMode(_chaseCamera);
// ModeChanged event fires from EnterChaseMode → OnCameraModeChanged
// captures mouse in raw mode automatically.
}
else
{
// Player entity not yet spawned — revert.
_playerMode = false;
Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet");
}
}
else
{
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f;
// ExitChaseMode fires ModeChanged → OnCameraModeChanged(true=fly) → raw mode stays ON
}
}
};
foreach (var mouse in _input.Mice)
{
mouse.MouseMove += (m, pos) =>
{
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 (_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).
_chaseCamera.YawOffset -= dx * 0.004f * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
else
{
// Normal chase: X turns the character, Y pitches camera.
_playerMouseDeltaX += dx * sens;
_chaseCamera.AdjustPitch(dy * 0.003f * sens);
}
}
else if (_cameraController.IsFlyMode)
{
float sens = _sensFly;
_cameraController.Fly.Look(dx * sens, dy * sens);
}
else
{
float sens = _sensOrbit;
if (m.IsButtonPressed(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;
};
mouse.MouseDown += (_, btn) =>
{
if (btn == MouseButton.Right && _playerMode
&& _cameraController?.IsChaseMode == true)
{
_rmbHeld = true;
}
};
mouse.MouseUp += (_, btn) =>
{
if (btn == MouseButton.Right)
{
// Camera stays at the orbited position — no snap back.
_rmbHeld = false;
}
};
mouse.Scroll += (_, scroll) =>
{
if (_cameraController is null) return;
// Chase mode: mouse wheel zooms (adjusts camera distance).
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
_chaseCamera.AdjustDistance(-scroll.Y * 0.8f);
}
// Fly mode: no scroll action (could adjust move speed later).
else if (_cameraController.IsFlyMode)
{
// no-op
}
// Orbit mode: wheel zooms the orbit camera.
else
{
_cameraController.Orbit.Distance = Math.Clamp(
_cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f);
}
};
}
_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);
// 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");
}
}
// 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);
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Vitals.VitalsPanel(_vitalsVm));
// 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(),
};
_panelHost.Register(
new AcDream.UI.Abstractions.Panels.Chat.ChatPanel(chatVm));
// 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;
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
Console.WriteLine("devtools: ImGui panel host ready (VitalsPanel + ChatPanel + DebugPanel registered)");
}
catch (Exception ex)
{
Console.WriteLine($"devtools: ImGui init failed: {ex.Message} — devtools disabled");
_imguiBootstrap?.Dispose();
_imguiBootstrap = null;
_panelHost = null;
_vitalsVm = null;
_debugVm = null;
_debugPanel = null;
}
}
uint centerLandblockId = 0xA9B4FFFFu;
Console.WriteLine($"loading world view centered on 0x{centerLandblockId:X8}");
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
var heightTable = region?.LandDefs.LandHeightTable;
if (heightTable is null || heightTable.Length < 256)
throw new InvalidOperationException("Region.LandDefs.LandHeightTable missing or truncated");
// Phase G.1: parse the full sky descriptor (day groups, keyframes,
// celestial mesh layers) and swap WorldTime's provider over to the
// dat-backed keyframes. The stub default provider is only used if
// the Region lacks HasSkyInfo.
if (region is not null)
{
_loadedSkyDesc = AcDream.Core.World.SkyDescLoader.LoadFromRegion(region);
if (_loadedSkyDesc is not null)
{
// Phase 3d: do NOT assign WorldTime.TickSize from
// SkyDesc.TickSize. Agent C's decompile (chunk_00500000.c:6241
// FUN_005062e0) shows SkyDesc.TickSize is the "next sky-tick
// deadline" period — a throttle — NOT a game-time
// advancement rate. ACE's server advances PortalYearTicks at
// 1.0 ticks per real-second (Timers.cs: `PortalYearTicks +=
// worldTickTimer.Elapsed.TotalSeconds`). Our client
// extrapolation between TimeSyncs must match: 1.0.
//
// Previous behavior: WorldTime.TickSize = 0.8 (from the live
// SkyDesc.TickSize). Between ~20s TimeSync gaps we fell 4
// ticks behind the server, producing a visible "acdream sky
// is behind retail" time-of-day mismatch (user-verified
// 2026-04-23).
WorldTime.TickSize = 1.0;
// Phase 3f: adopt the dat's GameTime.ZeroTimeOfYear as the
// calendar-extraction offset. Dereth's dat value is 3600
// (verified 2026-04-23 live dump); ACE's DerethDateTime.cs
// comment that "tick 0 = Morntide-and-Half" (3333.75
// offset = +7/16) is WRONG by 266.25 ticks against the
// authoritative dat. The mismatch cascaded into both the
// wrong hour label AND the wrong DayOfYear at boundary
// times (different LCG seed → different DayGroup roll),
// which explained the user's observation of "acdream
// clear night, retail stormy pre-dawn" at the same
// server PortalYearTicks.
if (region.GameTime is not null)
{
AcDream.Core.World.DerethDateTime.SetOriginOffsetFromDat(
region.GameTime.ZeroTimeOfYear);
Console.WriteLine(
$"sky: GameTime ZeroTimeOfYear={region.GameTime.ZeroTimeOfYear} " +
$"(was default {AcDream.Core.World.DerethDateTime.DayFractionOriginOffsetTicks})");
}
Console.WriteLine(
$"sky: loaded Region 0x13000000 — {_loadedSkyDesc.DayGroups.Count} day groups, " +
$"SkyDesc.TickSize={_loadedSkyDesc.TickSize} (throttle, not rate), " +
$"LightTickSize={_loadedSkyDesc.LightTickSize}");
// Initial DayGroup roll using whatever WorldTime currently
// has (either the hardcoded boot seed or a pre-arrived
// server sync). RefreshSkyForCurrentDay will re-roll when
// ServerTimeUpdated delivers the real ConnectRequest tick.
RefreshSkyForCurrentDay();
}
}
// Seed WorldTime to noon so outdoor scenes aren't pitch-black before
// the server sends its first TimeSync packet (offline rendering in
// particular never receives one).
//
// "Noon" here means sun at zenith — dayFraction = 0.5. Because
// DerethDateTime applies a +7/16 offset (tick 0 = Morntide-and-Half,
// hour 8 of 16), we need raw ticks = 476.25 (one hour past tick 0 =
// Midsong / Hour 9, which is what retail considers noon).
//
// Using `DayTicks * 0.5 = 3810` WOULD be correct if the offset were
// zero, but with our 3333.75-tick shift it lands on dayFraction
// 0.9375 — that's Gloaming-and-Half (sunset, nearly midnight),
// producing a dim orange sky with the sun below the horizon until
// TimeSync arrives.
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
// 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<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
_textureCache = new TextureCache(_gl, _dats);
_staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache);
// 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);
// 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);
// 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.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
_liveSession.TeleportStarted += OnTeleportStarted;
// 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.
AcDream.Core.Net.GameEventWiring.WireAll(
_liveSession.GameEvents, Items, Combat, SpellBook, Chat, LocalPlayer,
TurbineChat);
// 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<AcDream.UI.Abstractions.SendChatCmd>(cmd =>
{
if (string.IsNullOrEmpty(cmd.Text)) return;
switch (cmd.Channel)
{
case AcDream.UI.Abstractions.ChatChannelKind.Say:
// Phase J: drop optimistic /say echo. ACE's
// HandleActionTalk broadcasts a HearSpeech back
// to the sender too, and ChatLog.OnLocalSpeech
// detects own-guid match to render it as
// "You say, ...". Optimistic-echoing here
// doubled the line. ALSO: don't echo "@xxx"
// server-side admin commands — ACE consumes
// them silently and replies via SystemChat.
liveSession.SendTalk(cmd.Text);
break;
case AcDream.UI.Abstractions.ChatChannelKind.Tell:
if (string.IsNullOrEmpty(cmd.TargetName)) return;
liveSession.SendTell(cmd.TargetName, cmd.Text);
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Tell, cmd.Text,
targetOrChannel: cmd.TargetName);
break;
default:
// Phase I.6: try TurbineChat first for the global
// community channels (General/Trade/LFG/Roleplay/
// Society/Olthoi) — they ride 0xF7DE TurbineChat.
// Allegiance is double-routed: try TurbineChat first
// (when the player has a Turbine allegiance room) and
// fall back to the legacy 0x0147 ChatChannel.
//
// We do NOT optimistic-echo channels: ACE's
// TurbineChatHandler broadcasts EventSendToRoom back
// to the sender too, so we always get the canonical
// echo from the server. Optimistic-echoing here
// double-prints the message (one as "[Trade] hello"
// from us, one as "[Trade] +Acdream says, \"hello\""
// from the server). Trust the server.
var turbine = ResolveTurbineForKind(cmd.Channel, turbineChat);
if (turbine is not null)
{
uint cookie = turbineChat.NextContextId();
// Use the live player guid if it's been captured;
// otherwise 0 (server treats unknown sender_id
// gracefully — the cookie is what we care about).
uint senderGuid = _playerServerGuid != 0u
? _playerServerGuid
: playerGuid;
Console.WriteLine(
$"chat: outbound TurbineChat {turbine.Value.DisplayName} " +
$"room=0x{turbine.Value.RoomId:X8} chatType={turbine.Value.ChatType} " +
$"cookie=0x{cookie:X} sender=0x{senderGuid:X8} len={cmd.Text.Length}");
liveSession.SendTurbineChatTo(
roomId: turbine.Value.RoomId,
chatType: turbine.Value.ChatType,
dispatchType: (uint)AcDream.Core.Net.Messages.TurbineChat.DispatchType.SendToRoomById,
senderGuid: senderGuid,
text: cmd.Text,
cookie: cookie);
// No optimistic echo: server EventSendToRoom
// broadcast comes back with sender="+Acdream"
// and is rendered by ChatVM as
// "[Trade] +Acdream says, \"hello\"".
break;
}
var resolved = AcDream.UI.Abstractions.ChannelResolver.Resolve(cmd.Channel);
if (resolved is null)
{
// Diagnostic: the user picked a channel kind that
// (a) isn't a Turbine channel TurbineChatState
// knows about and (b) has no legacy ChatChannel
// mapping. Most common cause: TurbineChat hasn't
// been enabled yet (server didn't send 0x0295)
// and the kind is General/Trade/LFG/etc.
Console.WriteLine(
$"chat: SendChatCmd kind={cmd.Channel} dropped " +
$"(turbine.Enabled={turbineChat.Enabled} no legacy id)");
return;
}
Console.WriteLine(
$"chat: outbound legacy ChatChannel {resolved.Value.DisplayName} " +
$"id=0x{resolved.Value.ChannelId:X8} len={cmd.Text.Length}");
liveSession.SendChannel(resolved.Value.ChannelId, cmd.Text);
// Legacy channels (Fellowship / Allegiance / Patron /
// Monarch / Vassals / CoVassals) — keep the optimistic
// echo because legacy ChatChannel does NOT always
// broadcast back to the sender. ChannelName is the
// friendly display name so ChatVM renders it as
// "[Fellowship] +Acdream says, \"hello\"".
chat.OnSelfSent(
AcDream.Core.Chat.ChatKind.Channel, cmd.Text,
targetOrChannel: resolved.Value.DisplayName);
break;
}
});
// Issue #5: feed PrivateUpdateVital + PrivateUpdateVitalCurrent
// into LocalPlayer so VitalsPanel can draw Stam / Mana bars.
_liveSession.VitalUpdated += v =>
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
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);
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;
}
}
/// <summary>
/// Convert a Phase 4.7 CreateObject spawn into a WorldEntity with hydrated
/// mesh refs and register it in IGameState. Called from WorldSession events
/// on the main thread (Tick runs in the Silk.NET Update callback).
/// </summary>
private void OnLiveEntitySpawned(AcDream.Core.Net.WorldSession.EntitySpawn spawn)
{
// Phase A.1 hotfix: live CreateObject handler reads dats extensively
// (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned
// entity. All of it must run under the dat lock so it doesn't race
// with BuildLandblockForStreaming on the worker thread.
lock (_datLock)
{
OnLiveEntitySpawnedLocked(spawn);
}
}
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.
if (_entitiesByServerGuid.TryGetValue(spawn.Guid, out var existingEntity))
{
_worldState.RemoveEntityByServerGuid(spawn.Guid);
_worldGameState.RemoveById(existingEntity.Id);
_animatedEntities.Remove(existingEntity.Id);
// Physics collision registry entry is keyed by local id too.
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
// Dead-reckon state is keyed by SERVER guid (not local id) so we
// clear using the same guid the new spawn will use. Leaving old
// SnapResidual / DeadReckonedPos in would make the next first
// UpdatePosition look like a 2m-residual soft-snap.
_remoteDeadReckon.Remove(spawn.Guid);
_remoteLastMove.Remove(spawn.Guid);
}
// 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";
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} " +
$"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}");
// Target the statue specifically for full diagnostic dump: Name match
// is cheap and gives us exactly one entity's worth of log regardless
// of arrival order.
bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
if (isStatue)
{
Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}");
Console.WriteLine($"live: [STATUE] mtable=0x{(spawn.MotionTableId ?? 0):X8} stance=0x{(spawn.MotionState?.Stance ?? 0):X4} cmd=0x{(spawn.MotionState?.ForwardCommand ?? 0):X4}");
if (spawn.TextureChanges is { } tcs)
{
foreach (var tc in tcs)
Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}");
}
if (spawn.SubPalettes is { } sps)
{
Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}");
foreach (var subPal in sps)
Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}");
}
if (spawn.AnimPartChanges is { } apcs)
{
foreach (var apc in apcs)
Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}");
}
// Dump the BASE setup's part list before AnimPartChanges, so we can
// see how many parts the statue's Setup actually has + what their
// default GfxObjs are. The retail statue may have additional parts
// (e.g. a pedestal sub-mesh) that our setup loader is dropping or
// we're rendering with wrong default GfxObjs.
if (spawn.SetupTableId is { } sid && _dats is not null)
{
var baseSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(sid);
if (baseSetup is not null)
{
Console.WriteLine($"live: [STATUE] base Setup 0x{sid:X8} has {baseSetup.Parts.Count} parts:");
for (int pi = 0; pi < baseSetup.Parts.Count; pi++)
{
uint partGfxId = (uint)baseSetup.Parts[pi];
var pgfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(partGfxId);
int subCount = pgfx?.Surfaces.Count ?? -1;
Console.WriteLine($"live: [STATUE] part[{pi}] gfxObj=0x{partGfxId:X8} surfaces={subCount}");
}
Console.WriteLine($"live: [STATUE] placementFrames count={baseSetup.PlacementFrames.Count}");
}
}
}
if (_dats is null || _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<DatReaderWriter.DBObjs.Setup>(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<AcDream.Core.World.MeshRef>(flat);
var animPartChanges = spawn.AnimPartChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.AnimPartChange>();
foreach (var change in animPartChanges)
{
if (change.PartIndex < parts.Count)
{
parts[change.PartIndex] = new AcDream.Core.World.MeshRef(
change.NewModelId, parts[change.PartIndex].PartTransform);
}
}
// Build per-part texture overrides. The server sends TextureChanges as
// (partIdx, oldSurfaceTextureId, newSurfaceTextureId) where both ids
// are in the SurfaceTexture (0x05) range. Our sub-meshes are keyed
// by Surface (0x08) ids whose `OrigTextureId` field points to a
// SurfaceTexture. So we have to resolve each Surface → OrigTextureId,
// match that against the part's oldSurfaceTextureId set, and build
// a new dict keyed by Surface id → replacement OrigTextureId. The
// renderer then calls TextureCache.GetOrUploadWithOrigTextureOverride
// to get a texture decoded with the replacement SurfaceTexture
// substituted inside the Surface's decode chain.
var textureChanges = spawn.TextureChanges ?? Array.Empty<AcDream.Core.Net.Messages.CreateObject.TextureChange>();
Dictionary<int, Dictionary<uint, uint>>? resolvedOverridesByPart = null;
if (textureChanges.Count > 0)
{
// First pass: group (oldOrigTex → newOrigTex) per part.
var perPartOldToNew = new Dictionary<int, Dictionary<uint, uint>>();
foreach (var tc in textureChanges)
{
if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict))
{
dict = new Dictionary<uint, uint>();
perPartOldToNew[tc.PartIndex] = dict;
}
// Last write wins — matches observed duplicate semantics.
dict[tc.OldTexture] = tc.NewTexture;
}
// Second pass: resolve each affected part's Surface chain and
// build the Surface-id-keyed override map the renderer consumes.
bool isStatueDiag = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase);
resolvedOverridesByPart = new Dictionary<int, Dictionary<uint, uint>>();
for (int pi = 0; pi < parts.Count; pi++)
{
if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue;
var partGfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(parts[pi].GfxObjId);
if (partGfx is null)
{
if (isStatueDiag)
Console.WriteLine($"live: [STATUE] resolve part={pi} GfxObj 0x{parts[pi].GfxObjId:X8} missing");
continue;
}
_physicsDataCache.CacheGfxObj(parts[pi].GfxObjId, partGfx);
if (isStatueDiag)
Console.WriteLine($"live: [STATUE] resolve part={pi} gfx=0x{parts[pi].GfxObjId:X8} surfaces={partGfx.Surfaces.Count}");
Dictionary<uint, uint>? resolved = null;
foreach (var surfQid in partGfx.Surfaces)
{
uint surfId = (uint)surfQid;
var surfDat = _dats.Get<DatReaderWriter.DBObjs.Surface>(surfId);
if (surfDat is null) continue;
uint origTexId = (uint)surfDat.OrigTextureId;
bool hit = origTexId != 0 && oldToNew.TryGetValue(origTexId, out uint newOrigTex) && (newOrigTex != 0 || true);
if (isStatueDiag)
Console.WriteLine($"live: [STATUE] surface=0x{surfId:X8} origTex=0x{origTexId:X8} " + (hit ? "[MATCH]" : "[miss]"));
if (origTexId == 0) continue;
if (oldToNew.TryGetValue(origTexId, out uint newId))
{
resolved ??= new Dictionary<uint, uint>();
resolved[surfId] = newId;
}
}
if (resolved is not null)
resolvedOverridesByPart[pi] = resolved;
}
}
// Apply ObjScale by baking a scale matrix into each MeshRef's
// PartTransform. Scenery hydration already does this pattern
// (scaleMat baked into PartTransform at Setup flatten time).
// Fallback to 1.0 if the server didn't send ObjScale (common for
// creatures/characters whose size is intrinsic to the mesh).
float scale = spawn.ObjScale ?? 1.0f;
var scaleMat = System.Numerics.Matrix4x4.CreateScale(scale);
var meshRefs = new List<AcDream.Core.World.MeshRef>();
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
var mr = parts[partIdx];
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
IReadOnlyDictionary<uint, uint>? surfaceOverrides = null;
if (resolvedOverridesByPart is not null && resolvedOverridesByPart.TryGetValue(partIdx, out var partOverrides))
surfaceOverrides = partOverrides;
// Multiplication order matches offline scenery hydration:
// `PartTransform * scaleMat`. In row-vector semantics this means
// "apply PartTransform first (which includes the part-attachment
// translation), then scale in the resulting space." Using the
// opposite order (`scaleMat * PartTransform`) scales in mesh-local
// space first, which leaves the part-attachment offset unscaled —
// for multi-part entities like the Nullified Statue that causes
// the parts to drift relative to each other ("distorted") and the
// base anchor to end up below the ground ("sinks into foundry").
var transform = scale == 1.0f ? mr.PartTransform : mr.PartTransform * scaleMat;
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;
}
// 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);
}
var entity = new AcDream.Core.World.WorldEntity
{
Id = _liveEntityIdCounter++,
ServerGuid = spawn.Guid,
SourceGfxObjOrSetupId = spawn.SetupTableId.Value,
Position = worldPos,
Rotation = rot,
MeshRefs = meshRefs,
PaletteOverride = paletteOverride,
};
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;
// Phase B.2: capture the server-sent MotionTableId for our own
// character so UpdatePlayerAnimation can pass it to GetIdleCycle.
// The Setup's DefaultMotionTable is often 0 for human characters;
// the real table comes from PhysicsDescriptionFlag.MTable.
if (spawn.Guid == _playerServerGuid && spawn.MotionTableId is not null)
_playerMotionTableId = spawn.MotionTableId;
// Phase 6.4: register for per-frame playback if we resolved a real
// cycle with a non-zero framerate and at least two frames in the
// cycle (single-frame poses are static and don't need ticking).
// Diagnostic: log why we did / didn't register so we can tell
// which entities fall through the filter.
if (idleCycle is null)
_liveAnimRejectNoCycle++;
else if (idleCycle.Framerate == 0f)
_liveAnimRejectFramerate++;
else if (idleCycle.HighFrame <= idleCycle.LowFrame)
_liveAnimRejectSingleFrame++;
else if (idleCycle.Animation.PartFrames.Count <= 1)
_liveAnimRejectPartFrames++;
if (idleCycle is not null && idleCycle.Framerate != 0f
&& idleCycle.HighFrame > idleCycle.LowFrame
&& idleCycle.Animation.PartFrames.Count > 1)
{
// Snapshot per-part identity from the hydrated meshRefs so the
// tick can rebuild MeshRefs without redoing AnimPartChanges or
// texture-override resolution every frame.
var template = new (uint, IReadOnlyDictionary<uint, uint>?)[meshRefs.Count];
for (int i = 0; i < meshRefs.Count; i++)
template[i] = (meshRefs[i].GfxObjId, meshRefs[i].SurfaceOverrides);
// Create an AnimationSequencer if we can load the MotionTable.
AcDream.Core.Physics.AnimationSequencer? sequencer = null;
if (_animLoader is not null)
{
uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable;
if (mtableId != 0)
{
var mtable = _dats.Get<DatReaderWriter.DBObjs.MotionTable>(mtableId);
if (mtable is not null)
{
sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader);
uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle;
uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u;
sequencer.SetCycle(seqStyle, seqMotion);
}
}
}
_animatedEntities[entity.Id] = new AnimatedEntity
{
Entity = entity,
Setup = setup,
Animation = idleCycle.Animation,
LowFrame = Math.Max(0, idleCycle.LowFrame),
HighFrame = Math.Min(idleCycle.HighFrame, idleCycle.Animation.PartFrames.Count - 1),
Framerate = idleCycle.Framerate,
Scale = scale,
PartTemplate = template,
CurrFrame = idleCycle.LowFrame,
Sequencer = sequencer,
};
// Phase E.2: register entity's SoundTable so SoundTableHook can
// resolve creature-specific sounds (footsteps, attack vocalizations,
// damage grunts, etc). Server-sent SoundTable override would take
// precedence here when the wire layer delivers it.
if (_entitySoundTables is not null)
{
uint soundTableId = (uint)setup.DefaultSoundTable;
if (soundTableId != 0)
_entitySoundTables.Set(entity.Id, soundTableId);
}
}
// 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}");
}
}
/// <summary>
/// Bilinear sample of the landblock heightmap at (x, y) in landblock-local
/// world units. Matches the x-major indexing convention of LandblockMesh.
/// </summary>
private float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
{
// Exact port of WorldBuilder TerrainUtils.GetHeight (line 59-108).
// Barycentric interpolation over the cell's triangle pair, respecting
// the cell's split direction (SWtoNE vs SEtoNW).
const float CellSize = 24f;
uint cellX = (uint)(worldX / CellSize);
uint cellY = (uint)(worldY / CellSize);
if (cellX >= 8) cellX = 7;
if (cellY >= 8) cellY = 7;
uint landblockX = (block.Id >> 24) & 0xFFu;
uint landblockY = (block.Id >> 16) & 0xFFu;
var splitDirection = AcDream.Core.Terrain.TerrainBlending.CalculateSplitDirection(
landblockX, cellX, landblockY, cellY);
// 4 cell corners (heightmap x-major: Height[x*9 + y])
float h0 = heightTable[block.Height[cellX * 9 + cellY]]; // BL
float h1 = heightTable[block.Height[(cellX + 1) * 9 + cellY]]; // BR
float h2 = heightTable[block.Height[(cellX + 1) * 9 + (cellY + 1)]]; // TR
float h3 = heightTable[block.Height[cellX * 9 + (cellY + 1)]]; // TL
float lx = worldX - cellX * CellSize;
float ly = worldY - cellY * CellSize;
float s = lx / CellSize;
float t = ly / CellSize;
if (splitDirection == AcDream.Core.Terrain.CellSplitDirection.SWtoNE)
{
if (s + t <= 1f)
{
return h0 * (1f - s - t) + h1 * s + h3 * t;
}
else
{
float u = s + t - 1f;
float v = 1f - s;
float w = 1f - u - v;
return h1 * w + h2 * u + h3 * v;
}
}
else // SEtoNW
{
if (s >= t)
{
return h0 * (1f - s) + h1 * (s - t) + h2 * t;
}
else
{
return h0 * (1f - t) + h2 * s + h3 * (t - s);
}
}
}
/// <summary>
/// Phase 6.6: the server says an entity's motion has changed. Look up
/// the AnimatedEntity for that guid, re-resolve the idle cycle with the
/// new (stance, forward-command) override, and if the cycle is still
/// animated, swap in the new animation/frame range. Entities not in
/// the animated map (static props, entities rejected at spawn time)
/// are simply ignored — there's nothing to tick for them.
/// </summary>
private void OnLiveMotionUpdated(AcDream.Core.Net.WorldSession.EntityMotionUpdate update)
{
if (_dats is null) return;
if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return;
if (!_animatedEntities.TryGetValue(entity.Id, out var ae)) return;
// 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;
// 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 ?? 0f;
uint seqStyle = ae.Sequencer?.CurrentStyle ?? 0;
uint seqMotion = ae.Sequencer?.CurrentMotion ?? 0;
Console.WriteLine(
$"UM guid=0x{update.Guid:X8} 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
uint fullMotion;
if (!command.HasValue || command.Value == 0)
{
// Stop — return to the style's default substate (Ready).
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.
float speedMod = update.MotionState.ForwardSpeed ?? 1f;
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
{
// 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;
animSpeed = MathF.Abs(update.MotionState.TurnSpeed ?? 1f);
}
}
ae.Sequencer.SetCycle(fullStyle, animCycle, 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
if (_remoteDeadReckon.TryGetValue(update.Guid, out var remoteMot))
{
// Forward axis (Ready / WalkForward / RunForward / WalkBackward).
remoteMot.Motion.DoInterpretedMotion(
fullMotion, speedMod, modifyInterpretedState: true);
// 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;
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 the Commands list — one-shot Actions, Modifiers, and
// ChatEmotes (mask classes 0x10, 0x20, 0x13 per r03 §3). These
// live in the motion table's Links / Modifiers dicts, not
// Cycles, and are played on top of the current cycle via
// PlayAction which resolves the right dict and interleaves the
// action frames before the cyclic tail.
//
// A typical NPC wave looks like:
// ForwardCommand=0 (Ready) + Commands=[{0x0087=Wave, speed=1.0}]
// [{0x0003=Ready, ...}]
// Each item runs through PlayAction (for 0x10/0x20 mask) or the
// standard SetCycle path (for 0x40 SubState). We leave SubState
// commands to fall through to the next UpdateMotion; that's how
// retail handles transition sequences (Wave → Ready).
if (update.MotionState.Commands is { Count: > 0 } cmds)
{
foreach (var item in cmds)
{
// Restore the 32-bit MotionCommand from the wire's 16-bit
// truncation by OR-ing class bits. The class is encoded
// in the low byte's high nibble via command ranges:
// 0x0003, 0x0005-0x0010 — SubState class (0x40xx xxxx)
// 0x0087 (Wave), 0x007D (BowDeep) etc — ChatEmote (0x13xx xxxx)
// 0x0051-0x00A1 — Action class (0x10xx xxxx)
//
// The retail MotionCommand enum carries the class byte in
// bits 24-31. DatReaderWriter's enum values match. For
// broadcasts, servers emit only low 16 bits (ACE
// InterpretedMotionState.cs:139). We reconstruct via a
// range-based lookup. See MotionCommand.generated.cs.
uint fullCmd = AcDream.Core.Physics.MotionCommandResolver.ReconstructFullCommand(item.Command);
if (fullCmd == 0) continue;
// Action class: play through the link dict then drop back
// to the current cycle. Modifier class: resolve from the
// Modifiers dict and combine on top. SubState: cycle
// change; route through SetCycle so the style-specific
// cycle fallback applies.
uint cls = fullCmd & 0xFF000000u;
if ((cls & 0x10000000u) != 0 || (cls & 0x20000000u) != 0
|| cls == 0x12000000u || cls == 0x13000000u)
{
ae.Sequencer.PlayAction(fullCmd, item.Speed);
}
else if ((cls & 0x40000000u) != 0)
{
// Substate in the command list — typically the "and
// then return to Ready" item. Update the cycle.
ae.Sequencer.SetCycle(fullStyle, fullCmd, item.Speed);
}
// else: Style / UI / Toggle class — not animation-driving.
}
}
return;
}
// ── Legacy path (entities without a sequencer) ──────────────────
// Here we DO use GetIdleCycle because the legacy tick loop needs
// a concrete Animation + frame range. Only swap when the resolver
// returns a clearly-better cycle.
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: null,
stanceOverride: stance,
commandOverride: command);
bool newCycleIsGood = newCycle is not null
&& newCycle.Framerate != 0f
&& newCycle.HighFrame >= newCycle.LowFrame
&& newCycle.Animation.PartFrames.Count >= 1;
if (!newCycleIsGood) return;
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
ae.Framerate = newCycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
/// <summary>
/// Phase 6.7: the server says an entity moved. Translate its new
/// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform.
///
/// Phase B.3 extension: if the player controller is in PortalSpace and
/// this update is for our own character, detect a large position change
/// (different landblock or > 100 units distance). If detected, recenter
/// the streaming controller, resolve the new position through physics,
/// snap the player entity + controller, and return to InWorld. Also sends
/// LoginComplete so the server knows the client has loaded the destination.
/// </summary>
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);
// 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;
// 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;
}
rmState.Body.Position = worldPos;
// 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.TargetOrientation = rot;
rmState.LastServerPos = worldPos;
rmState.LastServerPosTime = (now - System.DateTime.UnixEpoch).TotalSeconds;
// Align the body's physics clock with our clock so update_object
// doesn't sub-step a huge initial gap.
rmState.Body.LastUpdateTime = rmState.LastServerPosTime;
// ACE broadcasts UpdatePosition WITHOUT HasVelocity for player
// remote motion — even while actively running. Per packet
// captures: UPs always arrive with velocity null. So we can't
// use UP-absent-velocity as a stop signal (was previously a
// bug that fired StopCompletely every UP → intermittent run).
//
// Stop is signaled by UpdateMotion(ForwardCommand = Ready =
// 0x41000003), handled in OnLiveMotionUpdated. UP's role here
// is just to hard-snap position and adopt velocity IF the
// packet happens to carry one (rare for players, common for
// scripted-path NPCs / missiles).
if (update.Velocity is { } svel)
{
rmState.Body.Velocity = svel;
// Only use the < 0.2 m/s stop signal when velocity was
// explicitly provided (i.e. server sent HasVelocity + tiny
// value = "I'm definitely stopped"). Absent velocity field
// carries no stop information for our ACE.
if (svel.LengthSquared() < 0.04f)
{
rmState.Motion.StopCompletely();
if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
&& aeForStop.Sequencer is not null)
{
uint curStyle = aeForStop.Sequencer.CurrentStyle;
uint readyCmd = (curStyle & 0xFF000000u) != 0
? ((curStyle & 0xFF000000u) | 0x01000003u)
: 0x41000003u;
aeForStop.Sequencer.SetCycle(curStyle, readyCmd);
}
}
}
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());
}
}
}
/// <summary>
/// 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.
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
Console.WriteLine($"live: teleport started (seq={sequence})");
}
/// <summary>
/// Phase 6c — server-sent PlayScript (0xF754) handler. Routes the
/// <c>(guid, scriptId)</c> pair into <see cref="_scriptRunner"/>
/// with the CAMERA's current world position as the anchor. For
/// scene-wide storm effects (lightning) the camera is the right
/// reference frame since the flash is meant to be "around the
/// player." For per-entity effects the runner's dedupe by
/// <c>(scriptId, entityId)</c> keeps multiple simultaneous plays
/// working on different guids.
///
/// <para>
/// Improvements for follow-up: look up the guid's actual last-
/// known world position from <c>_worldState</c> so per-entity
/// spell casts and emote gestures anchor correctly. For Phase 6
/// scope (lightning, which is Dereth-wide) the camera anchor is
/// sufficient.
/// </para>
/// </summary>
private void OnPlayScriptReceived(uint guid, uint scriptId)
{
if (_scriptRunner is null) return;
var camWorldPos = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var iv);
camWorldPos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
_scriptRunner.Play(scriptId, guid, camWorldPos);
}
/// <summary>
/// Phase 5d — retail <c>AdminEnvirons</c> (0xEA60) dispatcher.
/// Routes fog presets into the weather system's sticky override
/// slot and logs the sound cues (Thunder1..6, Roar, Bell, etc)
/// for now — actual sound playback needs a lookup table from
/// <c>EnvironChangeType</c> → wave asset, which we don't yet
/// have dat-indexed; follow-up will wire the thunder wave ids.
/// </summary>
private void OnEnvironChanged(uint environChangeType)
{
// Fog presets — values match AcDream.Core.World.EnvironOverride
// byte-for-byte (we deliberately mirrored retail's enum).
if (environChangeType <= 0x06u)
{
Weather.Override = (AcDream.Core.World.EnvironOverride)environChangeType;
Console.WriteLine(
$"live: AdminEnvirons fog override = " +
$"{(AcDream.Core.World.EnvironOverride)environChangeType}");
return;
}
// Sound cues 0x65..0x7B. Log by retail name for now; audio
// binding is a separate follow-up (needs sound-table indexing
// plus a PlaySound API on OpenAlAudioEngine that takes a
// retail sound enum → wave-id mapping).
string name = environChangeType switch
{
0x65u => "RoarSound",
0x66u => "BellSound",
0x67u => "Chant1Sound",
0x68u => "Chant2Sound",
0x69u => "DarkWhispers1Sound",
0x6Au => "DarkWhispers2Sound",
0x6Bu => "DarkLaughSound",
0x6Cu => "DarkWindSound",
0x6Du => "DarkSpeechSound",
0x6Eu => "DrumsSound",
0x6Fu => "GhostSpeakSound",
0x70u => "BreathingSound",
0x71u => "HowlSound",
0x72u => "LostSoulsSound",
0x75u => "SquealSound",
0x76u => "Thunder1Sound",
0x77u => "Thunder2Sound",
0x78u => "Thunder3Sound",
0x79u => "Thunder4Sound",
0x7Au => "Thunder5Sound",
0x7Bu => "Thunder6Sound",
_ => $"Unknown(0x{environChangeType:X2})",
};
Console.WriteLine(
$"live: AdminEnvirons sound cue = {name} " +
$"(0x{environChangeType:X2}) — audio binding pending");
}
/// <summary>
/// Phase A.1: streaming load delegate, runs on the worker thread.
/// Reads the landblock from the dats, hydrates its stab entities (same
/// path as the old preload), and returns a fully-populated LoadedLandblock.
/// Thread-safe: uses only DatCollection reads (documented thread-safe by
/// DatReaderWriter) and pure CPU work. No GL calls here.
///
/// MVP scope: stabs only. Scenery + interior added in Task 8.
/// </summary>
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<AcDream.Core.World.WorldEntity>(baseLoaded.Entities.Count);
foreach (var e in baseLoaded.Entities)
{
var meshRefs = new List<AcDream.Core.World.MeshRef>();
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
{
// Single GfxObj stab — identity part transform.
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(e.SourceGfxObjOrSetupId, gfx);
meshRefs.Add(new AcDream.Core.World.MeshRef(
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
}
}
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
// Multi-part Setup — flatten to per-part GfxObj refs.
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(e.SourceGfxObjOrSetupId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(e.SourceGfxObjOrSetupId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
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<AcDream.Core.World.WorldEntity>(hydrated);
merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY));
merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY));
return new AcDream.Core.World.LoadedLandblock(
baseLoaded.LandblockId,
baseLoaded.Heightmap,
merged);
}
/// <summary>
/// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single
/// landblock on the worker thread. Pure CPU — no GL calls.
///
/// Ported from the pre-streaming preload loop in GameWindow.OnLoad
/// (pre-Task-7 version, lines 329-405). Adapted to operate on a single
/// LoadedLandblock instead of iterating worldView.Landblocks.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildSceneryEntitiesForStreaming(
AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY)
{
var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null || _heightTable is null) return result;
var region = _dats.Get<DatReaderWriter.DBObjs.Region>(0x13000000u);
if (region is null) return result;
// Build a set of terrain vertex indices that have buildings on them,
// so the scenery generator can skip those cells (ACME conformance fix 4d).
HashSet<int>? buildingCells = null;
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is not null)
{
// Only Buildings suppress scenery. Stabs (LandBlockInfo.Objects) are
// static scenery placeholders themselves (rocks, tree clusters) that
// retail does NOT use to suppress scenery generation. Including them
// here over-suppressed scenery in town landblocks.
buildingCells = new HashSet<int>();
foreach (var bldg in lbInfo.Buildings)
{
int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8);
int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8);
buildingCells.Add(cx * 9 + cy);
}
}
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable);
if (spawns.Count == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace. Landblock IDs are formatted 0xXXYYFFFF
// where XX = landblock X coord (bits 24-31), YY = Y coord (bits 16-23).
// Both must go into our ID so landblocks don't collide.
// Format: 0x80 | XX | YY | local_index(8 bits) = 0x80XXYY_II.
// 256 slots per landblock is enough (SceneryGenerator caps ~200).
uint lbXByte = (lb.LandblockId >> 24) & 0xFFu;
uint lbYByte = (lb.LandblockId >> 16) & 0xFFu;
uint sceneryIdBase = 0x80000000u | (lbXByte << 16) | (lbYByte << 8);
uint localIndex = 0;
foreach (var spawn in spawns)
{
// Resolve the object to a mesh (same GfxObj/Setup logic as Stabs).
// Scale is baked into the root transform by wrapping each part's
// transform with a scale matrix.
var meshRefs = new List<AcDream.Core.World.MeshRef>();
var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale);
if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(spawn.ObjectId);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(spawn.ObjectId, gfx);
// Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain.
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat));
}
}
else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(spawn.ObjectId);
if (setup is not null)
{
_physicsDataCache.CacheSetup(spawn.ObjectId, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
// Compose: part's own transform, then the spawn's scale.
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 groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy)
?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
float finalZ = groundZ + spawn.LocalPosition.Z;
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;
}
/// <summary>
/// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell
/// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and
/// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls.
///
/// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary)
/// so ApplyLoadedTerrain can drain + upload them on the render thread.
///
/// Ported from pre-streaming preload lines 407-565.
/// </summary>
private List<AcDream.Core.World.WorldEntity> BuildInteriorEntitiesForStreaming(
uint landblockId, int lbX, int lbY)
{
var result = new List<AcDream.Core.World.WorldEntity>();
if (_dats is null) return result;
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
if (lbInfo is null || lbInfo.NumCells == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace: 0x40000000 | (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<DatReaderWriter.DBObjs.EnvCell>(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<DatReaderWriter.DBObjs.Environment>(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<AcDream.Core.World.MeshRef>();
if ((stab.Id & 0xFF000000u) == 0x01000000u)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(stab.Id);
if (gfx is not null)
{
_physicsDataCache.CacheGfxObj(stab.Id, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity));
}
}
else if ((stab.Id & 0xFF000000u) == 0x02000000u)
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(stab.Id);
if (setup is not null)
{
_physicsDataCache.CacheSetup(stab.Id, setup);
var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup);
foreach (var mr in flat)
{
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(mr.GfxObjId);
if (gfx is null) continue;
_physicsDataCache.CacheGfxObj(mr.GfxObjId, gfx);
_ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
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;
}
/// <summary>
/// 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.
/// </summary>
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);
}
}
/// <summary>
/// Step 4: build a <see cref="LoadedCell"/> for portal visibility and queue it
/// for render-thread registration. Called from the worker thread during
/// <see cref="BuildInteriorEntitiesForStreaming"/>.
/// </summary>
private void BuildLoadedCell(
uint envCellId,
DatReaderWriter.DBObjs.EnvCell envCell,
DatReaderWriter.Types.CellStruct cellStruct,
System.Numerics.Vector3 cellOrigin,
System.Numerics.Matrix4x4 cellTransform)
{
System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse);
// Compute local AABB from CellStruct vertices.
var boundsMin = new System.Numerics.Vector3(float.MaxValue);
var boundsMax = new System.Numerics.Vector3(float.MinValue);
foreach (var kvp in cellStruct.VertexArray.Vertices)
{
var v = kvp.Value;
var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z);
boundsMin = System.Numerics.Vector3.Min(boundsMin, pos);
boundsMax = System.Numerics.Vector3.Max(boundsMax, pos);
}
if (boundsMin.X == float.MaxValue)
{
boundsMin = System.Numerics.Vector3.Zero;
boundsMax = System.Numerics.Vector3.Zero;
}
// Build portal list and clip planes from CellPortals.
var portals = new List<CellPortalInfo>();
var clipPlanes = new List<PortalClipPlane>();
// 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<AcDream.Core.Physics.CellSurface>();
var portalPlanes = new List<AcDream.Core.Physics.PortalPlane>();
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>(
(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<DatReaderWriter.DBObjs.EnvCell>(envCellId);
if (envCell is null) continue;
if (envCell.EnvironmentId == 0) continue;
var environment = _dats.Get<DatReaderWriter.DBObjs.Environment>(
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<ushort, System.Numerics.Vector3>(
cellStruct.VertexArray.Vertices.Count);
foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices)
{
var localPos = vtx.Origin;
var worldPos = System.Numerics.Vector3.Transform(localPos, rot) + cellOriginWorld;
worldVerts[(ushort)vid] = worldPos;
}
// Extract polygon vertex-id lists from PhysicsPolygons.
// PhysicsPolygons is Dictionary<ushort, Polygon>; iterate Values.
var polyVids = new List<List<short>>(cellStruct.PhysicsPolygons.Count);
foreach (var poly in cellStruct.PhysicsPolygons.Values)
{
var vids = new List<short>(poly.VertexIds.Count);
foreach (var vid in poly.VertexIds)
vids.Add(vid);
polyVids.Add(vids);
}
cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids));
// Extract portal planes from this EnvCell's CellPortals.
// CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons),
// NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells.
foreach (var portal in envCell.CellPortals)
{
if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly))
continue;
if (poly.VertexIds.Count < 3)
continue;
// Collect ALL polygon vertices for accurate centroid + radius.
var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count];
bool allFound = true;
for (int pv = 0; pv < poly.VertexIds.Count; pv++)
{
if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv]))
{ allFound = false; break; }
}
if (!allFound) continue;
portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices(
portalVerts.AsSpan(),
portal.OtherCellId, // target cell (0xFFFF = outdoor)
envCellId & 0xFFFFu, // owner cell (low 16 bits)
(ushort)portal.Flags));
}
}
}
_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<DatReaderWriter.DBObjs.GfxObj>(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<DatReaderWriter.DBObjs.Setup>(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++;
}
}
}
// 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.
if ((_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<uint>();
foreach (var entity in lb.Entities)
{
if ((entity.Id & 0x80000000u) == 0) continue; // not scenery
bool anyHaveBounds = false;
foreach (var mr in entity.MeshRefs)
{
var vb = _physicsDataCache.GetVisualBounds(mr.GfxObjId);
if (vb is not null && vb.Radius > 0f) { anyHaveBounds = true; break; }
}
if (!anyHaveBounds)
{
sceneryNoCache++;
if (sampleMissing.Count < 3)
sampleMissing.Add(entity.SourceGfxObjOrSetupId);
}
}
if (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.
if (_streamingController is not null && _cameraController is not null)
{
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();
if (_cameraController is null || _input is null) return;
var kb = _input.Keyboards[0];
// Phase D.2a — suppress game-side WASD / interaction polling when
// ImGui has keyboard focus (e.g. a text field is active). Without
// this, typing "walk" into a chat field would actually walk.
bool suppressGameInput =
DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
if (suppressGameInput) return;
if (_cameraController.IsFlyMode)
{
_cameraController.Fly.Update(
dt,
w: kb.IsKeyPressed(Key.W),
a: kb.IsKeyPressed(Key.A),
s: kb.IsKeyPressed(Key.S),
d: kb.IsKeyPressed(Key.D),
up: kb.IsKeyPressed(Key.Space),
down: kb.IsKeyPressed(Key.ControlLeft),
boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight));
}
else if (_playerMode && _playerController is not null && _chaseCamera is not null)
{
// Phase B.2: player movement mode — WASD walks/runs, A/D turns,
// Z/X strafes, Shift runs, mouse X turns, mouse Y pitches camera.
float consumedMouseDeltaX = _playerMouseDeltaX;
_playerMouseDeltaX = 0f; // consume + reset so it doesn't accumulate
var input = new AcDream.App.Input.MovementInput(
Forward: kb.IsKeyPressed(Key.W),
Backward: kb.IsKeyPressed(Key.S),
StrafeLeft: kb.IsKeyPressed(Key.Z),
StrafeRight: kb.IsKeyPressed(Key.X),
TurnLeft: kb.IsKeyPressed(Key.A),
TurnRight: kb.IsKeyPressed(Key.D),
Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight),
MouseDeltaX: consumedMouseDeltaX,
Jump: kb.IsKeyPressed(Key.Space));
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.Position;
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.
_chaseCamera.Update(result.Position, _playerController.Yaw);
// 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);
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);
_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);
_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);
}
}
/// <summary>
/// Convert our internal yaw (math convention: 0=+X East, PI/2=+Y North)
/// to AC's quaternion heading convention.
/// AC heading: 0=West, 90=North, 180=East, 270=South.
/// Formula from holtburger Quaternion::from_heading.
/// </summary>
private static System.Numerics.Quaternion YawToAcQuaternion(float yaw)
{
// Our yaw → AC heading in degrees:
// yaw=0 → East → AC 180°, yaw=PI/2 → North → AC 90°
// heading_deg = 180 - yaw_degrees
float yawDeg = yaw * (180f / MathF.PI);
float headingDeg = 180f - yawDeg;
if (headingDeg < 0f) headingDeg += 360f;
if (headingDeg >= 360f) headingDeg -= 360f;
// holtburger from_heading: theta = (450 - heading_deg) in radians
float theta = (450f - headingDeg) * (MathF.PI / 180f);
float halfTheta = theta * 0.5f;
float w = MathF.Cos(halfTheta);
float z = MathF.Sin(halfTheta);
// Canonicalize: w must be non-negative
if (w < 0f) { w = -w; z = -z; }
return new System.Numerics.Quaternion(0f, 0f, z, w);
}
private void OnCameraModeChanged(bool isFlyMode)
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// Raw cursor mode for both fly AND chase (player) mode — both need
// mouse deltas for look/turn. Only orbit mode uses normal cursor.
bool needsRawCursor = isFlyMode || _playerMode;
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);
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 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;
// Update the rain/snow particle emitters when the weather kind
// changes. Keep the emitters fed by the ParticleSystem tick so
// visuals stay alive frame-over-frame.
UpdateWeatherParticles(atmo);
// Phase E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated.
// Tick the PhysicsScript runner BEFORE the particle system so any
// CreateParticleHook fired this frame has its emitter alive when
// the particle system advances.
_scriptRunner?.Tick((float)deltaSeconds);
_particleSystem?.Tick((float)deltaSeconds);
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);
// 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 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.
if (!cameraInsideCell)
{
_skyRenderer?.Render(camera, camPos, (float)WorldTime.DayFraction,
_activeDayGroup, kf);
}
_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);
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum,
neverCullLandblockId: playerLb,
visibleCellIds: visibility?.VisibleCellIds);
// 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);
// 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;
}
}
// 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);
_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;
_window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " +
$"lb {visibleLandblocks}/{totalLandblocks} visible | " +
$"ent {entityCount} | anim {animatedCount}";
_lastFps = fps;
_lastFrameMs = avgFrameTime;
_perfAccum = 0;
_perfFrameCount = 0;
}
}
/// <summary>
/// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
/// [LowFrame..HighFrame] interval, then rebuild that entity's
/// MeshRefs from the new frame's per-part transforms. Static
/// entities (no AnimatedEntity record) are untouched. The static
/// renderer reads the new MeshRefs on the next Draw call.
/// </summary>
private void TickAnimations(float dt)
{
// Retail has NO stop-detection heuristic — it relies on the
// server sending explicit UpdateMotion(Ready). The server-side
// stop signal flows the same way as any other motion change:
// alt releases W → MoveToState(Ready) → ACE broadcasts
// UpdateMotion(Ready) + UpdatePosition with zero velocity.
// On our side, both are handled: UpdateMotion routes through
// MotionInterpreter.DoInterpretedMotion which zeroes the
// interpreter's state, and UpdatePosition's HasVelocity~0 hits
// MotionInterpreter.StopCompletely. Anything else is server
// buggery (packet loss, ACE bug) — don't guess client-side.
var now = System.DateTime.UtcNow;
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
// 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))
{
// 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).
rm.Body.TransientState |= AcDream.Core.Physics.TransientStateFlags.Contact
| AcDream.Core.Physics.TransientStateFlags.OnWalkable
| AcDream.Core.Physics.TransientStateFlags.Active;
rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
// 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: 2.0f, // retail default for unknown remotes
stepDownHeight: 0.04f, // PhysicsGlobals.DefaultStepHeight
isOnGround: true, // remotes are forced OnWalkable above
body: rm.Body); // persist ContactPlane across frames for slope tracking
rm.Body.Position = resolveResult.Position;
if (resolveResult.CellId != 0)
rm.CellId = resolveResult.CellId;
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.Rotation = rm.Body.Orientation;
}
// ── Get per-part (origin, orientation) from either sequencer or legacy ──
IReadOnlyList<AcDream.Core.Physics.PartTransform>? seqFrames = null;
if (ae.Sequencer is not null)
{
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;
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
var scaleMat = ae.Scale == 1.0f
? System.Numerics.Matrix4x4.Identity
: System.Numerics.Matrix4x4.CreateScale(ae.Scale);
for (int i = 0; i < partCount; i++)
{
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
if (seqFrames is not null)
{
// Sequencer path.
if (i < seqFrames.Count)
{
origin = seqFrames[i].Origin;
orientation = seqFrames[i].Orientation;
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
}
else
{
// Legacy slerp path.
int frameIdx = (int)Math.Floor(ae.CurrFrame);
if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame
|| frameIdx >= ae.Animation.PartFrames.Count)
frameIdx = ae.LowFrame;
int nextIdx = frameIdx + 1;
if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count)
nextIdx = ae.LowFrame;
float t = ae.CurrFrame - frameIdx;
if (t < 0f) t = 0f; else if (t > 1f) t = 1f;
var partFrames = ae.Animation.PartFrames[frameIdx].Frames;
var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames;
if (i < partFrames.Count)
{
var f0 = partFrames[i];
var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0;
origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t);
orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t);
}
else
{
origin = System.Numerics.Vector3.Zero;
orientation = System.Numerics.Quaternion.Identity;
}
}
// Per-part default scale from the Setup, matching SetupMesh.Flatten's
// composition order: scale → rotate → translate.
var defaultScale = i < ae.Setup.DefaultScale.Count
? ae.Setup.DefaultScale[i]
: System.Numerics.Vector3.One;
var partTransform =
System.Numerics.Matrix4x4.CreateScale(defaultScale) *
System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) *
System.Numerics.Matrix4x4.CreateTranslation(origin);
// Bake the entity's ObjScale on top, matching the hydration
// order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned.
if (ae.Scale != 1.0f)
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, partTransform)
{
SurfaceOverrides = template.SurfaceOverrides,
});
}
ae.Entity.MeshRefs = newMeshRefs;
}
}
/// <summary>
/// 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.
///
/// <para>
/// Action motions (Jump, FallDown, emotes, attacks) are routed through
/// <see cref="AcDream.Core.Physics.AnimationSequencer.PlayAction"/> — 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.
/// </para>
/// </summary>
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.
float newSpeed = result.ForwardSpeed ?? 1f;
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<DatReaderWriter.DBObjs.Setup>(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<uint, uint>?)[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: use the MovementResult's ForwardSpeed for
// locomotion cycles. This mirrors what the server broadcasts for
// remote observers, and keeps our own character's animation rate
// in sync with movement velocity (a 1.5× RunRate player's anim
// plays 1.5× as fast — matching retail).
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
float animSpeed = 1f;
if (result.ForwardSpeed is { } fs && fs > 0f)
{
animSpeed = fs;
}
// 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;
}
ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed * animScale);
}
// 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;
}
/// <summary>
/// Phase 3a — re-roll the active DayGroup whenever the current
/// Dereth-day index differs from what we last installed. Idempotent
/// within the same server-day. Swaps both the
/// <see cref="AcDream.Core.World.SkyStateProvider"/> feeding
/// <see cref="WorldTime"/> (for lighting interp) and the cached
/// <see cref="_activeDayGroup"/> (for the sky-object render loop).
///
/// <para>
/// Honors <c>ACDREAM_DAY_GROUP=N</c> — when set, every call picks
/// group N regardless of day index. Useful for A/B testing each
/// weather preset against retail. See
/// <see cref="AcDream.Core.World.LoadedSkyDesc.SelectDayGroupIndex"/>
/// for the roller.
/// </para>
/// </summary>
private void RefreshSkyForCurrentDay()
{
if (_loadedSkyDesc is null || _loadedSkyDesc.DayGroups.Count == 0)
return;
// Retail FUN_00501990 seeds the LCG with the triple stored in
// TimeOfDay +0x64 (Year), +0x10 (misc. int), +0x68 (DayOfYear)
//
// The decompile agent labeled +0x10 "SecondsPerDay (int copy)"
// but a live memory probe of retail's acclient.exe (2026-04-23,
// tools/RetailTimeProbe) shows the value is actually **360** —
// semantically DaysPerYear, not seconds. So the LCG seed is
// seed = Year × DaysPerYear + DayOfYear
// which is literally "total days since epoch" (a flat day index),
// confirmed against retail's Year=116, DayOfYear=47, seed=41807.
//
// Previously we passed 7620 (DayTicks), producing seed 883967 —
// a completely different LCG output → wrong DayGroup pick →
// user-observed weather mismatch (acdream clear while retail
// stormy, 2026-04-23). The live probe nailed the fix.
double ticks = WorldTime.NowTicks;
int absYear = AcDream.Core.World.DerethDateTime.AbsoluteYear(ticks);
int dayOfYear = AcDream.Core.World.DerethDateTime.DayOfYear(ticks);
int secondsPerDay = AcDream.Core.World.DerethDateTime.DaysInAMonth
* AcDream.Core.World.DerethDateTime.MonthsInAYear; // 360
// Composite day key for change-detection and logging only; the
// LCG seed is computed inside SelectDayGroupIndex from (absYear,
// secondsPerDay, dayOfYear).
long dayIndex = (long)absYear * 360 + dayOfYear;
int idx = _loadedSkyDesc.SelectDayGroupIndex(absYear, secondsPerDay, dayOfYear);
var grp = idx >= 0 && idx < _loadedSkyDesc.DayGroups.Count
? _loadedSkyDesc.DayGroups[idx]
: null;
bool dayChanged = dayIndex != _loadedSkyDayIndex;
bool groupChanged = !ReferenceEquals(grp, _activeDayGroup);
if (!dayChanged && !groupChanged) return;
_loadedSkyDayIndex = dayIndex;
_activeDayGroup = grp;
if (grp is not null && grp.SkyTimes.Count > 0)
{
WorldTime.SetProvider(
new AcDream.Core.World.SkyStateProvider(
grp.SkyTimes.Select(s => s.Keyframe).ToList()));
// Phase 3e: drive the atmospheric weather (rain/snow emitters,
// fog-override categories, lightning strobe) from the retail
// DayGroup name. Stops the legacy WeatherSystem.RollKind hash
// from spawning rain particles on a "Sunny" day (user-observed
// rain regression 2026-04-23 after the retail picker landed on
// DayGroup[6] "Sunny" but the internal hash picked Rain).
Weather.SetKindFromDayGroupName(grp.Name);
Console.WriteLine(
$"sky: PY{absYear} day{dayOfYear} → DayGroup[{idx}] \"{grp.Name}\" " +
$"(Chance={grp.ChanceOfOccur:F2}, {grp.SkyObjects.Count} objects, " +
$"{grp.SkyTimes.Count} keyframes, weather={Weather.Kind})");
}
}
/// <summary>
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated <see cref="AcDream.Core.World.SkyKeyframe"/>,
/// plus the cell ambient. Indoor cells force the sun intensity to
/// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
/// </summary>
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; colors are already
// pre-multiplied by DirBright / AmbBright inside
// SkyDescLoader so we feed them straight into the UBO.
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);
}
}
/// <summary>
/// Keep the rain/snow camera-anchored emitters aligned with the
/// current weather state. Spawns on entry, stops on exit, with no
/// per-frame churn while the state is stable. Emitters are camera-
/// local (<see cref="AcDream.Core.Vfx.EmitterFlags.AttachLocal"/>)
/// so walking never leaves the rain volume (r12 §7).
/// </summary>
private void UpdateWeatherParticles(in AcDream.Core.World.AtmosphereSnapshot atmo)
{
if (_particleSystem is null) return;
if (atmo.Kind == _lastWeatherKind) return; // no change
// Stop any existing emitters first.
if (_rainEmitterHandle != 0)
{
_particleSystem.StopEmitter(_rainEmitterHandle, fadeOut: true);
_rainEmitterHandle = 0;
}
if (_snowEmitterHandle != 0)
{
_particleSystem.StopEmitter(_snowEmitterHandle, fadeOut: true);
_snowEmitterHandle = 0;
}
// Anchor at camera world position; AttachLocal keeps it moving.
var anchor = System.Numerics.Vector3.Zero;
if (_cameraController is not null)
{
System.Numerics.Matrix4x4.Invert(_cameraController.Active.View, out var inv);
anchor = new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
}
switch (atmo.Kind)
{
case AcDream.Core.World.WeatherKind.Rain:
case AcDream.Core.World.WeatherKind.Storm:
_rainEmitterHandle = _particleSystem.SpawnEmitter(
BuildRainDesc(), anchor);
break;
case AcDream.Core.World.WeatherKind.Snow:
_snowEmitterHandle = _particleSystem.SpawnEmitter(
BuildSnowDesc(), anchor);
break;
}
_lastWeatherKind = atmo.Kind;
}
/// <summary>
/// Rain emitter tuned per r12 §7: streaks falling at ~50 m/s with
/// a slight wind bias, 500 drops/sec, 2000 max alive, 1.2s life so
/// drops cover the ~60m fall at terminal velocity.
/// </summary>
private static AcDream.Core.Vfx.EmitterDesc BuildRainDesc() => new()
{
DatId = 0xFFFF_0001u, // synthetic id
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
AcDream.Core.Vfx.EmitterFlags.Billboard,
EmitRate = 500f,
MaxParticles = 2000,
LifetimeMin = 1.0f,
LifetimeMax = 1.4f,
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 50f,
SpawnDiskRadius = 15f,
InitialVelocity = new System.Numerics.Vector3(0.5f, 0f, -50f),
VelocityJitter = 2f,
Gravity = System.Numerics.Vector3.Zero,
StartColorArgb = 0x40B0C0E0u,
EndColorArgb = 0x20B0C0E0u,
StartAlpha = 0.3f,
EndAlpha = 0f,
StartSize = 0.05f,
EndSize = 0.05f,
};
/// <summary>
/// Snow emitter tuned per r12 §8: slow fall at ~2 m/s, tumbling
/// sideways drift, small billboards, 100 flakes/sec, long lifespan.
/// </summary>
private static AcDream.Core.Vfx.EmitterDesc BuildSnowDesc() => new()
{
DatId = 0xFFFF_0002u,
Type = AcDream.Core.Vfx.ParticleType.LocalVelocity,
Flags = AcDream.Core.Vfx.EmitterFlags.AttachLocal |
AcDream.Core.Vfx.EmitterFlags.Billboard,
EmitRate = 100f,
MaxParticles = 1000,
LifetimeMin = 4f,
LifetimeMax = 8f,
OffsetDir = new System.Numerics.Vector3(0, 0, 1),
MinOffset = 0f,
MaxOffset = 30f,
SpawnDiskRadius = 15f,
InitialVelocity = new System.Numerics.Vector3(0.3f, 0.2f, -2f),
VelocityJitter = 0.8f,
Gravity = System.Numerics.Vector3.Zero,
StartColorArgb = 0xE0FFFFFFu,
EndColorArgb = 0x80FFFFFFu,
StartAlpha = 0.85f,
EndAlpha = 0.3f,
StartSize = 0.08f,
EndSize = 0.06f,
};
// ── Phase I.2 — DebugPanel helpers ────────────────────────────────
//
// The ImGui DebugPanel reads through DebugVM closures that ask
// GameWindow for live state on every frame. The helper methods below
// are the *named* targets of those closures (and of the F-key
// shortcuts that share the same actions). Keeping them as methods
// (vs ad-hoc lambdas where the VM is constructed) means both the
// panel button and the keybind run the *same* code, so behavior
// can't drift between the two surfaces.
/// <summary>Player-mode-aware position source for the DebugPanel.</summary>
private System.Numerics.Vector3 GetDebugPlayerPosition()
{
if (_playerMode && _playerController is not null)
return _playerController.Position;
if (_cameraController?.Active is { } cam)
{
// Camera world position from inverse of view matrix — same
// computation used by the scene-lighting UBO each frame.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
return new System.Numerics.Vector3(inv.M41, inv.M42, inv.M43);
}
return System.Numerics.Vector3.Zero;
}
/// <summary>Heading in degrees, [0..360). Player yaw in player mode, camera-forward heading otherwise.</summary>
private float GetDebugPlayerHeadingDeg()
{
float deg;
if (_playerMode && _playerController is not null)
{
deg = _playerController.Yaw * (180f / MathF.PI);
}
else if (_cameraController?.Active is { } cam)
{
// Camera-relative heading from view matrix forward vector. Use
// the same -invView.Mxx convention the snapshot block used.
System.Numerics.Matrix4x4.Invert(cam.View, out var inv);
var fwd = new System.Numerics.Vector3(-inv.M31, -inv.M32, -inv.M33);
deg = MathF.Atan2(fwd.Y, fwd.X) * (180f / MathF.PI);
}
else
{
return 0f;
}
deg %= 360f;
if (deg < 0f) deg += 360f;
return deg;
}
private uint GetDebugPlayerCellId() =>
_playerMode && _playerController is not null ? _playerController.CellId : 0u;
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
private float GetActiveSensitivity()
{
if (_playerMode && _cameraController?.IsChaseMode == true) return _sensChase;
if (_cameraController?.IsFlyMode == true) return _sensFly;
return _sensOrbit;
}
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
// none → 0.0 (midnight) → 0.25 (dawn) → 0.5 (noon) → 0.75 (dusk) → none
_timeDebugStep = (_timeDebugStep + 1) % 5;
float? pick = _timeDebugStep switch
{
0 => (float?)null,
1 => 0.0f,
2 => 0.25f,
3 => 0.5f,
4 => 0.75f,
_ => null,
};
if (pick.HasValue)
{
WorldTime.SetDebugTime(pick.Value);
_debugVm?.AddToast($"Time override = {pick.Value:F2}");
}
else
{
WorldTime.ClearDebugTime();
_debugVm?.AddToast("Time override cleared");
}
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
var kinds = new[]
{
AcDream.Core.World.WeatherKind.Clear,
AcDream.Core.World.WeatherKind.Overcast,
AcDream.Core.World.WeatherKind.Rain,
AcDream.Core.World.WeatherKind.Snow,
AcDream.Core.World.WeatherKind.Storm,
};
_weatherDebugStep = (_weatherDebugStep + 1) % kinds.Length;
Weather.ForceWeather(kinds[_weatherDebugStep]);
_debugVm?.AddToast($"Weather = {kinds[_weatherDebugStep]}");
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugVm?.AddToast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
/// <summary>
/// Yields the registered DebugPanel(s) so F1 can flip their
/// visibility. Returns nothing when devtools are off.
/// </summary>
private IEnumerable<AcDream.UI.Abstractions.IPanel> EnumerateDebugPanel()
{
// The current ImGuiPanelHost only exposes Register/Unregister,
// not enumerate. We track the DebugPanel through the VM presence
// — the panel is registered iff _debugVm is non-null. Look it
// up via the panel ID convention.
// Defer the actual lookup to the panel host once it grows an
// accessor; for now, no-op when devtools are off.
if (_debugPanel is not null) yield return _debugPanel;
}
// Cached panel reference so EnumerateDebugPanel can return it. Set
// in the DevToolsEnabled construction block above; null otherwise.
private AcDream.UI.Abstractions.Panels.Debug.DebugPanel? _debugPanel;
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
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_sceneLightingUbo?.Dispose();
_skyRenderer?.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 ──────────────────
/// <summary>
/// Result of resolving a UI <see cref="AcDream.UI.Abstractions.ChatChannelKind"/>
/// to a runtime Turbine room. Returned by
/// <see cref="ResolveTurbineForKind"/> when the player has access
/// to that Turbine channel; null otherwise.
/// </summary>
private readonly record struct TurbineResolution(uint RoomId, uint ChatType, string DisplayName);
/// <summary>
/// Map a <see cref="AcDream.UI.Abstractions.ChatChannelKind"/> to a
/// runtime Turbine room id + chat-type. Returns null when
/// <paramref name="state"/> isn't <see cref="AcDream.Core.Chat.TurbineChatState.Enabled"/>
/// or the channel has no assigned room (e.g. player not in a society).
/// Mirrors holtburger's <c>resolve_turbine_channel</c>
/// (<c>references/holtburger/.../client/commands.rs</c> lines 64-98).
/// </summary>
private static TurbineResolution? ResolveTurbineForKind(
AcDream.UI.Abstractions.ChatChannelKind kind,
AcDream.Core.Chat.TurbineChatState state)
{
if (!state.Enabled) return null;
var (room, chatType, name) = kind switch
{
AcDream.UI.Abstractions.ChatChannelKind.Allegiance =>
(state.AllegianceRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance, "Allegiance"),
AcDream.UI.Abstractions.ChatChannelKind.General =>
(state.GeneralRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.General, "General"),
AcDream.UI.Abstractions.ChatChannelKind.Trade =>
(state.TradeRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade, "Trade"),
AcDream.UI.Abstractions.ChatChannelKind.Lfg =>
(state.LfgRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg, "LFG"),
AcDream.UI.Abstractions.ChatChannelKind.Roleplay =>
(state.RoleplayRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay, "Roleplay"),
AcDream.UI.Abstractions.ChatChannelKind.Society =>
(state.SocietyRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Society, "Society"),
AcDream.UI.Abstractions.ChatChannelKind.Olthoi =>
(state.OlthoiRoom, (uint)AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi, "Olthoi"),
_ => (0u, 0u, string.Empty),
};
if (room == 0u) return null;
return new TurbineResolution(room, chatType, name);
}
/// <summary>
/// Pick a human-readable label for a Turbine room broadcast. Uses
/// the chat-type when known (semantic name), falls back to the
/// numeric room id for unknown rooms.
/// </summary>
private static string TurbineRoomDisplayName(uint roomId, uint chatType)
{
return (AcDream.Core.Net.Messages.TurbineChat.ChatType)chatType switch
{
AcDream.Core.Net.Messages.TurbineChat.ChatType.Allegiance => "Allegiance",
AcDream.Core.Net.Messages.TurbineChat.ChatType.General => "General",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Trade => "Trade",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Lfg => "LFG",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Roleplay => "Roleplay",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Society => "Society",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyCelHan => "Celestial Hand",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyEldWeb => "Eldrytch Web",
AcDream.Core.Net.Messages.TurbineChat.ChatType.SocietyRadBlo => "Radiant Blood",
AcDream.Core.Net.Messages.TurbineChat.ChatType.Olthoi => "Olthoi",
_ => $"Room 0x{roomId:X8}",
};
}
}