acdream/src/AcDream.App/Rendering/GameWindow.cs
Erik 83e8d06ea7 feat(app): GameWindow wires Chat/Combat/Spellbook/Items into session
Exposes Core state classes as public fields on GameWindow so plugins +
UI panels can bind directly, then calls GameEventWiring.WireAll inside
live-session setup to connect the parsed GameEvent stream to them.
HearSpeech (standalone GameMessage, not 0xF7B0-wrapped) is routed via
a separate lambda since it has its own dispatch branch.

After this commit, every server-sent event that the Phase F.1 / E.4 /
E.5 / H.1 parsers handle actually mutates client state live:

- Chat: ChannelBroadcast, Tell, TransientMessage, Popup, HearSpeech
- Combat: UpdateHealth, Victim/Defender/Attacker/Evasion notifications,
  AttackDone
- Spellbook: MagicUpdateSpell, MagicRemoveSpell, enchantment
  add/remove/dispel/purge
- Items: WieldObject, InventoryPutObjInContainer

WorldTime + Lighting added as public-field Core services so the
renderer and plugin API can read "current day fraction" / "active
lights" directly.

Build green, 602 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:12:59 +02:00

3291 lines
160 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;
// On-screen debug HUD — info panel, stats panel, compass, keybind help.
// F1/F2/F4/F5/F6 toggle the individual panels (see the key handler).
// Null if no system font is available at startup; in that case the HUD
// is silently disabled and the rest of the client keeps working.
private TextRenderer? _textRenderer;
private BitmapFont? _debugFont;
private DebugOverlay? _debugOverlay;
// 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 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 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();
public readonly AcDream.Core.Combat.CombatState Combat = new();
public readonly AcDream.Core.Spells.Spellbook SpellBook = new();
public readonly AcDream.Core.Items.ItemRepository Items = new();
// 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();
// 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 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;
}
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)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowHelpPanel = !_debugOverlay.ShowHelpPanel;
_debugOverlay.Toast($"Help {(_debugOverlay.ShowHelpPanel ? "ON" : "OFF")}");
}
}
else if (key == Key.F2)
{
_debugCollisionVisible = !_debugCollisionVisible;
_debugOverlay?.Toast($"Collision wireframes {(_debugCollisionVisible ? "ON" : "OFF")}");
}
else if (key == Key.F4)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowInfoPanel = !_debugOverlay.ShowInfoPanel;
_debugOverlay.Toast($"Info panel {(_debugOverlay.ShowInfoPanel ? "ON" : "OFF")}");
}
}
else if (key == Key.F5)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowStatsPanel = !_debugOverlay.ShowStatsPanel;
_debugOverlay.Toast($"Stats panel {(_debugOverlay.ShowStatsPanel ? "ON" : "OFF")}");
}
}
else if (key == Key.F6)
{
if (_debugOverlay is not null)
{
_debugOverlay.ShowCompass = !_debugOverlay.ShowCompass;
_debugOverlay.Toast($"Compass {(_debugOverlay.ShowCompass ? "ON" : "OFF")}");
}
}
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;
_debugOverlay?.Toast($"{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);
// 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"));
_debugLines = new DebugLineRenderer(_gl, shadersDir);
// Debug HUD: load a system monospace font and set up the text overlay.
// Skips silently if no font is available (the rest of the client still works).
var fontBytes = BitmapFont.TryLoadSystemMonospaceFont();
if (fontBytes is not null)
{
_debugFont = new BitmapFont(_gl, fontBytes, pixelHeight: 15f, atlasSize: 512);
_textRenderer = new TextRenderer(_gl, shadersDir);
_debugOverlay = new DebugOverlay(_textRenderer, _debugFont);
Console.WriteLine($"debug overlay: loaded {fontBytes.Length / 1024}KB font, " +
$"atlas {_debugFont.AtlasWidth}x{_debugFont.AtlasHeight}, " +
$"lineHeight={_debugFont.LineHeight:F1}px");
}
else
{
Console.WriteLine("debug overlay: no system monospace font found; HUD disabled");
}
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 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");
}
}
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");
// 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 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 =>
{
_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
{
var endpoint = new System.Net.IPEndPoint(System.Net.IPAddress.Parse(host), 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 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);
// 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);
_liveSession.Connect(user, pass);
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
_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}");
_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);
}
// 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.
ushort stance = update.MotionState.Stance;
ushort? command = update.MotionState.ForwardCommand;
var newCycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats,
motionTableIdOverride: null, // same table; already burned into ae.Animation
stanceOverride: stance,
commandOverride: command);
// If the new cycle is bad (null, framerate=0, or single-frame), do
// NOT remove the entity from the animated set. Keep its existing
// cycle running so it continues to breathe / idle. Removing on
// re-resolve failure was a bug that silently unregistered NPCs the
// moment the server sent a motion update with a stance/command
// pair the resolver couldn't translate cleanly. Defensive: switch
// only when we have a clearly better cycle.
bool newCycleIsGood = newCycle is not null
&& newCycle.Framerate != 0f
&& newCycle.HighFrame > newCycle.LowFrame
&& newCycle.Animation.PartFrames.Count > 1;
// Wire server-echoed RunRate BEFORE the animation early-return.
// If the cycle can't resolve (bad stance), we still need the speed.
if (_playerController is not null
&& update.Guid == _playerServerGuid
&& update.MotionState.ForwardSpeed.HasValue
&& update.MotionState.ForwardSpeed.Value > 0f)
{
_playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value);
}
if (!newCycleIsGood)
return;
// (RunRate wiring moved above the early-return)
// Sequencer path
if (ae.Sequencer is not null)
{
uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle;
uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u;
ae.Sequencer.SetCycle(fullStyle, fullMotion);
}
// Legacy path
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);
entity.Position = worldPos;
entity.Rotation = rot;
// 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 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;
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(
lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY);
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)
{
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];
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)
{
_gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
// 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 E.3: advance live particle emitters AFTER animation tick
// so emitters spawned by hooks fired this frame get integrated.
_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);
// 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);
}
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
// Step 4: portal visibility — determine which interior cells to render.
// Extract camera world position from the inverse of the view matrix.
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. Fwd = -Z of the view
// matrix (OpenGL convention), up = +Y. Both live in the inverse
// view matrix's basis vectors.
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);
}
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
// 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);
// 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++;
}
// ── Debug HUD overlay ────────────────────────────────────────────
// Build a per-frame snapshot of state we want to show and hand it
// to the overlay. Drawn after all 3D passes so it sits on top.
if (_debugOverlay is not null && _textRenderer is not null && _debugFont is not null)
{
System.Numerics.Vector3 playerPos;
float headingDeg;
uint cellId;
bool onGround;
float vVel;
if (_playerMode && _playerController is not null)
{
playerPos = _playerController.Position;
// Yaw in math convention: 0 = +X east, PI/2 = +Y north.
// Convert to degrees in [0, 360).
headingDeg = _playerController.Yaw * (180f / MathF.PI);
headingDeg %= 360f;
if (headingDeg < 0f) headingDeg += 360f;
cellId = _playerController.CellId;
onGround = !_playerController.IsAirborne;
vVel = _playerController.VerticalVelocity;
}
else
{
playerPos = camPos;
var camFwd = new System.Numerics.Vector3(-invView.M31, -invView.M32, -invView.M33);
headingDeg = MathF.Atan2(camFwd.Y, camFwd.X) * (180f / MathF.PI);
if (headingDeg < 0f) headingDeg += 360f;
cellId = 0u;
onGround = false;
vVel = 0f;
}
// Nearest shadow object — surface-to-surface distance in XY
// (subtract player radius + obj radius). Negative == penetrating.
const float playerRadius = 0.48f;
float bestDist = float.PositiveInfinity;
string bestLabel = "-";
foreach (var obj in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = obj.Position.X - playerPos.X;
float dy = obj.Position.Y - playerPos.Y;
float d = MathF.Sqrt(dx * dx + dy * dy) - obj.Radius - playerRadius;
if (d < bestDist)
{
bestDist = d;
bestLabel = $"0x{obj.EntityId:X8} {obj.CollisionType}";
}
}
bool colliding = bestDist < 0.05f;
if (bestDist < 0f) bestDist = 0f;
// Select the active-mode sensitivity to display.
float activeSens;
if (_playerMode && _cameraController?.IsChaseMode == true)
activeSens = _sensChase;
else if (_cameraController?.IsFlyMode == true)
activeSens = _sensFly;
else
activeSens = _sensOrbit;
var snapshot = new DebugOverlay.Snapshot(
Fps: (float)_lastFps,
FrameTimeMs: (float)_lastFrameMs,
PlayerPos: playerPos,
HeadingDeg: headingDeg,
CellId: cellId,
OnGround: onGround,
InPlayerMode: _playerMode,
InFlyMode: _cameraController?.IsFlyMode ?? false,
VerticalVelocity: vVel,
EntityCount: _worldState.Entities.Count,
AnimatedCount: _animatedEntities.Count,
LandblocksVisible: visibleLandblocks,
LandblocksTotal: totalLandblocks,
ShadowObjectCount: _physicsEngine.ShadowObjects.TotalRegistered,
NearestObjDist: bestDist,
NearestObjLabel: bestLabel,
Colliding: colliding,
DebugWireframes: _debugCollisionVisible,
StreamingRadius: _streamingRadius,
MouseSensitivity: activeSens,
ChaseDistance: _chaseCamera?.Distance ?? 0f,
RmbOrbit: _rmbHeld);
_debugOverlay.Update((float)deltaSeconds);
var size = new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y);
_debugOverlay.Draw(snapshot, size);
}
}
// 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)
{
foreach (var kv in _animatedEntities)
{
var ae = kv.Value;
// ── 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 change.
if (animCommand == _playerCurrentAnimCommand) return;
_playerCurrentAnimCommand = animCommand;
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.)
if (ae.Sequencer is not null)
{
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
ae.Sequencer.SetCycle(fullStyle, animCommand);
}
// 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;
}
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();
_liveSession?.Dispose();
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_debugLines?.Dispose();
_textRenderer?.Dispose();
_debugFont?.Dispose();
_dats?.Dispose();
_input?.Dispose();
_gl?.Dispose();
}
public void Dispose() => _window?.Dispose();
}