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 _entities = Array.Empty(); /// /// 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 . /// Static decorations and entities with no motion table never /// appear in this map. /// private readonly Dictionary _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 /// /// 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. /// public required IReadOnlyList<(uint GfxObjId, IReadOnlyDictionary? 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 /// /// 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 /// keys the render list; this parallel dictionary keys by server guid. /// private readonly Dictionary _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(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(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(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(); 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(allEntities.Count); foreach (var e in allEntities) { var meshRefs = new List(); if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(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(e.SourceGfxObjOrSetupId); if (setup is not null) { var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(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(); var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(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(spawn.ObjectId); if (setup is not null) { var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(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((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(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(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(); if ((stab.Id & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(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(stab.Id); if (setup is not null) { var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); foreach (var mr in flat) { var gfx = _dats.Get(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; } } /// /// 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). /// 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(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(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(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(flat); var animPartChanges = spawn.AnimPartChanges ?? Array.Empty(); 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(); Dictionary>? resolvedOverridesByPart = null; if (textureChanges.Count > 0) { // First pass: group (oldOrigTex → newOrigTex) per part. var perPartOldToNew = new Dictionary>(); foreach (var tc in textureChanges) { if (!perPartOldToNew.TryGetValue(tc.PartIndex, out var dict)) { dict = new Dictionary(); 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>(); for (int pi = 0; pi < parts.Count; pi++) { if (!perPartOldToNew.TryGetValue(pi, out var oldToNew)) continue; var partGfx = _dats.Get(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? resolved = null; foreach (var surfQid in partGfx.Surfaces) { uint surfId = (uint)surfQid; var surfDat = _dats.Get(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(); 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(); for (int partIdx = 0; partIdx < parts.Count; partIdx++) { var mr = parts[partIdx]; var gfx = _dats.Get(mr.GfxObjId); if (gfx is null) continue; var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(mr.GfxObjId, subMeshes); IReadOnlyDictionary? 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(_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?)[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}"); } } /// /// Bilinear sample of the landblock heightmap at (x, y) in landblock-local /// world units. Matches the x-major indexing convention of LandblockMesh. /// 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; } /// /// 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. /// 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; } /// /// 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. /// 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); } } /// /// Phase 6.4: advance every animated entity's frame counter by /// * 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. /// 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(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(); }