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 TerrainChunkRenderer? _terrain; private Shader? _shader; private CameraController? _cameraController; private IMouse? _capturedMouse; private DatCollection? _dats; private float _lastMouseX; private float _lastMouseY; private InstancedMeshRenderer? _staticMesh; private Shader? _meshShader; private TextureCache? _textureCache; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; private readonly AcDream.App.Streaming.GpuWorldState _worldState = new(); private AcDream.App.Streaming.StreamingController? _streamingController; private int _streamingRadius = 2; // default 5×5 private uint? _lastLivePlayerLandblockId; // Phase B.3: physics engine — populated from the streaming pipeline. private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine = new(); // Step 4: portal-based interior cell visibility. private readonly CellVisibility _cellVisibility = new(); // Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker // thread and the render thread both read dats (BuildLandblockForStreaming // on the worker; ApplyLoadedTerrain + live-spawn handlers on the render // thread). Concurrent reads corrupt internal caches and produce // half-populated LandBlock.Height[] arrays, which caused terrain to render // as "a giant ball with spikes" before this lock was added. All _dats.Get // calls that can race with the worker thread MUST acquire this lock. private readonly object _datLock = new(); // Terrain build context shared across all streamed landblocks. Stored as // fields so ApplyLoadedTerrain (render-thread callback) can call // LandblockMesh.Build without re-deriving these each time. private float[]? _heightTable; private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx; private Dictionary? _surfaceCache; // Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes // (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains // this dict and uploads them via EnsureUploaded before the per-MeshRef loop. // ConcurrentDictionary is required because the worker and render threads // access this simultaneously without a broader lock. private readonly System.Collections.Concurrent.ConcurrentDictionary< uint, System.Collections.Generic.IReadOnlyList> _pendingCellMeshes = new(); // Step 4: pending LoadedCell objects built on the worker thread, drained // to _cellVisibility on the render thread in ApplyLoadedTerrain. private readonly System.Collections.Concurrent.ConcurrentBag _pendingCells = new(); /// /// 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] public AcDream.Core.Physics.AnimationSequencer? Sequencer; } private AcDream.Core.Physics.DatCollectionLoader? _animLoader; // Phase B.2: player movement mode. private AcDream.App.Input.PlayerMovementController? _playerController; private AcDream.App.Rendering.ChaseCamera? _chaseCamera; private bool _playerMode; private uint _playerServerGuid; private uint? _playerCurrentAnimCommand; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character // Accumulated mouse X delta for player turning; written in mouse-move // callback, consumed + reset in OnUpdate each frame. private float _playerMouseDeltaX; // Phase 4.7: optional live connection to an ACE server. Enabled only when // ACDREAM_LIVE=1 is in the environment — fully backward compatible with // the offline rendering pipeline. private AcDream.Core.Net.WorldSession? _liveSession; private int _liveCenterX; private int _liveCenterY; private uint _liveEntityIdCounter = 1_000_000u; // well above any dat-hydrated id /// /// 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 = false, // off during development so the perf overlay shows true framerate }; _window = Window.Create(options); _window.Load += OnLoad; _window.Update += OnUpdate; _window.Render += OnRender; _window.Closing += OnClosing; _window.Run(); } private void OnLoad() { _gl = GL.GetApi(_window!); _input = _window!.CreateInput(); foreach (var kb in _input.Keyboards) kb.KeyDown += (_, key, _) => { if (key == Key.F) _cameraController?.ToggleFly(); else if (key == Key.Escape) { if (_cameraController?.IsFlyMode == true) _cameraController.ToggleFly(); // exit fly, release cursor else if (_playerMode) { // Exit player mode on Escape too. _playerMode = false; _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; _playerCurrentAnimCommand = null; } else _window!.Close(); } // Phase B.2: Tab toggles between fly and player-movement mode. // Only active when a live session is in-world and we have a // player entity spawned on screen. else if (key == Key.Tab && _liveSession is not null && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld) { _playerMode = !_playerMode; if (_playerMode) { if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var playerEntity)) { _playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine); // Read the real step height from the player's Setup dat. if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { var playerSetup = _dats.Get(playerEntity.SourceGfxObjOrSetupId); _playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f) ? playerSetup.StepUpHeight : 2f; // default human step height } else { _playerController.StepUpHeight = 2f; // default human step height } // Derive initial cell ID from the entity's world position. int plbX = _liveCenterX + (int)MathF.Floor(playerEntity.Position.X / 192f); int plbY = _liveCenterY + (int)MathF.Floor(playerEntity.Position.Y / 192f); float plocalX = playerEntity.Position.X - (plbX - _liveCenterX) * 192f; float plocalY = playerEntity.Position.Y - (plbY - _liveCenterY) * 192f; uint pinitCellId = ((uint)plbX << 24) | ((uint)plbY << 16) | 0x0001u; // Resolve the initial position through the physics engine to // get the correct terrain Z. The server-sent Z may be stale // from a previous ACE relocation. With indoor transitions // disabled, Resolve will always snap to outdoor terrain Z. var initResult = _physicsEngine.Resolve( playerEntity.Position, pinitCellId & 0xFFFFu, System.Numerics.Vector3.Zero, 100f); // huge step height for initial snap _playerController.SetPosition(initResult.Position, initResult.CellId); // Derive initial yaw from the entity's rotation. // The render loop stores rotation as Yaw - PI/2 (to // compensate for AC models facing +Y at identity), so // we add PI/2 back when extracting to get the real yaw. var q = playerEntity.Rotation; float rawYaw = MathF.Atan2( 2f * (q.W * q.Z + q.X * q.Y), 1f - 2f * (q.Y * q.Y + q.Z * q.Z)); _playerController.Yaw = rawYaw + MathF.PI / 2f; _chaseCamera = new AcDream.App.Rendering.ChaseCamera { Aspect = _window!.Size.X / (float)_window.Size.Y, }; _playerMouseDeltaX = 0f; _cameraController?.EnterChaseMode(_chaseCamera); // ModeChanged event fires from EnterChaseMode → OnCameraModeChanged // captures mouse in raw mode automatically. } else { // Player entity not yet spawned — revert. _playerMode = false; Console.WriteLine($"live: Tab pressed but player entity 0x{_playerServerGuid:X8} not found yet"); } } else { _cameraController?.ExitChaseMode(); _playerController = null; _chaseCamera = null; _playerCurrentAnimCommand = null; _playerMouseDeltaX = 0f; // ExitChaseMode fires ModeChanged → OnCameraModeChanged(true=fly) → raw mode stays ON } } }; foreach (var mouse in _input.Mice) { mouse.MouseMove += (m, pos) => { if (_cameraController is null) return; float dx = pos.X - _lastMouseX; float dy = pos.Y - _lastMouseY; if (_playerMode && _cameraController.IsChaseMode) { // Phase B.2: player mode — mouse X turns the character, // mouse Y adjusts chase camera pitch. // Accumulate X for the controller to consume in OnUpdate. _playerMouseDeltaX += dx; _chaseCamera?.AdjustPitch(dy * 0.005f); } else if (_cameraController.IsFlyMode) { // Raw cursor mode: Silk.NET gives deltas via position. Compute delta from last. _cameraController.Fly.Look(dx, dy); } else { if (m.IsButtonPressed(MouseButton.Left)) { _cameraController.Orbit.Yaw -= dx * 0.005f; _cameraController.Orbit.Pitch = Math.Clamp( _cameraController.Orbit.Pitch + dy * 0.005f, 0.1f, 1.5f); } } _lastMouseX = pos.X; _lastMouseY = pos.Y; }; mouse.Scroll += (_, scroll) => { if (_cameraController is null || _cameraController.IsFlyMode) return; _cameraController.Orbit.Distance = Math.Clamp( _cameraController.Orbit.Distance - scroll.Y * 20f, 50f, 2000f); }; } _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.Enable(EnableCap.DepthTest); string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders"); _shader = new Shader(_gl, Path.Combine(shadersDir, "terrain.vert"), Path.Combine(shadersDir, "terrain.frag")); _meshShader = new Shader(_gl, Path.Combine(shadersDir, "mesh_instanced.vert"), Path.Combine(shadersDir, "mesh_instanced.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); _animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); 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 TerrainChunkRenderer(_gl, _shader, terrainAtlas); int centerX = (int)((centerLandblockId >> 24) & 0xFFu); int centerY = (int)((centerLandblockId >> 16) & 0xFFu); // Build blending context from the terrain atlas. Stored as fields so // ApplyLoadedTerrain (render-thread callback invoked per streamed lb) // can call LandblockMesh.Build without re-deriving these every time. var terrainTypeToLayerBytes = new Dictionary(terrainAtlas.TerrainTypeToLayer.Count); foreach (var kvp in terrainAtlas.TerrainTypeToLayer) terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value; const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl) ? rl : AcDream.Core.Terrain.SurfaceInfo.None; _blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext( TerrainTypeToLayer: terrainTypeToLayerBytes, RoadLayer: roadLayer, CornerAlphaLayers: terrainAtlas.CornerAlphaLayers, SideAlphaLayers: terrainAtlas.SideAlphaLayers, RoadAlphaLayers: terrainAtlas.RoadAlphaLayers, CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes, SideAlphaTCodes: terrainAtlas.SideAlphaTCodes, RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes); _heightTable = heightTable; _surfaceCache = new Dictionary(); _textureCache = new TextureCache(_gl, _dats); _staticMesh = new InstancedMeshRenderer(_gl, _meshShader, _textureCache); // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). // Values outside [0, 8] fall back to the field default of 2. var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS"); if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8) _streamingRadius = r; Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})"); // The streamer's load delegate wraps LandblockLoader.Load + stab // hydration. Scenery + interior will land in Task 8. _streamer = new AcDream.App.Streaming.LandblockStreamer( loadLandblock: id => BuildLandblockForStreaming(id)); _streamer.Start(); _streamingController = new AcDream.App.Streaming.StreamingController( enqueueLoad: _streamer.EnqueueLoad, enqueueUnload: _streamer.EnqueueUnload, drainCompletions: _streamer.DrainCompletions, applyTerrain: ApplyLoadedTerrain, state: _worldState, radius: _streamingRadius, removeTerrain: id => { _terrain?.RemoveLandblock(id); _physicsEngine.RemoveLandblock(id); _cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu); }); // 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.TeleportStarted += OnTeleportStarted; _liveSession.Connect(user, pass); if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0) { Console.WriteLine("live: no characters on account; disconnecting"); _liveSession.Dispose(); _liveSession = null; return; } var chosen = _liveSession.Characters.Characters[0]; _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads 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) { // Phase A.1 hotfix: live CreateObject handler reads dats extensively // (Setup, GfxObj, Surface, SurfaceTexture) to hydrate the spawned // entity. All of it must run under the dat lock so it doesn't race // with BuildLandblockForStreaming on the worker thread. lock (_datLock) { OnLiveEntitySpawnedLocked(spawn); } } private void OnLiveEntitySpawnedLocked(AcDream.Core.Net.WorldSession.EntitySpawn spawn) { _liveSpawnReceived++; // Log every spawn that arrives so we can inventory what the server // sends (including the ones we can't render yet). The Name field // is the critical one — we can grep the log for "Nullified Statue // of a Drudge" or similar to find a specific weenie by its // in-game name. string posStr = spawn.Position is { } sp ? $"({sp.PositionX:F1},{sp.PositionY:F1},{sp.PositionZ:F1})@0x{sp.LandblockId:X8}" : "no-pos"; string setupStr = spawn.SetupTableId is { } su ? $"0x{su:X8}" : "no-setup"; string nameStr = spawn.Name is { Length: > 0 } n ? $"\"{n}\"" : "no-name"; int animPartCount = spawn.AnimPartChanges?.Count ?? 0; int texChangeCount = spawn.TextureChanges?.Count ?? 0; int subPalCount = spawn.SubPalettes?.Count ?? 0; Console.WriteLine( $"live: spawn guid=0x{spawn.Guid:X8} name={nameStr} setup={setupStr} pos={posStr} " + $"animParts={animPartCount} texChanges={texChangeCount} subPalettes={subPalCount}"); // Target the statue specifically for full diagnostic dump: Name match // is cheap and gives us exactly one entity's worth of log regardless // of arrival order. bool isStatue = spawn.Name is not null && spawn.Name.Contains("Statue", StringComparison.OrdinalIgnoreCase); if (isStatue) { Console.WriteLine($"live: [STATUE] objScale={spawn.ObjScale?.ToString("F3") ?? "null"}"); Console.WriteLine($"live: [STATUE] mtable=0x{(spawn.MotionTableId ?? 0):X8} stance=0x{(spawn.MotionState?.Stance ?? 0):X4} cmd=0x{(spawn.MotionState?.ForwardCommand ?? 0):X4}"); if (spawn.TextureChanges is { } tcs) { foreach (var tc in tcs) Console.WriteLine($"live: [STATUE] texChange part={tc.PartIndex} old=0x{tc.OldTexture:X8} new=0x{tc.NewTexture:X8}"); } if (spawn.SubPalettes is { } sps) { Console.WriteLine($"live: [STATUE] basePalette=0x{(spawn.BasePaletteId ?? 0):X8}"); foreach (var subPal in sps) Console.WriteLine($"live: [STATUE] subPalette id=0x{subPal.SubPaletteId:X8} offset={subPal.Offset} length={subPal.Length}"); } if (spawn.AnimPartChanges is { } apcs) { foreach (var apc in apcs) Console.WriteLine($"live: [STATUE] animPart index={apc.PartIndex} newModel=0x{apc.NewModelId:X8}"); } // Dump the BASE setup's part list before AnimPartChanges, so we can // see how many parts the statue's Setup actually has + what their // default GfxObjs are. The retail statue may have additional parts // (e.g. a pedestal sub-mesh) that our setup loader is dropping or // we're rendering with wrong default GfxObjs. if (spawn.SetupTableId is { } sid && _dats is not null) { var baseSetup = _dats.Get(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++, ServerGuid = spawn.Guid, SourceGfxObjOrSetupId = spawn.SetupTableId.Value, Position = worldPos, Rotation = rot, MeshRefs = meshRefs, PaletteOverride = paletteOverride, }; var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( Id: entity.Id, SourceId: entity.SourceGfxObjOrSetupId, Position: entity.Position, Rotation: entity.Rotation); _worldGameState.Add(snapshot); _worldEvents.FireEntitySpawned(snapshot); // Phase A.1: register entity into GpuWorldState so the next frame picks // it up. AppendLiveEntity is a no-op if the landblock isn't loaded yet // (can happen if the server sends CreateObjects before we finish loading). _worldState.AppendLiveEntity(spawn.Position!.Value.LandblockId, entity); _liveSpawnHydrated++; // Phase 6.6/6.7: remember the server-guid → WorldEntity mapping so // UpdateMotion / UpdatePosition events can reseat this entity by guid. _entitiesByServerGuid[spawn.Guid] = entity; // Phase B.2: capture the server-sent MotionTableId for our own // character so UpdatePlayerAnimation can pass it to GetIdleCycle. // The Setup's DefaultMotionTable is often 0 for human characters; // the real table comes from PhysicsDescriptionFlag.MTable. if (spawn.Guid == _playerServerGuid && spawn.MotionTableId is not null) _playerMotionTableId = spawn.MotionTableId; // 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); // Create an AnimationSequencer if we can load the MotionTable. AcDream.Core.Physics.AnimationSequencer? sequencer = null; if (_animLoader is not null) { uint mtableId = spawn.MotionTableId ?? (uint)setup.DefaultMotionTable; if (mtableId != 0) { var mtable = _dats.Get(mtableId); if (mtable is not null) { sequencer = new AcDream.Core.Physics.AnimationSequencer(setup, mtable, _animLoader); uint seqStyle = stanceOverride is > 0 ? (uint)stanceOverride.Value : (uint)mtable.DefaultStyle; uint seqMotion = commandOverride is > 0 ? (uint)commandOverride.Value : 0x41000003u; sequencer.SetCycle(seqStyle, seqMotion); } } } _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, Sequencer = sequencer, }; } // 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 the new cycle is bad (null, framerate=0, or single-frame), do // NOT remove the entity from the animated set. Keep its existing // cycle running so it continues to breathe / idle. Removing on // re-resolve failure was a bug that silently unregistered NPCs the // moment the server sent a motion update with a stance/command // pair the resolver couldn't translate cleanly. Defensive: switch // only when we have a clearly better cycle. bool newCycleIsGood = newCycle is not null && newCycle.Framerate != 0f && newCycle.HighFrame > newCycle.LowFrame && newCycle.Animation.PartFrames.Count > 1; if (!newCycleIsGood) return; // Wire server-echoed RunRate into the player's MotionInterpreter. // The server broadcasts the character's real Run-skill-derived ForwardSpeed // in UpdateMotion; without this the player would always move at 4.0 m/s // (ForwardSpeed = 1.0 hardcoded in MotionInterpreter defaults). if (_playerController is not null && update.Guid == _playerServerGuid && update.MotionState.ForwardSpeed.HasValue && update.MotionState.ForwardSpeed.Value > 0f) { _playerController.ApplyServerRunRate(update.MotionState.ForwardSpeed.Value); } // Sequencer path if (ae.Sequencer is not null) { uint fullStyle = stance != 0 ? (0x80000000u | (uint)stance) : ae.Sequencer.CurrentStyle; uint fullMotion = command is > 0 ? (uint)command.Value : 0x41000003u; ae.Sequencer.SetCycle(fullStyle, fullMotion); } // Legacy path 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. /// /// Phase B.3 extension: if the player controller is in PortalSpace and /// this update is for our own character, detect a large position change /// (different landblock or > 100 units distance). If detected, recenter /// the streaming controller, resolve the new position through physics, /// snap the player entity + controller, and return to InWorld. Also sends /// LoginComplete so the server knows the client has loaded the destination. /// private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update) { // Phase A.1: track the most recently updated entity's landblock so the // streaming controller can follow the player. TODO: filter by our own // character guid once we reliably know it from CharacterList. _lastLivePlayerLandblockId = update.Position.LandblockId; if (!_entitiesByServerGuid.TryGetValue(update.Guid, out var entity)) return; var p = update.Position; int lbX = (int)((p.LandblockId >> 24) & 0xFFu); int lbY = (int)((p.LandblockId >> 16) & 0xFFu); var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, 0f); var worldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ) + origin; var rot = new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW); entity.Position = worldPos; entity.Rotation = rot; // Phase B.3: portal-space arrival detection. // Only runs for our own player character while in PortalSpace. if (_playerController is not null && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace && update.Guid == _playerServerGuid) { // Compute old landblock coords from controller position (using the // current streaming origin as the reference center). var oldPos = _playerController.Position; int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f); int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f); bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); bool farAway = System.Numerics.Vector3.Distance(worldPos, oldPos) > 100f; if (differentLandblock || farAway) { Console.WriteLine( $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); // 1. Recenter the streaming controller on the new landblock. _liveCenterX = lbX; _liveCenterY = lbY; // Recompute worldPos with new center (it becomes local-to-center). // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ) // relative to the new origin — which maps to world-space (0,0,0) + local offset. // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically. var newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); // (after recentering, origin is (0,0,0) since lb == center) // 2. Resolve through physics for the correct ground Z. uint newCellId = p.LandblockId; var resolved = _physicsEngine.Resolve( newWorldPos, newCellId, System.Numerics.Vector3.Zero, _playerController.StepUpHeight); var snappedPos = new System.Numerics.Vector3( resolved.Position.X, resolved.Position.Y, resolved.Position.Z); // 3. Snap player entity + controller. entity.Position = snappedPos; entity.Rotation = rot; _playerController.SetPosition(snappedPos, resolved.CellId); // 4. Recenter chase camera on the new position. _chaseCamera?.Update(snappedPos, _playerController.Yaw); // 5. Return to InWorld. _playerController.State = AcDream.App.Input.PlayerState.InWorld; Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); // 5. Send LoginComplete to tell the server the client finished loading. // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440), // retail clients call send_login_complete() after each portal transition. // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path // doesn't also send one. We send directly here instead. _liveSession?.SendGameAction( AcDream.Core.Net.Messages.GameActionLoginComplete.Build()); } } } /// /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). /// Freeze movement input by setting the player controller to PortalSpace. /// The controller's Update() will return a zero-movement result until the /// destination UpdatePosition arrives and OnLivePositionUpdated resets the /// state to InWorld. /// private void OnTeleportStarted(uint sequence) { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; Console.WriteLine($"live: teleport started (seq={sequence})"); } /// /// Phase A.1: streaming load delegate, runs on the worker thread. /// Reads the landblock from the dats, hydrates its stab entities (same /// path as the old preload), and returns a fully-populated LoadedLandblock. /// Thread-safe: uses only DatCollection reads (documented thread-safe by /// DatReaderWriter) and pure CPU work. No GL calls here. /// /// MVP scope: stabs only. Scenery + interior added in Task 8. /// private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId) { if (_dats is null) return null; // Phase A.1 hotfix: hold the dat lock for the entire load. The worker // thread mustn't read dats concurrently with the render thread's // ApplyLoadedTerrain / live-spawn handlers. Hold time is bounded by // the size of a single landblock's CPU-side build (tens of ms worst // case), which blocks the render thread for at most that duration. // This is the minimum correct behavior; a future pass can reduce // contention by pre-building render-thread work on the worker. lock (_datLock) { return BuildLandblockForStreamingLocked(landblockId); } } private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(uint landblockId) { if (_dats is null) return null; var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId); if (baseLoaded is null) return null; int lbX = (int)((landblockId >> 24) & 0xFFu); int lbY = (int)((landblockId >> 16) & 0xFFu); var worldOffset = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, 0f); // Hydrate the stabs: same logic as the old OnLoad preload. Each stab // entity from LandblockLoader carries a SourceGfxObjOrSetupId that we // expand into per-part MeshRefs via SetupMesh.Flatten / GfxObjMesh.Build. // GPU upload (EnsureUploaded) happens on the render thread in // ApplyLoadedTerrain — NOT here. var hydrated = new List(baseLoaded.Entities.Count); foreach (var e in baseLoaded.Entities) { var meshRefs = new List(); if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x01000000u) { // Single GfxObj stab — identity part transform. var gfx = _dats.Get(e.SourceGfxObjOrSetupId); if (gfx is not null) meshRefs.Add(new AcDream.Core.World.MeshRef( e.SourceGfxObjOrSetupId, System.Numerics.Matrix4x4.Identity)); } else if ((e.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u) { // Multi-part Setup — flatten to per-part GfxObj refs. var setup = _dats.Get(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; meshRefs.Add(mr); } } } if (meshRefs.Count == 0) continue; var entity = new AcDream.Core.World.WorldEntity { Id = e.Id, SourceGfxObjOrSetupId = e.SourceGfxObjOrSetupId, Position = e.Position + worldOffset, Rotation = e.Rotation, MeshRefs = meshRefs, }; hydrated.Add(entity); } // Task 8: merge stabs + scenery + interior into one entity list. var merged = new List(hydrated); merged.AddRange(BuildSceneryEntitiesForStreaming(baseLoaded, lbX, lbY)); merged.AddRange(BuildInteriorEntitiesForStreaming(landblockId, lbX, lbY)); return new AcDream.Core.World.LoadedLandblock( baseLoaded.LandblockId, baseLoaded.Heightmap, merged); } /// /// Phase A.1 Task 8: generate scenery (trees, rocks, bushes) for a single /// landblock on the worker thread. Pure CPU — no GL calls. /// /// Ported from the pre-streaming preload loop in GameWindow.OnLoad /// (pre-Task-7 version, lines 329-405). Adapted to operate on a single /// LoadedLandblock instead of iterating worldView.Landblocks. /// private List BuildSceneryEntitiesForStreaming( AcDream.Core.World.LoadedLandblock lb, int lbX, int lbY) { var result = new List(); if (_dats is null || _heightTable is null) return result; var region = _dats.Get(0x13000000u); if (region is null) return result; // Build a set of terrain vertex indices that have buildings on them, // so the scenery generator can skip those cells (ACME conformance fix 4d). HashSet? buildingCells = null; var lbInfo = _dats.Get( (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfo is not null) { buildingCells = new HashSet(); foreach (var stab in lbInfo.Objects) { int cx = Math.Clamp((int)(stab.Frame.Origin.X / 24f), 0, 8); int cy = Math.Clamp((int)(stab.Frame.Origin.Y / 24f), 0, 8); buildingCells.Add(cx * 9 + cy); } foreach (var bldg in lbInfo.Buildings) { int cx = Math.Clamp((int)(bldg.Frame.Origin.X / 24f), 0, 8); int cy = Math.Clamp((int)(bldg.Frame.Origin.Y / 24f), 0, 8); buildingCells.Add(cx * 9 + cy); } } var spawns = AcDream.Core.World.SceneryGenerator.Generate( _dats, region, lb.Heightmap, lb.LandblockId, buildingCells, _heightTable); if (spawns.Count == 0) return result; var lbOffset = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, 0f); // Per-landblock id namespace: 0x80000000 | (lbId & 0x00FFFF00) | local_index. // The landblock coord occupies bits 16-23 (X) and 8-15 (Y) — both fit in the // 0x00FFFF00 mask. Local index uses bits 0-7 (256 slots per landblock), which // is enough because SceneryGenerator caps at ~200 spawns per block in practice. uint sceneryIdBase = 0x80000000u | (lb.LandblockId & 0x00FFFF00u); uint localIndex = 0; foreach (var spawn in spawns) { // Resolve the object to a mesh (same GfxObj/Setup logic as Stabs). // Scale is baked into the root transform by wrapping each part's // transform with a scale matrix. var meshRefs = new List(); var scaleMat = System.Numerics.Matrix4x4.CreateScale(spawn.Scale); if ((spawn.ObjectId & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(spawn.ObjectId); if (gfx is not null) { // Sub-meshes pre-built CPU-side; upload deferred to ApplyLoadedTerrain. _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(spawn.ObjectId, scaleMat)); } } else if ((spawn.ObjectId & 0xFF000000u) == 0x02000000u) { var setup = _dats.Get(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; _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); // Compose: part's own transform, then the spawn's scale. meshRefs.Add(new AcDream.Core.World.MeshRef(mr.GfxObjId, mr.PartTransform * scaleMat)); } } } if (meshRefs.Count == 0) continue; // Sample terrain Z at (localX, localY) to lift scenery onto the ground. float localX = spawn.LocalPosition.X; float localY = spawn.LocalPosition.Y; float groundZ = SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY); var hydrated = new AcDream.Core.World.WorldEntity { Id = sceneryIdBase + localIndex++, SourceGfxObjOrSetupId = spawn.ObjectId, Position = new System.Numerics.Vector3(localX, localY, groundZ) + lbOffset, Rotation = spawn.Rotation, MeshRefs = meshRefs, }; result.Add(hydrated); } return result; } /// /// Phase A.1 Task 8: walk a landblock's EnvCells and produce (a) the cell /// room-mesh entity (Phase 7.1) for each EnvCell with an EnvironmentId, and /// (b) a WorldEntity per StaticObject in each cell. Pure CPU — no GL calls. /// /// Cell sub-meshes are stored in _pendingCellMeshes (ConcurrentDictionary) /// so ApplyLoadedTerrain can drain + upload them on the render thread. /// /// Ported from pre-streaming preload lines 407-565. /// private List BuildInteriorEntitiesForStreaming( uint landblockId, int lbX, int lbY) { var result = new List(); if (_dats is null) return result; var lbInfo = _dats.Get((landblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfo is null || lbInfo.NumCells == 0) return result; var lbOffset = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, 0f); // Per-landblock id namespace: 0x40000000 | (lbId & 0x00FFFF00) | local_counter. // Distinct from scenery (0x80000000+) and stabs (ids from LandblockLoader). uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u); uint localCounter = 0; uint firstCellId = (landblockId & 0xFFFF0000u) | 0x0100u; for (uint offset = 0; offset < lbInfo.NumCells; offset++) { uint envCellId = firstCellId + offset; var envCell = _dats.Get(envCellId); if (envCell is null) continue; // Phase 7.1: build and register room geometry for this EnvCell. DatReaderWriter.Types.CellStruct? cellStruct = null; if (envCell.EnvironmentId != 0) { var environment = _dats.Get(0x0D000000u | envCell.EnvironmentId); if (environment is not null && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) { var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); if (cellSubMeshes.Count > 0) { _pendingCellMeshes[envCellId] = cellSubMeshes; var cellOrigin = envCell.Position.Origin + lbOffset + new System.Numerics.Vector3(0f, 0f, 0.02f); var cellTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) * System.Numerics.Matrix4x4.CreateTranslation(cellOrigin); var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform); var cellEntity = new AcDream.Core.World.WorldEntity { Id = interiorIdBase + localCounter++, SourceGfxObjOrSetupId = envCellId, Position = System.Numerics.Vector3.Zero, Rotation = System.Numerics.Quaternion.Identity, MeshRefs = new[] { cellMeshRef }, ParentCellId = envCellId, }; result.Add(cellEntity); // Step 4: build LoadedCell for portal visibility. BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform); } } } // Phase 2d: static objects inside the EnvCell. foreach (var stab in envCell.StaticObjects) { var meshRefs = new List(); if ((stab.Id & 0xFF000000u) == 0x01000000u) { var gfx = _dats.Get(stab.Id); if (gfx is not null) { _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(new AcDream.Core.World.MeshRef(stab.Id, System.Numerics.Matrix4x4.Identity)); } } else if ((stab.Id & 0xFF000000u) == 0x02000000u) { var setup = _dats.Get(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; _ = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); meshRefs.Add(mr); } } } if (meshRefs.Count == 0) continue; // Stabs inside EnvCells are already in landblock-local coordinates // (same space as LandBlockInfo.Objects stabs). Adding cellOrigin would // be wrong — see Phase 2d comment in the pre-streaming preload. var worldPos = stab.Frame.Origin + lbOffset; var worldRot = stab.Frame.Orientation; var hydrated = new AcDream.Core.World.WorldEntity { Id = interiorIdBase + localCounter++, SourceGfxObjOrSetupId = stab.Id, Position = worldPos, Rotation = worldRot, MeshRefs = meshRefs, ParentCellId = envCellId, }; result.Add(hydrated); } } return result; } /// /// Phase A.1: render-thread callback from StreamingController.Tick /// whenever a new landblock's terrain + entities are ready for GPU upload. /// Mirrors the terrain-build + entity-upload part of the old preload. /// Must only be called from the render thread. /// private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb) { if (_terrain is null || _dats is null || _blendCtx is null || _heightTable is null || _surfaceCache is null) return; // Phase A.1 hotfix: render-thread path also takes the dat lock so it // doesn't race with BuildLandblockForStreaming on the worker thread. // Hold the lock across the entire apply because we read dats below // (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from // LandblockMesh.Build. lock (_datLock) { ApplyLoadedTerrainLocked(lb); } } /// /// Step 4: build a for portal visibility and queue it /// for render-thread registration. Called from the worker thread during /// . /// private void BuildLoadedCell( uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, DatReaderWriter.Types.CellStruct cellStruct, System.Numerics.Vector3 cellOrigin, System.Numerics.Matrix4x4 cellTransform) { System.Numerics.Matrix4x4.Invert(cellTransform, out var inverse); // Compute local AABB from CellStruct vertices. var boundsMin = new System.Numerics.Vector3(float.MaxValue); var boundsMax = new System.Numerics.Vector3(float.MinValue); foreach (var kvp in cellStruct.VertexArray.Vertices) { var v = kvp.Value; var pos = new System.Numerics.Vector3(v.Origin.X, v.Origin.Y, v.Origin.Z); boundsMin = System.Numerics.Vector3.Min(boundsMin, pos); boundsMax = System.Numerics.Vector3.Max(boundsMax, pos); } if (boundsMin.X == float.MaxValue) { boundsMin = System.Numerics.Vector3.Zero; boundsMax = System.Numerics.Vector3.Zero; } // Build portal list and clip planes from CellPortals. var portals = new List(); var clipPlanes = new List(); // Compute cell centroid in local space for InsideSide determination. var centroid = (boundsMin + boundsMax) * 0.5f; foreach (var portal in envCell.CellPortals) { portals.Add(new CellPortalInfo( portal.OtherCellId, portal.PolygonId, (ushort)portal.Flags)); // Build clip plane from the portal polygon. if (cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly) && poly.VertexIds.Count >= 3) { // Get first 3 vertices in local space for the plane. System.Numerics.Vector3 p0 = System.Numerics.Vector3.Zero, p1 = System.Numerics.Vector3.Zero, p2 = System.Numerics.Vector3.Zero; bool found = true; if (cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[0], out var v0)) p0 = new System.Numerics.Vector3(v0.Origin.X, v0.Origin.Y, v0.Origin.Z); else found = false; if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[1], out var v1)) p1 = new System.Numerics.Vector3(v1.Origin.X, v1.Origin.Y, v1.Origin.Z); else found = false; if (found && cellStruct.VertexArray.Vertices.TryGetValue((ushort)poly.VertexIds[2], out var v2)) p2 = new System.Numerics.Vector3(v2.Origin.X, v2.Origin.Y, v2.Origin.Z); else found = false; if (found) { var normal = System.Numerics.Vector3.Normalize( System.Numerics.Vector3.Cross(p1 - p0, p2 - p0)); float d = -System.Numerics.Vector3.Dot(normal, p0); // Determine InsideSide: which side of the plane the cell centroid is on. // If centroid dot > 0 → inside is positive half-space (InsideSide=0). float centroidDot = System.Numerics.Vector3.Dot(normal, centroid) + d; int insideSide = centroidDot >= 0 ? 0 : 1; clipPlanes.Add(new PortalClipPlane { Normal = normal, D = d, InsideSide = insideSide, }); } else { clipPlanes.Add(default); } } else { clipPlanes.Add(default); } } var loaded = new LoadedCell { CellId = envCellId, WorldPosition = cellOrigin, WorldTransform = cellTransform, InverseWorldTransform = inverse, LocalBoundsMin = boundsMin, LocalBoundsMax = boundsMax, Portals = portals, ClipPlanes = clipPlanes, }; _pendingCells.Add(loaded); } private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb) { if (_terrain is null || _dats is null || _blendCtx is null || _heightTable is null || _surfaceCache is null) return; uint lbXu = (lb.LandblockId >> 24) & 0xFFu; uint lbYu = (lb.LandblockId >> 16) & 0xFFu; int lbX = (int)lbXu; int lbY = (int)lbYu; var origin = new System.Numerics.Vector3( (lbX - _liveCenterX) * 192f, (lbY - _liveCenterY) * 192f, 0f); // Build terrain mesh data on the render thread (pure CPU; acceptable // for the MVP; a future pass can move it to the worker thread). var meshData = AcDream.Core.Terrain.LandblockMesh.Build( lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache); _terrain.AddLandblock(lb.LandblockId, meshData, origin); // Step 4: drain pending LoadedCells from the worker thread. while (_pendingCells.TryTake(out var cell)) _cellVisibility.AddCell(cell); // Compute the per-landblock AABB for frustum culling. XY from the // landblock's world origin + 192 footprint. Z from the terrain vertex // range padded +50 above (for trees/buildings) and -10 below (for // basements). TerrainRenderer already scans vertices internally; we // replicate here so GpuWorldState has the same bounds for the static // mesh renderer's culling pass. { float zMin = float.MaxValue, zMax = float.MinValue; foreach (var v in meshData.Vertices) { float z = v.Position.Z; if (z < zMin) zMin = z; if (z > zMax) zMax = z; } if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; } zMax += 50f; // generous pad for trees and buildings zMin -= 10f; // below-ground buffer for basements/cellars var aabbMin = new System.Numerics.Vector3(origin.X, origin.Y, zMin); var aabbMax = new System.Numerics.Vector3(origin.X + 192f, origin.Y + 192f, zMax); _worldState.SetLandblockAabb(lb.LandblockId, aabbMin, aabbMax); } // Phase B.3: populate the physics engine with terrain + indoor cell // surfaces for this landblock. Runs under _datLock (same lock as the // rest of ApplyLoadedTerrainLocked) so dat reads are safe. { uint lbPhysX = (lb.LandblockId >> 24) & 0xFFu; uint lbPhysY = (lb.LandblockId >> 16) & 0xFFu; var terrainSurface = new AcDream.Core.Physics.TerrainSurface( lb.Heightmap.Height, _heightTable, lbPhysX, lbPhysY); var cellSurfaces = new List(); var portalPlanes = new List(); var lbInfo = _dats.Get( (lb.LandblockId & 0xFFFF0000u) | 0xFFFEu); if (lbInfo is not null && lbInfo.NumCells > 0) { uint firstCellId = (lb.LandblockId & 0xFFFF0000u) | 0x0100u; for (uint offset = 0; offset < lbInfo.NumCells; offset++) { uint envCellId = firstCellId + offset; var envCell = _dats.Get(envCellId); if (envCell is null) continue; if (envCell.EnvironmentId == 0) continue; var environment = _dats.Get( 0x0D000000u | envCell.EnvironmentId); if (environment is null) continue; if (!environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) continue; // Transform CellStruct vertices from cell-local to world space. var rot = envCell.Position.Orientation; var cellOriginWorld = envCell.Position.Origin + origin; var worldVerts = new Dictionary( cellStruct.VertexArray.Vertices.Count); foreach (var (vid, vtx) in cellStruct.VertexArray.Vertices) { var localPos = vtx.Origin; var worldPos = System.Numerics.Vector3.Transform(localPos, rot) + cellOriginWorld; worldVerts[(ushort)vid] = worldPos; } // Extract polygon vertex-id lists from PhysicsPolygons. // PhysicsPolygons is Dictionary; iterate Values. var polyVids = new List>(cellStruct.PhysicsPolygons.Count); foreach (var poly in cellStruct.PhysicsPolygons.Values) { var vids = new List(poly.VertexIds.Count); foreach (var vid in poly.VertexIds) vids.Add(vid); polyVids.Add(vids); } cellSurfaces.Add(new AcDream.Core.Physics.CellSurface(envCellId, worldVerts, polyVids)); // Extract portal planes from this EnvCell's CellPortals. // CellPortal.PolygonId indexes cellStruct.Polygons (rendering polygons), // NOT PhysicsPolygons — confirmed by ACViewer EnvCell.find_transit_cells. foreach (var portal in envCell.CellPortals) { if (!cellStruct.Polygons.TryGetValue(portal.PolygonId, out var poly)) continue; if (poly.VertexIds.Count < 3) continue; // Collect ALL polygon vertices for accurate centroid + radius. var portalVerts = new System.Numerics.Vector3[poly.VertexIds.Count]; bool allFound = true; for (int pv = 0; pv < poly.VertexIds.Count; pv++) { if (!worldVerts.TryGetValue((ushort)poly.VertexIds[pv], out portalVerts[pv])) { allFound = false; break; } } if (!allFound) continue; portalPlanes.Add(AcDream.Core.Physics.PortalPlane.FromVertices( portalVerts.AsSpan(), portal.OtherCellId, // target cell (0xFFFF = outdoor) envCellId & 0xFFFFu, // owner cell (low 16 bits) (ushort)portal.Flags)); } } } _physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces, portalPlanes, origin.X, origin.Y); } // Upload every GfxObj referenced by this landblock's entities. // EnsureUploaded is idempotent so duplicates across landblocks are free. if (_staticMesh is not null) { // Task 8: drain any pending EnvCell room-mesh sub-meshes first. // The worker thread pre-built these CPU-side and stored them in // _pendingCellMeshes. We must upload them here (render thread) before // the per-MeshRef loop below tries to look them up via GfxObjMesh.Build, // which would fail because EnvCell ids (0xAAAA01xx) aren't real GfxObj // dat ids. EnsureUploaded is idempotent so calling it here then seeing // the same id again in the loop below is safe. foreach (var entity in lb.Entities) { foreach (var meshRef in entity.MeshRefs) { if (_pendingCellMeshes.TryRemove(meshRef.GfxObjId, out var cellSubMeshes)) _staticMesh.EnsureUploaded(meshRef.GfxObjId, cellSubMeshes); } } // Now upload regular GfxObj sub-meshes (stabs, scenery, interior stabs). // Skip any ids already uploaded (includes the cell meshes just drained). foreach (var entity in lb.Entities) { foreach (var meshRef in entity.MeshRefs) { // Skip EnvCell synthetic ids — already handled above (or already // uploaded on a prior tick). GfxObj ids are 0x01xxxxxx; Setup ids // are 0x02xxxxxx; anything else is not a GfxObj dat record. if ((meshRef.GfxObjId & 0xFF000000u) != 0x01000000u) continue; var gfx = _dats.Get(meshRef.GfxObjId); if (gfx is null) continue; var subMeshes = AcDream.Core.Meshing.GfxObjMesh.Build(gfx, _dats); _staticMesh.EnsureUploaded(meshRef.GfxObjId, subMeshes); } } } // Register each stab as a plugin snapshot so the plugin host has // visibility into the streaming world state. foreach (var entity in lb.Entities) { var snapshot = new AcDream.Plugin.Abstractions.WorldEntitySnapshot( Id: entity.Id, SourceId: entity.SourceGfxObjOrSetupId, Position: entity.Position, Rotation: entity.Rotation); _worldGameState.Add(snapshot); _worldEvents.FireEntitySpawned(snapshot); } } private void OnUpdate(double dt) { // Phase A.1: advance the streaming controller FIRST so the initial // landblocks are loaded into GpuWorldState before live-session // CreateObject events drain. The earlier order (live tick first, // streaming tick second) caused the initial CreateObject flood from // login to land before any landblock was loaded; AppendLiveEntity // is a no-op for unloaded landblocks, so all 40+ NPCs/weenies were // silently dropped on the first frame and never rendered. if (_streamingController is not null && _cameraController is not null) { int observerCx = _liveCenterX; int observerCy = _liveCenterY; if (_playerMode && _playerController is not null) { // Player mode: follow the physics-resolved player position. // The player walks via the local physics engine; the server // doesn't echo back our autonomous moves, so _lastLivePlayer* // stays at the login position. Compute the landblock from the // controller's current world-space position instead. var pp = _playerController.Position; observerCx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); observerCy = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); } else if (_liveSession is not null && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld && _lastLivePlayerLandblockId is { } lid) { // Live mode (fly camera): follow the server's last-known player position. observerCx = (int)((lid >> 24) & 0xFFu); observerCy = (int)((lid >> 16) & 0xFFu); } else { // Offline: project the fly camera's world-space position back into // landblock coordinates. OrbitCamera doesn't expose Position via // ICamera, but FlyCamera.Position is always updated (even when the // orbit camera is Active), so this is safe in both modes. // Each landblock is 192 world units wide. var camPos = _cameraController.Fly.Position; observerCx = _liveCenterX + (int)System.Math.Floor(camPos.X / 192f); observerCy = _liveCenterY + (int)System.Math.Floor(camPos.Y / 192f); } _streamingController.Tick(observerCx, observerCy); // Re-inject persistent entities rescued from unloaded landblocks // into the current center landblock (the one the observer is in). var rescued = _worldState.DrainRescued(); if (rescued.Count > 0) { uint centerLb = (uint)((observerCx << 24) | (observerCy << 16) | 0xFFFF); foreach (var entity in rescued) _worldState.AppendLiveEntity(centerLb, entity); } } // Drain pending live-session traffic AFTER streaming so any incoming // CreateObject events find their landblock already loaded in // GpuWorldState. Non-blocking — returns immediately if no datagrams // are in the kernel buffer. Fires EntitySpawned events synchronously. _liveSession?.Tick(); if (_cameraController is null || _input is null) return; var kb = _input.Keyboards[0]; if (_cameraController.IsFlyMode) { _cameraController.Fly.Update( dt, w: kb.IsKeyPressed(Key.W), a: kb.IsKeyPressed(Key.A), s: kb.IsKeyPressed(Key.S), d: kb.IsKeyPressed(Key.D), up: kb.IsKeyPressed(Key.Space), down: kb.IsKeyPressed(Key.ControlLeft), boost: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight)); } else if (_playerMode && _playerController is not null && _chaseCamera is not null) { // Phase B.2: player movement mode — WASD walks/runs, A/D turns, // Z/X strafes, Shift runs, mouse X turns, mouse Y pitches camera. float consumedMouseDeltaX = _playerMouseDeltaX; _playerMouseDeltaX = 0f; // consume + reset so it doesn't accumulate var input = new AcDream.App.Input.MovementInput( Forward: kb.IsKeyPressed(Key.W), Backward: kb.IsKeyPressed(Key.S), StrafeLeft: kb.IsKeyPressed(Key.Z), StrafeRight: kb.IsKeyPressed(Key.X), TurnLeft: kb.IsKeyPressed(Key.A), TurnRight: kb.IsKeyPressed(Key.D), Run: kb.IsKeyPressed(Key.ShiftLeft) || kb.IsKeyPressed(Key.ShiftRight), MouseDeltaX: consumedMouseDeltaX, Jump: kb.IsKeyPressed(Key.Space)); var result = _playerController.Update((float)dt, input); // Update the player entity's position + rotation so it renders at // the physics-resolved location each frame. if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) { pe.Position = result.Position; pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f); // Move the player entity to its current landblock in GpuWorldState // so it doesn't get frustum-culled when the player walks away from // the spawn landblock. Without this, the entity stays in the spawn // landblock's entity list and disappears when that landblock is culled. var pp = _playerController.Position; int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); uint currentLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); _worldState.RelocateEntity(pe, currentLb); } // Update chase camera. _chaseCamera.Update(result.Position, _playerController.Yaw); // Send outbound movement messages to the live server. if (_liveSession is not null) { // Convert world position back to AC wire coordinates. // World origin is _liveCenterX/_liveCenterY; each landblock is 192 units. int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f); int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f); float localX = result.Position.X - (lbX - _liveCenterX) * 192f; float localY = result.Position.Y - (lbY - _liveCenterY) * 192f; uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu); var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z); var wireRot = System.Numerics.Quaternion.CreateFromAxisAngle( System.Numerics.Vector3.UnitZ, _playerController.Yaw); if (result.MotionStateChanged) { var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.MoveToState.Build( gameActionSequence: seq, forwardCommand: result.ForwardCommand, forwardSpeed: result.ForwardSpeed, sidestepCommand: result.SidestepCommand, sidestepSpeed: result.SidestepSpeed, turnCommand: result.TurnCommand, turnSpeed: result.TurnSpeed, holdKey: result.ForwardCommand == 0x44000007u ? 1u : (uint?)null, cellId: wireCellId, position: wirePos, rotation: wireRot, instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, forcePositionSequence: _liveSession.ForcePositionSequence); _liveSession.SendGameAction(body); } if (_playerController.HeartbeatDue) { var seq = _liveSession.NextGameActionSequence(); var body = AcDream.Core.Net.Messages.AutonomousPosition.Build( gameActionSequence: seq, cellId: wireCellId, position: wirePos, rotation: wireRot, instanceSequence: _liveSession.InstanceSequence, serverControlSequence: _liveSession.ServerControlSequence, teleportSequence: _liveSession.TeleportSequence, forcePositionSequence: _liveSession.ForcePositionSequence); _liveSession.SendGameAction(body); } } // Update the player entity's animation cycle to match current motion. UpdatePlayerAnimation(result); } } private void OnCameraModeChanged(bool isFlyMode) { if (_input is null) return; var mouse = _input.Mice.FirstOrDefault(); if (mouse is null) return; // Raw cursor mode for both fly AND chase (player) mode — both need // mouse deltas for look/turn. Only orbit mode uses normal cursor. bool needsRawCursor = isFlyMode || _playerMode; mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal; _capturedMouse = needsRawCursor ? mouse : null; } // Performance overlay state — updated every ~0.5s and written to the // window title so there's zero rendering cost (no font/overlay needed). private double _perfAccum; private int _perfFrameCount; private void OnRender(double deltaSeconds) { _gl!.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit); // Phase 6.4: advance per-entity animation playback before drawing // so the renderer always sees the up-to-date per-part transforms. if (_animatedEntities.Count > 0) TickAnimations((float)deltaSeconds); int visibleLandblocks = 0; int totalLandblocks = 0; if (_cameraController is not null) { var camera = _cameraController.Active; var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * camera.Projection); // Never cull the landblock the player is currently on. uint? playerLb = null; if (_playerMode && _playerController is not null) { var pp = _playerController.Position; int plx = _liveCenterX + (int)System.Math.Floor(pp.X / 192f); int ply = _liveCenterY + (int)System.Math.Floor(pp.Y / 192f); playerLb = (uint)((plx << 24) | (ply << 16) | 0xFFFF); } _terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); // Step 4: portal visibility — determine which interior cells to render. // Extract camera world position from the inverse of the view matrix. System.Numerics.Matrix4x4.Invert(camera.View, out var invView); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; // Conditional depth clear: when camera is inside a building, clear // depth (not color) so interior geometry writes fresh Z values on top // of the terrain color buffer. Exit portals show outdoor terrain color // because we kept the color buffer. Matching ACME GameScene.cs pattern. if (cameraInsideCell) _gl!.Clear(ClearBufferMask.DepthBufferBit); _staticMesh?.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: visibility?.VisibleCellIds); // Count visible vs total for the perf overlay. foreach (var entry in _worldState.LandblockEntries) { totalLandblocks++; if (AcDream.App.Rendering.FrustumCuller.IsAabbVisible(frustum, entry.AabbMin, entry.AabbMax)) visibleLandblocks++; } } // Update the window title with performance stats every ~0.5s. _perfAccum += deltaSeconds; _perfFrameCount++; if (_perfAccum >= 0.5) { double avgFrameTime = _perfAccum / _perfFrameCount * 1000.0; double fps = _perfFrameCount / _perfAccum; int entityCount = _worldState.Entities.Count; int animatedCount = _animatedEntities.Count; _window!.Title = $"acdream | {fps:F0} fps | {avgFrameTime:F1} ms | " + $"lb {visibleLandblocks}/{totalLandblocks} visible | " + $"ent {entityCount} | anim {animatedCount}"; _perfAccum = 0; _perfFrameCount = 0; } } /// /// 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; // ── Get per-part (origin, orientation) from either sequencer or legacy ── IReadOnlyList? seqFrames = null; if (ae.Sequencer is not null) { seqFrames = ae.Sequencer.Advance(dt); } else { // Legacy path (entities without a MotionTable / sequencer). int span = ae.HighFrame - ae.LowFrame; if (span <= 0) continue; ae.CurrFrame += dt * ae.Framerate; 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; } 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++) { System.Numerics.Vector3 origin; System.Numerics.Quaternion orientation; if (seqFrames is not null) { // Sequencer path. if (i < seqFrames.Count) { origin = seqFrames[i].Origin; orientation = seqFrames[i].Orientation; } else { origin = System.Numerics.Vector3.Zero; orientation = System.Numerics.Quaternion.Identity; } } else { // Legacy slerp path. 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; 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; 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; } } // 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(orientation) * System.Numerics.Matrix4x4.CreateTranslation(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; } } /// /// Phase B.2: switch the locally-controlled player entity's animation cycle /// to match the current motion command. Only re-resolves when the command /// actually changes (forward → run, idle → walk, etc.) to avoid re-building /// the animation entry every frame. /// private void UpdatePlayerAnimation(AcDream.App.Input.MovementResult result) { if (_dats is null) return; // Determine the animation command: forward takes priority, then sidestep, // then turn, then idle (Ready 0x41000003). uint animCommand; if (result.ForwardCommand is { } fwd) animCommand = fwd; else if (result.SidestepCommand is { } ss) animCommand = ss; else if (result.TurnCommand is { } tc) animCommand = tc; else animCommand = 0x41000003u; // Ready (idle) // Fast path: no change. if (animCommand == _playerCurrentAnimCommand) return; _playerCurrentAnimCommand = animCommand; if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; // The player entity may not be in _animatedEntities if a post-spawn // UpdateMotion removed it (Phase 6.8 pattern). In that case, load // the Setup and re-register. This is the player's own character so // we always want it animated in player mode. if (!_animatedEntities.TryGetValue(pe.Id, out var ae)) { var setup = _dats.Get(pe.SourceGfxObjOrSetupId); if (setup is null) return; // Build a minimal part template from the entity's current MeshRefs. var template = new (uint, IReadOnlyDictionary?)[pe.MeshRefs.Count]; for (int i = 0; i < pe.MeshRefs.Count; i++) template[i] = (pe.MeshRefs[i].GfxObjId, pe.MeshRefs[i].SurfaceOverrides); ae = new AnimatedEntity { Entity = pe, Setup = setup, Animation = null!, // filled below LowFrame = 0, HighFrame = 0, Framerate = 30f, Scale = 1f, PartTemplate = template, CurrFrame = 0f, }; _animatedEntities[pe.Id] = ae; } // The motion table cycle key is (style << 16) | (command & 0xFFFFFF). // Without a stance override, the resolver uses the table default // (which always maps to the idle/Ready cycle regardless of command). // Pass the NonCombat stance (0x003D) so the resolver builds the // correct cycle key for walk/run/turn commands. ushort cmdOverride = (ushort)(animCommand & 0xFFFFu); const ushort NonCombatStance = 0x003D; var cycle = AcDream.Core.Meshing.MotionResolver.GetIdleCycle( ae.Setup, _dats, motionTableIdOverride: _playerMotionTableId, stanceOverride: NonCombatStance, commandOverride: cmdOverride); // Sequencer path: SetCycle handles adjust_motion internally // (TurnLeft→TurnRight with negative speed, etc.) if (ae.Sequencer is not null) { uint fullStyle = 0x80000000u | (uint)NonCombatStance; ae.Sequencer.SetCycle(fullStyle, animCommand); } // Legacy path: update the manual slerp fields (for entities without sequencer) if (cycle is null || cycle.Framerate == 0f || cycle.HighFrame <= cycle.LowFrame) return; ae.Animation = cycle.Animation; ae.LowFrame = Math.Max(0, cycle.LowFrame); ae.HighFrame = Math.Min(cycle.HighFrame, cycle.Animation.PartFrames.Count - 1); ae.Framerate = cycle.Framerate; ae.CurrFrame = ae.LowFrame; } private void OnClosing() { // Phase A.1: join the streamer worker thread before tearing down GL // state. The worker may still be processing a load job that references // _dats; Dispose cancels the token and waits up to 2s for the thread. _streamer?.Dispose(); _liveSession?.Dispose(); _staticMesh?.Dispose(); _textureCache?.Dispose(); _meshShader?.Dispose(); _terrain?.Dispose(); _shader?.Dispose(); _dats?.Dispose(); _input?.Dispose(); _gl?.Dispose(); } public void Dispose() => _window?.Dispose(); }