Makes NPCs and other server-spawned entities actually move and transition animations based on the live server feed. Before this, Phase 6.6/6.7 only parsed the messages and fired events that nothing consumed, so NPCs stayed frozen at their CreateObject spawn point playing one idle cycle forever. Changes: - GameWindow now keeps a parallel _entitiesByServerGuid dictionary built at CreateObject hydration time so motion / position updates can find the target entity by its server guid. - WorldEntity.Position and Rotation become get/set (like MeshRefs did in Phase 6.4) so the position-update handler can reseat an existing entity in place without reallocating MeshRefs. - OnLiveMotionUpdated re-resolves the cycle via MotionResolver using the server's new (stance, forward-command) override and either swaps the AnimatedEntity's current cycle or removes it from the animated set if the new pose is static. - OnLivePositionUpdated translates the new landblock-local position into acdream world space (same math as CreateObject hydration) and writes it back onto the entity. Subscriptions are added alongside the existing EntitySpawned hook so the three handlers run synchronously on the UDP pump thread, matching the existing pattern. 194 tests green (98 Core + 96 Core.Net). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1264 lines
60 KiB
C#
1264 lines
60 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;
|
|
private IReadOnlyList<AcDream.Core.World.WorldEntity> _entities = Array.Empty<AcDream.Core.World.WorldEntity>();
|
|
|
|
/// <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 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 = true,
|
|
};
|
|
|
|
_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
|
|
_window!.Close();
|
|
}
|
|
};
|
|
|
|
foreach (var mouse in _input.Mice)
|
|
{
|
|
mouse.MouseMove += (m, pos) =>
|
|
{
|
|
if (_cameraController is null) return;
|
|
|
|
if (_cameraController.IsFlyMode)
|
|
{
|
|
// Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last.
|
|
float dx = pos.X - _lastMouseX;
|
|
float dy = pos.Y - _lastMouseY;
|
|
_cameraController.Fly.Look(dx, dy);
|
|
}
|
|
else
|
|
{
|
|
if (m.IsButtonPressed(MouseButton.Left))
|
|
{
|
|
_cameraController.Orbit.Yaw -= (pos.X - _lastMouseX) * 0.005f;
|
|
_cameraController.Orbit.Pitch = Math.Clamp(
|
|
_cameraController.Orbit.Pitch + (pos.Y - _lastMouseY) * 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);
|
|
|
|
// Load the 3x3 neighbor grid.
|
|
var worldView = AcDream.Core.World.WorldView.Load(_dats, centerLandblockId);
|
|
Console.WriteLine($"loaded {worldView.Landblocks.Count} landblocks in 3x3 grid");
|
|
|
|
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
|
|
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
|
|
|
|
// Shared blending context + SurfaceInfo cache across all loaded
|
|
// landblocks. Palette codes are deterministic so two landblocks that
|
|
// happen to share a cell layout hit the cache instead of rebuilding.
|
|
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;
|
|
|
|
var 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);
|
|
|
|
var surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
|
|
|
|
foreach (var lb in worldView.Landblocks)
|
|
{
|
|
uint lbX = (lb.LandblockId >> 24) & 0xFFu;
|
|
uint lbY = (lb.LandblockId >> 16) & 0xFFu;
|
|
|
|
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
|
|
lb.Heightmap, lbX, lbY, heightTable, blendCtx, surfaceCache);
|
|
|
|
// Compute world origin for this landblock relative to the center.
|
|
var origin = new System.Numerics.Vector3(
|
|
((int)lbX - centerX) * 192f,
|
|
((int)lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
_terrain.AddLandblock(meshData, origin);
|
|
}
|
|
Console.WriteLine($"terrain: {surfaceCache.Count} unique palette codes across {worldView.Landblocks.Count} landblocks");
|
|
|
|
_textureCache = new TextureCache(_gl, _dats);
|
|
_staticMesh = new StaticMeshRenderer(_gl, _meshShader, _textureCache);
|
|
|
|
// Hydrate entities from ALL loaded landblocks, not just the center.
|
|
var allEntities = worldView.AllEntities.ToList();
|
|
Console.WriteLine($"hydrating {allEntities.Count} entities across {worldView.Landblocks.Count} landblocks");
|
|
|
|
var hydratedEntities = new List<AcDream.Core.World.WorldEntity>(allEntities.Count);
|
|
foreach (var e in allEntities)
|
|
{
|
|
var meshRefs = new List<AcDream.Core.World.MeshRef>();
|
|
|
|
if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u)
|
|
{
|
|
var gfx = _dats.Get<DatReaderWriter.DBObjs.GfxObj>(e.SourceGfxObjOrSetupId);
|
|
if (gfx is not null)
|
|
{
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(e.SourceGfxObjOrSetupId, subMeshes);
|
|
meshRefs.Add(new AcDream.Core.World.MeshRef(
|
|
e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity));
|
|
}
|
|
}
|
|
else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
|
|
{
|
|
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;
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
|
meshRefs.Add(mr);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (meshRefs.Count > 0)
|
|
{
|
|
// Add the landblock origin to the entity's position so the static
|
|
// mesh renderer draws it at the correct world location.
|
|
var sourceLandblock = worldView.Landblocks.First(lb => lb.Entities.Contains(e));
|
|
int lbX = (int)((sourceLandblock.LandblockId >> 24) & 0xFFu);
|
|
int lbY = (int)((sourceLandblock.LandblockId >> 16) & 0xFFu);
|
|
var worldOffset = new System.Numerics.Vector3(
|
|
(lbX - centerX) * 192f,
|
|
(lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
var hydrated = new AcDream.Core.World.WorldEntity
|
|
{
|
|
Id = e.Id,
|
|
SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId,
|
|
Position = e.Position + worldOffset,
|
|
Rotation = e.Rotation,
|
|
MeshRefs = meshRefs,
|
|
};
|
|
hydratedEntities.Add(hydrated);
|
|
|
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
Id: hydrated.Id,
|
|
SourceId: hydrated.SourceGfxObjOrSetupId,
|
|
Position: hydrated.Position,
|
|
Rotation: hydrated.Rotation);
|
|
_worldGameState.Add(snapshot);
|
|
_worldEvents.FireEntitySpawned(snapshot);
|
|
}
|
|
}
|
|
|
|
// Phase 2c: procedural scenery — trees, bushes, rocks, fences from
|
|
// Region.SceneInfo. These aren't stored as explicit Stab entries; they're
|
|
// generated deterministically from per-vertex TerrainInfo.Scenery bits.
|
|
int scenerySpawned = 0;
|
|
uint sceneryIdCounter = 0x80000000u; // high bit set to avoid colliding with Stab ids
|
|
foreach (var lb in worldView.Landblocks)
|
|
{
|
|
var spawns = AcDream.Core.World.SceneryGenerator.Generate(
|
|
_dats, region!, lb.Heightmap, lb.LandblockId);
|
|
if (spawns.Count == 0) continue;
|
|
|
|
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
|
|
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
|
|
var lbOffset = new System.Numerics.Vector3(
|
|
(lbX - centerX) * 192f,
|
|
(lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
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)
|
|
{
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(spawn.ObjectId, subMeshes);
|
|
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;
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
|
// 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 = sceneryIdCounter++,
|
|
SourceGfxObjOrSetupId = spawn.ObjectId,
|
|
Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset,
|
|
Rotation = spawn.Rotation,
|
|
MeshRefs = meshRefs,
|
|
};
|
|
hydratedEntities.Add(hydrated);
|
|
|
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
Id: hydrated.Id,
|
|
SourceId: hydrated.SourceGfxObjOrSetupId,
|
|
Position: hydrated.Position,
|
|
Rotation: hydrated.Rotation);
|
|
_worldGameState.Add(snapshot);
|
|
_worldEvents.FireEntitySpawned(snapshot);
|
|
scenerySpawned++;
|
|
}
|
|
}
|
|
Console.WriteLine($"scenery: spawned {scenerySpawned} entities across {worldView.Landblocks.Count} landblocks");
|
|
|
|
// Phase 2d: walk interior EnvCells and add their StaticObjects. Buildings'
|
|
// rooftop statues, doors, interior decorations, and other in-building static
|
|
// objects live here rather than in LandBlockInfo.Objects. EnvCell ids for a
|
|
// landblock are packed at 0xAAAABBBB where AAAA is the landblock id high word
|
|
// and BBBB starts at 0x0100 — documented on LandBlockInfo.NumCells.
|
|
// Phase 7.1: also build each EnvCell's room geometry (walls/floors/ceilings)
|
|
// from CellStruct.Polygons + EnvCell.Surfaces.
|
|
int interiorSpawned = 0;
|
|
int cellMeshSpawned = 0;
|
|
uint interiorIdCounter = 0x40000000u; // distinct from scenery (0x80000000+) and stabs
|
|
foreach (var lb in worldView.Landblocks)
|
|
{
|
|
// Re-fetch LandBlockInfo to get NumCells. WorldView.LoadedLandblock exposes
|
|
// Heightmap + Entities but not the raw info record.
|
|
var lbInfo = _dats.Get<DatReaderWriter.DBObjs.LandBlockInfo>((lb.LandblockId & 0xFFFF0000u) | 0xFFFEu);
|
|
if (lbInfo is null || lbInfo.NumCells == 0) continue;
|
|
|
|
int lbX = (int)((lb.LandblockId >> 24) & 0xFFu);
|
|
int lbY = (int)((lb.LandblockId >> 16) & 0xFFu);
|
|
var lbOffset = new System.Numerics.Vector3(
|
|
(lbX - centerX) * 192f,
|
|
(lbY - centerY) * 192f,
|
|
0f);
|
|
|
|
// Interior cells start at 0xAAAA0100 and run for NumCells.
|
|
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;
|
|
|
|
// Phase 7.1: build and register room geometry for this EnvCell.
|
|
// Each EnvCell has an EnvironmentId that points to an Environment dat
|
|
// containing CellStruct geometry (vertex arrays + polygons). The surfaces
|
|
// list on the EnvCell (not the CellStruct) maps polygon PosSurface indices
|
|
// to unqualified surface ids (OR with 0x08000000 for the full dat id).
|
|
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)
|
|
{
|
|
// Use the EnvCell dat id as the GPU upload key. EnvCell ids
|
|
// live in 0xAAAA01xx space which is disjoint from GfxObj
|
|
// (0x01xxxxxx) and Setup (0x02xxxxxx) ids — no collision risk.
|
|
_staticMesh.EnsureUploaded(envCellId, cellSubMeshes);
|
|
|
|
// Cell vertices are in env-local space. The per-cell world
|
|
// transform is: rotate(envCell.Position.Orientation) then
|
|
// translate(envCell.Position.Origin + landblock offset).
|
|
// We bake the full transform into PartTransform and leave
|
|
// the WorldEntity at identity so the renderer's
|
|
// model = PartTransform * entityRoot = cellTransform * I
|
|
// gives the correctly positioned cell mesh.
|
|
//
|
|
// Z lift: buildings sit ON the terrain mesh, so the ground
|
|
// floor of every building is coincident in Z with the terrain
|
|
// polygon beneath it. Without a bias the two fight for the
|
|
// same depth and flicker as the camera moves. A small lift
|
|
// (2 cm) is invisible from human scale but breaks the tie
|
|
// cleanly in the cell mesh's favor.
|
|
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 = interiorIdCounter++,
|
|
SourceGfxObjOrSetupId = envCellId,
|
|
Position = System.Numerics.Vector3.Zero,
|
|
Rotation = System.Numerics.Quaternion.Identity,
|
|
MeshRefs = new[] { cellMeshRef },
|
|
};
|
|
hydratedEntities.Add(cellEntity);
|
|
|
|
var cellSnapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
Id: cellEntity.Id,
|
|
SourceId: cellEntity.SourceGfxObjOrSetupId,
|
|
Position: cellEntity.Position,
|
|
Rotation: cellEntity.Rotation);
|
|
_worldGameState.Add(cellSnapshot);
|
|
_worldEvents.FireEntitySpawned(cellSnapshot);
|
|
cellMeshSpawned++;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach (var stab in envCell.StaticObjects)
|
|
{
|
|
// Resolve stab id to mesh (same as LandBlockInfo.Objects).
|
|
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)
|
|
{
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(stab.Id, subMeshes);
|
|
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;
|
|
var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats);
|
|
_staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes);
|
|
meshRefs.Add(mr);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (meshRefs.Count == 0) continue;
|
|
|
|
// Stabs inside EnvCells are already in landblock-local coordinates
|
|
// (same coordinate space as LandBlockInfo.Objects stabs) — NOT
|
|
// cell-local. The EnvCell.Position field tells the physics engine
|
|
// which cell owns the object, but it doesn't translate coordinates.
|
|
// Adding cellOrigin was a wrong assumption that left interior objects
|
|
// floating ~150 units in the air.
|
|
var worldPos = stab.Frame.Origin + lbOffset;
|
|
var worldRot = stab.Frame.Orientation;
|
|
|
|
var hydrated = new AcDream.Core.World.WorldEntity
|
|
{
|
|
Id = interiorIdCounter++,
|
|
SourceGfxObjOrSetupId = stab.Id,
|
|
Position = worldPos,
|
|
Rotation = worldRot,
|
|
MeshRefs = meshRefs,
|
|
};
|
|
hydratedEntities.Add(hydrated);
|
|
|
|
var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot(
|
|
Id: hydrated.Id,
|
|
SourceId: hydrated.SourceGfxObjOrSetupId,
|
|
Position: hydrated.Position,
|
|
Rotation: hydrated.Rotation);
|
|
_worldGameState.Add(snapshot);
|
|
_worldEvents.FireEntitySpawned(snapshot);
|
|
interiorSpawned++;
|
|
}
|
|
}
|
|
}
|
|
Console.WriteLine($"interior: spawned {interiorSpawned} static objects from EnvCells");
|
|
Console.WriteLine($"interior: built {cellMeshSpawned} cell room meshes");
|
|
|
|
_entities = hydratedEntities;
|
|
Console.WriteLine($"hydrated {_entities.Count} entities total (stabs + buildings + scenery + interior)");
|
|
|
|
// 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];
|
|
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)
|
|
{
|
|
_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);
|
|
|
|
// Extend the render list so the next frame picks up the new entity.
|
|
// We copy into a new list because _entities is typed as IReadOnlyList.
|
|
var extended = new List<AcDream.Core.World.WorldEntity>(_entities) { entity };
|
|
_entities = extended;
|
|
_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 (newCycle is null || newCycle.Framerate == 0f
|
|
|| newCycle.HighFrame <= newCycle.LowFrame
|
|
|| newCycle.Animation.PartFrames.Count <= 1)
|
|
{
|
|
// New pose is a static one — stop animating and leave the
|
|
// entity on its last rendered frame. Removing from the map
|
|
// means the tick no longer updates it.
|
|
_animatedEntities.Remove(entity.Id);
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
private void OnUpdate(double dt)
|
|
{
|
|
// Drain any pending live-session traffic. Non-blocking — returns
|
|
// immediately if no datagrams are in the kernel buffer. Fires
|
|
// EntitySpawned events synchronously on this thread.
|
|
_liveSession?.Tick();
|
|
|
|
if (_cameraController is null || _input is null) return;
|
|
if (!_cameraController.IsFlyMode) return;
|
|
|
|
var kb = _input.Keyboards[0];
|
|
_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));
|
|
}
|
|
|
|
private void OnCameraModeChanged(bool isFlyMode)
|
|
{
|
|
if (_input is null) return;
|
|
var mouse = _input.Mice.FirstOrDefault();
|
|
if (mouse is null) return;
|
|
|
|
mouse.Cursor.CursorMode = isFlyMode ? CursorMode.Raw : CursorMode.Normal;
|
|
_capturedMouse = isFlyMode ? mouse : null;
|
|
}
|
|
|
|
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);
|
|
|
|
if (_cameraController is not null)
|
|
{
|
|
_terrain?.Draw(_cameraController.Active);
|
|
_staticMesh?.Draw(_cameraController.Active, _entities);
|
|
}
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
}
|
|
|
|
private void OnClosing()
|
|
{
|
|
_liveSession?.Dispose();
|
|
_staticMesh?.Dispose();
|
|
_textureCache?.Dispose();
|
|
_meshShader?.Dispose();
|
|
_terrain?.Dispose();
|
|
_shader?.Dispose();
|
|
_dats?.Dispose();
|
|
_input?.Dispose();
|
|
_gl?.Dispose();
|
|
}
|
|
|
|
public void Dispose() => _window?.Dispose();
|
|
}
|