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>
1833 lines
87 KiB
C#
1833 lines
87 KiB
C#
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();
|
||
}
|