acdream/src/AcDream.App/Rendering/GameWindow.cs
Erik b341193cfe fix(app+core): Phase B.2 — increase step height + resolve initial Z from terrain
Two fixes for the "position never changes when walking" bug:

1. StepUpHeight was 1.0 units — too tight. The player started at
   Z=92.2 (ACE relocation from previous session) but terrain Z was
   ~94, so every movement attempt had a Z delta of 1.8 which
   exceeded the limit. Increased to 5.0 (forgiving for MVP; AC
   default for humans is ~2 from Setup.StepUpHeight).

2. Initial position now resolves through PhysicsEngine with a huge
   step height (100) to snap to the correct terrain Z regardless
   of where the server-sent Z currently is. With indoor transitions
   disabled, this always produces the outdoor terrain height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:11:50 +02:00

1833 lines
87 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 TerrainRenderer? _terrain;
private Shader? _shader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
private float _lastMouseX;
private float _lastMouseY;
private StaticMeshRenderer? _staticMesh;
private Shader? _meshShader;
private TextureCache? _textureCache;
// 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();
// 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();
/// <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]
}
// 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;
// Accumulated mouse X delta for player turning; written in mouse-move
// callback, consumed + reset in OnUpdate each frame.
private float _playerMouseDeltaX;
// 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()
{
_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.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);
// 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 server-sent rotation
// rather than hardcoding. Extract yaw from the quaternion.
var q = playerEntity.Rotation;
float yaw = MathF.Atan2(
2f * (q.W * q.Z + q.X * q.Y),
1f - 2f * (q.Y * q.Y + q.Z * q.Z));
_playerController.Yaw = yaw;
Console.WriteLine($"[PLAYER-INIT] entityPos=({playerEntity.Position.X:F1},{playerEntity.Position.Y:F1},{playerEntity.Position.Z:F1}) " +
$"entityRot=({q.X:F3},{q.Y:F3},{q.Z:F3},{q.W:F3}) " +
$"initCellId=0x{(pinitCellId & 0xFFFFu):X4} " +
$"yaw={yaw:F3} " +
$"physics landblocks={_physicsEngine.LandblockCount}");
_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)
{
// Phase B.2: player mode — mouse X turns the character,
// mouse Y adjusts chase camera pitch.
// Accumulate X for the controller to consume in OnUpdate.
_playerMouseDeltaX += dx;
_chaseCamera?.AdjustPitch(dy * 0.005f);
}
else if (_cameraController.IsFlyMode)
{
// Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
_cameraController.Fly.Look(dx, dy);
}
else
{
if (m.IsButtonPressed(MouseButton.Left))
{
_cameraController.Orbit.Yaw -= dx * 0.005f;
_cameraController.Orbit.Pitch = Math.Clamp(
_cameraController.Orbit.Pitch + dy * 0.005f,
0.1f, 1.5f);
}
}
_lastMouseX = pos.X;
_lastMouseY = pos.Y;
};
mouse.Scroll += (_, scroll) =>
{
if (_cameraController is null || _cameraController.IsFlyMode) return;
_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.vert"),
Path.Combine(shadersDir, "mesh.frag"));
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);
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 TerrainRenderer(_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 StaticMeshRenderer(_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);
});
// 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.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
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++;
// 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 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;
}
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;
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++,
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 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);
_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,
};
}
// 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 static float SampleTerrainZ(DatReaderWriter.DBObjs.LandBlock block, float[] heightTable, float worldX, float worldY)
{
const float CellSize = 24f;
const int VerticesPerSide = 9;
float fx = Math.Clamp(worldX / CellSize, 0f, VerticesPerSide - 1);
float fy = Math.Clamp(worldY / CellSize, 0f, VerticesPerSide - 1);
int x0 = (int)MathF.Floor(fx);
int y0 = (int)MathF.Floor(fy);
int x1 = Math.Min(x0 + 1, VerticesPerSide - 1);
int y1 = Math.Min(y0 + 1, VerticesPerSide - 1);
float tx = fx - x0;
float ty = fy - y0;
// Heightmap is packed x-major (Height[x*9+y]) matching LandblockMesh.
float h00 = heightTable[block.Height[x0 * 9 + y0]];
float h10 = heightTable[block.Height[x1 * 9 + y0]];
float h01 = heightTable[block.Height[x0 * 9 + y1]];
float h11 = heightTable[block.Height[x1 * 9 + y1]];
float hx0 = h00 * (1 - tx) + h10 * tx;
float hx1 = h01 * (1 - tx) + h11 * tx;
return hx0 * (1 - ty) + hx1 * ty;
}
/// <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;
if (!newCycleIsGood)
return;
ae.Animation = newCycle!.Animation;
ae.LowFrame = Math.Max(0, newCycle.LowFrame);
ae.HighFrame = Math.Min(newCycle.HighFrame, newCycle.Animation.PartFrames.Count - 1);
ae.Framerate = newCycle.Framerate;
ae.CurrFrame = ae.LowFrame;
}
/// <summary>
/// Phase 6.7: the server says an entity moved. Translate its new
/// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform.
/// </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;
}
/// <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)
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)
{
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;
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;
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
_dats, region, lb.Heightmap, lb.LandblockId);
if (spawns.Count == 0) return result;
var lbOffset = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
// Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index.
// The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the
// 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which
// is enough because SceneryGenerator caps at ~200 spawns per block in practice.
uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u);
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)
{
// 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)
{
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;
_ = 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.
float localX = spawn.LocalPosition.X;
float localY = spawn.LocalPosition.Y;
float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
var hydrated = new AcDream.Core.World.WorldEntity
{
Id = sceneryIdBase + localIndex++,
SourceGfxObjOrSetupId = spawn.ObjectId,
Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
Rotation = spawn.Rotation,
MeshRefs = meshRefs,
};
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.
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 var cellStruct))
{
var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats);
if (cellSubMeshes.Count > 0)
{
// Store in the pending dict so ApplyLoadedTerrain can upload on
// the render thread. The key is the EnvCell dat id — same key
// used in the MeshRef below so EnsureUploaded can find it.
_pendingCellMeshes[envCellId] = cellSubMeshes;
// Z lift: 2 cm to avoid depth-fighting with terrain polygon.
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 },
};
result.Add(cellEntity);
}
}
}
// 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)
{
_ = 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)
{
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;
_ = 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,
};
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);
}
}
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);
// 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.
{
var terrainSurface = new AcDream.Core.Physics.TerrainSurface(lb.Heightmap.Height, _heightTable);
var cellSurfaces = new List<AcDream.Core.Physics.CellSurface>();
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));
}
}
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, 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;
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
_staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes);
}
}
}
// 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 (_liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
&& _lastLivePlayerLandblockId is { } lid)
{
// Live mode: 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);
}
// 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);
var result = _playerController.Update((float)dt, input);
// DIAG: dump player state every ~60 frames to see what's happening.
if (_perfFrameCount % 60 == 0)
{
Console.WriteLine($"[PLAYER] pos=({result.Position.X:F1},{result.Position.Y:F1},{result.Position.Z:F1}) " +
$"cell=0x{result.CellId:X8} ground={result.IsOnGround} " +
$"yaw={_playerController.Yaw:F2} " +
$"fwdCmd={result.ForwardCommand?.ToString("X8") ?? "idle"} " +
$"stateChanged={result.MotionStateChanged}");
}
// 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);
}
// 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 = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw);
if (result.MotionStateChanged)
{
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: result.ForwardCommand == 0x44000007u ? 1u : (uint?)null,
cellId: wireCellId,
position: wirePos,
rotation: wireRot,
instanceSequence: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
_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: 0,
serverControlSequence: 0,
teleportSequence: 0,
forcePositionSequence: 0);
_liveSession.SendGameAction(body);
}
}
// Update the player entity's animation cycle to match current motion.
UpdatePlayerAnimation(result);
}
}
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);
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);
_terrain?.Draw(camera, frustum);
_staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum);
// 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++;
}
}
// 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}";
_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;
int span = ae.HighFrame - ae.LowFrame;
if (span <= 0) continue;
ae.CurrFrame += dt * ae.Framerate;
// Wrap into [LowFrame, HighFrame]. Use a guarded modulo so
// big dts (first frame after a stall) don't blow the loop.
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;
}
// Phase 6.5: blend between adjacent keyframes using the fractional
// part of CurrFrame so the animation is smooth at any framerate
// instead of snapping to integer frame indices.
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; // cycle wraps within [LowFrame, HighFrame]
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;
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++)
{
// Slerp between the current and next keyframe per part.
// Out-of-range parts get an identity transform — defensive
// for setups whose part count exceeds the animation's bone
// count.
System.Numerics.Vector3 origin;
System.Numerics.Quaternion orientation;
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;
}
var frame = new DatReaderWriter.Types.Frame { Origin = origin, Orientation = orientation };
// 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(frame.Orientation) *
System.Numerics.Matrix4x4.CreateTranslation(frame.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.
/// </summary>
private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result)
{
if (_dats is null) return;
// Determine the animation command: forward takes priority, then sidestep,
// then turn, then idle (Ready 0x41000003).
uint animCommand;
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;
// 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;
}
ushort cmdOverride = (ushort)(animCommand & 0xFFFFu);
var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle(
ae.Setup, _dats, commandOverride: cmdOverride);
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();
_staticMesh?.Dispose();
_textureCache?.Dispose();
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_dats?.Dispose();
_input?.Dispose();
_gl?.Dispose();
}
public void Dispose() => _window?.Dispose();
}