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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Code review follow-up to commit 0904372. Five Important fixes plus
three Minor polish items found by the reviewer before StreamingController
depends on this class under churn.
I1: Dispose is now thread-safe via Interlocked.Exchange on an int
guard. Two concurrent Dispose calls no longer double-dispose the
CancellationTokenSource.
I2: EnqueueLoad/EnqueueUnload now throw ObjectDisposedException when
called after Dispose instead of silently dropping the job. Jobs
vanishing into a completed channel was a debugging hazard.
I3: Start throws ObjectDisposedException when called after Dispose
instead of silently doing nothing (the old guard only checked
whether the thread was non-null, not whether the streamer was
still usable).
I4: New test Load_ExecutesLoaderOnBackgroundThread captures the
loader delegate's ManagedThreadId and asserts it differs from
the test thread's id, proving the whole reason this class
exists (off-thread execution) is actually happening.
I5: New LandblockStreamResult.WorkerCrashed record type for the
outer catch in WorkerLoop. Previously the crash path wrote
Failed(0, ex.ToString()) which collided with landblock (0, 0)
in the north ocean, making "worker crashed" indistinguishable
from "landblock 0 failed to load".
Minor polish:
- M1: Test spin constants (SpinTimeoutMs, SpinStepMs,
SpinMaxIterations) extracted so the 200 x 10ms pattern has one
source of truth.
- M2: DefaultDrainBatchSize public const on LandblockStreamer so
the batch cap has a name and a comment explaining why 4.
- M3: Safety-argument comment on the sync-over-async
WaitToReadAsync call explaining why it cannot deadlock (dedicated
thread, no SyncContext).
- M6: XML remarks on the class and on DrainCompletions documenting
threading contract (Enqueue = any thread, Drain = single consumer
thread).
112 Core + 96 Core.Net tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Background thread pulls load/unload jobs from an inbox channel, invokes
a caller-supplied Func<uint, LoadedLandblock?> (production wraps
LandblockLoader.Load, tests inject a fake), and posts results to an
outbox channel the render thread drains. Graceful shutdown via
CancellationToken; failed loads reported rather than retried.
4 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LandblockStreamJob (Load/Unload) and LandblockStreamResult
(Loaded/Failed/Unloaded) are the channel payload types the next
task's LandblockStreamer will use. Separate file because they're
shared between the worker thread and the render thread and deserve
a focused home.
Folds in two carryover nits from the Task 1 fix review:
- Stale "radius + 1" comments in StreamingRegionTests updated to
match the real radius+2 threshold (no numeric-assertion changes).
- Single-step recenter test now asserts Visible.Count == 25 and
Resident.Count == 30, locking in the Visible/Resident semantic
split behaviorally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Review follow-up from commit 11df793. Three fixes:
1. Visible semantics: StreamingRegion.Visible now strictly describes the
current (2r+1)×(2r+1) window, not window + hysteresis retainees.
Added a parallel Resident property exposing the actual loaded set
(window + hysteresis buffer). This matters because StreamingController
(next task) reads these to decide what to render vs what to unload;
conflating them in one set would have forced awkward post-processing
downstream.
2. Doc/code disagreement: updated the RecenterTo and RegionDiff doc
comments from "radius + 1" to "radius + 2" to match the actual
implementation (which is what the tests require). Also updated the
plan doc so future readers don't hit the same contradiction.
3. Edge-clamping test coverage: added a single-axis edge test
(cx=0, cy=50 → 15 entries) and an ID-encoding test (radius=0 at
0x12,0x34 → 0x1234FFFE) so a swapped-shift bug in EncodeLandblockId
or an asymmetric off-by-one would fail a test instead of passing
silently.
9 tests green, full suite regressions-free.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure data type describing the set of landblocks inside the current
streaming window, with a diff-style Recenter that returns (toLoad,
toUnload) pairs the LandblockStreamer consumes as jobs. Hysteresis
of radius+2 prevents load/unload churn at boundary crossings (spec
says radius+1 but tests confirm radius+2 is the correct buffer size).
First piece of Phase A.1 per docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md.
7 new tests, all green. Total suite: 105/105.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The user reported the lifestone crystal (AlphaBlend part 3 of the
4-part 0x020002EE setup) rendered with one side consistently missing
— looked like "a box with one side missing, you can see into it"
while the whole thing rotated.
Isolated via experiment: routing the crystal through the opaque pass
(no blending, depth write on) produced a whole solid shape. Routing
it through the Phase 9.1 translucent pass (blending on, depth write
off) produced the hole. Mesh build was eliminated as the cause.
Root cause: our translucent pass matched WorldBuilder's state
(SrcAlpha/OneMinusSrcAlpha, DepthMask(false)) but NOT its culling
state. WorldBuilder enables GL_CULL_FACE with per-batch CullMode
(references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
BaseObjectRenderManager.cs:361-365). Without face culling, the 58
triangles of the closed crystal shell drew in dict-iteration order;
back faces that happened to draw AFTER front faces composited over
them because depth-write-off meant nothing recorded depth within the
translucent set. One face of the crystal ended up permanently
overwritten by its own backside.
Fix: in pass 2 (translucent) enable GL_CULL_FACE with GL_BACK and
CCW front-face winding. Our mesh builder emits pos-side triangles as
(0, i, i+1), which is CCW in standard OpenGL conventions, so GL_BACK
correctly drops the inward-facing side. Back-face culling is disabled
again after pass 2 so subsequent renderers (terrain etc.) see the
default state.
Known limitation: neg-side polys on translucent surfaces — which my
pos/neg mesh-build fix would have emitted with reversed winding —
now get culled in the translucent pass. AC rarely uses double-sided
polygons on translucent surfaces so this is acceptable, and the
opaque pass still renders them correctly. A future Phase 9.3 can
track CullMode per sub-mesh and draw double-sided translucents with
GL_NONE if it turns out to matter.
Also strips the Portal/Lifestone [DIAG] spawn dump that served as
one-shot evidence gathering during the investigation.
194 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Makes NPCs and other server-spawned entities actually move and
transition animations based on the live server feed. Before this,
Phase 6.6/6.7 only parsed the messages and fired events that nothing
consumed, so NPCs stayed frozen at their CreateObject spawn point
playing one idle cycle forever.
Changes:
- GameWindow now keeps a parallel _entitiesByServerGuid dictionary
built at CreateObject hydration time so motion / position updates
can find the target entity by its server guid.
- WorldEntity.Position and Rotation become get/set (like MeshRefs did
in Phase 6.4) so the position-update handler can reseat an existing
entity in place without reallocating MeshRefs.
- OnLiveMotionUpdated re-resolves the cycle via MotionResolver using
the server's new (stance, forward-command) override and either
swaps the AnimatedEntity's current cycle or removes it from the
animated set if the new pose is static.
- OnLivePositionUpdated translates the new landblock-local position
into acdream world space (same math as CreateObject hydration) and
writes it back onto the entity.
Subscriptions are added alongside the existing EntitySpawned hook so
the three handlers run synchronously on the UDP pump thread, matching
the existing pattern.
194 tests green (98 Core + 96 Core.Net).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server sends UpdateMotion whenever an entity's motion state changes:
NPCs starting a walk cycle, creatures switching to a combat stance,
doors opening, a player waving, etc. Phase 6.1-6.4 already handles
rendering different (stance, forward-command) pairs for the INITIAL
CreateObject, but without this message NPCs freeze in whatever pose
they spawned with and never transition to walking/fighting.
Added UpdateMotion.TryParse with the same ServerMotionState the
CreateObject path uses, reached via a slightly different outer
layout (guid + instance seq + header'd MovementData; the MovementData
starts with the 8-byte sequence/autonomous header this time rather
than being preceded by a length field). Only the (stance, forward-
command) pair is extracted — same subset CreateObject grabs.
WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C
body parses successfully. The App-side wiring (guid→entity lookup
and AnimatedEntity cycle swap) is intentionally deferred to a
separate commit because it touches GameWindow which is currently
being edited by the Phase 9.1 translucent-pass work.
89 Core.Net tests (was 83, +6 for UpdateMotion coverage).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first-30-spawns dump was one-shot diagnostic to find the HighFrame=-1
sentinel bug. User confirmed breathing works post-fix (15 animated
entities registered at spawn 60, zero single-frame rejections — all
remaining rejections are framerate=0 static objects like chests,
doors, wells, which legitimately shouldn't idle-animate). The summary
animated-count line stays since it's useful for ongoing debugging.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Diagnostic spawn dump revealed the player character arrives with
`low=0 high=-1 framerate=30.00 partFrames=33`. The -1 is ACViewer's
"play the whole animation" sentinel (see
references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113
set_animation_id). My Phase 6.1 code used the raw int values, so
the downstream registration filter evaluated `HighFrame(-1) > LowFrame(0)`
as false and threw away every animated entity whose AnimData used the
sentinel — which, from the live dump, appears to be basically all of
them.
MotionResolver.GetIdleCycle now does the same four-step clamp ACViewer
does: -1 HighFrame → NumFrames-1, clamp LowFrame and HighFrame to
NumFrames-1, and collapse to LowFrame if LowFrame > HighFrame. The
IdleCycle carried up to GameWindow is always in terms of real frame
indices the playback loop can step through. Static poses (framerate==0
or single frame after resolution) still skip registration correctly.
168 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The user reported the ground-floor of buildings flickers between the
cell mesh floor and the terrain polygon underneath. Classic Z-fight:
both surfaces are coincident in Z because buildings rest ON the
terrain, and depth-buffer precision picks a winner per-pixel per
frame. Add a 2cm Z lift to every EnvCell transform so the cell floor
wins cleanly. Human-scale invisible.
Separately: NPCs in Phase 6.4 aren't visibly breathing. To tell
whether they're being rejected by our registration filter (framerate
== 0, single-frame cycle, or one-frame animation) vs. just having
short cycles the user can't see, add four rejection counters around
the idleCycle check and print them in the summary line next to the
other spawn diagnostics. Next run will tell us exactly which bucket
eats NPCs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The user noted that the previous 35 u/s default felt too fast for
exploring scenery — overshooting buildings and skipping past entities
constantly. Drop default to 12 u/s (≈AC's run speed), and bind Shift
to a 50 u/s boost for fast travel between landblocks. Backwards
compatible: the new boost parameter on FlyCamera.Update has a default
of false, so any existing caller behaves the same.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Interior walls, floors, and ceilings were invisible because the Phase 2d
walker only consumed StaticObjects and skipped each cell's CellStruct
(VertexArray + Polygons + EnvCell.Surfaces). This commit ports the same
fan-triangulated per-surface bucket pattern from GfxObjMesh into a new
CellMesh module, then wires it into the interior walker so each EnvCell
now contributes both its static props and its room mesh. The cell's world
transform (rotation * translation(cellOrigin + lbOffset)) is baked into
MeshRef.PartTransform with WorldEntity at identity, matching how
StaticMeshRenderer composes model = PartTransform * entityRoot.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 6.4 advanced CurrFrame as a float but only sampled the integer
floor, so animations stepped instead of flowing. Phase 6.5 takes the
fractional part as a blend factor t and slerps each part's
orientation + lerps its origin between PartFrames[floor] and
PartFrames[floor+1] (wrapping back to LowFrame at HighFrame). The
result is smooth motion at any framerate without changing the cycle
length or frame indices.
Defensive: if a part index exceeds the keyframe's bone list (rare,
typically when AnimPartChanges grew the part count above the
animation's NumParts) it falls back to identity instead of throwing.
160 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.
MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.
WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.
GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.
OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.
160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Foundry's drudge statue Setup (0x020007DD) has DefaultMotionTable=0,
so MotionResolver returned null and the renderer fell back to
PlacementFrames[Default] — an upright pose, which is wrong. The retail
crouched/aggressive pose comes from a per-instance motion table the
server attaches via PhysicsDescriptionFlag.MTable (confirmed live as
0x090000DA for the statue).
CreateObject.TryParse was already walking the MTable field but
discarding the value. Now it captures it as Parsed.MotionTableId and
WorldSession.EntitySpawn forwards it. GameWindow passes it as the
motionTableIdOverride to MotionResolver.GetIdleFrame, so the cycle
lookup uses the server-supplied table when the dat-side default is
empty. With this in place the drudge resolves a real cycle and
renders in the correct crouched pose.
Trimmed the heavy STATUE motion-table dump diagnostics now that the
mechanism is verified; left a one-line summary so future regressions
remain debuggable. 160 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>