Commit graph

254 commits

Author SHA1 Message Date
Erik
8402aee703 research: full animation pseudocode from decompiled acclient.exe
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:

- Sequence::update_internal (1021 bytes, the core frame advance loop)
- Sequence::advance_to_next_animation (node transitions)
- Sequence::append_animation (queue management)
- MotionTableManager::PerformMovement (1878 bytes, full state machine)
- AddAnimationsToSequence (transition link → sequence nodes)
- GetStartFramePosition / GetEndFramePosition (reverse playback support)
- AdjustNodeSpeed (negative speed = swapped start/end frames)

Key findings:
- framePosition is a 64-bit DOUBLE, not float
- Negative speedScale swaps startFrame↔endFrame at the node level
- update_internal handles both forward and reverse in one loop
- Frame triggers fire at every integer boundary crossing
- The keyframe slerp lives in the renderer, not the sequencer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:43:44 +02:00
Erik
67b51a3e6f fix(anim): implement adjust_motion — TurnLeft/SideStepLeft play backward
ROOT CAUSE FIX for missing left-side animations.

The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.

Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.

Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
  left→right with speed *= -1 (TurnLeft, SideStepLeft) or
  speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
  decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
  SetCycle (sequencer handles remapping internally), keeps legacy
  fallback for non-sequencer entities

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:17:26 +02:00
Erik
0e66078e57 chore: leave ANIM-DIAG logging for next session
Turn-left / strafe-left / walk-backward animations still not playing
despite the left→right fallback. Diagnostic logging added to
UpdatePlayerAnimation to capture exactly what happens when these
commands fire. Next session: check the log output, the issue may be
that UpdatePlayerAnimation is never called for these commands (the
priority logic skips turn when forward is active, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:54:19 +02:00
Erik
1b4fdac13c fix(anim): pass resolved command (not original) to sequencer SetCycle
The left→right fallback resolved the cycle correctly but still passed
the original TurnLeft/SideStepLeft command to the sequencer. The
sequencer did its own internal cycle lookup with the left-side command
and found nothing → no animation played.

Fix: track which command actually resolved (after fallback) and pass
that to SetCycle so the sequencer's internal lookup matches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:51:39 +02:00
Erik
0868849b3d fix(anim): left-side motion fallback to right-side animation
AC MotionTables typically define cycles for TurnRight and SideStepRight
but not TurnLeft/SideStepLeft — the left variants reuse the right-side
animation played in reverse. When the left-side command doesn't resolve
to a cycle, fall back to the right-side equivalent so the player isn't
stuck in idle pose.

Fallback map:
  TurnLeft (0x000E) → TurnRight (0x000D)
  SideStepLeft (0x0010) → SideStepRight (0x000F)
  WalkBackward (0x0006) → WalkForward (0x0005)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:49:47 +02:00
Erik
e77dc1a0e8 fix(anim): call Sequencer.SetCycle on motion changes
The sequencer was initialized at spawn but never notified when the
player started walking/running/turning. UpdatePlayerAnimation and
OnLiveMotionUpdated both updated the legacy slerp fields but the
sequencer path in TickAnimations reads from Sequencer.Advance(dt),
which stayed at the initial idle cycle.

Fix: both methods now call ae.Sequencer.SetCycle(style, motion) when
the sequencer exists, alongside the legacy field updates (which serve
as fallback for entities without a sequencer).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:44:38 +02:00
Erik
ed37f0969b feat(anim): carefully integrate AnimationSequencer into TickAnimations
Surgical integration that ONLY replaces the frame interpolation path,
preserving the exact transform composition pipeline that builds MeshRefs.

The key insight from the reverted attempt: the sequencer's Advance(dt)
returns raw (Origin, Orientation) per part — these feed into the SAME
transform composition as before:
  CreateScale(defaultScale) * CreateFromQuat(orientation) * CreateTranslation(origin)
  then * scaleMat for entity ObjScale
  then → MeshRef with template GfxObjId + SurfaceOverrides

What changed:
- AnimatedEntity gains optional Sequencer field
- DatCollectionLoader created once at dat-open time
- Entity registration creates sequencer when MotionTable is loadable
- TickAnimations: if sequencer exists, Advance(dt) produces per-part
  transforms; otherwise falls back to Phase 6.5 manual slerp
- Transform composition + MeshRef building is UNCHANGED

What was NOT changed (the previous attempt broke these):
- Per-part defaultScale application
- Entity ObjScale (scaleMat) multiplication
- PartTemplate GfxObjId + SurfaceOverrides forwarding
- The entire MeshRef construction block

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:40:52 +02:00
Erik
53a4bf4225 Revert "feat(anim): integrate AnimationSequencer into GameWindow rendering loop"
This reverts commit e4dae3d217.
2026-04-13 00:30:25 +02:00
Erik
e4dae3d217 feat(anim): integrate AnimationSequencer into GameWindow rendering loop
Wire the new AnimationSequencer into per-entity animation playback:

- Added `Sequencer` field to `AnimatedEntity`; populated at spawn time
  by loading the entity's MotionTable and calling SetCycle on the resolved
  idle (style, motion) so playback starts immediately.
- Added `_animLoader` (DatCollectionLoader) initialized alongside `_dats`
  so sequencer instances share a single animation loader.
- `TickAnimations`: when an entity has a sequencer, calls `seq.Advance(dt)`
  and reads back `PartTransform[]` instead of doing manual frame-index math
  and Quaternion.Slerp. Falls back to the Phase 6.5 manual slerp for entities
  whose MotionTable couldn't be loaded (missing dat / offline mode).
- `OnLiveMotionUpdated`: calls `sequencer.SetCycle(style, motion)` when
  available so motion changes prepend transition-link frames for smooth blending.
- `UpdatePlayerAnimation`: same — calls `sequencer.SetCycle(NonCombatStyleFull, cmd)`
  on motion changes; also creates a sequencer when the player entity is
  lazy-registered for the first time.

The existing manual-slerp fallback is kept verbatim so behavior is unchanged
for any entity that can't be backed by a sequencer. Build clean, 426 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:27:11 +02:00
Erik
14569558fb refactor(physics): wire PhysicsBody + MotionInterpreter into PlayerMovementController
Replace the ad-hoc movement simulation with the ported retail physics:

- PlayerMovementController now owns a PhysicsBody (gravity, friction, Euler
  integration with sub-stepping) and a MotionInterpreter (motion state machine,
  speed constants from retail dat).

- Orientation quaternion is synced from Yaw each frame (Yaw=0 → +X, matching
  the cos/sin convention the camera and outbound messages expect).

- Horizontal velocity is composed from MotionInterpreter.get_state_velocity()
  speeds (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25 from
  decompiled globals) then pushed via PhysicsBody.set_local_velocity so the
  orientation quaternion rotates them into world space correctly.

- Vertical velocity (gravity / jump / fall) is snapshot before DoMotion calls
  so apply_current_movement's set_local_velocity(0,0,0) can't clobber it.

- Jump delegates to MotionInterpreter.jump() + LeaveGround() which calls
  get_leave_ground_velocity() → DefaultJumpVz=10.0 (retail value).

- PhysicsEngine.Resolve is still called each frame with zero delta to sample
  terrain/cell Z under the body and set Contact+OnWalkable accordingly.

- Drive UpdatePhysicsInternal(dt) directly instead of update_object(wallClock)
  to avoid the MinQuantum (~33ms) guard that would silently drop 60fps frames.

Test update: jump loop extended from 30→50 frames to cover the longer flight
time from retail DefaultJumpVz=10 (≈2.04s) vs old JumpImpulse=5 (≈1.02s).

303 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:08:02 +02:00
Erik
9d4967a461 fix(core): ACME cross-check fixes — normals, placement, scenery
Four fixes from the ACME StaticObjectManager cross-reference:

1. GfxObjMesh: normalize vertex normals (1d). Dat normals may not be
   unit-length; without normalization, lighting is wrong per-vertex.

2. SetupMesh: add third-fallback placement frame (2a). If neither
   Resting nor Default exists, use the first available frame from
   PlacementFrames. Matches ACME's GetDefaultPlacementFrame.

3. SceneryGenerator: building cell exclusion (4d). Compute which
   terrain vertices have buildings (from LandBlockInfo.Objects +
   Buildings), skip scenery spawns in those cells. Prevents trees
   from spawning inside building footprints.

4. SceneryGenerator: slope filter (4e). Compute terrain normal Z at
   each displaced position and check against ObjectDesc.MinSlope /
   MaxSlope bounds. Prevents trees from spawning on cliff faces.

Also confirmed 4f (scenery Z=0) is NOT a bug — GameWindow's hydrator
lifts scenery to terrain Z at line 1213. The Z=0 in SceneryGenerator
is a placeholder correctly overridden at render time.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:52:08 +02:00
Erik
131594d91b fix(core): correct triangle boundary conditions in TerrainSurface.SampleZ
ROOT CAUSE FIX for persistent slope Z clipping.

The SWtoNE/SEtoNW triangle boundary tests were swapped. AC's naming is
counter-intuitive: "SWtoNE cut" means BL and TR are the ISOLATED vertices
— the shared hypotenuse runs TL(0,1)→BR(1,0), so the dividing test is
tx+ty=1, NOT ty=tx. We had them backwards, causing every cell to sample
from the wrong triangle — up to 7.5 unit Z errors on steep terrain.

Fixed by cross-referencing WorldBuilder-ACME-Edition which has:
- ClientReference.cs: faithful C# port of decompiled AC client code
- TerrainConformanceTests.cs: verified against 25,600 cells
- TerrainGeometryGenerator.GetHeight(): matches the mesh index buffer

Also removes the slope gradient hack from PlayerMovementController —
no longer needed since SampleZ now returns exact triangle-surface Z.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:27:16 +02:00
Erik
78d43a0914 fix(core+app): slope gradient compensation for feet clipping
The triangle-aware Z sampling from the previous commit produces exact
terrain-surface Z values, but a point-sampled Z on a tilted surface
places the character's center on the surface while their feet (which
extend horizontally) clip into the rising terrain ahead/behind.

Fix: in PlayerMovementController, sample Z 1 unit ahead in the walk
direction and add 40% of the gradient as an upward bias. This
compensates for the character's collision cylinder radius on slopes
while producing zero bias on flat ground. The bias is applied in the
movement controller (gameplay concern) not in TerrainSurface.SampleZ
(which stays exact for physics/tests).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:13:16 +02:00
Erik
c5de445e5c fix(core): AC2D render split formula + triangle-aware Z sampling
Two fundamental terrain fixes based on the AC2D + holtburger deep dive:

1. Terrain split formula: replaced WorldBuilder's physics-path formula
   (214614067/1813693831) with AC2D's render-path formula (0x0CCAC033,
   0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits
   for some cells. Since the render mesh uses this formula, the physics
   Z sampler must match it to avoid misalignment on slopes.

2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface
   with per-triangle barycentric interpolation. Each cell is split into
   two triangles (using the same AC2D formula). SampleZ determines which
   triangle the query point falls in, then interpolates within that
   triangle. This produces Z values that exactly match the visual terrain
   mesh — no more slope clipping.

Removes the multi-point Z sampling hack from PlayerMovementController
(no longer needed with exact triangle Z).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 22:08:42 +02:00
Erik
29fe0c0714 fix(app): terrain cull exempt + yaw preservation on Tab toggle
1. Terrain culling: TerrainRenderer.Draw now accepts neverCullLandblockId,
   matching StaticMeshRenderer. Both renderers skip frustum-culling the
   player's current landblock. Previously only entities were exempt but
   the terrain under the player still disappeared when looking away.

2. Yaw drift on Tab toggle: render loop stores rotation as Yaw - PI/2
   (AC model facing offset), but yaw extraction on re-entering player
   mode didn't compensate. Each Tab cycle rotated the player 90 degrees.
   Now adds PI/2 back when extracting from the quaternion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:35:40 +02:00
Erik
a3b389603d fix(app): multi-point Z sampling + never-cull player landblock
1. Slope clipping: replaced single foot-forward Z sample with 4-point
   sampling (forward, back, left, right at 0.7 units). Takes the max Z
   across all samples so both uphill and downhill slopes keep feet above
   the terrain mesh surface. Removed the +0.1 Z bias entirely.

2. Player culling: replaced per-entity scan (alwaysVisibleEntityId) with
   per-landblock skip (neverCullLandblockId). The player's current
   landblock is computed from _playerController.Position and passed to
   the renderer. Simpler, faster, and more reliable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:29:54 +02:00
Erik
6f05c298cf fix(app+core): Phase B.3 — streaming follows player, AC jump physics
Three fixes for user-reported movement bugs:

1. Character disappears far from spawn: streaming observer now computed
   from _playerController.Position when player mode is active, instead
   of _lastLivePlayerLandblockId which only updates from server echoes
   (never for autonomous moves). The 5x5 streaming window now follows
   the player as they walk.

2. Jump physics from ACE: JumpImpulse=5.0 and GravityAccel=9.8
   matching AC's formula: velocity_z = sqrt(height * 19.6) where
   height = BurdenMod * (JumpSkill / (JumpSkill + 1300) * 22.2 + 0.05)
   For a new char (skill=100, burden=50%): height≈1.31, vz≈5.07.

3. Gravity reduced from 20 to 9.8 (AC's F_GRAVITY constant).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:06:42 +02:00
Erik
192e066182 fix(app+core): Phase B.3 — player cull-exempt, jump height, slope Z
Three user-reported movement fixes:

1. Player disappears when facing away: StaticMeshRenderer now accepts
   an alwaysVisibleEntityId. When a culled landblock contains the
   player entity, it is still drawn. Prevents the frustum culler from
   hiding the player character when they walk far from their spawn
   landblock.

2. Jump too high: JumpImpulse reduced from 10.0 to 3.5 (placeholder;
   retail scales by Jump skill value from the server).

3. Slope Z alignment: replaced the frame-delta slope bias with a
   foot-forward sampling approach — sample terrain Z at 1 unit ahead
   in the walk direction and use max(center, foot) as the ground Z.
   Handles multi-grade slopes where the terrain rises faster than a
   single-point sample tracks.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:24:50 +02:00
Erik
dc0341e85a fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
Two targeted fixes for user-reported movement bugs:

1. Wall bounce: PortalPlane.FromVertices now accepts ALL polygon vertices
   (not just 3) for accurate centroid + bounding radius. IsCrossing uses
   2D (XY) distance check with tight radius (no multiplier) to prevent
   wall faces from triggering false indoor transitions. Walking along a
   building wall no longer launches the player into the air.

2. Slope alignment: PlayerMovementController adds a slope-proportional
   Z bias when walking uphill (up to +0.8 on steep slopes, grounded
   only). Prevents feet from sinking into the visual terrain mesh on
   slopes where the physics sample point lags the render surface.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 19:08:46 +02:00
Erik
41013ce3e3 fix(core+app): Phase B.3 — Setup.StepUpHeight + scenery road exclusion
Four targeted fixes for user-reported movement/visual bugs:

1. Player entity disappearing: GpuWorldState now supports persistent
   entities (MarkPersistent/DrainRescued). The player character survives
   landblock unloads and gets re-injected into the streaming window at
   the current center landblock.

2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController
   keeps the character model above terrain z-fighting edge cases.

3. Camera after portal teleport: ChaseCamera.Update now called
   immediately after teleport snap so the camera recenters on the new
   position instead of lingering at the pre-teleport location.

4. Scenery on roads: SceneryGenerator now checks road status at the
   final displaced position (not just the origin vertex), catching
   objects that drift from non-road vertices onto road cells.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:56:45 +02:00
Erik
ae06f9c0ff feat(net+app): Phase B.3 — portal-space state machine for teleports
PlayerTeleport (0xF751) is a standalone GameMessage (u16 sequence,
align-4). When received, WorldSession fires TeleportStarted(uint sequence).

GameWindow subscribes: OnTeleportStarted sets PlayerMovementController.State
= PortalSpace, freezing all WASD/physics input. OnLivePositionUpdated
detects arrival (different landblock or >100 unit jump on our character guid),
recenters the streaming origin, resolves physics for ground Z, snaps the
player entity + controller, returns State to InWorld, and sends
GameActionLoginComplete directly (matching holtburger's PlayerTeleport
handler: send_login_complete on every portal transition).

PlayerMovementController gains PlayerState enum + early-return guard: if
State == PortalSpace, Update() returns a zero-movement result immediately
so no MoveToState / AutonomousPosition messages are emitted during transit.

WorldSession gains ResetLoginComplete() for callers that need to re-arm
the latch (documented; not called by the teleport path since we send
LoginComplete directly rather than through the PlayerCreate latch).

Opcode source: holtburger/crates/holtburger-protocol/src/opcodes.rs:84
Wire layout: holtburger/crates/.../movement/messages/teleport.rs

Build: 0 errors. Tests: 283 passed, 0 failed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:32:41 +02:00
Erik
768a9a0619 fix(app+core): Phase B.3 — Setup.StepUpHeight + scenery road exclusion
StepUpHeight: when Tab enters player mode, read Setup.StepUpHeight from the
player entity's dat and apply it to the controller (fallback 2f for non-Setup
entities or when the dat value is zero). Previously hardcoded to 5.0 which
made step-up too permissive.

Road exclusion: SceneryGenerator now skips terrain vertices where bits 0-1 of
the raw terrain word are non-zero. These bits encode the road type (GetRoad()
in ACViewer's Landblock.cs). Trees, rocks and bushes will no longer be placed
on road surfaces.

Added SceneryGenerator.IsRoadVertex(ushort) public helper + 9 unit tests
(theory + fact) verifying the road-bit convention matches TerrainInfo.Road.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:27:36 +02:00
Erik
8252523b8b feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine
Replace the disabled if(false) outdoor→indoor branch with real portal-plane
crossing logic. LandblockPhysics now carries IReadOnlyList<PortalPlane> Portals
(populated at load time; GameWindow passes Array.Empty for now until Task 3).

Resolve logic:
- Outdoor player: tests all portals where TargetCellId==0xFFFF (outside-facing);
  crossing enters the portal's OwnerCellId.
- Indoor player: tests portals where OwnerCellId==currentCell; crossing to
  TargetCellId==0xFFFF exits to terrain, otherwise transitions room-to-room.
- Landblock boundary crossing: unchanged — candidatePos landblock lookup already
  picks the adjacent block's terrain naturally.

Tests: renamed disabled test → Resolve_OutdoorThroughPortal_TransitionsToIndoor;
added Resolve_IndoorThroughExitPortal_TransitionsToOutdoor and
Resolve_LandblockBoundary_PicksAdjacentTerrain. 274 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:22:55 +02:00
Erik
228eecbb31 chore(app): Phase B.2 — strip diagnostic logging + fix indoor-transition test
Removes all [PLAYER], [PLAYER-INIT], [PLAYER-ANIM] diagnostic dump
lines now that walking, camera, and animation are verified working.

Updates PhysicsEngineTests.Resolve_EnterIndoorCell to match the new
behavior (outdoor→indoor transition disabled in the B.2 MVP): the
test now asserts the player stays outdoor at terrain Z instead of
transitioning to the indoor cell.

265 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:27:49 +02:00
Erik
980e79dae9 fix(app): Phase B.2 — fix camera rotation offset + pass server MotionTableId to walk animation
Two fixes:

1. Camera side-view: AC character models face +Y in their default
   orientation, but our yaw convention has +X at yaw=0. Added a
   -PI/2 offset to the character's rotation so the model faces
   the actual walk direction. Camera was already correct — it was
   the model rotation that was 90 degrees off.

2. No walk animation: UpdatePlayerAnimation loaded the Setup
   directly from dats, but Setup.DefaultMotionTable is 0 for human
   characters — the real motion table comes from the server's
   PhysicsDescriptionFlag.MTable field in CreateObject. Without
   the override, GetIdleCycle returned null for every command.
   Now stores _playerMotionTableId from the spawn event and
   passes it as motionTableIdOverride to GetIdleCycle.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:17:55 +02:00
Erik
b341193cfe fix(app+core): Phase B.2 — increase step height + resolve initial Z from terrain
Two fixes for the "position never changes when walking" bug:

1. StepUpHeight was 1.0 units — too tight. The player started at
   Z=92.2 (ACE relocation from previous session) but terrain Z was
   ~94, so every movement attempt had a Z delta of 1.8 which
   exceeded the limit. Increased to 5.0 (forgiving for MVP; AC
   default for humans is ~2 from Setup.StepUpHeight).

2. Initial position now resolves through PhysicsEngine with a huge
   step height (100) to snap to the correct terrain Z regardless
   of where the server-sent Z currently is. With indoor transitions
   disabled, this always produces the outdoor terrain height.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:11:50 +02:00
Erik
97c17c5bc3 fix(app): Phase B.2 — use server position directly, fix yaw wrap + turn spam
Three more fixes from the diagnostic dump:

1. Initial position: PhysicsEngine.Resolve was mapping the player
   into an indoor EnvCell (foundry at Z=66) when they're standing
   on outdoor terrain at Z=93+. The cell-containment check was too
   aggressive for initial placement. Now uses the server-sent
   position directly — the server already gave us a valid position.

2. Yaw unbounded: mouse delta accumulated without wrapping, growing
   to 24+ radians. Now wraps to [-PI, PI] after every turn.

3. Turn command spam: MouseDeltaX > 0.5 threshold was too low for
   raw pixel deltas. Any mouse jitter triggered turnCmd flips every
   frame → stateChanged=True → MoveToState flood to the server.
   Mouse turning now only affects yaw directly; turn COMMANDS only
   come from A/D keyboard (matching retail client behavior where
   mouse-look doesn't generate a TurnRight/TurnLeft command).

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:58:25 +02:00
Erik
6202c5d153 fix(app): Phase B.2 — three first-run bugs in player movement mode
1. Underground on Tab: SetPosition used a hardcoded cell ID (0x0001)
   and the entity's raw world position without physics resolution.
   Now runs PhysicsEngine.Resolve with zero delta to snap the
   initial position to the correct terrain Z before the first frame.

2. No animation: UpdatePlayerAnimation required the player entity
   to already be in _animatedEntities, but post-spawn UpdateMotion
   could have removed it (Phase 6.8 pattern). Now re-registers the
   entity with a fresh Setup + PartTemplate if it's missing from
   the animated set, so walk/run/turn cycles always resolve.

3. Side view (no turning): OnCameraModeChanged only set CursorMode.Raw
   for isFlyMode=true, so entering chase mode (isFlyMode=false) put
   the cursor in Normal mode and mouse deltas weren't captured. Now
   sets Raw cursor for both fly AND player mode.

Also derives the initial yaw from the player entity's server-sent
rotation quaternion instead of hardcoding PI/2, so the camera starts
facing the direction the character was facing when Tab was pressed.

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:44:11 +02:00
Erik
4ce7b65ee8 feat(app): Phase B.2 — wire player movement into GameWindow
Tab toggles between fly mode and player-controlled ground movement
when a live session is in-world. WASD walks/runs, A/D + mouse X
turns the character, Z/X strafes, Shift runs. PhysicsEngine resolves
positions against terrain each frame.

MoveToState sent on motion state changes (start/stop walking,
direction change, speed change). AutonomousPosition heartbeat sent
every ~200ms while moving. Walk/run/turn/idle animations resolved
locally via MotionResolver and played through the existing
TickAnimations path. ChaseCamera follows in third-person, mouse Y
adjusts camera pitch.

Tab on Escape also exits player mode gracefully. Mouse delta is
accumulated in the mouse-move callback and consumed+reset each
OnUpdate frame (no accumulation drift).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:33:53 +02:00
Erik
e5e1245efb feat(app): Phase B.2 — CameraController chase mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:29:47 +02:00
Erik
d9cd2b0b1d feat(app): Phase B.2 — PlayerMovementController (input → physics → motion state)
Per-frame controller that reads MovementInput (WASD/ZX/Shift/mouse),
drives PhysicsEngine.Resolve for collision, and tracks motion state
changes for outbound server messages + animation switching. Walk
(~4 u/s) and run (~7 u/s) speeds match AC retail. Heartbeat timer
triggers AutonomousPosition every ~200ms while moving.

5 new tests covering idle, forward, run, turn, and state-change
detection.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:27:07 +02:00
Erik
84d7d06008 feat(app): Phase B.2 — ChaseCamera (third-person follow camera)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 14:26:20 +02:00
Erik
9bd4d1eed8 feat(app): Phase B.3 — wire PhysicsEngine into streaming pipeline
Populates the collision engine with TerrainSurface + CellSurface
entries when landblocks stream in, removes them when they stream
out. CellSurface vertices are transformed from cell-local to world
space using EnvCell.Position orientation + origin.

Phase B.2 (player movement mode) will call PhysicsEngine.Resolve()
to get collision-validated positions before sending them to the
server.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:58:07 +02:00
Erik
5e96521f92 chore(app): disable VSync during development
VSync was locking the framerate to 32fps (half of 60Hz) because
frames consistently took slightly over the 16.67ms budget. With
VSync off the perf overlay shows the true framerate so we can
measure the actual impact of frustum culling and future optimizations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:22:54 +02:00
Erik
a33f3c4c7f feat(app): performance overlay in window title
Updates the window title every ~0.5s with:
  FPS | frame time (ms) | visible/total landblocks | entity count | animated count

Example: "acdream | 60 fps | 16.7 ms | lb 12/25 visible | ent 847 | anim 19"

Zero rendering cost — uses Silk.NET's window title setter, no font
renderer or GPU overlay needed. The visible/total landblock ratio
makes Phase A.2 frustum culling's impact visible at a glance: turning
the camera should show the visible count drop as landblocks behind
the camera get culled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:02:00 +02:00
Erik
3c9fc63af7 feat(app): Phase A.2 — wire frustum culling into terrain + static-mesh renderers
Per-landblock AABB culling against the view frustum. Each loaded
landblock has a 192×192 XY footprint + a Z range derived from the
terrain vertex min/max (padded +50 above / -10 below for entities
on top and basements). One AABB test per landblock per frame;
landblocks fully outside the frustum skip ALL their terrain draws
and entity draws (both opaque and translucent passes).

GpuWorldState gains SetLandblockAabb + LandblockEntries (per-landblock
iteration with AABB data). TerrainRenderer.Draw and
StaticMeshRenderer.Draw both accept an optional FrustumPlanes and
skip culled landblocks. GameWindow.OnRender extracts FrustumPlanes
from camera.View * camera.Projection and passes to both.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:53:18 +02:00
Erik
07fde88534 feat(app): Phase A.2 — FrustumCuller + FrustumPlanes (Gribb-Hartmann)
Per-landblock frustum culling for the streaming renderer. Extracts
6 normalized view-frustum planes from a View×Projection matrix using
the standard Gribb-Hartmann method. IsAabbVisible tests the AABB's
most-positive vertex against each plane — conservative (no false
negatives) and zero-allocation.

Key implementation note: System.Numerics.Matrix4x4 uses ROW-VECTOR
convention (clip = worldPos * VP), so Gribb-Hartmann must operate on
the COLUMNS of the matrix (not rows). The spec's row-based pseudocode
assumed column-major (OpenGL) convention; the fix is col4 ± col{1..3}.

7 new tests covering ortho, perspective (front/behind/left/far/
near-straddling), and acdream's actual Z-up camera convention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 08:49:17 +02:00
Erik
5666e05a85 fix(core+app): Phase 6.8 — keep NPCs animated when post-spawn motion update is unmappable
User reported that some NPCs (Pathwarden, Town Crier) didn't breathe.
Diagnostic logging revealed:

1. Both NPCs are correctly registered as animated at CreateObject
   time with the standard 30fps human breath cycle (anim=0x03000001).
2. Immediately after spawn the server sends an UpdateMotion with
   stance=0x0003 cmd=0x0000 for these NPCs.
3. MotionResolver.GetIdleCycle returned NULL for that combination,
   because StyleDefaults didn't have an entry for stance=3.
4. OnLiveMotionUpdated treated the NULL as "switch to a static pose"
   and removed the entity from _animatedEntities.

Two fixes:

A. MotionResolver.ResolveIdleCycleInternal — when stance is set but
   StyleDefaults has no entry for it, fall back to the table's
   DefaultStyle/DefaultSubstate instead of returning null. The
   server-supplied stance was just an unmappable override; the table
   default is the correct "I have no better information" answer.
   Pulled the table-default lookup into a small TryGetTableDefault
   helper so both fallback paths use the same code.

B. OnLiveMotionUpdated — never REMOVE an animated entity. If the
   re-resolved cycle is bad (null, framerate=0, or single-frame),
   leave the existing cycle running so the entity continues to
   breathe with whatever it already had. The defensive "remove on
   re-resolve failure" was the bug — it silently un-registered NPCs
   the moment the server sent any partial motion update.

Together these mean: any NPC that successfully registers as animated
at spawn stays animated, even if the server's subsequent motion
updates are incomplete or use stance values our resolver doesn't
have a mapping for.

Strips the [BREATHE] and [MOTION] diagnostic spew added during the
investigation now that the cause is identified.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:23 +02:00
Erik
133c22ed2f fix(app): Phase A.1 — restore MaxCompletionsPerFrame=4 (uncap caused OOM)
The previous fix in f792931 set MaxCompletionsPerFrame to int.MaxValue
on the theory that synchronous loading made the cap pointless. That
ignored the GPU upload cost: applying 25 landblocks in one Tick
allocates ~25 terrain VBOs + hundreds of entity GfxObj sub-mesh VBOs
+ all unique texture uploads in a single frame, which observably
crashes with OutOfMemoryException on the first frame after login.

The pending-spawn list (also added in f792931) is what actually
fixes the spawn-drop bug — it makes the cap safe by parking
late-arriving spawns until their landblock loads. With both fixes:

- Cap=4 spreads the 25-landblock first-frame load over ~7 frames
  (~116ms at 60fps, below human perception)
- Spawns for the 21 not-yet-loaded landblocks land in pending and
  back-fill as each one arrives over the next 6 frames
- No data lost, no OOM

219 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:23:11 +02:00
Erik
f792931d21 fix(app): Phase A.1 — pending-spawn list in GpuWorldState (proper fix)
Fifth and final Phase A.1 hotfix. Replaces the previous "drop on
miss" semantics in GpuWorldState.AppendLiveEntity with a per-landblock
pending bucket that survives the race where a CreateObject arrives
before its landblock has been streamed in.

Root cause:
The post-login spawn flood (40+ NPCs/items) drains in a single
WorldSession.Tick() call. The synchronous streamer enqueues all 25
visible-window landblocks in one shot but StreamingController.Tick
was capped at MaxCompletionsPerFrame=4, so only 4 landblocks landed
in GpuWorldState on the first frame. The center landblock 0xA9B4FFFF
may or may not have been in those first 4 (HashSet iteration order
is undefined). Spawns whose target landblock wasn't yet loaded were
silently dropped by AppendLiveEntity. Re-ordering the OnUpdate
(streaming first, live second) didn't fix it because the cap still
limited to 4 per frame; spawns for landblocks #5+ kept dropping
until the queue drained, by which point the spawn flood was over.

The reordering was correct but insufficient. The cap was a relic of
the original async streamer design (limit GPU upload spikes per
frame). With the synchronous streamer there's no backlog to spread,
so the cap was pure latency for no benefit. Setting it to int.MaxValue
restores "drain everything you just enqueued" semantics.

The pending-spawn list is the *correct* architecture fix that makes
the system robust against any future ordering bug, not just the cap:
- AppendLiveEntity for an unloaded landblock parks the entity in a
  per-landblock pending bucket instead of dropping it.
- AddLandblock drains pending entries for its landblock and merges
  them into the loaded record before storing.
- RemoveLandblock drops pending entries for the same landblock —
  if the player moved away, the spawns are no longer relevant; the
  server resends them via CreateObject when the player returns.

Diagnostic counter PendingLiveEntityCount exposes the bucket size
so future regressions are visible without spelunking.

7 new GpuWorldStateTests pin the contract:
- AppendLiveEntity_LandblockAlreadyLoaded_AppendsImmediately
- AppendLiveEntity_LandblockNotLoaded_ParksInPending
- AddLandblock_DrainsPendingEntriesForThatLandblock
- AddLandblock_DoesNotDrainPendingForADifferentLandblock
- RemoveLandblock_DropsPendingForThatLandblock
- RemoveLandblock_LoadedThenRemoved_DropsItsEntities
- IsLoaded_ReturnsTrueForLoaded_FalseForPendingOnly

Also removes the diagnostic Console.WriteLine I added in the previous
debugging round and the old LiveAppendsResolved/Dropped counters that
were never read by anyone.

219 tests green (212 + 7 new).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:19:40 +02:00
Erik
4b01c95ecb fix(app): Phase A.1 — run StreamingController.Tick before WorldSession.Tick
Fourth Phase A.1 hotfix. Terrain rendered correctly after the 0xFFFF
fix and the canonicalize fix, but live NPCs and weenies remained
invisible. Root cause: in OnUpdate the live session was draining its
incoming-message queue BEFORE the streaming controller had a chance
to load the initial 5×5 window. The first frame's order was:

  1. _liveSession.Tick() — drains the post-login CreateObject flood
     (40+ NPCs + items at the player's spawn landblock)
  2. _streamingController.Tick() — first call, loads the 5×5 window

AppendLiveEntity is a no-op when its target landblock isn't loaded
yet. So all 40+ spawns landed in step 1 before any landblock existed
in GpuWorldState, were silently dropped, and never came back even
after the landblocks finished loading in step 2.

Fix: swap the order. Streaming runs first so the initial window
exists in GpuWorldState before any CreateObject events drain. This is
correct because the streaming Tick is now synchronous (per the
531c9f9 hotfix) — by the time it returns, all landblocks in the
window are loaded and ready for AppendLiveEntity to find them.

A more robust solution would be a pending-spawn list inside
GpuWorldState that backfills entities when their landblock loads
later. That stays as a future improvement; the simple reorder is
correct for the dominant case (login → flood of spawns into the
already-known initial landblock).

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:13:25 +02:00
Erik
8941447204 fix(app): Phase A.1 — canonicalize live spawn landblockId in AppendLiveEntity
After the 0xFFFF terminator fix in f83a8c1, terrain renders correctly
but live NPCs and weenies disappeared. Root cause: the server's
ServerPosition.LandblockId is in cell-resolved form (0xAAAA00CC where
the low 16 bits are the cell index within the landblock), but the
streaming system stores landblocks in GpuWorldState keyed by their
canonical 0xAAAAFFFF form. AppendLiveEntity was passing the raw
server id straight into the dict TryGetValue, missing every time, and
silently dropping the spawn.

Fix: canonicalize at the GpuWorldState boundary by masking with
(0xFFFF0000u | 0xFFFFu). The XML doc on the method explains the two
forms so future callers don't have to guess. Calling code stays
unchanged.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 23:10:18 +02:00
Erik
f83a8c1674 fix(app): Phase A.1 — encode landblock IDs with 0xFFFF terminator, not 0xFFFE
ROOT CAUSE of the "giant ball with spikes" terrain corruption that
the previous two hotfix attempts (lock + synchronous loading) failed
to address. Threading was a red herring all along.

AC dat conventions:
  0xAAAA0xFFFF — LandBlock dat (terrain heightmap)
  0xAAAA0xFFFE — LandBlockInfo dat (static-object metadata)

WorldView.NeighborLandblockIds correctly uses 0xFFFF. My
StreamingRegion.EncodeLandblockId from Phase A.1 Task 1 used 0xFFFE
by mistake. Every streaming load was therefore calling
LandblockLoader.Load with the LandBlockInfo id, which makes
DatCollection ask DatBinReader to read a LandBlock from the
LandBlockInfo file. The reader's internal buffer position lands in
the middle of the wrong file's bytes, ReadBytesInternal asks for an
out-of-range slice, throws ArgumentOutOfRangeException, and the
landblocks that DON'T throw return half-populated LandBlock objects
whose Height[] arrays contain garbage. Garbage Z values render as
the spike pattern.

The kicker: my Task-1 review fix added a test
(Constructor_SmallRadius_IDsMatchEncodingRule) that asserted
Assert.Contains(0x1234FFFEu, region.Visible). The test was passing
because it pinned the wrong value. I literally codified the bug.

Fix: change EncodeLandblockId's terminator from 0xFFFEu to 0xFFFFu
and update the test to assert 0x1234FFFFu. The XML doc on Visible
now explicitly explains the 0xFFFF/0xFFFE distinction so this can't
recur.

The previous two hotfixes (_datLock in c991fb2, synchronous streamer
in 531c9f9) stay in place — _datLock is defensive belt-and-suspenders
that documents which entry points read dats, and synchronous loading
is correct-by-default until we decide whether to reintroduce
background loading (Phase A.3 may make it unnecessary anyway).

212 tests green. With this fix the streaming should actually work.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:59:21 +02:00
Erik
531c9f9349 fix(app): Phase A.1 — make LandblockStreamer synchronous (DatCollection isn't thread-safe)
Second hotfix attempt for the "ball of spikes" terrain corruption.
The previous _datLock fix was insufficient because dat reads happen
from many render-thread code paths I didn't enumerate (animation
tick, OnLiveMotionUpdated, OnLivePositionUpdated, the live spawn
hydration, ApplyLoadedTerrain) and locking each is invasive and
fragile.

DatReaderWriter's DatCollection is fundamentally not thread-safe:
DatBinReader's internal buffer position is shared per-database, so
two concurrent .Get<T> calls corrupt each other's read state. The
ArgumentOutOfRangeException at DatBinReader.ReadBytesInternal in
the failure log is the smoking gun — one read started reading a
LandBlock, another moved the reader's position, the first one
asked for the wrong number of bytes.

Until Phase A.3 introduces a thread-safe dat wrapper (or until we
preload all dats into pure in-memory dictionaries), the streamer
runs synchronously: EnqueueLoad invokes the load delegate inline
on the calling thread and writes the result to the outbox in a
single call. The render-thread DrainCompletions loop picks it up
on the same frame.

API surface unchanged — Channel-based outbox, EnqueueLoad/Unload,
DrainCompletions, Start (now no-op), Dispose all preserved. Move
back to async loading is a single-class change once dat thread
safety lands.

Cost: visible frame hitch when crossing landblock boundaries
(rendering the new landblock is now on the render thread). For
default 5×5 the hitch is one landblock per cardinal step, ~50ms
worst case. Acceptable for the MVP — correctness over hitches.

Updated the off-thread test to assert the new synchronous contract
(loader runs on the calling thread). The other 4 tests still pass
unchanged because their spin-drain pattern works with synchronous
delivery too.

The previous _datLock from commit c991fb2 stays in place as
defensive belt-and-suspenders — it's free in synchronous mode and
keeps the contract documented at every dat-reading entry point.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:56:19 +02:00
Erik
c991fb23ce fix(app): Phase A.1 — serialize DatCollection access behind _datLock
Critical hotfix for the first live run of streaming. User reported
terrain rendered as "a giant ball with spikes with textures on" and
the log showed concurrent-read corruption:

    streaming: load failed for 0xA8B3FFFE: ArgumentOutOfRangeException
    at AcDream.Core.World.LandblockLoader.Load ... line 18

Line 18 is `dats.Get<LandBlock>(landblockId)`. Root cause: my spec
claimed DatCollection is thread-safe for reads. It isn't. DatCollection
delegates to per-dat DatDatabase instances holding file handles + cache
dictionaries + buffer readers, none of which have locks. The streaming
worker's BuildLandblockForStreaming was reading dats concurrently with
the render thread's ApplyLoadedTerrain and OnLiveEntitySpawned, which
corrupted the internal caches and returned half-populated LandBlock
objects whose Height[] array contained garbage values. Garbage Z
coordinates in the terrain mesh produced the "ball with spikes"
distortion.

Fix: add a single lock object `_datLock` on GameWindow and wrap the
three entry points that read dats on competing threads:

- BuildLandblockForStreaming (worker thread)
- ApplyLoadedTerrain (render thread via StreamingController.Tick)
- OnLiveEntitySpawned (render thread via WorldSession events)

Each is split into a public wrapper that takes the lock and a private
...Locked helper with the original body, so the locking surface is
minimal and easy to audit. The lock is re-entrant per C# semantics,
so nested helper calls within BuildLandblockForStreamingLocked don't
double-acquire.

Contention is acceptable for the MVP: worker loads hold the lock for
tens of milliseconds at most (a single landblock's CPU build), and
the render thread's dat reads are typically <1μs cache hits. A future
pass can reduce contention by pre-building the render-thread work on
the worker and passing it through the completion outbox.

Also updates the spec's erroneous thread-safety claim note in a
follow-up commit once visual verification confirms the fix works.

212 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:49:37 +02:00
Erik
fca299780c feat(app): Phase A.1 Task 8 — restore scenery + interior in streamed loads
Task 7 shipped the streaming MVP with stabs only; this commit ports
the pre-streaming scenery generator and EnvCell interior walker
into BuildLandblockForStreaming so every streamed landblock now
carries the full entity set the old one-shot preload produced.

Scenery (trees/rocks/bushes) from SceneryGenerator.Generate runs
per-landblock on the worker thread with a landblock-derived id
namespace (0x80000000 | (lbId & 0x00FFFF00) | local_index) so
scenery ids don't collide across landblocks.

Interior (EnvCell walls/floors/ceilings via Phase 7.1 CellMesh
plus static interior objects) runs on the worker thread with a
0x40000000-based id namespace. Cell sub-meshes are pre-built on
the worker and handed to the render thread via a
ConcurrentDictionary<uint, IReadOnlyList<GfxObjSubMesh>> which
ApplyLoadedTerrain drains before its per-MeshRef upload loop.

The per-MeshRef upload loop in ApplyLoadedTerrain now skips
non-0x01xxxxxx ids (EnvCell synthetic ids, Setup ids) so it no
longer attempts GfxObj.Get on ids that aren't GfxObj dat records.

The cross-thread cell-mesh dictionary is the only shared mutable
state between the worker and render threads — everything else
flows through the Channel<LandblockStreamResult> outbox.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:39:33 +02:00
Erik
efcf0c30d0 feat(app): Phase A.1 — wire StreamingController into GameWindow (MVP)
Replaces the one-shot 3×3 preload in OnLoad with
StreamingController + LandblockStreamer. Runtime-configurable
window radius via ACDREAM_STREAM_RADIUS (default 2 → 5×5). OnUpdate
drives StreamingController.Tick once per frame with the current
observer landblock coordinates (camera-offset in offline, player
last-known in live).

_entities flat list replaced by GpuWorldState.Entities. Live
CreateObject handler uses GpuWorldState.AppendLiveEntity instead of
the old list-rebuild-and-replace pattern. Streamer is disposed in
OnClosing before GL teardown so the worker thread is joined before
we release resources.

Terrain build dependencies (heightTable, blendCtx, surfaceCache) are
stored as fields so ApplyLoadedTerrain can call LandblockMesh.Build
on the render thread without re-deriving them per landblock.
ICamera.Position fix: offline observer coordinate uses
_cameraController.Fly.Position (FlyCamera exposes Position; ICamera
does not), which is always up-to-date regardless of active camera mode.

MVP scope: stabs only. Scenery (trees/rocks/bushes) and interior
(EnvCell walls/floors + static objects) will land in Task 8 and
are currently DROPPED from streamed landblocks. The offline view
will show terrain + stabs but no vegetation and no building
interiors until Task 8 lands. Live mode is unaffected since
CreateObject spawns come through a different path.

212 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:34:34 +02:00
Erik
9067c4f60b feat(app): Phase A.1 — StreamingController glue
Called once per frame from OnUpdate. Owns a StreamingRegion and uses
delegates into LandblockStreamer + a terrain-apply callback so unit
tests can inject fakes. Handles first-tick bootstrap (whole window
loads), boundary recenter (diff against previous center), and
drain completions (up to N per frame to cap GPU upload spikes).

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:26:55 +02:00
Erik
6b70b1201d feat(app): Phase A.1 — GpuWorldState render-thread entity registry
Replaces GameWindow._entities flat list with a per-landblock dict
keyed by landblock id. The flat entity view is rebuilt on add/remove
so the renderer keeps its simple "iterate Entities" loop. Also
provides AppendLiveEntity for the CreateObject path that spawns
entities into an already-loaded landblock after hydration.

Not thread-safe — all mutation is render-thread only.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:24:26 +02:00
Erik
495f87a4ad feat(app): Phase A.1 — TerrainRenderer.RemoveLandblock for streaming unloads
TerrainRenderer's internal landblock collection is now a Dictionary
keyed by landblock id so the streaming system can release GPU
resources per-landblock as the visible window moves. AddLandblock
takes the id as its first parameter; if the same id is added twice,
the old buffers are freed before the new ones land (defensive but
cheap). RemoveLandblock is a no-op for unknown ids and deletes
VBO/EBO/VAO for known ones.

Single existing caller in GameWindow.cs updated to pass the id.

Build green. No unit tests — direct-to-GL methods need a live context.
Tasks 5-7 will validate end-to-end.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:22:49 +02:00