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>
The single most important document in the project. Defines:
Architecture: 6-layer stack (Platform → Renderer → Network → World →
Game Objects → Plugin API). The code is modern C#; the behavior
matches the retail client exactly.
GameEntity: the unified entity class that replaces the current
scattered state (WorldEntity + AnimatedEntity + guid dicts + player
controller). Every world object is a GameEntity with PhysicsBody +
AnimationSequencer + CellTracker + MotionInterpreter + AppearanceState.
Per-frame update order: Network → Streaming → Input → Entity tick
(motion → physics → collision → cell → animation) → Render → Plugin.
Execution plan (R1-R8):
R1: GameEntity refactor (unify scattered state)
R2: Thin GameWindow (extract to proper systems)
R3: CellBSP + wall collision (indoor transitions)
R4: Complete animation state machine
R5: Lighting from decompiled AdjustPlanes
R6: Server compliance (authoritative Z, keepalive)
R7: Interaction (doors, NPCs, chat, inventory)
R8: Plugin API completion (Lua macros)
Also updates CLAUDE.md to establish the architect role and reference
the architecture doc as the single source of truth.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 2 of the audit remediation plan.
Re-enables the outdoor→indoor portal transition in PhysicsEngine with
an added containment check: after detecting a portal plane crossing,
verify the target cell's floor polygon actually covers the candidate
position AND the floor Z is within step height of the player's Z.
This prevents the wall-bounce bug (where portal planes on upper
floors captured outdoor positions) while allowing genuine doorway
transitions. Without full CellBSP, the SampleFloorZ + Z-proximity
check is the best available approximation per the indoor transition
research (docs/research/acclient_indoor_transitions_pseudocode.md).
Source: ACE EnvCell.find_transit_cells validates via
sphere_intersects_cell in the target cell's local space. Our
SampleFloorZ + Z check is the equivalent without BSP.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sprint 2 research for indoor transitions. Documents:
- ACE EnvCell.find_transit_cells: sphere-plane + BSP containment
- ACE SortCell/BuildingObj.check_building_transit: outdoor→indoor
- PortalSide semantics and portal polygon plane testing
- Gap analysis: acdream needs CellBSP, BldPortal list, VisibleCells
Key finding: full accuracy requires CellBSP (physics BSP from dat)
for sphere_intersects_cell. Current PortalPlane.IsCrossing is a valid
approximation. ACME's AABB PointInCell is an intermediate option.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Performed a side-by-side comparison of every LCG formula in SceneryGenerator.cs
against the decompiled retail acclient.exe (Ghidra output):
Scene-selection hash chunk_00530000.c:1144 — MATCH (0x2a7f2b89·x+0x6c1ac587)·y - 0x421be3bd·x + 0x7f8cda01
Per-object frequency chunk_00530000.c:1168-74 — MATCH accumulator pattern cellMat2*(0x5b67+j)
X displacement chunk_005A0000.c:4858-66 — MATCH offset 0xb2cd=45773
Y displacement chunk_005A0000.c:4871-78 — MATCH offset 0x11c0f=72719
Quadrant rotation chunk_005A0000.c:4880-4902 — MATCH constants 0x6f7bd965/0x421be3bd/-0x17fcedfd
Object rotation hash chunk_005A0000.c:4924-26 — MATCH offset 0xf697=63127
Scale hash ACViewer ObjectDesc.cs — MATCH offset 0x7f51=32593 (chunk not dumped)
Key finding: the decompiled client normalises signed-int LCG values with
"if (val < 0) val += 2^32" before dividing by 2^32. Our unchecked((uint)(...))
is exactly equivalent. ACViewer's reference omits this cast for some formulas
(displacement, rotation) and is subtly wrong for those; our implementation
already had the correct uint cast throughout.
Added inline decompiled-source citations to all five algorithm sites plus
an updated class-level doc comment noting the audit status and implementation note.
No behaviour change — comments only.
Co-Authored-By: Claude Sonnet 4.6 <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>
ROOT CAUSE of "twitching" / stuck-on-frame-0 for reverse animations
(TurnRight with negative dat framerate, StrafeRight, etc.):
The frame swap (StartFrame↔EndFrame for negative speed) made EndFrame=0,
and GetStartFramePosition returned (0+1)-eps = 0.999. The cursor
oscillated between 0.0 and 0.999 — floor() of anything in [0,1) is
always 0, so only frame 0 ever rendered.
Fix: DON'T swap. Keep StartFrame=0, EndFrame=N-1 regardless of speed
sign. GetStartFramePosition for negative speed returns (N-1+1)-eps ≈ N,
so the cursor starts near the high end and counts down through ALL
frames. The Advance loop's reverse boundary check uses StartFrame (the
low value) correctly without the swap.
Also strips diagnostic logging from AnimationSequencer and GameWindow.
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 ground-up rewrite of AnimationSequencer.cs using the retail AC client
pseudocode (docs/research/acclient_animation_pseudocode.md) as the direct
translation guide. Every key algorithmic difference from the previous patched
implementation is addressed:
1. _framePosition is now double (64-bit), matching Sequence+0x30 in the retail
client binary. Previously float, which accumulated rounding error over long
sessions.
2. FUN_005267E0 (multiply_framerate) is now correctly applied at node load time:
negative speedScale swaps startFrame↔endFrame so the advance loop counts DOWN
from (EndFrame+1)-epsilon toward EndFrame, exactly matching the retail layout.
3. update_internal (FUN_005261D0) is faithfully ported: one loop handles both
forward and reverse; boundary detection uses EndFrame as the lower bound for
reverse playback (matching the post-swap field semantics); remainder time
propagates correctly across node boundaries for large dt values.
4. GetStartFramePosition (FUN_00526880) and GetEndFramePosition (FUN_005268B0)
formulas are now correct: negative speed starts at (EndFrame+1)-epsilon,
ends at StartFrame; positive speed starts at StartFrame, ends at (EndFrame+1)-epsilon.
5. advance_to_next_animation (FUN_00525EB0) wraps to _firstCyclic when the
linked list is exhausted, matching the retail loop-forever semantics.
6. adjust_motion (ACE MotionInterp.cs:394-428) remapping is unchanged and
correct: TurnLeft→TurnRight, SideStepLeft→SideStepRight (negate speed),
WalkBackward→WalkForward (negate×0.65 BackwardsFactor).
7. SlerpRetailClient (FUN_005360d0) is unchanged — the pseudocode confirms the
existing implementation is correct.
AnimationSequencerTests grows from 9 to 17 tests:
- Negative-speed playback: TurnLeft remaps and cursor initializes near EndFrame+1
- Reverse frame position decreases (not increases) over time
- Reverse wrap at start boundary recovers and loops
- advance_to_next_animation: link node drains then enters cycle
- Cycle loops repeatedly without crash or position drift
All 431 tests green (109 net + 322 core).
Co-Authored-By: Claude Sonnet 4.6 <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>
Backward-playback nodes (TurnLeft, SideStepLeft) have LowFrame >
HighFrame after multiply_framerate swaps them. Math.Clamp(x, 19, 0)
throws because min > max.
Fix: compute rangeLo/rangeHi from Min/Max of Low/High, use those
for clamping. Also step nextIdx in the playback direction (forward
+1, backward -1) instead of always +1.
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>
Port the animation playback engine from the decompiled retail client
into AcDream.Core.Physics.AnimationSequencer.
## What this adds
**AnimationSequencer** (src/AcDream.Core/Physics/AnimationSequencer.cs):
- Frame advancer: `frameNum += framerate * dt`, bounds-checks against
AnimData.HighFrame/LowFrame (with sentinel resolution for HighFrame=-1),
wraps at cycle boundaries. Matches ACE's `Sequence.update_internal`.
- Quaternion slerp (`SlerpRetailClient`): ported from decompiled
`FUN_005360d0` (chunk_00530000.c:4799-4846):
1. dot-product sign-flip to take the shorter arc
2. fallback to linear blend when 1-dot <= 1e-4 (near-parallel)
3. sin-based slerp for all other cases
4. validate weights lie in [0,1] before using sin result (retail
client validation step that guards degenerate inputs)
- Transition link resolution: `GetLink(style, fromMotion, toMotion)`
mirrors ACE's `MotionTable.get_link` positive-speed path.
DatReaderWriter layout: `Links[style<<16|(from&0xFFFFFF)]` is a
`MotionCommandData` whose `.MotionData[toMotion]` is the transition
`MotionData`. Link frames are prepended before the cyclic tail, so
idle->walk plays the short transition clip then loops the walk cycle.
- `IAnimationLoader` / `DatCollectionLoader`: thin abstraction so the
sequencer is testable offline without opening dat files.
- Public API: `SetCycle(style, motion, speedMod)` + `Advance(dt)`
returning `IReadOnlyList<PartTransform>` (Origin+Orientation per part).
**AnimationSequencerTests** (tests/...Physics/AnimationSequencerTests.cs):
14 tests, all offline, covering slerp math, frame wrap, transition link
prepend, no-link direct switch, same-motion fast path, reset.
317 tests green, 0 warnings.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the ad-hoc movement simulation with the ported retail physics:
- PlayerMovementController now owns a PhysicsBody (gravity, friction, Euler
integration with sub-stepping) and a MotionInterpreter (motion state machine,
speed constants from retail dat).
- Orientation quaternion is synced from Yaw each frame (Yaw=0 → +X, matching
the cos/sin convention the camera and outbound messages expect).
- Horizontal velocity is composed from MotionInterpreter.get_state_velocity()
speeds (WalkAnimSpeed=3.12, RunAnimSpeed=4.0, SidestepAnimSpeed=1.25 from
decompiled globals) then pushed via PhysicsBody.set_local_velocity so the
orientation quaternion rotates them into world space correctly.
- Vertical velocity (gravity / jump / fall) is snapshot before DoMotion calls
so apply_current_movement's set_local_velocity(0,0,0) can't clobber it.
- Jump delegates to MotionInterpreter.jump() + LeaveGround() which calls
get_leave_ground_velocity() → DefaultJumpVz=10.0 (retail value).
- PhysicsEngine.Resolve is still called each frame with zero delta to sample
terrain/cell Z under the body and set Contact+OnWalkable accordingly.
- Drive UpdatePhysicsInternal(dt) directly instead of update_object(wallClock)
to avoid the MinQuantum (~33ms) guard that would silently drop 60fps frames.
Test update: jump loop extended from 30→50 frames to cover the longer flight
time from retail DefaultJumpVz=10 (≈2.04s) vs old JumpImpulse=5 (≈1.02s).
303 tests green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds CollisionPrimitives.cs with C# ports of FUN_005384e0 / FUN_00539500 /
FUN_00539ba0 / FUN_00539110 / FUN_00539060 / FUN_0053a230 / FUN_0053a040 /
FUN_00538eb0 / FUN_00538f50 — covering ray-sphere, sphere-poly contact,
find-time-of-collision, face-normal computation, ray-plane intersection,
walkable checks, edge-normal slide, and sphere landing.
Key findings from cross-referencing with ACE's Polygon.cs:
- The edge-perpendicular formula is cross(N, edge) (normal × edge), matching
the retail param_1[9/10/8] order in the decompiled loops.
- find_time_of_collision uses t = (dot(origin,N)+D) / dot(dir,N); the sign
is negative when approaching from above — contact = origin − dir*t.
- land_on_sphere only succeeds when the sphere centre is within one radius of
the plane (dist < r), which is the "settling onto ground" scenario.
26 new tests green; full suite 367/367 green.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Complete decompilation of the retail Asheron's Call client using
Ghidra 12.0.4 + pyghidra headless. 22,225 of 22,226 functions
successfully decompiled in 75 seconds.
Output: docs/research/decompiled/ (54 files, 688,567 lines of C)
Key findings already identified:
- CLandBlockStruct::ConstructPolygons at chunk_00530000.c:2270
(split direction formula with 0x0CCAC033 constants)
- Motion command handlers at chunk_00510000.c (0x45000005 etc)
- Motion interpreter at chunk_00520000.c
- Portal space UI at chunk_004D0000.c and chunk_00560000.c
Next: identify CPhysicsObj, CMotionInterp, collision, and movement
functions by cross-referencing against ACE's C# port.
Co-Authored-By: Claude Opus 4.6 (1M context) <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>
Rewrote the reference repos section with:
1. Clear domain-to-oracle mapping table — every domain (terrain, rendering,
networking, movement, etc.) has a named primary oracle and secondary
reference. No ambiguity about which repo to check first.
2. NEVER GUESS policy: "read the reference FIRST, write code SECOND.
Always." Explicitly calls out the triangle-boundary Z bug as the
cautionary tale (5 failed fix attempts from guessing vs 1 fix from
checking ACME's ClientReference.cs).
3. Quick-reference file lists for ACME (6 key files) and holtburger
(6 key files) so future sessions can jump straight to the right code.
4. WorldBuilder original explicitly marked as SUPERSEDED for terrain
algorithms (ACME has conformance tests, original doesn't).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ACME Edition contains ClientReference.cs — a faithful C# port of the
decompiled retail AC client (CLandBlockStruct.cpp) with exact offsets.
This is ground truth for terrain algorithms and already solved the
triangle-boundary Z bug that resisted 5 other fix attempts.
Key resources: ClientReference.cs (oracle), TerrainConformanceTests.cs
(4M+ cell sweep), StaticObjectManager.cs (GfxObj+Setup+CreaturePalette),
EnvCellManager.cs (dungeon portal visibility).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for persistent slope Z clipping.
The SWtoNE/SEtoNW triangle boundary tests were swapped. AC's naming is
counter-intuitive: "SWtoNE cut" means BL and TR are the ISOLATED vertices
— the shared hypotenuse runs TL(0,1)→BR(1,0), so the dividing test is
tx+ty=1, NOT ty=tx. We had them backwards, causing every cell to sample
from the wrong triangle — up to 7.5 unit Z errors on steep terrain.
Fixed by cross-referencing WorldBuilder-ACME-Edition which has:
- ClientReference.cs: faithful C# port of decompiled AC client code
- TerrainConformanceTests.cs: verified against 25,600 cells
- TerrainGeometryGenerator.GetHeight(): matches the mesh index buffer
Also removes the slope gradient hack from PlayerMovementController —
no longer needed since SampleZ now returns exact triangle-surface Z.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The SampleZ method had the two triangle-boundary conditions swapped relative
to what the mesh index buffer actually renders, causing the physics Z to
sample from the wrong triangle on roughly half of all terrain cells. The
error could be up to 7.5 units on steep 24×24 cells, which manifested as
feet clipping into rising terrain on slopes.
Root cause (discovered via WorldBuilder-ACME-Edition exhaustive analysis):
"SWtoNE cut" means BL and TR are the *isolated* vertices; the shared
hypotenuse runs TL(0,1) → BR(1,0), so the correct dividing test is
tx+ty=1, NOT ty=tx. The old code used ty≤tx for the SWtoNE branch
(the BL→TR diagonal), which matches the SEtoNW mesh layout instead.
Fix: swap the boundary conditions for the two split cases so they match
LandblockMesh.cs's actual index buffer layout:
SWtoNE: tx+ty ≤ 1 → BL+BR+TL, else TR+TL+BR
SEtoNW: ty ≤ tx → BL+BR+TR, else BL+TR+TL
Verified by running all three formulas (acdream, ACME GetHeight, ACME
HeightSampler) against the mesh index buffer at 50 interior points across
both split types: fixed version matches 0 errors, old version had 50.
All 283 tests still pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The triangle-aware Z sampling from the previous commit produces exact
terrain-surface Z values, but a point-sampled Z on a tilted surface
places the character's center on the surface while their feet (which
extend horizontally) clip into the rising terrain ahead/behind.
Fix: in PlayerMovementController, sample Z 1 unit ahead in the walk
direction and add 40% of the gradient as an upward bias. This
compensates for the character's collision cylinder radius on slopes
while producing zero bias on flat ground. The bias is applied in the
movement controller (gameplay concern) not in TerrainSurface.SampleZ
(which stays exact for physics/tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Exhaustive analysis of two working AC clients revealing three critical
findings that reshape acdream's movement system:
1. Server-authoritative Z: neither AC2D nor holtburger computes local
terrain Z for the player. AC2D sends keys, receives position. Holtburger
dead-reckons for smoothing but the server overrides.
2. Terrain split formula mismatch: AC2D and ACViewer's render path use
0x0CCAC033-based FSplitNESW; WorldBuilder (our source) uses a different
214614067-based physics formula. Our terrain mesh triangulation doesn't
match the real AC client's, causing Z mismatches on slopes.
3. Movement deduplication: MoveToState sent once per state change, not per
frame. AutonomousPosition heartbeat every 1 second.
Also adds AC2D to CLAUDE.md reference repos section.
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>