Commit graph

63 commits

Author SHA1 Message Date
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
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
f48f2745c4 feat(anim): AnimationSequencer with transition links + retail slerp
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>
2026-04-13 00:22:42 +02:00
Erik
e3f8f95dfc feat(core): port decompiled AC client MotionInterpreter — 10 methods, 45 tests
C# port of CMotionInterp from chunk_00520000.c — the AC client's
motion state machine that controls walk/run/jump/turn.

Methods ported:
- PerformMovement: top-level 5-case dispatcher
- DoMotion: process raw motion command from packet
- StopCompletely: reset to Ready (0x41000003)
- get_state_velocity: compute velocity for current interpreted state
- apply_current_movement: push velocity to PhysicsBody
- jump: initiate jump (validate + set extent + leave ground)
- get_jump_v_z: vertical jump velocity (delegates to WeenieObj)
- get_leave_ground_velocity: full 3D launch vector
- jump_is_allowed: requires Gravity + Contact + OnWalkable
- contact_allows_move: slope angle + state checks

Supporting types: MotionCommand constants, MovementType enum,
WeenieError enum, RawMotionState/InterpretedMotionState structs,
IWeenieObject interface.

412 total tests (303 core + 109 net). 45 new MotionInterpreter tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 00:00:39 +02:00
Erik
6a5d8c1580 feat(core): port decompiled AC client physics — CollisionPrimitives + PhysicsBody
Two major C# ports from the decompiled retail AC client (acclient.exe):

1. CollisionPrimitives (9 functions, 26 tests):
   - SphereIntersectsRay, RayPlaneIntersect, CalcNormal
   - SphereIntersectsPoly, FindTimeOfCollision
   - HitsWalkable, FindWalkableCollision
   - SlideSphere, LandOnSphere
   Ported from chunk_00530000.c functions FUN_005384e0 through FUN_0053a230.
   Cross-referenced against ACE's Physics/ C# port for algorithm verification.

2. PhysicsBody (7 methods, 31 tests):
   - update_object (top-level per-frame, sub-stepped at MaxQuantum=0.1)
   - UpdatePhysicsInternal (Euler: pos += v*dt + 0.5*a*dt²)
   - calc_acceleration (gravity=-9.8 when HasGravity)
   - set_velocity (clamp to MaxVelocity=50)
   - set_local_velocity (body→world via quaternion)
   - set_on_walkable, calc_friction (ground normal + pow decay)
   Ported from chunk_00510000.c/chunk_00500000.c.
   Struct layout confirmed against ACE PhysicsObj field offsets.

367 total tests green (258 core + 109 net). 57 new tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:54:51 +02:00
Erik
21fd550909 feat(physics): port 9 collision primitives from acclient.exe (chunk_00530000.c)
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>
2026-04-12 23:53:47 +02:00
Erik
9d4967a461 fix(core): ACME cross-check fixes — normals, placement, scenery
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>
2026-04-12 22:52:08 +02:00
Erik
3ae7f5e7ae fix(physics): correct terrain Z sampling triangle layout in TerrainSurface
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>
2026-04-12 22:25:32 +02:00
Erik
78d43a0914 fix(core+app): slope gradient compensation for feet clipping
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>
2026-04-12 22:13:16 +02:00
Erik
c5de445e5c fix(core): AC2D render split formula + triangle-aware Z sampling
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>
2026-04-12 22:08:42 +02:00
Erik
dc0341e85a fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
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>
2026-04-12 19:08:46 +02:00
Erik
41013ce3e3 fix(core+app): Phase B.3 — Setup.StepUpHeight + scenery road exclusion
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>
2026-04-12 18:56:45 +02:00
Erik
9dbb2cbd5c fix(core): Phase B.3 — add centroid + radius bounds to PortalPlane crossing test
Portal planes are infinite — IsCrossing only checked if positions
were on opposite sides of the plane, without verifying the crossing
point was near the actual portal opening. Walking 50m from a
building but crossing the same plane extension triggered false indoor
transitions, sinking the player into underground cell floors.

Fix: FromVertices now computes centroid (average of 3 vertices) and
bounding radius (max vertex distance + 2 unit padding). IsCrossing
rejects crossings where both positions are further than 2×radius
from the centroid. Only nearby crossings (within doorway range)
trigger a transition.

Also fixes jump-not-landing: the false portal transitions were
producing wrong resolvedGroundZ values during the jump arc, making
the landing check (candidateZ <= resolvedGroundZ) never true.

283 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 18:39:50 +02:00
Erik
777893783a fix(core): Phase B.3 — restore SceneryGenerator road exclusion check
The IsRoadVertex check and helper were dropped by a linter pass after the
previous commit. Re-adding them explicitly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:29:20 +02:00
Erik
8252523b8b feat(core): Phase B.3 — CellPortal-based indoor/outdoor transitions in PhysicsEngine
Replace the disabled if(false) outdoor→indoor branch with real portal-plane
crossing logic. LandblockPhysics now carries IReadOnlyList<PortalPlane> Portals
(populated at load time; GameWindow passes Array.Empty for now until Task 3).

Resolve logic:
- Outdoor player: tests all portals where TargetCellId==0xFFFF (outside-facing);
  crossing enters the portal's OwnerCellId.
- Indoor player: tests portals where OwnerCellId==currentCell; crossing to
  TargetCellId==0xFFFF exits to terrain, otherwise transitions room-to-room.
- Landblock boundary crossing: unchanged — candidatePos landblock lookup already
  picks the adjacent block's terrain naturally.

Tests: renamed disabled test → Resolve_OutdoorThroughPortal_TransitionsToIndoor;
added Resolve_IndoorThroughExitPortal_TransitionsToOutdoor and
Resolve_LandblockBoundary_PicksAdjacentTerrain. 274 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:22:55 +02:00
Erik
cb46d892d5 feat(core): Phase B.3 — PortalPlane (plane math + crossing detection)
Adds the foundational portal-plane record for cell transition detection.
PortalPlane.FromVertices computes a normalised plane from 3 coplanar
polygon vertices via cross product + dot product; IsCrossing tests whether
a movement vector straddles the plane (strictly negative dot-product
product — exact-on-plane position returns false as specified).

4 new unit tests: normal construction, opposite-side crossing, same-side
no-crossing, start-on-plane no-crossing.  All 269 tests green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 18:17:48 +02:00
Erik
3b2f56c531 fix(core): Phase B.2 MVP — disable outdoor→indoor transition entirely
Three attempts at guarding the indoor transition (cellId mask,
Z threshold, terrainZ comparison) all failed because CellSurface
floor polygons are too aggressive — building footprints, roofs, and
upper floors all have PhysicsPolygons that cover wide XY areas at
various Z levels, and ANY outdoor position near a building matches
a cell floor. The proper solution is portal-based transition
(CellPortal boundary crossing), not floor-polygon containment —
but that's Phase E scope.

For the B.2 MVP: outdoor players NEVER transition to indoor cells.
The else-if branch is compiled out with `if (false)`. Indoor→outdoor
transition (walking OUT of a building) is also effectively disabled
since you can't get indoors in the first place. Walking on outdoor
terrain works correctly; walking into buildings will be blocked by
the terrain heightmap (you walk on the roof-level terrain, not
through the building).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:08:36 +02:00
Erik
5280950806 fix(core): Phase B.2 — require indoor floor below terrain for outdoor→indoor transition
Previous cellId-mask fix was necessary but insufficient: the engine
correctly identified the player as outdoor, but then immediately
transitioned to an indoor cell because a CellSurface floor polygon
covered the player's XY at a Z within stepUpHeight. The floor
polygon was a roof or upper floor of a nearby building that happens
to sit at terrain level — not a walkable indoor floor the player
should snap to.

Fix: outdoor→indoor transition now requires bestCellZ < terrainZ - 1.
A genuine indoor transition is into a cell whose floor is BELOW the
terrain surface (basement, ground floor of elevated building). Cells
at or above terrain Z are roofs/upper floors viewed from outside and
must not capture the player.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:05:54 +02:00
Erik
05d835ff33 fix(core): Phase B.2 — mask cellId to low 16 bits in PhysicsEngine.Resolve
ROOT CAUSE of the fall-through-ground bug. The currentlyIndoor check
compared the FULL 32-bit cell ID (0xA9B40001, includes landblock
prefix) against 0x0100. Every outdoor cell ID with a landblock
prefix is >= 0x0100, so the engine ALWAYS took the "stay indoors"
path, snapping the player to the nearest EnvCell floor at Z=66
instead of the outdoor terrain at Z=94.

ACE confirmed the bug: "AddWorldObjectInternal: couldn't spawn
+Acdream at 0xA9B40121 [84.2 37.7 66.0]" — we were sending the
server an indoor cell ID with a below-terrain Z position.

Fix: mask cellId with 0xFFFF before the indoor check (outdoor
cells are 0x0001–0x0040; indoor are 0x0100+; the high 16 bits
are the landblock prefix and must be stripped). Also mask the
returned targetCellId from CellSurface (which carries the full
EnvCell dat id) to just the cell index.

265 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 15:04:24 +02:00
Erik
97c17c5bc3 fix(app): Phase B.2 — use server position directly, fix yaw wrap + turn spam
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>
2026-04-12 14:58:25 +02:00
Erik
88d446d11d feat(core): Phase B.3 — PhysicsEngine (top-level collision resolver)
Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.

Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).

One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.

6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:54:28 +02:00
Erik
19aa8ce5d0 feat(core): Phase B.3 — TerrainSurface (outdoor heightmap Z + cell ID)
Extracts the bilinear heightmap interpolation from GameWindow's
inlined SampleTerrainZ into a reusable class. Also adds outdoor
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).

First component of the physics collision engine.

6 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:51:54 +02:00
Erik
520589911b feat(core): Phase B.3 — CellSurface (indoor floor polygon projection)
Projects an XY point onto a cell's floor polygons via brute-force
triangle iteration + barycentric Z interpolation. Fan-triangulates
quads and larger polygons. Returns null when outside all floor
surfaces. Accepts pre-transformed world-space vertex positions so
the caller handles EnvCell coordinate transforms.

Second component of the physics collision engine.

4 new tests, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 09:51:22 +02:00
Erik
5666e05a85 fix(core+app): Phase 6.8 — keep NPCs animated when post-spawn motion update is unmappable
User reported that some NPCs (Pathwarden, Town Crier) didn't breathe.
Diagnostic logging revealed:

1. Both NPCs are correctly registered as animated at CreateObject
   time with the standard 30fps human breath cycle (anim=0x03000001).
2. Immediately after spawn the server sends an UpdateMotion with
   stance=0x0003 cmd=0x0000 for these NPCs.
3. MotionResolver.GetIdleCycle returned NULL for that combination,
   because StyleDefaults didn't have an entry for stance=3.
4. OnLiveMotionUpdated treated the NULL as "switch to a static pose"
   and removed the entity from _animatedEntities.

Two fixes:

A. MotionResolver.ResolveIdleCycleInternal — when stance is set but
   StyleDefaults has no entry for it, fall back to the table's
   DefaultStyle/DefaultSubstate instead of returning null. The
   server-supplied stance was just an unmappable override; the table
   default is the correct "I have no better information" answer.
   Pulled the table-default lookup into a small TryGetTableDefault
   helper so both fallback paths use the same code.

B. OnLiveMotionUpdated — never REMOVE an animated entity. If the
   re-resolved cycle is bad (null, framerate=0, or single-frame),
   leave the existing cycle running so the entity continues to
   breathe with whatever it already had. The defensive "remove on
   re-resolve failure" was the bug — it silently un-registered NPCs
   the moment the server sent any partial motion update.

Together these mean: any NPC that successfully registers as animated
at spawn stays animated, even if the server's subsequent motion
updates are incomplete or use stance values our resolver doesn't
have a mapping for.

Strips the [BREATHE] and [MOTION] diagnostic spew added during the
investigation now that the cause is identified.

220 tests still green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 00:05:23 +02:00
Erik
3fd774515a fix(core): emit both sides of double-sided polygons in GfxObjMesh
The lifestone (and likely any weenie with closed shells using NoPos /
Negative / Both stippling) rendered with visible holes where you could
see inside it — confirmed via the user's "see into it" description.
Root cause: GfxObjMesh.Build skipped any polygon whose PosSurface was
out of range, which is exactly what a NoPos-stippled or
negative-only polygon looks like. Backface culling isn't involved
(acdream has it disabled); we were simply dropping triangles.

Ported the pos/neg emission rule from
references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/
ObjectMeshManager.cs (lines 955-971 and 1510-1577):

  pos side: emit when !Stippling.NoPos and PosSurface is valid
  neg side: emit when Stippling.Negative, Stippling.Both, OR
            (!Stippling.NoNeg && SidesType == CullMode.Clockwise)

The "Clockwise CullMode means NegUVIndices are on the wire" rule is
non-obvious but matches how Polygon.Unpack reads NegUVIndices, so
any closed mesh relying on that convention now renders correctly.

Neg-side triangles get the reversed fan winding and a negated vertex
normal. With culling off the winding only matters for lighting
consistency, but keeping the semantics right future-proofs the
fix if we ever enable back-face culling for a perf pass. The
dedup cache is keyed by (posIdx, uvIdx, isNeg) so the same vertex
can carry different normals on the pos and neg sides.

Pos-side winding is preserved at the original (0, i, i+1) order so
the existing single-triangle and fan-triangulation tests still pass
— neg side uses (i+1, i, 0), which is the same shape mirrored.

194 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:59:10 +02:00
Erik
7375f7ad32 feat(app+core): Phase 6.6+6.7 — wire UpdateMotion/UpdatePosition into GameWindow
Makes NPCs and other server-spawned entities actually move and
transition animations based on the live server feed. Before this,
Phase 6.6/6.7 only parsed the messages and fired events that nothing
consumed, so NPCs stayed frozen at their CreateObject spawn point
playing one idle cycle forever.

Changes:
 - GameWindow now keeps a parallel _entitiesByServerGuid dictionary
   built at CreateObject hydration time so motion / position updates
   can find the target entity by its server guid.
 - WorldEntity.Position and Rotation become get/set (like MeshRefs did
   in Phase 6.4) so the position-update handler can reseat an existing
   entity in place without reallocating MeshRefs.
 - OnLiveMotionUpdated re-resolves the cycle via MotionResolver using
   the server's new (stance, forward-command) override and either
   swaps the AnimatedEntity's current cycle or removes it from the
   animated set if the new pose is static.
 - OnLivePositionUpdated translates the new landblock-local position
   into acdream world space (same math as CreateObject hydration) and
   writes it back onto the entity.

Subscriptions are added alongside the existing EntitySpawned hook so
the three handlers run synchronously on the UDP pump thread, matching
the existing pattern.

194 tests green (98 Core + 96 Core.Net).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:40:17 +02:00
Erik
a71db90310 feat(net): Phase 6.6 — parse UpdateMotion (0xF74C) into MotionUpdated event
Server sends UpdateMotion whenever an entity's motion state changes:
NPCs starting a walk cycle, creatures switching to a combat stance,
doors opening, a player waving, etc. Phase 6.1-6.4 already handles
rendering different (stance, forward-command) pairs for the INITIAL
CreateObject, but without this message NPCs freeze in whatever pose
they spawned with and never transition to walking/fighting.

Added UpdateMotion.TryParse with the same ServerMotionState the
CreateObject path uses, reached via a slightly different outer
layout (guid + instance seq + header'd MovementData; the MovementData
starts with the 8-byte sequence/autonomous header this time rather
than being preceded by a length field). Only the (stance, forward-
command) pair is extracted — same subset CreateObject grabs.

WorldSession dispatches MotionUpdated(guid, state) when a 0xF74C
body parses successfully. The App-side wiring (guid→entity lookup
and AnimatedEntity cycle swap) is intentionally deferred to a
separate commit because it touches GameWindow which is currently
being edited by the Phase 9.1 translucent-pass work.

89 Core.Net tests (was 83, +6 for UpdateMotion coverage).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:33:26 +02:00
Erik
49c1a9d29e fix(core): resolve AnimData HighFrame=-1 sentinel before filtering cycles
Diagnostic spawn dump revealed the player character arrives with
`low=0 high=-1 framerate=30.00 partFrames=33`. The -1 is ACViewer's
"play the whole animation" sentinel (see
references/ACViewer/.../Physics/Animation/AnimSequenceNode.cs:96-113
set_animation_id). My Phase 6.1 code used the raw int values, so
the downstream registration filter evaluated `HighFrame(-1) > LowFrame(0)`
as false and threw away every animated entity whose AnimData used the
sentinel — which, from the live dump, appears to be basically all of
them.

MotionResolver.GetIdleCycle now does the same four-step clamp ACViewer
does: -1 HighFrame → NumFrames-1, clamp LowFrame and HighFrame to
NumFrames-1, and collapse to LowFrame if LowFrame > HighFrame. The
IdleCycle carried up to GameWindow is always in terms of real frame
indices the playback loop can step through. Static poses (framerate==0
or single frame after resolution) still skip registration correctly.

168 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 20:25:22 +02:00
Erik
d379a75984 feat(core): SurfaceDecoder — add PFID_P8, PFID_R8G8B8, and PFID_X8R8G8B8
Entities using 8-bit palette-indexed textures (PFID_P8), uncompressed 24-bit
RGB surfaces (PFID_R8G8B8), and 32-bit packed-BGR surfaces (PFID_X8R8G8B8)
were all rendering as solid magenta. PFID_P8 uses the same palette-lookup and
isClipMap (indices 0..7 → fully transparent) convention as the existing
INDEX16 decoder, reading one byte per pixel instead of two. PFID_R8G8B8 and
PFID_X8R8G8B8 decode on-disk B,G,R[,X] byte order to R,G,B,255 RGBA8 output
for OpenGL PixelFormat.Rgba upload; the X padding byte in X8R8G8B8 is
discarded rather than forwarded as alpha.

168 tests green (85 AcDream.Core.Tests + 83 AcDream.Core.Net.Tests), including
9 new SurfaceDecoder tests covering correct channel mapping, clipmap
transparency, truncated-data fallback, and palette-missing fallback for P8.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:23:57 +02:00
Erik
a538183caa feat(core+app): Phase 7.1 — render EnvCell room geometry (walls/floors/ceilings)
Interior walls, floors, and ceilings were invisible because the Phase 2d
walker only consumed StaticObjects and skipped each cell's CellStruct
(VertexArray + Polygons + EnvCell.Surfaces). This commit ports the same
fan-triangulated per-surface bucket pattern from GfxObjMesh into a new
CellMesh module, then wires it into the interior walker so each EnvCell
now contributes both its static props and its room mesh. The cell's world
transform (rotation * translation(cellOrigin + lbOffset)) is baked into
MeshRef.PartTransform with WorldEntity at identity, matching how
StaticMeshRenderer composes model = PartTransform * entityRoot.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:16:45 +02:00
Erik
f0fa067566 feat(core+app): Phase 6.4 — per-frame animation playback (breathing/idle cycles)
Phase 6.1-6.3 resolved the right cycle and rendered its first frame
as a static pose. Phase 6.4 actually walks the cycle over time so
creatures, characters, and props animate their idle motion — the
breathing the user noticed was missing after Phase 6.1.

MotionResolver gains GetIdleCycle() returning IdleCycle(Animation,
LowFrame, HighFrame, Framerate). The existing GetIdleFrame() now
shares a private ResolveIdleCycleInternal helper, so the resolution
algorithm (motion-table override, stance/command priority, fallback)
is identical for both entry points and stays in one place.

WorldEntity.MeshRefs becomes a get/set so the per-frame tick can
swap in fresh per-part transforms without rebuilding the entity.
Static decorations never get touched.

GameWindow keeps a Dictionary<entityId, AnimatedEntity> for entities
whose motion table resolved to a multi-frame, non-zero-framerate
cycle. AnimatedEntity caches a per-part template (gfxObjId +
surfaceOverrides + scale) snapshot taken from the hydration pass so
the tick doesn't redo AnimPartChange/TextureChange resolution every
frame — only the per-part transform matrices are recomputed.

OnRender calls TickAnimations(dt) before Draw. The tick advances each
entity's CurrFrame by dt*Framerate, wraps it inside [LowFrame, HighFrame],
samples the corresponding AnimationFrame, and rebuilds the entity's
MeshRefs by composing scale → quaternion rotate → translate per part
in the same order SetupMesh.Flatten uses, then baking the entity's
ObjScale on top in the same PartTransform * scaleMat order as the
hydration path.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 19:08:08 +02:00
Erik
120e801ecf feat(net+core): Phase 6.2 — honor server CurrentMotionState for idle pose
CreateObject's MovementData was being skipped past, so the renderer
always fell back to the MotionTable's default style/substate. That's
correct for most NPCs and characters but wrong for entities the
server explicitly puts into a non-default stance — most visibly the
Foundry's Nullified Statue of a Drudge, which the server sends with
a combat stance + Crouch ForwardCommand override and which therefore
rendered as an upright drudge instead of the aggressive crouched
statue you see on the retail client.

CreateObject.TryParse now extracts ServerMotionState (Stance +
optional ForwardCommand) from the inner MovementData. The header=false
layout was confirmed via ACE/.../WorldObject_Networking.cs:326 plus
MovementData.cs::Write and InterpretedMotionState.cs::Write. Only the
two fields the resolver needs are read; remaining InterpretedMotionState
bytes are skipped via the outer length so we don't have to handle
alignment of fields we don't care about.

MotionResolver.GetIdleFrame now takes optional stanceOverride and
commandOverride. Resolution priority is server-stance+command →
server-stance + style-default substate → MotionTable default. If the
composed cycle key doesn't resolve we fall back to the table default
rather than returning null, so a partial server override never makes
the entity worse than Phase 6.1.

160 tests green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:48:33 +02:00
Erik
090d265a4e feat(core+app): Phase 6.1 — resolve idle motion frame from MotionTable
Walks each entity's Setup → MotionTable → Animation chain to get the
per-part frame for its default idle pose, then uses that frame in
SetupMesh.Flatten instead of the static PlacementFrames lookup. For
creatures and characters this should produce the upright "Resting"
pose (e.g. for the Nullified Statue of a Drudge) instead of the
Setup-default crouch.

This is a minimal Phase 6 cut: render the FIRST frame of the IDLE
motion as a static pose. No per-frame interpolation, no walking, no
attack motions, no transitions. Those are larger pieces tracked in
docs/plans/2026-04-11-roadmap.md under Phase 6.

Algorithm ported from references/ACViewer/.../Physics/Animation/
MotionTable.SetDefaultState:

  1. Look up Setup.DefaultMotionTable (0x09XXXXXX). 0 → no motion,
     fall back to PlacementFrames.
  2. MotionTable.StyleDefaults[DefaultStyle] → default substate.
  3. cycleKey = (DefaultStyle << 16) | (substate & 0xFFFFFF)
  4. MotionTable.Cycles[cycleKey] → MotionData.
  5. MotionData.Anims[0].AnimId → Animation dat.
  6. Animation.PartFrames[animData.LowFrame] → AnimationFrame
     containing the per-part transforms for the idle pose.

Added:
  - Core/Meshing/MotionResolver.cs: pure function GetIdleFrame(setup,
    dats) that walks the chain and returns an AnimationFrame or null.
    null is the "no motion data" sentinel and means caller should fall
    back to PlacementFrames.
  - SetupMesh.Flatten now takes an optional AnimationFrame override
    parameter. Pose source priority is:
      override → PlacementFrames[Resting] → PlacementFrames[Default]
    So existing call sites that don't pass an override get the Phase 5d
    Resting-fallback behavior unchanged. Static scenery is unaffected.
  - GameWindow.OnLiveEntitySpawned (live-mode hydrator) calls
    MotionResolver.GetIdleFrame and passes the result to Flatten.

Other Flatten call sites (offline scenery, interior EnvCells, scenery
generator) NOT yet wired — those use static dat hydration where the
entities don't have meaningful motion tables. The user-visible win
from this commit is in the live spawn pipeline only.

Things I'm not certain about and will check via the live run:
  - Whether Animation.PartFrames are in entity-root-relative space
    (matching PlacementFrames) or parent-relative (would need a parent
    walk we don't do). ACViewer's UpdateParts applies frames per-part
    without walking parents, suggesting root-relative — same convention
    as PlacementFrames.
  - Whether the resolver's null fallback is hit for creatures whose
    Setup.DefaultMotionTable happens to be 0 (would silently regress
    to Default placement). Worth checking if drudge looks the same.

Tests: 77 core + 83 net = 160, all green. No new tests yet because
the change is data-driven and best validated end-to-end via the live
run rather than synthetic dat fixtures (which would require fabricating
a complete MotionTable + Animation chain just to test the lookup).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:36:59 +02:00
Erik
82aa2ba1d9 fix(core): SetupMesh prefers Placement.Resting over Default (Phase 5d)
The Nullified Statue of a Drudge renders correctly in scale + color +
texture after Phases 5a/b/c, but the user reported the figure on top
looks like the wrong drudge model — specifically the pose is wrong.
acdream shows a hunched aggressive crouch with arms forward, retail
shows an upright statue stance with arms at sides. Same drudge mesh,
different pose.

Diagnosis from a targeted statue dump:
  [STATUE] objScale=3.500
  [STATUE] base Setup 0x020007DD has 17 parts (full drudge body rig)
  [STATUE] animPart index=1 newModel=0x01001B91   ← NO-OP, same as default
  [STATUE]   placementFrames count=1

The animPart change is a no-op (replaces part 1 with the id it already
has). The Setup is the standard drudge body. So the difference HAS to
come from the per-part placement frame. With only 1 placement frame,
there's exactly one pose to use — and our SetupMesh.Flatten only checks
Placement.Default.

Fix found by reading ACViewer's Physics/PartArray.cs::CreateMesh:

  public static PartArray CreateMesh(PhysicsObj owner, uint setupDID) {
      var mesh = new PartArray();
      ...
      if (!mesh.SetMeshID(setupDID)) return null;
      mesh.SetPlacementFrame(0x65);     // ← always Resting after create
      return mesh;
  }

0x65 = 101 = Placement.Resting. ACViewer puts EVERY mesh into the
Resting pose immediately after creation, regardless of object type.
For drudges/characters/creatures:
  - Default = aggressive battle crouch (what we render)
  - Resting = upright idle pose (what retail's statue actually shows)

The statue's single placement frame is keyed by Resting, so our
"only check Default" code returned no frame and the parts ended up at
Setup-root with identity orientation — which happened to look like a
clawing-forward pose because each part's local mesh starts in roughly
that shape.

Fix: SetupMesh.Flatten now tries Resting first and falls back to
Default. Static scenery setups (which only define Default) are
unaffected; creatures and characters now render in their proper idle
pose. One-line conceptual change with effects on every multi-part
live entity in the world.

The reference-priority rule in CLAUDE.md saved me here: I'd already
chased this through ACE.Server and DatReaderWriter looking for a parent-
hierarchy walking algorithm before checking ACViewer's Physics/PartArray.
The pose fix lives in ACViewer's renderer, exactly where I'd expect
"the canonical client-side visual pipeline" per the rule's wording.

Tests: 77 core + 83 net = 160, all green. Existing scenery
SetupMesh tests still pass because their setups only define Default.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 18:21:13 +02:00
Erik
733f8ff601 feat(net+app): SubPalette overlays applied to palette-indexed textures (Phase 5b)
Implements the other half of ObjDesc: SubPalettes (palette-range
overlays) that repaint palette-indexed textures with per-entity color
schemes. Ported algorithm from ACViewer Render/TextureCache.IndexToColor
after the user pointed out I was prematurely implementing from scratch
instead of checking all the reference repos.

The Nullified Statue of a Drudge sends (setup=0x020007DD with a drudge
GfxObj animPart replacing part 1, plus 2 texChanges targeted at part 1,
plus 1 subpalette id=0x04001351 offset=0 length=0). The TextureChanges
swap fine detail surfaces; the SubPalette with length=0 ("entire palette"
per Chorizite docs) remaps the drudge's flesh-tone palette to stone.
Without this commit, the statue looked like a normal flesh drudge
because palette-indexed textures decoded with the base flesh palette.

Added:
  - Core/World/PaletteOverride.cs: per-entity record carrying
    BasePaletteId + a list of (SubPaletteId, Offset, Length) range
    overlays. Documents the "offset/length are wire-scaled by 8"
    convention and the "length=0 means whole palette" sentinel.
  - WorldEntity.PaletteOverride nullable field. Per-entity (same across
    all parts), in contrast to MeshRef.SurfaceOverrides which is per-part.
  - TextureCache.GetOrUploadWithPaletteOverride: new entry point that
    composes the effective palette at decode time. Composite cache key
    is (surfaceId, origTexOverride, paletteHash) so entities with
    equivalent palette setups share the GL texture.
  - ComposePalette: ports ACViewer's IndexToColor overlay loop:
      for each subpalette sp:
          startIdx = sp.Offset * 8             // multiply back from wire
          count = sp.Length == 0 ? 2048 : sp.Length * 8   // sentinel
          for j in [0, count):
              composed[j + startIdx] = subPal.Colors[j + startIdx]
    Critical detail: copies from the SAME offset in the sub palette, not
    from [0]. Both base and sub are treated as full palettes sharing an
    index space.
  - StaticMeshRenderer.Draw: three-way switch on (entity.PaletteOverride,
    meshRef.SurfaceOverrides) picks the right TextureCache path:
      - Both → palette override (it handles origTex override internally)
      - Only tex override → GetOrUploadWithOrigTextureOverride
      - Neither → plain GetOrUpload
  - GameWindow.OnLiveEntitySpawned: builds PaletteOverride from
    spawn.BasePaletteId + spawn.SubPalettes when the server sent any.

Reference note: the user asked "but I mean THIS MUST BE IN WORLDBUILDER"
which was the right push. WorldBuilder is actually a dat VIEWER and its
ClothingTableBrowserViewModel is a 10-line stub — it doesn't apply
palette overlays because it doesn't need to. The actual algorithm lives
in ACViewer (a MonoGame character viewer), which I should have checked
earlier. CLAUDE.md updated with a standing rule: always cross-reference
all four of references/ACE, ACViewer, WorldBuilder, Chorizite.ACProtocol,
plus holtburger. A single reference can be misleading; the intersection
is usually the truth.

Tests: 77 core + 83 net = 160, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:30:08 +02:00
Erik
b69d776179 feat(net+app): TextureChanges applied via Surface→OrigTex resolution (Phase 5a)
Finishes the TextureChange half of ObjDesc. Characters' clothing now
renders with correct per-part textures (user-verified "looks good"
after previous "partial coverage" / "wrong clothes"). The Nullified
Statue still looks like a flesh-colored drudge because the statue's
color comes from SubPalettes (palette-indexed texture recoloring),
which is the remaining major Phase 5 piece.

The first attempt at TextureChange application was silently broken by
an ID-type mismatch: the server encodes OldTexture/NewTexture as
SurfaceTexture (0x05XXXXXX) ids, but my sub-meshes are keyed by
Surface (0x08XXXXXX) ids. The override dict was keyed by one type
and looked up by the other, so TryGetValue never hit and no override
actually applied.

Diagnosed via Phase 1 systematic debugging with resolve-level logging:

  live: spawn +Acdream texChanges=20
  live:   texChange part=0 old=0x05000BB0 new=0x0500025D
  ...
  live:   resolve part=0 surface=0x08000519 origTex=0x05000BB0 [MATCH]
  live:   resolve part=0 surface=0x0800051C origTex=0x05000CBE [MATCH]
  ... 10/10 lines [MATCH]

The [MATCH] lines proved the server's OldTexture IS reachable via a
Surface→OrigTextureId lookup, just needed keying by the right value.

Fix:
  - TextureCache.GetOrUploadWithOrigTextureOverride(surfaceId, origTexOverride):
    loads the base Surface dat for its color/flags/palette, but
    substitutes the override SurfaceTexture id in the decode chain.
    Caches under a (surfaceId, origTexOverride) composite key.
  - MeshRef.SurfaceOverrides is now Dictionary<uint, uint> keyed by
    Surface id, value = replacement OrigTextureId. Null means no
    overrides.
  - GameWindow.OnLiveEntitySpawned now does TWO passes when texture
    changes are present:
      1. Group the raw server changes by PartIndex into (oldOrigTex →
         newOrigTex) dicts
      2. For each affected part's post-animPartChange GfxObj, iterate
         its Surfaces list, resolve each Surface → OrigTextureId, and
         if that matches a raw change's oldOrigTex, write an entry
         Surface id → newOrigTex into the final override map
  - StaticMeshRenderer.Draw: when sub-mesh surface id has an override,
    call GetOrUploadWithOrigTextureOverride instead of GetOrUpload.

Verified live: +Acdream's clothing renders correctly, NPCs are
"much better" (characters previously naked are now dressed). Statue
has the full mechanical pipeline working (resolve diagnostic shows
2/2 Surfaces [MATCH] for the statue's override dict) but its visible
color comes from the separate SubPalette overlay that isn't wired yet.

Also added a statue-targeted diagnostic block that dumps its full
ObjDesc contents (texChanges + subPalettes + animPartChanges) by
name match, which is how I traced the Nullified Statue of a Drudge's
specific ObjDesc. Lives under `if (isStatue && ...)` so normal logins
aren't spammed.

Cross-referenced against two new references this session:
  * references/Chorizite.ACProtocol (cloned from github.com/Chorizite/
    Chorizite.ACProtocol.git on user's suggestion) — confirms the
    ObjDesc field order and PackedDword-of-known-type convention.
  * references/WorldBuilder/... (already in repo) — confirms the
    Surface→OrigTexture→SurfaceTexture→RenderSurface chain and the
    P8/INDEX16 palette decode path.

Tests: 77 core + 83 net = 160, all green.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 16:22:23 +02:00
Erik
e0dfecdf23 feat(core+app): per-cell terrain texture blending (Phase 3c.4)
The visual-win commit that wires up the Phase 3c.1/.2/.3 building blocks:
Holtburg's terrain now uses AC's real per-cell texture-merge blend
(base + up to 3 terrain overlays + up to 2 road overlays, with alpha
masks from the alpha atlas) instead of the flat per-vertex single-layer
atlas lookup that preceded it.

Geometry rewrite:
  - New TerrainVertex struct (40 bytes): Position(vec3) + Normal(vec3) +
    Data0..3 (4x uint32 packed blend recipe)
  - LandblockMesh.Build is now cell-based: iterates 8x8 cells instead of
    the old 9x9 vertex grid, emits 6 vertices per cell (two triangles),
    384 total vertices per landblock
  - For each cell: extract 4-corner terrain/road values → GetPalCode →
    BuildSurface (cached across landblocks via a shared surfaceCache) →
    FillCellData → split direction from CalculateSplitDirection → emit
    6 vertices in the exact gl_VertexID % 6 order WorldBuilder's vertex
    shader expects
  - Per-vertex normals preserved via Phase 3b central-difference
    precomputation on the 9x9 heightmap, interpolated smoothly across
    the cell (we deliberately didn't adopt WorldBuilder's dFdx/dFdy
    flat-shade approach — Phase 3a/3b user-tuned lighting was worth
    keeping)

Renderer rewrite:
  - TerrainRenderer VAO: vec3 Position, vec3 Normal, 4x uvec4 byte
    attributes for Data0..3. The uvec4-of-bytes read pattern matches
    Landscape.vert so the ported shader math stays byte-for-byte
    identical to WorldBuilder's.
  - Binds both atlases: terrain atlas on unit 0 (uTerrain), alpha atlas
    on unit 1 (uAlpha)

Shader rewrite (ports of WorldBuilder Landscape.vert/.frag, trimmed):
  - terrain.vert: unpacks the 4 data bytes + rotation bits, derives the
    cell corner from gl_VertexID % 6 + splitDir, rotates the cell-local
    UV per overlay's rotation field, and computes world-space normal
    for the fragment shader
  - terrain.frag: maskBlend3 three-layer alpha-weighted composite for
    terrain overlays, inverted-alpha road combine, final composite
    base * (1-ovlA)*(1-rdA) + ovl * ovlA*(1-rdA) + road * rdA. Phase
    3a/3b directional lighting applied on top (SUN_DIR, AMBIENT=0.25,
    DIFFUSE=0.75, in sync with mesh.frag).
  - Editor uniforms (grid, brush, unwalkable slopes) deliberately
    omitted — not applicable to a game client
  - Per-texture tiling factor hardcoded to 1.0 for now (WorldBuilder
    reads it from uTexTiling[36] uploaded from the dats); one tile per
    cell = 8 tiles per landblock-side, slightly coarser than the old
    ~2x-per-cell tiling. Tunable via the TILE constant if needed.

TerrainAtlas grew parallel TCode/RCode lists (CornerAlphaTCodes,
SideAlphaTCodes, RoadAlphaRCodes) so TerrainBlendingContext can be
built without the mesh loader touching the dats directly.

GameWindow builds a TerrainBlendingContext once, shares a Dictionary
<uint, SurfaceInfo> surfaceCache across all 9 landblocks. Output:
"terrain: 137 unique palette codes across 9 landblocks" — avg ~15
unique per landblock, cache reuse healthy.

LandblockMeshTests rewritten for 384-vertex layout. 77/77 tests green.
Visual smoke run launches clean: no shader compile/link errors, no
GL warnings, terrain renders to the screen.

User visual verification is the final acceptance gate for Phase 3c.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 14:02:15 +02:00
Erik
a6cd56663f feat(core): terrain surface recipe + cell data packing (Phase 3c.3)
Ports WorldBuilder's full BuildTexture / FillCellData pipeline as
pure CPU functions in TerrainBlending.cs, along with the SurfaceInfo
recipe record and a TerrainBlendingContext input struct that carries
the atlas index lists the algorithm needs.

This is still pure algorithm work — no GL, no shaders, no mesh gen
changes. Visual Phase 3c.4 next commit wires it into LandblockMesh
and rewrites the terrain shaders to consume Data0..3.

Added (all ports of WorldBuilder LandSurfaceManager methods):
  - ExtractTerrainCodes: inverse of GetPalCode terrain bits
  - PseudoRandomIndex: deterministic hash over palette code for alpha
    variant selection; overflow-dependent int math matches WorldBuilder
    byte-for-byte
  - RotateTerrainCode: *2 with wrap (1→2→4→8→1, multi-corner patterns
    handled in tests)
  - GetRoadCodes: decodes the 8-bit road mask into up to two canonical
    road patterns + allRoad flag; magic 0xE/0xD/0xB/0x7 switch kept verbatim
  - FindTerrainAlpha: picks corner vs side alpha map, walks the 4
    rotations looking for a TCode match, returns (alphaLayer, rotation)
    or (255, 0) for "not found"
  - FindRoadAlpha: same idea for road maps, iterates all maps from a
    pseudo-random offset
  - BuildSurface: composes the above into a SurfaceInfo, handling the
    all-road, all-duplicate-terrain, and distinct-terrain cases via
    BuildOverlayLayers + BuildWithDuplicates (ports GetTerrainTextures +
    BuildTerrainCodesWithDuplicates)
  - FillCellData: packs a SurfaceInfo + CellSplitDirection into the 4
    uint32 vertex attributes Data0..Data3. Byte layout documented in
    XML comment and matches WorldBuilder's Landscape.vert uvec4 byte
    unpacking exactly.

SurfaceInfo record carries resolved atlas byte layers directly (base +
3 terrain overlays + 2 road overlays, each with optional alpha layer
and 0-3 rotation). Sentinel 255 = "slot unused".

Tests (14 new, 75/75 total):
  - ExtractTerrainCodes round-trip with GetPalCode
  - RotateTerrainCode single-corner cycle + multi-corner patterns
  - GetRoadCodes: no-road, all-road, single-corner road
  - PseudoRandomIndex: range, count=0 guard, determinism
  - BuildSurface: all-grass → base only; all-road → road as base;
    two-grass-two-dirt → base + overlay
  - FillCellData: full round-trip bit layout with recognizable
    byte values in every slot, plus a no-road1 case that verifies
    the texRd1 slot collapses to 255 when road1 alpha is absent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:53:32 +02:00
Erik
a8459eecbb feat(core+app): alpha atlas loading (Phase 3c.2)
Loads AC's terrain blending alpha masks into a second GL_TEXTURE_2D_ARRAY
alongside the existing terrain atlas. The alpha atlas is built but not
yet sampled by any shader — that wiring lands in Phase 3c.4.

SurfaceDecoder additions:
  - Handles PFID_A8 (generic single-byte-alpha) by replicating each
    alpha byte into all four RGBA channels
  - Same branch handles PFID_CUSTOM_LSCAPE_ALPHA (0xF4), AC's landscape-
    specific alpha format — the bit layout is identical, just a different
    format ID to distinguish the asset class in the dats. I only found
    this by adding a diagnostic in the first iteration (initial attempt
    returned Magenta for every alpha map because I only wired PFID_A8)
  - 3 new tests: 2x2 A8 round-trip, short-source fallback, and a
    CUSTOM_LSCAPE_ALPHA test verifying it's routed through the same path

TerrainAtlas additions:
  - New GlAlphaTexture property plus CornerAlphaLayers / SideAlphaLayers
    / RoadAlphaLayers index lists so the coming BuildSurface port can
    cite atlas layers by source category
  - BuildAlphaAtlas walks TexMerge.CornerTerrainMaps, SideTerrainMaps,
    RoadMaps and uploads each decoded mask as a layer in insertion
    order; categories carry their atlas-layer index in the respective
    list
  - Fallback handling (single-layer white) when TexMerge is missing or
    every map fails to decode
  - Alpha atlas uses ClampToEdge wrap so repeating tile sampling at
    mask boundaries doesn't produce seams
  - Dispose() now cleans up both textures

On Holtburg's region the log prints:
  TerrainAtlas: 33 terrain layers at 512x512
  AlphaAtlas:    8 layers at 512x512  (corners=4, sides=1, roads=3)

Tests: 61/61 passing. No visual change expected this commit (shader
still ignores Data0..3 and the alpha sampler).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:45:40 +02:00
Erik
e6cfcb612b feat(core): terrain palette + cell split math (Phase 3c.1)
First of four steps porting WorldBuilder's texture-merge terrain
blending. This commit is pure CPU math with no GL or dat dependencies
so the ported logic can be verified in isolation before it starts
driving real rendering.

Ported:
  - GetPalCode(r1..r4, t1..t4): packs corner terrain/road bits into
    a 32-bit palette code (bit layout documented in XML comment)
  - CalculateSplitDirection: deterministic hash picking SWtoNE vs
    SEtoNW triangulation for a cell; magic constants kept exact to
    match AC's server-side collision triangulation
  - CellSplitDirection enum with values matching WorldBuilder's so
    later bit-packing stays byte-identical

Tests (10 new, 58/58 passing total):
  - GetPalCode golden value for all-grass-no-roads: 0x10008421
    (hand-computed from the bit layout, not derived from a run)
  - GetPalCode all-zero produces only the sizeBits marker
  - GetPalCode determinism, road-flag isolation (r1 flip touches
    only bit 26), size bit always set, terrain region bounded to
    bits 0-19
  - CalculateSplitDirection hand-computed golden for (0,0,0,0):
    (1813693831 - 1369149221) * (1/2^32) ~= 0.1035 < 0.5 -> SWtoNE
  - Determinism
  - Across a full 8x8 landblock the hash produces a mix of both
    split directions (would fail if the hash collapses)

Deferred to Phase 3c.3 (need dat data for TexMerge):
  BuildSurface, FillCellData, PseudoRandomIndex, SurfaceInfo

Reference: WorldBuilder Chorizite.OpenGLSDLBackend/Lib/LandSurfaceManager.cs
           WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 13:36:35 +02:00
Erik
c95481ea69 feat(core): per-vertex terrain normals via central differences (Phase 3b)
LandblockMesh was hardcoding Normal=Vector3.UnitZ for every vertex,
which meant Phase 3a's directional lighting gave every terrain fragment
the same brightness — flat-looking terrain regardless of slope.

Now computes a real per-vertex normal by sampling the 4 heightmap
neighbors and taking central differences on the heights. The surface
is z=h(x,y), tangents are Sx=(1,0,dh/dx) and Sy=(0,1,dh/dy), and the
normal is their cross product: (-dh/dx, -dh/dy, 1) normalized. Edge
vertices use forward/backward difference instead of central.

Heightmap is pre-sampled into a 9x9 float grid before the vertex loop
so neighbor lookups don't hit the heightTable dictionary-style 81 times
per vertex — one pass to precompute, one pass to emit vertices.

Existing tests still pass: flat landblocks produce flat normals
(constant heights → zero derivatives → UnitZ), so the Phase 1 tests'
"all vertices same Z" assertion remains accurate.

Combined with 3268556 (lighting), terrain hills now visually catch the
sun on their sunward slopes and darken on shadowed slopes. Holtburg's
gentle rolling hills should look considerably more three-dimensional.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 22:23:35 +02:00
Erik
9970811dc3 feat(core): procedural scenery from Region.SceneInfo (Phase 2c)
Adds SceneryGenerator.Generate which walks Region.TerrainInfo.TerrainTypes
+ Region.SceneInfo.SceneTypes for each landblock vertex, selects a scene
using the AC client's pseudo-random LCG hash of global cell coordinates,
then rolls each ObjectDesc's frequency, computes a displaced cell-local
position, random scale, and random rotation — the exact algorithm
ACViewer ports from the retail AC client's get_land_scenes().

Phase 2 rendered 239 explicit Stab+Building entities on the 3x3 Holtburg
grid but was missing every procedurally-placed tree, bush, rock, fence,
and small decoration because these are not stored as LandBlockInfo entries.
This adds 419 scenery entities across the same 9 landblocks, bringing the
total to 658.

Integration in GameWindow.OnLoad: after the existing Stab/Building
hydration loop, iterate each landblock's scenery spawns, resolve each
to a GfxObj or Setup via the same mesh pipeline, bake the random scale
into each MeshRef's PartTransform so the static mesh renderer doesn't
need a scale field on WorldEntity, and sample the landblock heightmap
bilinearly for the ground Z (simpler than ACViewer's find_terrain_poly
slope-aware placement).

Deliberate deferrals for first pass:
- No slope-based rejection (obj.MinSlope/MaxSlope). Trees may end up on
  cliffs they shouldn't be on.
- No road-overlap rejection. Scenery may spawn in roads.
- No building-overlap rejection. Scenery may clip buildings.
- No WeenieObj handling (those are dynamic spawns, not static scenery).

All three filters will be added in a follow-up phase when we have the
walkable-polygon infrastructure they need.

Build clean, 48 tests still pass, smoke verified: "scenery: spawned 419
entities across 9 landblocks", process runs without exceptions.

Addresses the user visual feedback after Phase 2b: "some extra details
are missing, like a tree and the statue on top of the foundry". The tree
issue is now fixed (419 trees/bushes/rocks/etc placed). The foundry
statue may still be missing if it's a hierarchical Setup part (Phase 2a's
SetupMesh.Flatten intentionally doesn't walk ParentIndex) — that's a
separate fix if smoke verification shows it's still missing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:07:12 +02:00
Erik
0c0c042dca feat(core): add IGameState, IEvents, WorldEvents with replay-on-subscribe
Adds WorldEntitySnapshot, IGameState, IEvents abstractions; WorldEvents
implements replay-on-subscribe with per-handler exception swallowing;
WorldGameState tracks entities; AppPluginHost exposes all three; stubs
wired in Program.cs to keep build green ahead of Task 9 live wiring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:29:29 +02:00
Erik
78ce099440 fix(core): LandblockMesh keys atlas lookup on TerrainInfo.Type
Task 1's subagent used the raw ushort as the map key because the test
used raw ushort 7 as the value. But the atlas map is built from
Region.TerrainInfo.LandSurfaces.TexMerge.TerrainDesc which keys on
TerrainTextureType enum values, extracted from bits 2-6 of the
TerrainInfo ushort per DatReaderWriter's Types/TerrainInfo.cs.

Reverts to using block.Terrain[hi].Type so the Task 2 TerrainAtlas can
actually find matching keys against real dat terrain. The test is
updated to encode Type=7 correctly as (7 << 2) in the raw ushort.
2026-04-10 20:18:09 +02:00
Erik
324abed6eb feat(core): add Vertex.TerrainLayer + LandblockMesh layer map
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 20:16:25 +02:00
Erik
cc55c3f812 fix: heightmap transpose + solid-color + translucency + clipmap textures
Three root causes found via systematic debugging after the user reported
that the dc60405 texture fix and 4763b97 height table fix had no visible
effect on Holtburg.

## Heightmap transpose (LandblockMesh.Build)

Phase 1's LandblockMesh.Build indexed block.Height as y*9+x but AC packs
per-vertex heights in x-major order (x*9+y, matching ACViewer's
LandblockStruct: Height[x * VertexDim + y]). The bug was invisible on
flat landblocks (Phase 1 smoke test) but left buildings buried by 10-13
world-Z units on Holtburg, because building Frame.Origin positions
reference the un-transposed ground truth.

Diagnostic evidence (before fix, Holtburg 0xA9B4FFFF):
  entity 0x020000A5 at ( 84.6,126.0) entityZ= 66.03 terrainZ= 78.15 delta=-12.13
  entity 0x02000118 at ( 74.2,139.9) entityZ= 66.03 terrainZ= 78.92 delta=-12.89

After fix: deltas are 0.03 to 2.18 — buildings now sit on the ground
with small positive offsets for foundations.

Regression test added: Build_HeightmapPackedAsXMajor_NotYMajor asserts
asymmetric heights land at the correct world positions.

## Solid-color surfaces with Translucency=1.0 (SurfaceDecoder.DecodeSolidColor)

The "bright pink doors and windows" the user saw were 11 Holtburg
surfaces with OrigTextureId==0 — these carry a ColorValue instead of
a texture chain. Phase 2a's TextureCache dropped them into the magenta
fallback. All 11 turned out to be Base1Solid|Translucent with
Translucency=1.00, meaning "fully transparent placeholder surface"
(debug ColorValue is gray/green/red/blue/black, never displayed).

DecodeSolidColor now takes a translucency parameter and multiplies
alpha by (1 - translucency), so Translucency=1.0 → alpha=0, and the
mesh shader's existing alpha discard (< 0.5) makes the pixel invisible.

TextureCache honors Surface.Type.HasFlag(Base1Solid) and passes
surface.Translucency through.

Regression tests added: DecodeSolidColor_Opaque_PreservesAlpha and
DecodeSolidColor_FullyTranslucent_AlphaGoesToZero.

## Clipmap alpha-key (DecodeIndex16)

AC convention (per ACViewer TextureCache.IndexToColor): on surfaces
marked Base1ClipMap, palette indices 0..7 are treated as fully
transparent regardless of their actual palette color. Without this,
low-index pixels on clipmap surfaces (typically doorway cutouts and
foliage) render as opaque using whatever sentinel color is at those
palette slots.

DecodeRenderSurface now takes an isClipMap parameter. TextureCache
passes Surface.Type.HasFlag(Base1ClipMap). DecodeIndex16 forces
rgba=(0,0,0,0) when isClipMap && idx < 8.

Regression test added: DecodeIndex16_ClipMap_ZerosAlphaForLowIndices.

## Notes

- dc60405's PFID_INDEX16 palette decoder remains correct — no change.
- 4763b97's LandHeightTable wiring remains correct — real-table lookup
  still runs, it just happens to be linear at Holtburg's height range.
  The fix is forward-compatible with mountains elsewhere.
- All three bugs were invisible to the original unit tests. The new
  regression tests pin them down.

## State

- dotnet build: 0 warnings, 0 errors
- dotnet test: 42 passing (was 38 + 4 new)
- Runtime: 126 entities hydrated on Holtburg, no exceptions, no
  magenta fallback (counter was 11, now 0 via diagnostic confirmation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:37:06 +02:00
Erik
dc60405ebc fix(textures): palette-indexed surfaces + alpha cutout shader
Addresses the 'doors, windows, and alpha-keyed parts render bright
pink' issue the user observed after the Phase 2a visual checkpoint.

SurfaceDecoder gains a second overload taking an optional Palette
parameter. When the render surface format is PFID_INDEX16 and a
palette is supplied, each 16-bit value in SourceData is treated as
an index into Palette.Colors (a List<ColorARGB>) and the corresponding
ARGB color's channels are written to the output buffer. The original
no-palette overload is preserved so the Task 3 unit tests that
confirm INDEX16 -> magenta fallback still describe their behavior
correctly (INDEX16 without a palette still returns magenta).

TextureCache now resolves the RenderSurface's DefaultPaletteId via
the dats and passes the resulting Palette (or null) to the decoder.

mesh.frag adds an alpha cutout: fragments with sampled alpha < 0.5
are discarded. Without this, transparent regions of alpha-keyed
textures (doors, windows, foliage cutouts) would render as opaque
rectangles using the texture's background color. This is the
standard alpha-tested approach, simpler than full alpha blending
and matches how AC's original client rendered these surfaces.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:12:05 +02:00
Erik
4763b973da fix(terrain): use real LandHeightTable from Region dat
Phase 1 simplified per-vertex height as byte * 2.0f, but AC stores
heights as byte indices into a 256-entry non-linear float lookup
(Region.LandDefs.LandHeightTable). Static object placements in
LandBlockInfo use the real table, so terrain rendered with the
simplified scale left buildings floating or buried.

LandblockMesh.Build now takes an explicit float[] heightTable so
the core code stays testable without a DatCollection. GameWindow
loads Region id 0x13000000 once at startup and passes its
LandDefs.LandHeightTable into every landblock mesh build. The
Phase 1 tests use an identity table (i * 2f for i in 0..255) so
their expectations remain unchanged.

Addresses the 'buildings buried and floating' issue the user
observed after the Phase 2a visual checkpoint.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:09:27 +02:00
Erik
5d35f4fe46 feat(core): add WorldView with 3x3 neighbor landblock computation 2026-04-10 18:02:41 +02:00