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>
Backward-playback nodes (TurnLeft, SideStepLeft) have LowFrame >
HighFrame after multiply_framerate swaps them. Math.Clamp(x, 19, 0)
throws because min > max.
Fix: compute rangeLo/rangeHi from Min/Max of Low/High, use those
for clamping. Also step nextIdx in the playback direction (forward
+1, backward -1) instead of always +1.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE FIX for missing left-side animations.
The AC client's MotionTable has NO cycles for TurnLeft (0x000E),
SideStepLeft (0x0010), or WalkBackward (0x0006). The real client
calls adjust_motion() which remaps these to their right-side
equivalents with NEGATIVE speed before looking up the cycle. Then
multiply_framerate() swaps LowFrame↔HighFrame so the animation
plays backward.
Source: ACE MotionInterp.cs:394-428, decompiled FUN_005267E0.
Changes:
- AnimationSequencer.SetCycle: adds adjust_motion block that remaps
left→right with speed *= -1 (TurnLeft, SideStepLeft) or
speed *= -0.65 (WalkBackward = BackwardsFactor)
- LoadAnimNode: when framerate < 0, swaps Low↔High (matching the
decompiled multiply_framerate)
- GameWindow.UpdatePlayerAnimation: passes original animCommand to
SetCycle (sequencer handles remapping internally), keeps legacy
fallback for non-sequencer entities
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
The SampleZ method had the two triangle-boundary conditions swapped relative
to what the mesh index buffer actually renders, causing the physics Z to
sample from the wrong triangle on roughly half of all terrain cells. The
error could be up to 7.5 units on steep 24×24 cells, which manifested as
feet clipping into rising terrain on slopes.
Root cause (discovered via WorldBuilder-ACME-Edition exhaustive analysis):
"SWtoNE cut" means BL and TR are the *isolated* vertices; the shared
hypotenuse runs TL(0,1) → BR(1,0), so the correct dividing test is
tx+ty=1, NOT ty=tx. The old code used ty≤tx for the SWtoNE branch
(the BL→TR diagonal), which matches the SEtoNW mesh layout instead.
Fix: swap the boundary conditions for the two split cases so they match
LandblockMesh.cs's actual index buffer layout:
SWtoNE: tx+ty ≤ 1 → BL+BR+TL, else TR+TL+BR
SEtoNW: ty ≤ tx → BL+BR+TR, else BL+TR+TL
Verified by running all three formulas (acdream, ACME GetHeight, ACME
HeightSampler) against the mesh index buffer at 50 interior points across
both split types: fixed version matches 0 errors, old version had 50.
All 283 tests still pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The triangle-aware Z sampling from the previous commit produces exact
terrain-surface Z values, but a point-sampled Z on a tilted surface
places the character's center on the surface while their feet (which
extend horizontally) clip into the rising terrain ahead/behind.
Fix: in PlayerMovementController, sample Z 1 unit ahead in the walk
direction and add 40% of the gradient as an upward bias. This
compensates for the character's collision cylinder radius on slopes
while producing zero bias on flat ground. The bias is applied in the
movement controller (gameplay concern) not in TerrainSurface.SampleZ
(which stays exact for physics/tests).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two fundamental terrain fixes based on the AC2D + holtburger deep dive:
1. Terrain split formula: replaced WorldBuilder's physics-path formula
(214614067/1813693831) with AC2D's render-path formula (0x0CCAC033,
0x421BE3BD, 0x6C1AC587, 0x519B8F25). The two produce different splits
for some cells. Since the render mesh uses this formula, the physics
Z sampler must match it to avoid misalignment on slopes.
2. Triangle-aware Z: replaced bilinear interpolation in TerrainSurface
with per-triangle barycentric interpolation. Each cell is split into
two triangles (using the same AC2D formula). SampleZ determines which
triangle the query point falls in, then interpolates within that
triangle. This produces Z values that exactly match the visual terrain
mesh — no more slope clipping.
Removes the multi-point Z sampling hack from PlayerMovementController
(no longer needed with exact triangle Z).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two targeted fixes for user-reported movement bugs:
1. Wall bounce: PortalPlane.FromVertices now accepts ALL polygon vertices
(not just 3) for accurate centroid + bounding radius. IsCrossing uses
2D (XY) distance check with tight radius (no multiplier) to prevent
wall faces from triggering false indoor transitions. Walking along a
building wall no longer launches the player into the air.
2. Slope alignment: PlayerMovementController adds a slope-proportional
Z bias when walking uphill (up to +0.8 on steep slopes, grounded
only). Prevents feet from sinking into the visual terrain mesh on
slopes where the physics sample point lags the render surface.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Four targeted fixes for user-reported movement/visual bugs:
1. Player entity disappearing: GpuWorldState now supports persistent
entities (MarkPersistent/DrainRescued). The player character survives
landblock unloads and gets re-injected into the streaming window at
the current center landblock.
2. Feet sinking into terrain: +0.15 Z bias in PlayerMovementController
keeps the character model above terrain z-fighting edge cases.
3. Camera after portal teleport: ChaseCamera.Update now called
immediately after teleport snap so the camera recenters on the new
position instead of lingering at the pre-teleport location.
4. Scenery on roads: SceneryGenerator now checks road status at the
final displaced position (not just the origin vertex), catching
objects that drift from non-road vertices onto road cells.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Portal planes are infinite — IsCrossing only checked if positions
were on opposite sides of the plane, without verifying the crossing
point was near the actual portal opening. Walking 50m from a
building but crossing the same plane extension triggered false indoor
transitions, sinking the player into underground cell floors.
Fix: FromVertices now computes centroid (average of 3 vertices) and
bounding radius (max vertex distance + 2 unit padding). IsCrossing
rejects crossings where both positions are further than 2×radius
from the centroid. Only nearby crossings (within doorway range)
trigger a transition.
Also fixes jump-not-landing: the false portal transitions were
producing wrong resolvedGroundZ values during the jump arc, making
the landing check (candidateZ <= resolvedGroundZ) never true.
283 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
Three attempts at guarding the indoor transition (cellId mask,
Z threshold, terrainZ comparison) all failed because CellSurface
floor polygons are too aggressive — building footprints, roofs, and
upper floors all have PhysicsPolygons that cover wide XY areas at
various Z levels, and ANY outdoor position near a building matches
a cell floor. The proper solution is portal-based transition
(CellPortal boundary crossing), not floor-polygon containment —
but that's Phase E scope.
For the B.2 MVP: outdoor players NEVER transition to indoor cells.
The else-if branch is compiled out with `if (false)`. Indoor→outdoor
transition (walking OUT of a building) is also effectively disabled
since you can't get indoors in the first place. Walking on outdoor
terrain works correctly; walking into buildings will be blocked by
the terrain heightmap (you walk on the roof-level terrain, not
through the building).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previous cellId-mask fix was necessary but insufficient: the engine
correctly identified the player as outdoor, but then immediately
transitioned to an indoor cell because a CellSurface floor polygon
covered the player's XY at a Z within stepUpHeight. The floor
polygon was a roof or upper floor of a nearby building that happens
to sit at terrain level — not a walkable indoor floor the player
should snap to.
Fix: outdoor→indoor transition now requires bestCellZ < terrainZ - 1.
A genuine indoor transition is into a cell whose floor is BELOW the
terrain surface (basement, ground floor of elevated building). Cells
at or above terrain Z are roofs/upper floors viewed from outside and
must not capture the player.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ROOT CAUSE of the fall-through-ground bug. The currentlyIndoor check
compared the FULL 32-bit cell ID (0xA9B40001, includes landblock
prefix) against 0x0100. Every outdoor cell ID with a landblock
prefix is >= 0x0100, so the engine ALWAYS took the "stay indoors"
path, snapping the player to the nearest EnvCell floor at Z=66
instead of the outdoor terrain at Z=94.
ACE confirmed the bug: "AddWorldObjectInternal: couldn't spawn
+Acdream at 0xA9B40121 [84.2 37.7 66.0]" — we were sending the
server an indoor cell ID with a below-terrain Z position.
Fix: mask cellId with 0xFFFF before the indoor check (outdoor
cells are 0x0001–0x0040; indoor are 0x0100+; the high 16 bits
are the landblock prefix and must be stripped). Also mask the
returned targetCellId from CellSurface (which carries the full
EnvCell dat id) to just the cell index.
265 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three more fixes from the diagnostic dump:
1. Initial position: PhysicsEngine.Resolve was mapping the player
into an indoor EnvCell (foundry at Z=66) when they're standing
on outdoor terrain at Z=93+. The cell-containment check was too
aggressive for initial placement. Now uses the server-sent
position directly — the server already gave us a valid position.
2. Yaw unbounded: mouse delta accumulated without wrapping, growing
to 24+ radians. Now wraps to [-PI, PI] after every turn.
3. Turn command spam: MouseDeltaX > 0.5 threshold was too low for
raw pixel deltas. Any mouse jitter triggered turnCmd flips every
frame → stateChanged=True → MoveToState flood to the server.
Mouse turning now only affects yaw directly; turn COMMANDS only
come from A/D keyboard (matching retail client behavior where
mouse-look doesn't generate a TurnRight/TurnLeft command).
265 tests still green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Combines TerrainSurface + CellSurface into a single Resolve() API
that handles outdoor terrain walking, indoor floor walking,
outdoor<->indoor cell transitions, step-height enforcement, and
ground detection.
Step-height blocks upward Z deltas exceeding the limit (walls,
cliffs); downhill movement is always accepted. Indoor transitions
pick the cell whose floor Z is closest to the entity's current Z
(handles multi-story buildings). Reports IsOnGround=false when
no landblock or surface covers the entity's position (gravity
applied by the caller).
One API mismatch fixed vs plan: plan encoded the upper 16 landblock
bits into the returned cell ID, but the tests assert the raw cell ID
(0x0100, <0x0100) — so Resolve returns targetCellId directly.
6 new tests covering flat terrain, slopes, step-height rejection,
indoor entry/exit, and void detection. 243 total, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extracts the bilinear heightmap interpolation from GameWindow's
inlined SampleTerrainZ into a reusable class. Also adds outdoor
cell ID computation (8×8 grid of 24-unit cells, 0x0001..0x0040).
First component of the physics collision engine.
6 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Projects an XY point onto a cell's floor polygons via brute-force
triangle iteration + barycentric Z interpolation. Fan-triangulates
quads and larger polygons. Returns null when outside all floor
surfaces. Accepts pre-transformed world-space vertex positions so
the caller handles EnvCell coordinate transforms.
Second component of the physics collision engine.
4 new tests, all green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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.
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>
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>
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>