Most scenery objects (trees, rocks) use CylSphere collision from
their Setup, not PhysicsBSP. Register these in ShadowObjectRegistry
with a Cylinder collision type. FindObjCollisions now handles both:
- BSP: full polygon collision via BSPQuery (buildings, stabs)
- Cylinder: radial + vertical cylinder-sphere test (trees, NPCs)
Diagnostics showed 170 CylSphere entities vs 278 BSP entities in
the Holtburg landblock alone — this roughly doubles collision coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace simplified push-out with retail-faithful SlideSphere and
AdjustOffset from transition_pseudocode.md. Crease-projection between
collision normal and contact plane produces smooth wall-sliding.
Object collision uses proper rotation transform to object-local space.
SlideSphere (section 6): computes crease direction via cross product
of collision normal and contact plane normal, projects displacement
onto the crease, then applies the correction offset. Handles three
cases: crease exists, parallel same-direction, parallel opposing.
AdjustOffset (section 6): adds safety check to keep sphere above
contact plane by computing signed distance and pushing up along Z
when the sphere dips below.
FindObjCollisions: removes ad-hoc penetration push-out, now calls
SlideSphere after BSP hit detection for proper wall-slide behavior.
Also fixes: ShadowEntry gains Rotation field, tests updated to match
Register signature, unused variables removed from GameWindow.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Register static entities into terrain cells during streaming.
Transition system queries nearby objects and runs BSP collision.
Player can no longer walk through trees and buildings.
- ShadowObjectRegistry: 24m×24m cell index, Register/Deregister/
RemoveLandblock/GetNearbyObjects matching retail AC's approach
- PhysicsEngine: ShadowObjects property + DataCache wiring point;
RemoveLandblock now also clears shadow objects; TryGetLandblockContext
helper lets Transition resolve landblock id+offset for a world pos
- Transition.FindObjCollisions: queries registry, broad-phase sphere test,
narrow-phase BSPQuery.SphereIntersectsPoly in object-local space,
returns Slid on hit to redirect movement along the surface
- GameWindow.ApplyLoadedTerrainLocked: registers each static entity after
physics BSP data is cached; selects radius from BSP bounding sphere or
Setup.Radius; wires PhysicsDataCache into engine on OnLoad
- 16 new ShadowObjectRegistry unit tests, all 361 tests green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
AC heading convention: 0=West, 90=North, 180=East, 270=South.
Our internal yaw: 0=+X (East), PI/2=+Y (North).
Conversion: heading_deg = 180 - yaw_degrees, then holtburger's
from_heading formula: theta=(450-heading).toRad, w=cos(θ/2), z=sin(θ/2).
Previously sent raw Yaw as axis-angle rotation which was ~90° off.
Other clients now see correct facing direction.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Build and send GameAction(Jump) with extent + world-space launch
velocity + sequence counters. Wire format from holtburger
JumpActionData::pack. Server can now validate and replicate jumps
to nearby clients.
Also compute RunRate locally via PlayerWeenie.InqRunRate when
running (server doesn't echo UpdateMotion ForwardSpeed to sender).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fixes for jump physics:
- Skip ground-snap when velocity Z > 0 (prevents immediate re-landing
at high framerates where per-frame Z delta < 0.05 snap threshold)
- Guard apply_current_movement velocity write behind OnWalkable check
(prevents MotionInterpreter.DoMotion from zeroing jump velocity on
every frame while airborne)
- Guard PlayerMovementController velocity replacement behind OnWalkable
(preserves momentum during airborne flight)
Also fix run speed: compute RunRate locally via PlayerWeenie.InqRunRate
instead of waiting for server UpdateMotion echo (server doesn't echo
to sender). With Run skill 200, run speed is now 9.5 m/s instead of
4.0 m/s.
Strip all diagnostic logging from previous debug sessions.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
SmallVelocity threshold (0.25 m/s) in UpdatePhysicsInternal was zeroing
velocity every frame while airborne at the jump apex. With vel~0.01 m/s
and gravity adding only 0.012/frame, the zeroing won every frame and
the character got stuck at peak height forever.
Fix: only apply small-velocity zeroing when OnWalkable (grounded).
While airborne, gravity must accumulate freely through the zero-crossing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Load PhysicsBSP and PhysicsPolygons from GfxObj dats during streaming.
BSPQuery.SphereIntersectsPoly traverses the tree for collision detection.
Ported from decompiled FUN_00539270, cross-ref ACE BSPNode.sphere_intersects_poly.
- PhysicsDataCache: thread-safe ConcurrentDictionary-backed cache of GfxObjPhysics
(BSP tree + polygon dict + vertex array) and SetupPhysics (capsule dimensions).
CacheGfxObj/CacheSetup are idempotent — safe to call at every dat load site.
- BSPQuery.SphereIntersectsPoly: recursive BSP descent with bounding-sphere broad
phase, leaf polygon test via existing CollisionPrimitives.SphereIntersectsPoly
(FUN_00539500), and splitting-plane classification for internal nodes.
- GameWindow: _physicsDataCache populated at all GfxObj/Setup dat load sites
(streaming worker path, live-spawn path, ApplyLoadedTerrain render-thread path).
- 6 new unit tests covering null node, bounding-sphere miss, leaf hit, no-contact,
internal node recursion, and empty cache behaviour. All 447 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold spacebar to charge (0→1 over 1s), release to jump. Height from
GetJumpHeight formula using Jump skill via PlayerWeenie. Jump physics
use MotionInterpreter.jump() → LeaveGround() → get_leave_ground_velocity().
JumpExtent is returned in MovementResult (non-null when jump fires this
frame) so GameWindow can log and eventually send the server jump packet.
Double-jump is prevented by jump_is_allowed() checking Contact+OnWalkable
flags before allowing another jump. Tests updated to use charge-then-release
pattern matching the new input model.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Parse ForwardSpeed from UpdateMotion (0xF74C) InterpretedMotionState.
Feed server-echoed RunRate into the player's MotionInterpreter so
get_state_velocity produces the correct speed. Previously hardcoded
at 1.0 (4.0 m/s), now matches character's Run skill.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Port ACME's EnvCellManager portal visibility system:
- New CellVisibility class: BFS portal traversal from camera cell,
portal-side clip-plane test, FindCameraCell with grace period
- LoadedCell data populated during streaming (portals, clip planes,
world/inverse transforms, local AABB from CellStruct vertices)
- WorldEntity.ParentCellId tags interior entities for filtering
- InstancedMeshRenderer.Draw accepts optional visibleCellIds set —
interior entities whose parent cell isn't visible are skipped
- Conditional depth clear between terrain and static mesh when
camera is inside a cell (ACME GameScene.cs pattern)
When camera is outdoors, all interiors render (visibleCellIds=null).
When camera enters a building, only BFS-reachable cells render.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace guessed sun direction (0.5, 0.4, 0.6) with ACME's verified
value (0.5, 0.3, -0.3) from GameScene.cs:238. Replace hardcoded
ambient/diffuse (0.25/0.75) with ACME's ambient intensity 0.45 from
LandscapeEditorSettings.cs:108.
Terrain shaders now match ACME Landscape.vert/frag pattern:
- Vertex shader computes Lambert term with xLightDirection uniform
- Fragment shader applies: color * (clamp(lambert, 0, 1) + xAmbient)
Static object shader matches ACME StaticObject.vert:
- LightingFactor = max(dot(N, -L), 0) + ambient
- Removed separate uDiffuseIntensity (ACME doesn't have one)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the single-giant-buffer TerrainRenderer with TerrainChunkRenderer
that groups landblocks into 16×16 chunks, each with its own VAO/VBO/EBO.
Matches ACME's ChunkMetrics/ChunkRenderData/TerrainGPUResourceManager
pattern:
- Pre-allocated max-size VBO (~3.75MB) + EBO per chunk with DynamicDraw
- Incremental glBufferSubData uploads per landblock slot (no full rebuild)
- Fixed slot layout: slot = (localX * 16 + localY), vertBase = slot * 384
- EBO contains only indices for occupied slots → tight draw calls
- One DrawElements per chunk with chunk-level AABB frustum culling
- Empty chunks auto-dispose GPU resources
Streaming-friendly: add/remove a single landblock touches only its slot
in the chunk buffer, not the entire terrain.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace 25 per-landblock VAOs with one shared buffer set. Vertex positions
are now baked in world space during AddLandblock (worldOrigin added to each
vertex), so uModel is eliminated from terrain.vert entirely. Buffer rebuild
happens on the cold path (landblock load/unload) via RebuildGpuBuffers.
Draw loop: bind VAO once, then one glDrawElements per visible landblock
into its sub-range of the shared EBO — same frustum-cull logic, no
VAO/VBO rebind overhead per landblock.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Replaces the per-entity glUniform uModel path with a shared instance VBO and
DrawElementsInstanced. All instance model matrices are uploaded to GPU once per
frame; the VAO's per-instance attribute pointers (locations 3–6, divisor=1) are
updated with a byte-offset re-point per group so a single VBO serves all groups
without requiring DrawElementsInstancedBaseInstance (not in Silk.NET 2.23).
Changes:
- InstancedMeshRenderer: add _instanceVbo, _instanceBuffer scratch; EnsureUploaded
sets up mat4 instance attrs (locs 3–6) from the shared VBO; Draw builds the flat
float[] of all instance matrices once then calls DrawElementsInstanced per sub-mesh.
Drops the unused uint TerrainLayer attribute (loc 3 from vertex VBO) — mesh shaders
never used it. Adds InstanceGroup helper to track per-group buffer offsets.
- mesh_instanced.frag: replace sampler2DArray+uTextureLayer with sampler2D uDiffuse,
matching the existing TextureCache / individual-texture pipeline.
- mesh_instanced.vert+frag: track as committed files (were untracked).
- Shader.cs: add SetVec3 helper needed for uLightDirection uniform.
- GameWindow.cs: switch mesh shader load from mesh.vert/.frag to
mesh_instanced.vert/.frag.
Visual output is identical: same entities, same textures, same lighting constants
(SUN_DIR=(0.5,0.4,0.6), AMBIENT=0.25, DIFFUSE=0.75 — moved from frag to vert).
Build: clean. Tests: 431/431 green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Groups all (entity, meshRef) pairs by GfxObjId before drawing so each
GfxObj's sub-meshes are processed as a contiguous batch. Still uses
per-entity uniform uModel — visual output is identical to the old
StaticMeshRenderer — but the _groups dict is the structural prerequisite
for swapping to DrawElementsInstanced in the follow-up commit.
Key changes:
- New InstancedMeshRenderer.cs with CollectGroups() that fills
_groups[gfxObjId] = List<InstanceEntry> each frame, reusing the
inner List<> objects to avoid per-frame allocation.
- Same two-pass (opaque+clipmap first, translucent second) draw logic
from StaticMeshRenderer, now iterating over groups rather than raw
entity/meshRef pairs.
- GameWindow.cs: field and constructor swapped from StaticMeshRenderer
to InstancedMeshRenderer — public API is identical.
- 431 tests green, 0 warnings.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
TWO root causes for "character disappears when walking far":
1. MarkPersistent stored SERVER GUID but RemoveLandblock checked LOCAL
entity.Id — different namespaces, never matched. Fixed by adding
WorldEntity.ServerGuid field and checking it in RemoveLandblock.
2. Even with rescue working, the player entity stays in its SPAWN
landblock's entity list forever. When the player walks to a new
landblock and the spawn landblock gets frustum-culled, the entity
disappears because neverCullLandblockId is computed from the
player's current position (new landblock) but the entity is stored
in the old landblock.
Fixed by calling GpuWorldState.RelocateEntity every frame in the
player-mode update loop. This moves the entity from whatever
landblock it's currently in to the one matching its actual position.
The scan is O(entities) but only runs for one entity per frame.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE: MarkPersistent(chosen.Id) stored the SERVER GUID (e.g.
0x5000000A) but RemoveLandblock checked entity.Id which is the LOCAL
sequential counter (e.g. 42). Different number spaces → never matched
→ persistent rescue never triggered → player entity lost on unload.
Fix:
- Added WorldEntity.ServerGuid field (0 for dat-hydrated scenery)
- Live entity spawn sets ServerGuid = spawn.Guid
- RemoveLandblock checks entity.ServerGuid against _persistentGuids
- MarkPersistent still stores the server GUID (correct)
This bug has been reported across multiple sessions as "character
disappears when walking far." The neverCullLandblockId fix only
prevented frustum culling but didn't prevent the entity from being
removed from the render list entirely.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 1a of the audit remediation plan.
Extracts the 4 movement sequence counters from inbound server messages
and echoes them in outbound MoveToState + AutonomousPosition instead
of hardcoded zeros:
- instanceSequence (slot 8 in CreateObject PhysicsData timestamps)
- teleportSequence (slot 4, also from PlayerTeleport 0xF751)
- serverControlSequence (slot 5)
- forcePositionSequence (slot 6, also from UpdatePosition 0xF748)
Source: holtburger player/types.rs:237-245, mutations.rs:182-706.
The server uses these to detect stale/reordered movement packets.
Previously all zeros → server couldn't distinguish epoch boundaries.
Changes:
- CreateObject.Parsed: +4 sequence fields extracted from timestamps
- UpdatePosition.Parsed: +3 sequence fields from trailing u16s
- WorldSession: tracks 4 counters, updates from CreateObject/
UpdatePosition/PlayerTeleport for the player's own GUID
- GameWindow: passes tracked values to MoveToState.Build and
AutonomousPosition.Build
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rewritten AnimationSequencer from the decompiled pseudocode, then
carefully integrated into GameWindow using the same surgical approach:
only replace the frame interpolation source, keep the transform
composition pipeline (scale → rotate → translate → entity scale →
MeshRef) UNTOUCHED.
Key differences from the reverted attempt (e4dae3d):
- Sequencer returns raw PartTransform (Origin, Orientation) — NOT
pre-composed matrices. The existing transform pipeline consumes
these identically to the legacy slerp output.
- Legacy slerp path is kept as explicit fallback for entities
without a MotionTable.
- SetCycle called from both UpdatePlayerAnimation and
OnLiveMotionUpdated — adjust_motion handles left→right remapping
internally with negative framerate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>