The user noted that the previous 35 u/s default felt too fast for exploring scenery — overshooting buildings and skipping past entities constantly. Drop default to 12 u/s (≈AC's run speed), and bind Shift to a 50 u/s boost for fast travel between landblocks. Backwards compatible: the new boost parameter on FlyCamera.Update has a default of false, so any existing caller behaves the same. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1152 lines
55 KiB
C#
1152 lines
55 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
|
|
private int _liveSpawnReceived; // diagnostics
|
|
private int _liveSpawnHydrated;
|
|
private int _liveDropReasonNoPos;
|
|
private int _liveDropReasonNoSetup;
|
|
private int _liveDropReasonSetupDatMissing;
|
|
private int _liveDropReasonNoMeshRefs;
|
|
|
|
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);
|
|
_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);
|
|
_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);
|
|
_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);
|
|
_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);
|
|
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.
|
|
var cellTransform =
|
|
System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
|
|
System.Numerics.Matrix4x4.CreateTranslation(envCell.Position.Origin + lbOffset);
|
|
|
|
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);
|
|
_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);
|
|
_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.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);
|
|
_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.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).
|
|
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: 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;
|
|
}
|
|
|
|
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();
|
|
}
|