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>
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>
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>
Portal planes are infinite — IsCrossing only checked if positions
were on opposite sides of the plane, without verifying the crossing
point was near the actual portal opening. Walking 50m from a
building but crossing the same plane extension triggered false indoor
transitions, sinking the player into underground cell floors.
Fix: FromVertices now computes centroid (average of 3 vertices) and
bounding radius (max vertex distance + 2 unit padding). IsCrossing
rejects crossings where both positions are further than 2×radius
from the centroid. Only nearby crossings (within doorway range)
trigger a transition.
Also fixes jump-not-landing: the false portal transitions were
producing wrong resolvedGroundZ values during the jump arc, making
the landing check (candidateZ <= resolvedGroundZ) never true.
283 tests still green.
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>
The IsRoadVertex check and helper were dropped by a linter pass after the
previous commit. Re-adding them explicitly.
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>
Adds the foundational portal-plane record for cell transition detection.
PortalPlane.FromVertices computes a normalised plane from 3 coplanar
polygon vertices via cross product + dot product; IsCrossing tests whether
a movement vector straddles the plane (strictly negative dot-product
product — exact-on-plane position returns false as specified).
4 new unit tests: normal construction, opposite-side crossing, same-side
no-crossing, start-on-plane no-crossing. All 269 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Comprehensive spec for completing the physics/movement system:
CellPortal-based indoor/outdoor/room-to-room transitions via
sphere-plane intersection, multi-landblock boundary crossing,
momentum-preserving jump with gravity arc, portal-space state
machine for teleports, Setup.StepUpHeight hookup, and
scenery-on-road exclusion fix.
Replaces the current "outdoor heightmap sampler with disabled
indoor transitions" with the full world-navigation system AC's
client uses. 12 acceptance criteria, ~20 new unit tests planned.
Co-Authored-By: Claude Opus 4.6 (1M context) <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 attempts at guarding the indoor transition (cellId mask,
Z threshold, terrainZ comparison) all failed because CellSurface
floor polygons are too aggressive — building footprints, roofs, and
upper floors all have PhysicsPolygons that cover wide XY areas at
various Z levels, and ANY outdoor position near a building matches
a cell floor. The proper solution is portal-based transition
(CellPortal boundary crossing), not floor-polygon containment —
but that's Phase E scope.
For the B.2 MVP: outdoor players NEVER transition to indoor cells.
The else-if branch is compiled out with `if (false)`. Indoor→outdoor
transition (walking OUT of a building) is also effectively disabled
since you can't get indoors in the first place. Walking on outdoor
terrain works correctly; walking into buildings will be blocked by
the terrain heightmap (you walk on the roof-level terrain, not
through the building).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous cellId-mask fix was necessary but insufficient: the engine
correctly identified the player as outdoor, but then immediately
transitioned to an indoor cell because a CellSurface floor polygon
covered the player's XY at a Z within stepUpHeight. The floor
polygon was a roof or upper floor of a nearby building that happens
to sit at terrain level — not a walkable indoor floor the player
should snap to.
Fix: outdoor→indoor transition now requires bestCellZ < terrainZ - 1.
A genuine indoor transition is into a cell whose floor is BELOW the
terrain surface (basement, ground floor of elevated building). Cells
at or above terrain Z are roofs/upper floors viewed from outside and
must not capture the player.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE of the fall-through-ground bug. The currentlyIndoor check
compared the FULL 32-bit cell ID (0xA9B40001, includes landblock
prefix) against 0x0100. Every outdoor cell ID with a landblock
prefix is >= 0x0100, so the engine ALWAYS took the "stay indoors"
path, snapping the player to the nearest EnvCell floor at Z=66
instead of the outdoor terrain at Z=94.
ACE confirmed the bug: "AddWorldObjectInternal: couldn't spawn
+Acdream at 0xA9B40121 [84.2 37.7 66.0]" — we were sending the
server an indoor cell ID with a below-terrain Z position.
Fix: mask cellId with 0xFFFF before the indoor check (outdoor
cells are 0x0001–0x0040; indoor are 0x0100+; the high 16 bits
are the landblock prefix and must be stripped). Also mask the
returned targetCellId from CellSurface (which carries the full
EnvCell dat id) to just the cell index.
265 tests still green.
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>
Outbound GameAction message builders for player movement:
- MoveToState (0xF61C): sent on motion state changes (start/stop
walking, turn, speed change). Carries RawMotionState (flag-driven
variable fields) + WorldPosition + sequence numbers.
- AutonomousPosition (0xF753): periodic position heartbeat sent
every ~200ms while moving. No RawMotionState — just WorldPosition
+ sequences + contact byte.
Both follow the GameAction envelope pattern (0xF7B1 + sequence +
action type) established by GameActionLoginComplete. Wire format
ported from references/holtburger movement protocol — field order
and alignment match exactly (contact byte + pad_to_4).
Also:
- Adds WriteFloat to PacketWriter (needed by both builders)
- Adds SendGameAction + NextGameActionSequence to WorldSession
(public wrappers for PlayerMovementController in Task 2)
11 new tests, 265 total, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <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>
Tab-toggled player mode with WASD ground walking, A/D + mouse
turning, Z/X strafing, Shift for run, third-person chase camera,
local walk/run/turn/idle animations, and outbound MoveToState +
AutonomousPosition server messages. Uses PhysicsEngine from B.3
for collision-resolved positions.
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>
Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.
Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).
One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.
6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extracts the bilinear heightmap interpolation from GameWindow's
inlined SampleTerrainZ into a reusable class. Also adds outdoor
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).
First component of the physics collision engine.
6 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Projects an XY point onto a cell's floor polygons via brute-force
triangle iteration + barycentric Z interpolation. Fan-triangulates
quads and larger polygons. Returns null when outside all floor
surfaces. Accepts pre-transformed world-space vertex positions so
the caller handles EnvCell coordinate transforms.
Second component of the physics collision engine.
4 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
5-task TDD plan: TerrainSurface (outdoor heightmap Z + cell ID),
CellSurface (indoor floor polygon projection via barycentric interp),
PhysicsEngine (top-level resolver with step-height + cell transitions),
GameWindow integration (populate from streaming), and roadmap update.
~16 new unit tests with fake data. No dat files or rendering needed.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Pure-computation collision engine for ground-based entity movement.
Three components: TerrainSurface (outdoor heightmap Z interpolation),
CellSurface (indoor EnvCell floor polygon projection), PhysicsEngine
(top-level resolver with step-height enforcement, outdoor/indoor
cell transitions via CellPortals, and gravity reporting).
Uses PhysicsPolygons from CellStruct for walkable surfaces with
brute-force polygon iteration (< 20 polys per cell). BSP tree
acceleration deferred — same collision fidelity, simpler code.
Standalone module with no rendering or networking dependencies.
~15-20 unit tests with fake data covering flat terrain, slopes,
stairs, wall rejection, and cell transitions. Integration with
the streaming system via ApplyLoadedTerrainLocked. Consumed by
Phase B.2 (player movement mode, separate spec).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
All Foundation sub-pieces now shipped:
A.1 ✓ Streaming landblock loader
A.2 ✓ Frustum culling (~160fps)
A.3 ✓ Background net receive thread
A.4 Folded into A.1 (deferred — DatCollection not thread-safe)
Phase A (Foundation) is complete. Next: Phase B (Gameplay).
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>
Moves the UDP receive onto a dedicated daemon thread that
continuously pulls raw datagrams from the kernel buffer and posts
them to a Channel<byte[]>. Tick() on the render thread drains the
channel instead of calling _net.TryReceive() directly. All decode,
fragment assembly, ISAAC crypto, event dispatch, and ack-sending
remain on the render thread — this is the minimal change that
prevents packet drops during render-thread stalls without the
complexity of moving decode/dispatch off-thread.
The net thread starts at the end of EnterWorld() after the handshake
is complete — during Connect() and EnterWorld(), PumpOnce() still
reads directly from the socket (the net thread isn't running yet).
Dispose() cancels the thread via CancellationToken, joins with a
2-second timeout, then disposes the socket.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
~160fps uncapped at 5×5 radius with per-landblock AABB culling.
Perf overlay in window title shows visible/total landblock ratio
so the culling impact is visible at a glance.
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>
When closing the acdream window, WorldSession.Dispose now sends
CharacterLogOff (game message 0xF653, no payload) followed by a bare
DISCONNECT control packet (header flag 0x8000) before closing the
UDP socket. This tells ACE to release the character lock immediately
instead of waiting for the ~60s network timeout — which was blocking
rapid iteration during testing since acdream now does a proper login
(Phase 4.8-4.10) and ACE holds the character in-world.
Pattern from references/holtburger/.../client/commands.rs lines
879-892 (Quit handler). Best-effort: if the socket is already dead,
the exception is eaten and Dispose finishes cleanup normally.
220 tests still green.
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>
User reported that even with the Phase 4.9 ack pump, acdream's
character still rendered to other clients as the purple loading haze.
Spent another round in holtburger's references and found two more
gaps in the post-EnterWorld handshake:
1. Server sends DddInterrogation (game opcode 0xF7E5) and waits for
the client to acknowledge dat-list versions. We never replied.
Build the canonical empty response (12 bytes: opcode + language=1
+ count=0 lists) and ship it as soon as DddInterrogation arrives.
2. LoginComplete was being sent immediately after CharacterEnterWorld
in Phase 4.8, which is too early — the server hasn't finished
creating the player object yet so it ignores LoginComplete and
the player stays in transition state. The correct trigger is the
server's PlayerCreate (0xF746) game message for our character;
that's when holtburger fires send_login_complete (see references/
holtburger/.../client/messages.rs::PlayerCreate handler).
Wired both into ProcessDatagram. Removed the unconditional
LoginComplete from the EnterWorld flow. Added a _loginCompleteSent
latch so re-PlayerCreate (e.g., across portal teleports) doesn't
re-fire LoginComplete during the same session.
Reference repo cited per the new CLAUDE.md guidance — holtburger is
the authoritative client-behavior reference. Should have looked there
sooner; this would have saved the Phase 4.8 false fix.
220 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the still-purple-haze symptom AND the ACE-side
"Network Timeout" drop after ~60s. acdream was never sending
acknowledgement packets back to the server, so the server's
reliability layer saw a one-way stream and eventually dropped the
session. During the 60s window the player rendered to other clients
as the stationary purple loading haze (AC's "this client is in
portal-space transition" indicator).
Pattern ported from
references/holtburger/crates/holtburger-session/src/session/
{send.rs::send_ack, receive.rs::finalize_ordered_server_packet}.
The proper holtburger pattern is per-packet acks, NOT a periodic
heartbeat: every received server packet with sequence > 0 and no
ACK_SEQUENCE flag of its own gets a bare control packet sent back
with:
PacketHeader {
Flags = ACK_SEQUENCE (0x4000),
Sequence = current_client_sequence (= last issued, no increment),
Id = session client id,
}
Body = u32 little-endian server sequence being acked
Acks are cleartext control packets (no EncryptedChecksum) and
re-use the most recently issued client sequence rather than
consuming a new one — they aren't part of the reliable stream the
server tracks for retransmits.
Wired into ProcessDatagram so both Tick (post-InWorld) and PumpOnce
(during Connect/EnterWorld) trigger acks on every received non-ack
server packet.
Also (per user request) upgrades the CLAUDE.md description of the
holtburger reference repo from "Rust AC client crate" to "almost-
complete Rust TUI AC client — the most authoritative reference for
client-side behavior in the project, look here FIRST for anything
WorldSession or message-builder related." This was the third time
in two days I would have saved hours by checking holtburger first
instead of guessing at the protocol from ACE alone.
220 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User reported that when they observed acdream's character through a
second AC client running on a different account, the character
rendered as a stationary purple haze (AC's "loading screen / portal
space" indicator) instead of a normal avatar. The character was
"in-world enough" to receive the CreateObject stream but never
"in-world enough" for the server to flip its first-enter-world flag,
push initial property updates / equipment overrides, or show the
character to other clients in the area.
Root cause: WorldSession.EnterWorld stopped after sending
CharacterEnterWorld (0xF657). The handshake is supposed to continue
with one more message — a GameAction(LoginComplete) — that ACE's
GameActionLoginComplete handler interprets as "client has exited
portal space, mark FirstEnterWorldDone, push property updates,
make the character visible to others."
Wire layout (confirmed via
references/ACE/Source/ACE.Server/Network/GameAction/GameActionPacket.cs
and .../Actions/GameActionLoginComplete.cs):
u32 game-message opcode = 0xF7B1 (GameAction)
u32 sequence = 0 (ACE ignores; TODO comment in source)
u32 GameActionType opc = 0x000000A1 (LoginComplete)
Send happens immediately after CharacterEnterWorld and just before
flipping the WorldSession state to InWorld. acdream has no portal-
space transition animation, so we can claim "loading complete" the
moment we've sent the EnterWorld message — the dat-side world is
already loaded by then.
1 new test (97 Core.Net total). 220 tests green overall.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move A.1 from "ahead" to "shipped" per the roadmap discipline rule.
The shipped row notes that the loader currently runs synchronously
(the original async-worker design hit DatCollection's lack of thread
safety) and that the Channel-based outbox API is preserved so async
loading can return cleanly when Phase A.3 lands a thread-safe dat
wrapper. Pending-spawn list in GpuWorldState handles live spawn /
streaming races without dropping data.
Quick-lookup table updated:
- "Can't walk past the loaded 3×3 window" → A.1 FIXED ✓
- "Frame hitch crossing landblock boundary" → Phase A.3
(synchronous loader for now; async returns when DatCollection is
thread-safe)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>