Commit graph

240 commits

Author SHA1 Message Date
Erik
4d335c74da fix(physics): use horizontal distance for cylinder broad-phase
The broad-phase rejection was using 3D distance for cylinder objects,
which includes the Z offset between player feet and cylinder base.
Trees have their origin at the base (Z=ground) while the player
sphere is at chest height (Z=ground+~2.5m). The 3D distance exceeded
the combined radius, causing the collision test to be skipped entirely.

Fix: use horizontal (XY) distance for cylinder broad-phase since
the vertical extent is checked separately in the cylinder test.
Also increase broad-phase margin from 1m to 2m.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:49:04 +02:00
Erik
efd6a06c7d fix(physics): search adjacent landblocks for collision objects
GetNearbyObjects now searches the player's landblock plus all 8
neighbors. Previously only searched one landblock, missing objects
near landblock boundaries — which includes most trees/rocks since
scenery is placed across the full streaming window.

Also added diagnostic logging (will strip after verification).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:44:50 +02:00
Erik
f2548146b5 fix(physics): push-out when spawned inside objects (stuck prevention)
When collision is detected at t=0 (already overlapping at step start),
push the sphere out along the collision normal by half-radius instead
of trying to slide with zero displacement (which gets stuck).

Returns Adjusted instead of Slid so the transition loop retries
from the pushed-out position.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:34:29 +02:00
Erik
8e1230c53b fix(physics): swept-sphere collision prevents penetration
Restructure FindObjCollisions to compute collision ALONG the movement
path instead of at the final position:

BSP: movement-aware SphereIntersectsPoly with front-face culling
  (dot(movement, normal) < 0). Only detects faces the sphere is
  approaching, matching retail Polygon.pos_hits_sphere.

Cylinder: quadratic ray-cylinder intersection computes parametric
  contact time t. If t < 1.0, sphere is rewound to the contact point.

Both: find the EARLIEST collision (minimum t), rewind sphere to
  contact point + small epsilon along normal, then SlideSphere.

This prevents the "walking into walls" penetration (BUG-005).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:23:10 +02:00
Erik
3997839d1a docs: update bugs.md — close BUG-002/003/004, add BUG-005/006/007
Closed: jump server packet (002), facing direction (003), run speed (004).
New: collision penetration (005), corner stuck (006), missing trees (007).
All collision bugs stem from static-overlap detection instead of
swept-sphere — needs Transition restructure to use FindTimeOfCollision.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:17:31 +02:00
Erik
fc96f1b7e3 chore: strip debug logging from collision diagnostics
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:16:57 +02:00
Erik
14b0a6e2b8 feat(physics): CylSphere collision for trees, rocks, NPCs
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>
2026-04-14 12:14:28 +02:00
Erik
2a4aaf4db7 feat(physics): port full BSPTree.find_collisions from retail
Replace simplified BSP overlap test with retail-faithful 6-path
collision dispatcher. Sphere-intersects-poly now uses movement
vector for front-face culling (prevents wall penetration).
All paths: placement/ethereal, checkWalkable, stepDown, collide,
contact+onWalkable, and default (not in contact).

Ported from ACE BSPTree.cs/BSPNode.cs/BSPLeaf.cs/Polygon.cs,
cross-referenced against decompiled chunk_00530000.c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:07:09 +02:00
Erik
e12d255d2e feat(physics): port full CTransition collision response from pseudocode
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>
2026-04-14 11:17:45 +02:00
Erik
e2f0c8580e feat(physics): cell-based ShadowObject collision
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>
2026-04-14 11:05:09 +02:00
Erik
246713e2cc feat(physics): wire CTransition sphere-sweep into player movement
Replace simple Z-snap PhysicsEngine.Resolve with ResolveWithTransition
that uses the ported CTransition sphere-sweep pipeline. Movement is
subdivided into sphere-radius steps, terrain collision tested at each
step with step-down for ground contact maintenance.

Falls back to simple Resolve if transition fails. Player controller
now passes pre/post integration positions to the transition system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 10:58:55 +02:00
Erik
6523c7199b fix(movement): correct facing direction quaternion convention
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>
2026-04-14 10:42:19 +02:00
Erik
5634e7114b feat(movement): send jump packet to server (opcode 0xF61B)
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>
2026-04-14 10:23:52 +02:00
Erik
bb7eced168 fix(movement): jump works locally (airborne velocity preserved)
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>
2026-04-14 10:17:59 +02:00
Erik
31cd5480dc fix(physics): jump apex velocity zeroing bug
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>
2026-04-14 10:13:27 +02:00
Erik
157ed9d974 fix(movement): jump works locally (airborne velocity preserved)
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)

Jump works locally but server packet not yet sent (BUG-002).
Facing direction mismatch logged as BUG-003.
RunRate not verified as BUG-004.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:12:11 +02:00
Erik
e08a06ac5b feat(physics): Transition.FindTransitionalPosition core algorithm
Port FindTransitionalPosition, TransitionalInsert, FindEnvCollisions,
AdjustOffset, DoStepDown, ValidateTransition from transition_pseudocode.md.
Outdoor terrain collision with step-down ground contact. Indoor BSP and
object collision deferred to subsequent tasks.

Also adds PhysicsEngine.SampleTerrainZ() which dispatches the terrain Z
query to the right registered landblock by world-space XY position.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:52:45 +02:00
Erik
9ea8ae5191 feat(physics): Transition system data structures
SpherePath, CollisionInfo, ObjectInfo, TransitionState, PhysicsGlobals.
Types match the pseudocode from transition_pseudocode.md, faithful to
decompiled CTransition + ACE Transition.cs naming.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:44:55 +02:00
Erik
13f56b62a0 docs(research): collision transition system pseudocode from decompiled + ACE
Cross-referenced ACE's Transition.cs, SpherePath.cs, CollisionInfo.cs,
BSPTree.cs, BSPNode.cs, LandCell.cs, EnvCell.cs, Sphere.cs against the
decompiled retail client (chunk_00530000.c FUN_005387c0, FUN_00538180).

Covers the full collision pipeline:
- FindTransitionalPosition (step subdivision, main loop)
- TransitionalInsert (per-step cell collision + response)
- FindEnvCollisions (terrain + indoor BSP paths)
- StepUp/StepDown (step height handling)
- AdjustOffset/SlideSphere (wall slide projection)
- ValidateTransition (post-step validation, FramesStationaryFall safety)

Documents which primitives are already ported in CollisionPrimitives.cs
and BSPQuery.cs, and catalogs what remains to be implemented.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:41:13 +02:00
Erik
874d267117 feat(physics): PhysicsDataCache + BSP sphere query
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>
2026-04-13 23:28:39 +02:00
Erik
0bec5d5296 feat(movement): spacebar charged jump with skill-based height
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>
2026-04-13 23:20:52 +02:00
Erik
5cb14da714 feat(physics): PlayerWeenie with retail Run/Jump formulas
Implements IWeenieObject with GetRunRate and GetJumpHeight from
decompiled client, cross-referenced against ACE MovementSystem.
Default skills (Run=200, Jump=100) used until skill parsing ships.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:15:25 +02:00
Erik
c7fa1d36fb feat(movement): wire server RunRate into player MotionInterpreter
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>
2026-04-13 23:11:49 +02:00
Erik
4988ea02c0 docs: movement completion implementation plan (7 tasks)
Layer 1: wire server RunRate + PlayerWeenie + charged jump
Layer 2: PhysicsDataCache + BSP sphere query from dats
Layer 3: decompile CTransition pseudocode + port transition system
Layer 4: cell-based ShadowObject registration + object collision

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:08:48 +02:00
Erik
b5e21abe1b docs: movement completion design spec (B.2/B.3)
Four-layer design for retail-faithful movement: speed from RunRate,
charged jump, BSP collision from decompiled CTransition, cell-based
object collision. Decompile-first methodology per CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 23:01:18 +02:00
Erik
cffc3ee343 feat(render): portal-based EnvCell visibility (Step 4)
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>
2026-04-13 22:20:52 +02:00
Erik
25090b6fc9 docs: add bugs.md for tracking known visual/gameplay bugs
Start with BUG-001: wrong cloth textures on characters (observed
during rendering rebuild verification).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 22:12:05 +02:00
Erik
31d3a4678f fix(lighting): port ACME lighting constants replacing guessed values
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>
2026-04-13 22:01:28 +02:00
Erik
1b3387f991 refactor(terrain): chunk-based TerrainChunkRenderer matching ACME architecture
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>
2026-04-13 21:50:40 +02:00
Erik
d35e4b6de7 perf(terrain): single shared VAO/VBO/EBO for all landblocks
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>
2026-04-13 21:30:43 +02:00
Erik
787e0f0aff fix(render): skip empty groups in instanced draw to prevent crash on Tab
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 18:55:29 +02:00
Erik
6a55838a10 perf(rendering): true DrawElementsInstanced — one draw call per (GfxObj × sub-mesh)
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>
2026-04-13 18:51:49 +02:00
Erik
b5099e2b21 refactor(rendering): introduce InstancedMeshRenderer with GfxObj grouping
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>
2026-04-13 18:46:20 +02:00
Erik
08e309c357 fix(streaming): relocate player entity to current landblock every frame
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>
2026-04-13 15:28:32 +02:00
Erik
c32cef7e87 fix(streaming): player entity no longer disappears on landblock unload
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>
2026-04-13 15:04:46 +02:00
Erik
adf626367e docs: comprehensive architecture plan for acdream
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>
2026-04-13 14:23:50 +02:00
Erik
a722c29759 feat(physics): re-enable indoor transitions with containment validation
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>
2026-04-13 14:05:03 +02:00
Erik
4782532c4b research: indoor transition pseudocode from ACE + decompiled analysis
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>
2026-04-13 13:56:16 +02:00
Erik
64b1fcb31e docs: update audit — Sprint 1 items verified (sequence counters + scenery LCG)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:51:39 +02:00
Erik
eeee4c5733 chore(scenery): audit SceneryGenerator against decompiled acclient.exe — all MATCH
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>
2026-04-13 13:50:04 +02:00
Erik
11974c2099 feat(net): track + echo movement sequence counters
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>
2026-04-13 13:45:39 +02:00
Erik
9e5258152d docs: development workflow + phase-by-phase audit
Adds mandatory decompile→verify→port workflow to CLAUDE.md:
- DECOMPILE FIRST before writing ANY AC-specific code
- Cross-reference against ACE/ACME (interpretation aids)
- Write pseudocode before porting (catches misinterpretations)
- Port faithfully — don't "improve" the retail code
- Conformance test the critical paths
- Integrate surgically — minimum changes to working code
- Phase completion checklist with decompiled-reference citations

Phase audit (docs/audit/2026-04-13-phase-audit.md) reviews all
shipped phases:
- 53% verified (decompiled/ACME conformance)
- 34% from good references (ACE/ACViewer/holtburger)
- 5% guessed (lighting, indoor transitions)
- 8% not AC-specific (streaming, culling)

Key gaps identified:
1. Lighting uses guessed sun direction — should use decompiled AdjustPlanes
2. Indoor transitions disabled — needs decompiled CEnvCell port
3. SceneryGenerator LCG not verified against decompiled code
4. CreateObject parser incomplete
5. Movement messages missing sequence counters

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 13:27:08 +02:00
Erik
0335e317d2 fix(anim): remove frame swap — cursor now traverses all frames in reverse
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>
2026-04-13 13:15:27 +02:00
Erik
a57c5ccb76 feat(anim): carefully integrate AnimationSequencer into TickAnimations
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>
2026-04-13 13:02:54 +02:00
Erik
78aef6d575 refactor(anim): rewrite AnimationSequencer as faithful decompiled-client port
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>
2026-04-13 12:59:32 +02:00
Erik
8402aee703 research: full animation pseudocode from decompiled acclient.exe
Complete pseudocode translation of the retail AC client's animation
system, extracted from chunk_00520000.c. Covers:

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 12:43:44 +02:00
Erik
ca7ae45518 fix(anim): handle reversed frame range in BuildBlendedFrame
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>
2026-04-13 12:31:32 +02:00
Erik
67b51a3e6f fix(anim): implement adjust_motion — TurnLeft/SideStepLeft play backward
ROOT CAUSE FIX for missing left-side animations.

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

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

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

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

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

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

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