Commit graph

284 commits

Author SHA1 Message Date
Erik
bf31e59805 fix(streaming): close #54 — plumb JobKind through BuildLandblockForStreaming
Bug A's fix (commit `9217fd9`) patched at the worker output by stripping
entities from far-tier `LoadedLandblock`s after the full `LoadNear` path
ran. The worker still wasted CPU on `LandBlockInfo` reads + entity
hydration + `SceneryGenerator` math + interior-cell walks for ~544
far-tier LBs at radius=12, just to throw the work away.

This commit plumbs `LandblockStreamJobKind` through to the factory so the
worker can branch at the source:

- `LandblockStreamer.cs`: replace the `Func<uint, LoadedLandblock?>`
  factory with `Func<uint, LandblockStreamJobKind, LoadedLandblock?>` as
  the primary ctor signature. Add a back-compat overload that wraps the
  old single-arg signature (`(id, _) => loadLandblock(id)`) so existing
  test code keeps compiling without modification — the 5 ctor sites in
  `LandblockStreamerTests.cs` now resolve to the overload. `HandleJob`
  passes `load.Kind` to the factory; the post-load entity-strip is
  retained as a `Debug.Assert` + Release safety net.

- `GameWindow.cs`: `BuildLandblockForStreaming(uint, JobKind)` branches
  on `kind == LoadFar` at the top — reads only the `LandBlock` heightmap
  dat and returns a `LoadedLandblock` with `Array.Empty<WorldEntity>()`.
  Skips `LandblockLoader.Load` (which reads `LandBlockInfo`),
  `BuildSceneryEntitiesForStreaming`, and `BuildInteriorEntitiesForStreaming`
  entirely. Near-tier path is unchanged. Both call sites updated to pass
  the kind through the lambda: `(id, kind) => BuildLandblockForStreaming(id, kind)`.

Tests: 1688/1696 (8 pre-existing physics/input failures unchanged).
Streaming-targeted filter (30 tests covering LandblockStreamer +
StreamingController + StreamingRegion) all green via the back-compat
overload — no test code needed updating.

Per-LB worker cost on far-tier: was ~tens of ms (full hydration,
including LandBlockInfo + scenery generation + interior cells); now a
single `LandBlock` dat read (~sub-ms).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:03:16 +02:00
Erik
28d2c6018e feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2)
GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality)
+ WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores
result in _resolvedQuality field. All six quality dimensions applied:

- NearRadius / FarRadius: replace old T16 env-var-only block; preset drives
  the radii, legacy ACDREAM_STREAM_RADIUS override still honoured.
- MsaaSamples: WindowOptions.Samples reads from startup quality resolution
  in Run() (pre-window-create read from SettingsStore). MSAA cannot change
  at runtime; ReapplyQualityPreset logs a restart-required warning if the
  new preset would change it.
- AnisotropicLevel: TerrainAtlas.SetAnisotropic() called after Build() and
  again in ReapplyQualityPreset. Temporarily removes bindless residency
  before the GL TexParameter call, re-makes resident after.
- AlphaToCoverage: WbDrawDispatcher.AlphaToCoverage property gates the
  glEnable/glDisable(SampleAlphaToCoverage) pair around the opaque pass.
- MaxCompletionsPerFrame: set on StreamingController after construction
  and after each mid-session restart.

ReapplyQualityPreset(QualityPreset) method handles mid-session changes
(Settings panel Quality dropdown Save): rebuilds streamer + controller for
radius changes, toggles A2C and aniso immediately, logs MSAA restart caveat.
onSaveDisplay callback updated to call ReapplyQualityPreset when Quality
field changes.

TerrainModernRenderer.Atlas property added to expose the atlas for
mid-session aniso updates.

991 tests passing, 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:43:06 +02:00
Erik
c473feedb3 feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG]
Per Phase A.5 spec §2 acceptance criterion 6: entity dispatcher median
≤ 2.0ms; terrain dispatcher median ≤ 1.0ms at standstill. When the
median exceeds the budget, prefix the DIAG line with " BUDGET_OVER" so
the regression is grep-friendly during perf testing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:28:45 +02:00
Erik
3b684db0f1 feat(A.5 T22): fog wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars
Per Phase A.5 spec §4.8: fog ramp is tuned to mask the N₁ scenery
boundary. FogStart = N₁ × 192m × 0.7 ≈ 538m at default radii (4/12).
FogEnd = N₂ × 192m × 0.95 ≈ 2188m. Multipliers exposed as env vars for
fast iteration during visual gate.

Override is injected into the UBO after SceneLightingUbo.Build() so fog
color, lightning flash and mode still come from the sky keyframe. Adds
ParseEnvFloat helper (InvariantCulture) for float env-var parsing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:55 +02:00
Erik
26b2871b10 feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage
Per Phase A.5 spec §4.9.2: ClipMap foliage uses binary alpha-cutoff.
At N₂=12 horizon distance the pixel-stepped silhouettes are visible.
A2C with MSAA 4x produces smooth retail-faithful tree edges.

GL context now requests Samples=4. WbDrawDispatcher's opaque pass
toggles GL_SAMPLE_ALPHA_TO_COVERAGE on/off around the multi-draw
indirect call. mesh_modern.frag's opaque pass now discards only
truly-empty (α<0.05) so the GPU derives sample mask from coverage;
transparent pass boundary logic is unchanged.

MSAA audit: no custom FBOs found — all rendering uses default
framebuffer. Sky/particles/ImGui are all MSAA-compatible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:25:59 +02:00
Erik
0afd741ea7 feat(A.5 T18): use cached WorldEntity AABB in dispatcher; populate at register
Per Phase A.5 spec §4.6 Change #2: WalkEntities's per-entity AABB
frustum cull was recomputing Position±5 per frame per entity. With
~10.7K entities (N1=4) at 240 FPS that is ~2.5M wasted Vector3
ops/sec.

Read the AABB from the WorldEntity cache (T8 schema) instead.
RefreshAabb runs lazily on AabbDirty=true. Populate at register time:

- LandblockLoader.BuildEntitiesFromInfo: RefreshAabb after each new
  WorldEntity construction (stabs + buildings). Refactored from
  inline object-initializer to named variable to enable the call.
- EntitySpawnAdapter.OnCreate: RefreshAabb after entity state init
  (position/rotation already set via the WorldEntity passed in).

Dynamic entities (NPCs, players) move every frame via direct
Position writes in GameWindow.cs. Migrated all three per-frame
write sites to SetPosition() (T8 mutator) so AabbDirty propagates:
  - line 5942: player entity render position update
  - line 6951: remote animated entity interpolated path
  - line 7279: remote animated entity landing/movement path

The lazy RefreshAabb in WalkEntities catches up on the next frame
after any SetPosition call — render thread only, no races.

Build green, 986 passed / 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:20:20 +02:00
Erik
31d312add3 fix(A.5 T16): debug overlay shows _nearRadius instead of legacy _streamingRadius
Cosmetic follow-up flagged by spec compliance review on T13-T16 bundle
(commits fb10c3f / aff35d2 / b8d80fe / c4fd373). The debug overlay's
getStreamingRadius callback was reading _streamingRadius — the legacy
single-tier field that's only updated by ACDREAM_STREAM_RADIUS. Operators
using the new ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS env vars would
see the overlay frozen at the default 2.

Switch to _nearRadius. The overlay still shows a single number (matching
its label "Streaming radius"); operators who want both tier numbers can
read the launch log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:01:30 +02:00
Erik
c4fd37384a feat(A.5 T16): wire two-tier streaming into GameWindow
GameWindow now constructs StreamingController with nearRadius / farRadius
defaults of 4 / 12 (per spec acceptance criterion). Env vars:
- ACDREAM_NEAR_RADIUS (default 4)
- ACDREAM_FAR_RADIUS  (default 12)
- ACDREAM_STREAM_RADIUS (legacy; if set, treats as nearRadius and
  bumps farRadius to max(stream, default))

Fields _nearRadius / _farRadius added alongside legacy _streamingRadius
(kept so the debug overlay's getStreamingRadius callback stays valid).

ApplyLoadedTerrainLocked routes to TerrainModernRenderer.AddLandblockWithMesh
(T15) instead of AddLandblock directly, making the two-tier entry point
the canonical call path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:58:12 +02:00
Erik
b8d80fe282 feat(A.5 T13): StreamingController two-tier Tick
Replaces the single-radius Tick with a two-tier model that consumes
StreamingRegion's TwoTierDiff (5-list) and routes to the appropriate
JobKind:

- ToLoadFar    -> _enqueueLoad(id, LoadFar)
- ToLoadNear   -> _enqueueLoad(id, LoadNear)
- ToPromote    -> _enqueueLoad(id, PromoteToNear)
- ToDemote     -> _state.RemoveEntitiesFromLandblock(id) on render thread
- ToUnload     -> _enqueueUnload(id)

Drain switch handles Loaded (terrain + entity layer), Promoted (entity
layer only -- terrain already loaded), Unloaded, Failed, WorkerCrashed.

Constructor signature: nearRadius/farRadius separate ints. Old single-
radius ctor removed; existing single-radius tests updated to pass
nearRadius=farRadius for backward-compat coverage.

GameWindow's enqueueLoad lambda updated from (id =>...) to (id, kind) =>
to match new Action<uint, LandblockStreamJobKind> signature; radius: arg
renamed to nearRadius:/farRadius: (both set to _streamingRadius until T16
wires the full two-tier env-var parsing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:56:57 +02:00
Erik
76e1a64d78 fix(A.5 T10): lock 2 missed _dats.Get<Setup> sites
Spec compliance review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947)
caught 2 unprotected dat reads that the original T10 audit missed:

- GameWindow.UpdatePlayerAnimation (line ~7546): reads Setup when the
  player entity is missing from _animatedEntities (post-respawn pattern).
- GameWindow.EnterPlayerModeNow (line ~8567): reads Setup when entering
  player mode to derive StepUpHeight / StepDownHeight from the dat.

Both run on the render thread post-_streamer.Start(), so they can race
with the worker thread's BuildLandblockForStreamingLocked. DatBinReader's
shared buffer position would corrupt — same class of "ball with spikes"
bug the original Phase A.1 hotfix addressed.

Wrap both reads in lock (_datLock).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:41:36 +02:00
Erik
0405947bac feat(A.5 T12): inject mesh-build dependency into LandblockStreamer
Replaces the T7-temporary default! MeshData placeholder. Streamer
now takes Func<uint, LoadedLandblock?, LandblockMeshData?> at
construction; the worker calls it after _loadLandblock succeeds and
passes the pre-built mesh into LandblockStreamResult.Loaded.

GameWindow's buildMeshOrNull factory takes the already-loaded
LoadedLandblock (lb.Heightmap is the LandBlock dat object), so no
additional dat read is needed — _heightTable and _blendCtx are
read-only after init, _surfaceCache is ConcurrentDictionary (T9).
Zero dat lock needed inside the mesh-build closure.

StreamingController._applyTerrain delegate signature widened to
Action<LoadedLandblock, LandblockMeshData> so the pre-built mesh
flows render-thread-side via the Loaded result. ApplyLoadedTerrainLocked
now accepts meshData and calls _terrain.AddLandblock directly, skipping
the per-frame LandblockMesh.Build that previously ran on the render
thread (~5ms per LB at radius=12 first traversal).

StreamingControllerTests updated: all four applyTerrain lambdas
adapted to the two-arg Action signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:35:45 +02:00
Erik
0cf86bb126 fix(A.5 T10): serialize DatCollection access via _datLock
Phase A.5 T11 activates the LandblockStreamer worker thread, making
concurrent dat reads possible. DatReaderWriter's DatBinReader uses a
shared buffer position internally — concurrent _dats.Get<T> calls from
worker + render thread corrupt that state and produce half-populated
LandBlock.Height[] arrays (renders as wildly distorted terrain).

The _datLock field already existed from the Phase A.1 hotfix, and the
high-traffic worker-facing paths (BuildLandblockForStreaming,
ApplyLoadedTerrain, OnLiveEntitySpawned) already hold it. This commit
updates the field comment to precisely document the T10 contract:
all worker-thread dat reads enter via factory closures that acquire
_datLock; render-thread paths are already covered by their outer
lock wrappers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:32:23 +02:00
Erik
c5f98b276e fix(A.5 T7-T9): migrate entity.Position= → SetPosition; add Promoted arm
Code review on commits 295bce9/a0741bd/4be392b flagged 1 Important + 3
Minor issues. Apply the actionable two:

Important: 6 sites in GameWindow.cs (lines 3900, 4017-4024, 4138, 4270,
4315) wrote entity.Position = X directly, bypassing T8's SetPosition
mutator and therefore never marking AabbDirty. When T18 lands the
dispatcher's "if AabbDirty refresh" cull gate, these direct writes
would silently leave AABB stale (frustum culls dynamic entities at
their previous positions). Migrated all 6 sites to SetPosition().

Minor: Added a silent case LandblockStreamResult.Promoted arm in
StreamingController.Tick with a TODO(A.5 T13) marker. Today the
streamer never produces Promoted, so the arm is unreachable; the
explicit case prevents a future reader from wondering why the case
is missing.

Deferred Minor: surfaceCache thread-safety XML doc comment + style
consistency on System.Collections.Generic using directive — non-
load-bearing cosmetic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:25:07 +02:00
Erik
4be392b361 refactor(A.5 T9): _surfaceCache -> ConcurrentDictionary for off-thread mesh build
Widens LandblockMesh.Build's surfaceCache parameter from Dictionary to
IDictionary so any IDictionary implementation compiles at call sites.
Switches GameWindow._surfaceCache from Dictionary to ConcurrentDictionary
so T11's streaming worker can call Build off the render thread without
a lock.

The TryGetValue+assign lookup inside Build is not atomic, but BuildSurface
is deterministic (same palCode -> same SurfaceInfo), making last-write-wins
under concurrent access benign. Comment added at the pattern site.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:55:53 +02:00
Erik
7dfa2af6c0 phase(N.5b): retire legacy terrain renderers
Deletes:
- TerrainChunkRenderer.cs (454 lines, replaced by TerrainModernRenderer)
- TerrainRenderer.cs (247 lines, older sibling, no production users)
- terrain.vert / terrain.frag (replaced by terrain_modern.{vert,frag})

Removes the temporary Task 8 perf-benchmark toggle (ACDREAM_LEGACY_TERRAIN
env var, _useLegacyTerrain field, parallel _terrainLegacy renderer
instance, [TERRAIN-DIAG/modern|legacy] label suffix). The modern path
is now the only path. Mirror N.5's mandatory-modern amendment: missing
GL_ARB_bindless_texture throws NotSupportedException at startup
(already in place via the BindlessSupport.TryCreate gate).

Three load-bearing research comments preserved verbatim from terrain.vert
into terrain_modern.vert before deletion: the MIN_FACTOR = 0.0 N-dot-L
floor block (cross-ref Lambert brightness split), the aPacked3 bit
layout, the gl_VertexID corner-table 2026-04-21 ConstructPolygons fix.

Also retires the now-orphaned _shader field (legacy terrain pipeline
was its only user).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:59:05 +02:00
Erik
da56063be5 fix(N.5b): black terrain — switch to uvec2 handle + sampler constructor
Symptom: terrain renders pure black in modern path (legacy renderer
correct). Diagnostic at TerrainModernRenderer.Draw showed:
  glProgramUniformHandle(prog=4, loc=5, handle=0x100251xxx) → GL_INVALID_OPERATION (0x0502)
on both terrain and alpha sampler uniforms.

Root cause: the `uniform sampler2DArray` + glProgramUniformHandleARB
combination is rejected by the NVIDIA Windows driver in this configuration.
The handle is valid and resident; the uniform location is valid; the
program is valid; but the driver refuses to bind a 64-bit handle to a
sampler uniform via the program-uniform path.

Fix: switch to N.5's mesh_modern pattern — pass each 64-bit handle as a
`uniform uvec2` (low + high 32-bit halves) and construct the sampler at
the use site via the GLSL `sampler2DArray(handle)` constructor. This
form is what ARB_bindless_texture documents as universally supported and
is what N.5 already uses successfully.

Files:
- terrain_modern.frag: replace `uniform sampler2DArray uTerrain/uAlpha`
  with `uniform uvec2 uTerrainHandle/uAlphaHandle` + `#define`s
- TerrainModernRenderer.cs: cache uvec2 uniform locations; set via
  `glProgramUniform2(program, loc, low32, high32)` per frame
- BindlessSupport.cs: remove now-unused `SetSamplerHandleUniform`,
  leave a comment noting why the helper was retired
- GameWindow.cs: also strip the temporary [TERRAIN-DBG] cursor-wrap
  print added during the perf-baseline investigation

Build green; 114/114 tests in N.5+N.5b filter still pass; user-verified
terrain renders correctly in modern path post-fix. Captured fresh perf
baseline:
- Legacy:  cpu_us median  1.5  / p95  3.0  (1 chunk = 1 glDrawElements)
- Modern:  cpu_us median  6.4-7.0 / p95  9-14 (51 visible LBs, 1 MDI call)

Modern is ~4× slower on CPU at radius=5 because the chunked legacy path
already collapsed the scene to one draw call. The architectural wins
(zero glBindTexture/frame; constant-cost dispatch as A.5 raises radius)
will be documented in T10's perf baseline doc; the spec's
"≥10% lower CPU" acceptance criterion is invalid at radius=5 and needs
revision.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 12:53:21 +02:00
Erik
55e516c538 fix(N.5b T8): TerrainDiagMedian/P95 IndexOutOfRangeException on first flush
First diag flush fires ~5s after process start (Environment.TickCount64
threshold), but at that point only 1 sample may have been recorded if
the user is mid-login. The original `copy[copy.Length - nz / 2]` form
underflowed to copy[copy.Length] when nz=1 (nz/2=0), throwing
IndexOutOfRangeException at GameWindow.cs:8799 on the first OnRender
after login.

Fix: use `copy.Length - 1 - (nz - 1) / 2` for median (always >= 0 for
nz >= 1, returns the single sample for nz=1) and clamp the percentile
offset via `(nz - 1) * 0.05` for the same reason.

Caught by user's perf-baseline launch with ACDREAM_LEGACY_TERRAIN=1
(the benchmark toggle from 336ad34). The bug exists in T8 itself
regardless of the toggle.

Build green; existing tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:40:22 +02:00
Erik
336ad34444 chore(N.5b): TEMPORARY perf benchmark toggle for legacy↔modern terrain
Adds an ACDREAM_LEGACY_TERRAIN=1 env var that routes Draw through the
legacy TerrainChunkRenderer instead of the new TerrainModernRenderer.
Both renderers are constructed and fed AddLandblock/RemoveLandblock so
they stay in sync; only one is drawn per frame. The [TERRAIN-DIAG]
log line is labeled /modern or /legacy so the user can tell which
numbers they're capturing.

Removed in Task 9 along with TerrainChunkRenderer.cs, terrain.vert,
and terrain.frag.

Usage:
  \$env:ACDREAM_LEGACY_TERRAIN = "1"   # legacy mode
  \$env:ACDREAM_LEGACY_TERRAIN = \$null # modern mode (default)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:36:13 +02:00
Erik
75913c1c97 phase(N.5b): wire TerrainModernRenderer into GameWindow
Swap TerrainChunkRenderer → TerrainModernRenderer (drop-in: same
AddLandblock/RemoveLandblock/Draw interface). Pass BindlessSupport
to TerrainAtlas.Build so GetBindlessHandles() is callable. Load the
new terrain_modern shader pair and pass to the renderer ctor. Add
[TERRAIN-DIAG] rollup mirroring the existing [WB-DIAG] pattern.

Bindless detection moved above terrain construction so atlas + ctor
can consume BindlessSupport (was previously detected after — order
required for N.5b).

Visual verification at four scenes (Holtburg flat + sloped, Foundry,
sloped landblock) is the next gate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:21:32 +02:00
Erik
dcae2b6b94 phase(N.5): retirement amendment — InstancedMeshRenderer + StaticMeshRenderer + WbFoundationFlag deleted
Final cross-cutting review of N.5 found that Task 15's deletion of
mesh_instanced.vert/.frag left InstancedMeshRenderer orphaned —
ACDREAM_USE_WB_FOUNDATION=0 silently rendered terrain+sky only with
no entities. The SHIP commit's "[x] ACDREAM_USE_WB_FOUNDATION=0 still
works" claim was inaccurate.

Resolution: formal retirement of the legacy renderer path within N.5
instead of deferring to N.6.

Deleted:
- src/AcDream.App/Rendering/InstancedMeshRenderer.cs
- src/AcDream.App/Rendering/StaticMeshRenderer.cs
- src/AcDream.App/Rendering/Wb/WbFoundationFlag.cs

GameWindow simplified — capability detection is unconditional, missing
bindless throws NotSupportedException with a clear message at startup.
WbDrawDispatcher + mesh_modern shader load are mandatory after init.
No escape hatch.

GpuWorldState simplified — WbFoundationFlag.IsEnabled guards on
AddLandblock/RemoveLandblock removed; adapter calls are unconditional
when the adapter is non-null.

PendingSpawnIntegrationTests updated — WbFoundationFlag.ForTestsOnly_ForceEnable
static ctor removed (flag is gone; adapter calls are unconditional).

The ApplyLoadedTerrain physics-data loop was also simplified: the
EnsureUploaded sub-loop that fed InstancedMeshRenderer is gone;
_pendingCellMeshes is now explicitly cleared to prevent unbounded
accumulation (the worker thread still populates it, but WB handles
EnvCell geometry through its own pipeline).

Spec §2 Decision 5 + §10 Out-of-Scope updated. Plan ship-amendment
section added. Roadmap updated (N.5 ships with retirement; N.6 scope
narrowed to perf-only). CLAUDE.md "WB integration cribs" updated.
Perf baseline doc updated. WbDrawDispatcher class summary docstring
corrected to describe the as-shipped SSBO + multi-draw-indirect path.
ISSUES.md #51 updated (terrain not in N.5 scope; deferred to N.7).

Bindless support is now a hard requirement. Modern desktop GPUs
universally expose GL_ARB_bindless_texture + GL_ARB_shader_draw_parameters;
if a user hits the NotSupportedException, that's a real bug report
worth investigating, not a silent fallback.

Build: 0 errors, 0 warnings. Tests: 71/71 (Wb+MatrixComposition+TextureCacheBindless filter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 22:01:36 +02:00
Erik
e6378b90ed phase(N.5) Task 15: delete legacy mesh_instanced shader files
mesh_instanced.vert + .frag deleted. WbDrawDispatcher always uses
mesh_modern when WB foundation is on. Legacy escape hatch
(ACDREAM_USE_WB_FOUNDATION=0 or bindless missing) runs through
InstancedMeshRenderer which has its own shader path — untouched.

GameWindow's else-branch removed; if bindless is missing, _meshShader
stays unloaded, _wbDrawDispatcher stays null, and _staticMesh is not
constructed (its guard requires _meshShader non-null). All downstream
_staticMesh usages were already null-safe (null-conditional operators
or explicit null guards). Two null-forgiving suppressors added at the
WbDrawDispatcher + SkyRenderer construction sites where the compiler
couldn't prove non-null but the logic guarantees it (both require
_bindlessSupport non-null, which implies _meshShader was assigned;
_textureCache is assigned unconditionally).

InstancedMeshRenderer.cs: the one reference to mesh_instanced was
a code comment (location 3 NOT used by mesh_instanced.vert) — not
a file load. Escape hatch code path is preserved; the shader comment
is now stale but low priority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:13:05 +02:00
Erik
f533414edf phase(N.5) Task 10: glMultiDrawElementsIndirect dispatch — visual verified
Replaces WbDrawDispatcher's per-group glDrawElementsInstancedBaseVertexBaseInstance
loop with two glMultiDrawElementsIndirect calls (opaque + transparent).
Per-frame uploads three SSBOs:
- _instanceSsbo @ binding=0 (mat4 per instance, indexed by gl_BaseInstanceARB + gl_InstanceID)
- _batchSsbo @ binding=1 (BatchData per group, indexed by gl_DrawIDARB)
- _indirectBuffer (DrawElementsIndirectCommand[] — opaque first, transparent second)

GameWindow swaps the shader load to mesh_modern when _bindlessSupport
is non-null. Capability detection + shader load now run in the right
order (capability before TextureCache + before Shader).

Deletes the obsolete DrawGroup stub, EnsureInstanceAttribs, _instanceBuffer,
_patchedVaos. ClassifyBatches + ResolveTexture already migrated in
Task 8 to use ulong bindless handles.

BuildIndirectArrays (Task 9) wired in: _opaqueDraws + _translucentDraws
are flattened into IndirectGroupInput[], laid out via the helper into
contiguous indirect commands + parallel BatchData[]. opaqueByteOffset=0,
transparentByteOffset = opaqueCount × DrawCommandStride.

Visual verification (USER GATE) PASS: Holtburg courtyard renders
identical to N.4 — terrain, scenery, characters, NPCs all visible
without artifacts. [N.5] modern path capabilities present + mesh_modern
shader loaded log lines confirm the boot path. [WB-DIAG] hot-path
counters show healthy entity/draw activity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:51:49 +02:00
Erik
86c471d2d1 phase(N.5) Task 7: dispatcher SSBO + indirect buffer infrastructure
Adds DrawElementsIndirectCommand struct (20-byte layout for
glMultiDrawElementsIndirect). Replaces _instanceVbo field on
WbDrawDispatcher with three buffers: _instanceSsbo (mat4[]),
_batchSsbo (BatchData[]), _indirectBuffer (DEIC[]). Adds BindlessSupport
constructor parameter — non-null required since the dispatcher is only
constructed when WB foundation is on (which implies bindless is present
per Task 6 capability detection).

Existing Draw() method substitutes _instanceVbo -> _instanceSsbo for
compile. Behavior is temporarily wrong (SSBO bound as ArrayBuffer for
per-vertex attribs); Tasks 9-10 fully rewrite the draw loop and the
per-frame uploads to use BindBufferBase + glMultiDrawElementsIndirect.

GameWindow construction site updated to add _bindlessSupport guard and
pass it as the new last argument to the constructor. Dispatcher is only
constructed when bindless is guaranteed present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:25:29 +02:00
Erik
12170f9d78 phase(N.5) Task 6 fixup: log symmetry + Silk extension shortcut
Code quality review caught:
- Silent failure when ARB_bindless_texture absent — the && short-circuit
  meant the most common fallback case (no bindless on the GPU) had no
  log, while ARB_shader_draw_parameters absent did log. Restructured to
  three nested ifs so each failure path logs symmetrically.
- Redundant `bindless is not null` guard removed (TryCreate's non-null
  guarantee covers it; the nested-if structure makes this implicit).
- HasShaderDrawParameters in BindlessSupport.cs replaced its manual
  GL_NUM_EXTENSIONS scan with `gl.IsExtensionPresent(...)` — same
  pattern WB uses, less code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:21:10 +02:00
Erik
93ebd9e433 phase(N.5) Task 6: GameWindow capability detection + plumb BindlessSupport
Detects ARB_bindless_texture + ARB_shader_draw_parameters at startup
when WbFoundationFlag is enabled. Stores BindlessSupport on GameWindow
and passes it to TextureCache so the parallel Texture2DArray upload
path is available to future bindless callers.

Mesh shader load remains mesh_instanced for now — Task 10 swaps to
mesh_modern after Tasks 7-9 rewire the dispatcher to consume the
bindless + SSBO + indirect machinery.

Capability missing → BindlessSupport stays null → TextureCache runs
without the bindless path → legacy callers (StaticMeshRenderer,
InstancedMeshRenderer, ParticleRenderer, current WbDrawDispatcher
draw loop) are unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 20:15:06 +02:00
Erik
943652dc97 phase(N.4) Tasks 22+23 fixup: trigger WB mesh loads + correct SurfaceId source
Task 26 visual verification surfaced three bugs in the dispatcher.
Two are fixed here; the third is documented as a remaining issue.

1. WB's IncrementRefCount only bumps a usage counter — it does NOT
   trigger mesh loading. Fixed in WbMeshAdapter.IncrementRefCount:
   call PrepareMeshDataAsync(id, isSetup: false) on first registration.
   Result auto-enqueues to _stagedMeshData (line 510 of WB's
   ObjectMeshManager) which Tick() drains onto the GPU.

2. EntitySpawnAdapter never registered per-instance entity meshes
   with WB. LandblockSpawnAdapter only registers atlas-tier
   (ServerGuid == 0); per-instance entities fell through. Fixed by
   adding optional IWbMeshAdapter constructor param + tracking unique
   GfxObj ids per server-guid for IncrementRefCount on OnCreate /
   DecrementRefCount on OnRemove.

3. WbDrawDispatcher.ResolveTexture used batch.SurfaceId which WB
   never populates (line 1746 of ObjectMeshManager only sets
   batch.Key — the TextureKey struct that has SurfaceId). Switched
   to batch.Key.SurfaceId.

Plus diagnostic counters (ACDREAM_WB_DIAG=1) for entity-seen / drawn
/ mesh-missing / draws-issued counts.

Status: with these fixes the dispatcher now issues real draw calls
(~16K/frame, validated via diagnostic). However visual verification
shows characters appear "exploded" (parts spaced too far apart) and
scenery (trees/rocks/fences/buildings) does not appear. Root cause
analysis pending — Adjustment 7 in the plan documents the deferred
work. Flag stays default-off; legacy renderer remains the
production path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:50:21 +02:00
Erik
01cff4144f phase(N.4) Tasks 22+23: WbDrawDispatcher + surface metadata side-table
WbDrawDispatcher draws all entities through WB's ObjectRenderData
(VAO/VBO per GfxObj, per-batch IBO) using acdream's TextureCache for
texture resolution. Two-pass rendering (opaque+ClipMap, then
translucent) matching the existing InstancedMeshRenderer pattern.
Per-entity single-instance drawing for N.4 simplicity — true
instancing grouping deferred to N.6.

Atlas-tier entities: mesh from WB, texture from TextureCache via
batch SurfaceId. Per-instance-tier entities: AnimatedEntityState
drives part overrides + hidden-parts, palette/surface overrides
resolve through TextureCache's composite-key caches.

Side-table population (Task 23 folded in): WbMeshAdapter now takes
DatCollection and populates AcSurfaceMetadataTable on first
IncrementRefCount per GfxObj. The side-table provides TranslucencyKind
(critical for ClipMap alpha-test on vegetation) plus Luminosity,
Diffuse, SurfOpacity, NeedsUvRepeat, DisableFog for sky-pass and
lighting.

GameWindow wiring: when WbFoundationFlag is enabled, WbDrawDispatcher
draws everything and InstancedMeshRenderer is skipped. Flag-off path
is unchanged.

Matrix composition: restPose * animOverride * entityWorld, matching
the spec. Three MatrixCompositionTests verify the contract.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:30:33 +02:00
Erik
5b4fd4b61d phase(N.4) Adjustment 6: add PartOverrides + HiddenPartsMask to WorldEntity
Resolves Adjustment 4 (Option A): WorldEntity now carries the server-
sent AnimPartChange data as PartOverrides and a HiddenPartsMask bitmask.
EntitySpawnAdapter.OnCreate populates AnimatedEntityState from these
fields at spawn time. GameWindow's CreateObject handler converts the
network-layer AnimPartChange records into lightweight PartOverride
structs.

This unblocks Task 22: the WbDrawDispatcher can now resolve per-part
GfxObj overrides and hidden-part suppression from entity state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 15:10:22 +02:00
Erik
c02c307bee phase(N.4) Task 17: EntitySpawnAdapter for server-spawned per-instance content
Routes server-spawned (CreateObject) entities through the per-instance
rendering path. Filter: ServerGuid != 0. Atlas-tier entities (procedural,
ServerGuid == 0) flow through LandblockSpawnAdapter (Task 11) instead.

For entities with PaletteOverride set, walks each MeshRef.SurfaceOverrides
map and calls TextureCache.GetOrUploadWithPaletteOverride to pre-warm the
palette-composed GL texture before the first draw. Surfaces not in the
SurfaceOverrides map (i.e. whose ids are only known after opening the GfxObj
dat) are decoded lazily by the draw dispatcher on first use, consistent with
StaticMeshRenderer.

Builds AnimatedEntityState per server-guid via injected sequencer factory
(Func<WorldEntity, AnimationSequencer>). The factory decouples the adapter
from DatCollection so tests pass a stub lambda without a GL context.

OnRemove releases per-entity state. Unknown guids no-op.

Introduces ITextureCachePerInstance: thin seam interface over the palette
decode path so EntitySpawnAdapter tests can use a CapturingTextureCache
mock without constructing a GL context. TextureCache implements it.

Adjustment 4 documented in source comments: WorldEntity does not currently
expose HiddenPartsMask or AnimPartChanges (they are consumed upstream in the
network layer before the WorldEntity is built). HideParts / SetPartOverride
calls are placeholder TODO'd for when those fields are promoted.

Wired into GpuWorldState.AppendLiveEntity (OnCreate) and
RemoveEntityByServerGuid (OnRemove). Constructed in GameWindow under the
ACDREAM_USE_WB_FOUNDATION flag alongside LandblockSpawnAdapter. Sequencer
factory captures _dats + _animLoader at construction time; falls back to an
empty Setup + MotionTable via NullAnimLoader when dats are unavailable.

10 new tests: server-spawn routing, atlas-tier skip, palette decode pre-warm
(with and without surface overrides), OnRemove lifecycle, unknown-guid noop,
multi-entity isolation. All pass; 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:46:34 +02:00
Erik
bf53cb4fce phase(N.4): WbMeshAdapter.Tick — drain WB pipeline queues per frame
Without this, ObjectMeshManager.StagedMeshData and
OpenGLGraphicsDevice._glThreadQueue grow unbounded as background
workers prep mesh data + queue GL actions. Visual stress test of
flag-on at radius 7 showed real FPS drop and rising frame latency
from this leak.

Tick() drains both queues:
1. _graphicsDevice.ProcessGLQueue() applies pending GL state.
2. Loop _meshManager.StagedMeshData.TryDequeue -> UploadMeshData
   to materialize VAO/VBO/IBO for each prepared mesh.

Wired into GameWindow's render loop before draw work begins.
No-op when adapter is uninitialized or disposed.

Pattern matches WB's reference ObjectRenderManagerBase.ProcessUploads
without the prioritization heuristics (we're not yet drawing the
results — Task 22's WbDrawDispatcher will add prioritization when
visual budget matters).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:24:32 +02:00
Erik
931a690c4c phase(N.4) Task 12: wire LandblockSpawnAdapter into GpuWorldState
GpuWorldState's constructor accepts an optional LandblockSpawnAdapter.
AddLandblock calls OnLandblockLoaded with the post-merge loaded record;
RemoveLandblock calls OnLandblockUnloaded with the landblock id at the
top of the method (before state mutation).

Both calls are gated behind WbFoundationFlag.IsEnabled — no behavioral
change with flag off (existing tests pass without modification).

GameWindow constructs the adapter under the flag and threads it into
GpuWorldState. With flag on, atlas-tier scenery now drives WB ref
counts; per-instance entities (ServerGuid != 0) are filtered out by
the adapter and don't reach WB.

Foundation for Task 13 (memory budget verification under stress).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:56:40 +02:00
Erik
4ad7a985cf phase(N.4) Task 9: real WB pipeline bring-up + InstancedMeshRenderer routing
WbMeshAdapter now actually constructs the WB pipeline:
- OpenGLGraphicsDevice(gl, logger, DebugRenderSettings)
- DefaultDatReaderWriter(datDir) — opens its own file handles for now
  (memory cost ~50-100MB of duplicate index caches, acceptable for
  foundation work per plan Adjustment 1)
- ObjectMeshManager(graphicsDevice, dats, NullLogger)

InstancedMeshRenderer.EnsureUploaded routes through the adapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; uses a WbManagedSentinel entry
in the local cache to mark "this GfxObj lives in WB now". CollectGroups
skips sentinel entries; both Draw passes skip them; Dispose skips them
(no GL resources to free — ObjectMeshManager owns those). Task 22's
WbDrawDispatcher will eventually draw WB-managed objects. With flag
off, behavior is byte-identical to before.

WbMeshAdapter constructor signature changed from (GL, DatCollection,
Logger) to (GL, string datDir, Logger). Updated tests to use
CreateUninitialized() for behavior tests and single null-GL guard test
for constructor validation. GameWindow updated to pass _datDir and to
wire _wbMeshAdapter into InstancedMeshRenderer.

AcDream.App.csproj gets direct ProjectReferences to WorldBuilder.Shared
and Chorizite.OpenGLSDLBackend — project refs are not transitive in
.NET, so AcDream.App must list them explicitly even though AcDream.Core
already references them.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:31:30 +02:00
Erik
502c3a87e4 phase(N.4) Tasks 6+7: skip dat-reader bridge; wire WbMeshAdapter into GameWindow
Task 6 (dat-reader bridge) obsoleted: WB ships DefaultDatReaderWriter
which takes a dat-directory path and constructs all four databases
(Portal/HighRes/Language + CellRegions) internally. We can use it
directly instead of bridging our DatCollection. Adjustment 1 noted
in the plan; full bring-up deferred to Task 9.

Task 7: GameWindow constructs WbMeshAdapter when
ACDREAM_USE_WB_FOUNDATION=1 is set; pairs with Dispose. Field is
null when flag is off, so no behavioral effect on default-off path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 13:21:47 +02:00
Erik
a4693954d8 fix(scenery): #48 unify scenery Z with physics-path triangle picker
Closes #48. Trees on sloped cells visibly hovered above the visible
terrain because GameWindow.SampleTerrainZ (the bilinear fallback used
during scenery hydration before physics registers a landblock) had
its diagonal arms swapped — used the SEtoNW triangle test on SWtoNE
cells and vice versa. The ACDREAM_DUMP_SCENERY_Z=1 diagnostic showed
every scenery line ran through the bilinear path (streaming race),
so on hilly terrain scenery was placed at a Z up to ~1.5 m off from
the visible mesh.

Latent since ff325ab (2026-04-17 "feat(ui): debug overlay + refined
input controls" carrying along the upgrade). That commit reached for
WorldBuilder TerrainUtils.GetHeight as the secondary oracle and
re-derived the triangle-pair tests; the named-retail / ACE algorithm
in TerrainSurface.SampleZ (used by the physics path / player Z) was
always correct, so player feet stayed flush — the two paths just
disagreed and only scenery noticed.

Fix:
- TerrainSurface.InterpolateZInTriangle (private static) — single
  source of truth for the triangle pick + barycentric Z, sourced
  from FUN_00532a50 / ACE LandblockStruct.ConstructPolygons.
- TerrainSurface.SampleZFromHeightmap (public static) — heightmap-
  byte-array variant for the scenery hydration fallback. Both this
  and TerrainSurface.SampleZ (instance) now delegate to the same
  InterpolateZInTriangle.
- GameWindow.SampleTerrainZ — thin wrapper over the new static.
- TerrainSurfaceTests.SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock
  asserts both sampler paths agree at 1500 sample points across both
  diagonals, so future drift gets caught.

The ACDREAM_DUMP_SCENERY_Z=1 diagnostic in BuildSceneryEntitiesForStreaming
is kept committed (env-var gated, zero cost when off) — useful for
the related #49 scenery (X, Y) placement investigation filed in the
same commit.

Visual verified at Holtburg landblock 0xA9B30001 2026-05-06: the
formerly floating 32 m pines (setups 0x020002D3 / 0x020002D9) now
sit flush on the visible terrain mesh.

Test baseline: dotnet test reports the same 8 pre-existing motion /
BSP step-up failures as the handoff doc warned about — no new
failures introduced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 14:30:25 +02:00
Erik
c1bb43ab89 Merge main into claude/strange-ardinghelli-d810cd
Brings in #38 render-interpolation camera work before testing #48
diagnostic dump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:58:06 +02:00
Erik
8ee76deefd diag(scenery): #48 ACDREAM_DUMP_SCENERY_Z dump
Per-spawn / per-rendered-mesh log line at scenery hydration: rendered
gfx id, sample source (physics vs bilinear), groundZ, BaseLoc.Z,
finalZ, mesh vertex Z range, and DIDDegrade slot 0 metadata. One log
line lets the user identify a floating tree by world coords and the
data picks the hypothesis (BaseLoc.Z addition / sampler drift /
DIDDegrade selection). Diagnostic-first per CLAUDE.md before the fix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 17:57:10 +02:00
Erik
71b1622293 fix(camera): #38 render-interpolate player motion
Keep local physics authoritative at the retail 30 Hz MinQuantum, but expose a render-only position that lerps between completed physics ticks for the player mesh and chase-camera target. Network outbound continues to use the discrete physics position.

Also make the visually confirmed #47 humanoid close-detail DIDDegrade path default-on, with ACDREAM_RETAIL_CLOSE_DEGRADES=0 left as a diagnostic opt-out.

Verification: dotnet build AcDream.slnx -c Debug; focused #38 interpolation tests passed; visual confirmed smooth 2026-05-06. Full dotnet test AcDream.slnx -c Debug --no-build still has the known 8 AcDream.Core.Tests baseline failures.

Co-authored-by: Codex <codex@openai.com>
2026-05-06 17:53:34 +02:00
Erik
0bd9b9693b fix(rendering): #47 — walk DIDDegrade for retail close-detail meshes
Humanoid bodies (Setup 0x02000001 + heritage variants) rendered visibly
flat / bulky vs retail because we drew the base GfxObj id from Setup /
AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as the entry point to a DIDDegrade
table; close/player rendering uses Degrades[0].Id, which is the
higher-detail mesh that carries bicep / deltoid / shoulder geometry.

ACViewer also has this bug — it was the key signal it isn't acdream-
specific. Both clients drew the LOD-3 base mesh (e.g. 14 verts / 17
polys for Aluvian Male upper arm 0x01000055), missing the close-
detail variant (0x01001795: 32 verts / 60 polys).

Adds GfxObjDegradeResolver that walks the table with safe fallbacks
at every step. Wired in GameWindow after AnimPartChange application
and before texture-change resolution so texture overrides match the
resolved mesh's surfaces. Gated by ACDREAM_RETAIL_CLOSE_DEGRADES=1
and scoped to humanoid setups (34 parts with >=8 null-sentinel
attachment slots) while the fix bakes — the change is harmless on
non-humanoid setups (resolver falls back to base when no degrade
table) but we hold the broader sweep until LOD distance plumbing
lands.

User confirmed visually 2026-05-06: bicep, deltoid, and back-muscle
definition match retail.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 16:46:23 +02:00
Erik
e697a9ad1e docs(issues): file #47 (humanoid bulky-shape bug); land DUMP_CLOTHING diagnostics
Filed #47 in docs/ISSUES.md — humanoid characters using Setup 0x02000001
(players + Woodsman + other Aluvian NPCs) render visibly bulkier and less
shape-defined than retail's view. Drudges and other monster setups render
identically. Independent of equipment (naked +Je still shows it).
Investigation this session ruled out 0xF625 ObjDescEvent drops (real bug,
fixed in e471527, but doesn't explain shape), HiddenParts overlap,
ParentIndex walking (animation frames are setup-root coords already),
and player-specific data flow (NPCs using same setup affected too).

Diagnostic infrastructure landed alongside the issue (env-var-gated, no
runtime cost when off):
- ACDREAM_DUMP_CLOTHING=1 now also prints:
  - setup.Parts.Count, flatten.Count, APC count on header
  - ParentIndex[] and DefaultScale[] arrays
  - IdleFrame per-part Origin + Orientation (first 17 parts)
  - per-part EMIT line: gfx, subMeshes count, triangle count
  - TOTAL triangle / meshRef counts per entity
This is what nailed down "all 34 parts emit" + "animation frames are
setup-root not parent-local" + "humans get setup-wide 180°-Z rotation
that drudges don't" — saved hours next session.

Open hypotheses for #47 next session: per-face vs smoothed vertex
normals (per-vertex normals from dat may be face-style for human
GfxObjs but smooth for monsters), low cell ambient leaving back faces
flat-shadowed, missing MSAA on the GL window.
2026-05-06 11:30:41 +02:00
Erik
e471527924 feat(net): wire 0xF625 ObjDescEvent for live appearance updates
Retail-driven players observed from acdream rendered with stale
appearance — wrong skin/hair palettes, missing clothing — because
ACE's mid-session appearance broadcasts (equip/unequip/tailoring/
recipe/option-toggle) ride opcode 0xF625 ObjDescEvent and acdream
silently dropped them. Initial CreateObject carries the appearance
at spawn time, but every later equip change only updates via 0xF625
(per Skunkwors protocol docs in ACE/.../GameMessageObjDescEvent.cs).
Retail handles via SmartBox::HandleObjDescEvent (named-retail 0x453340).

Why: the retail observer sees the *server-relayed* view of remotes,
not retail's local build, so dropping ObjDescEvent freezes appearance
at the partial state in the first CreateObject.

How:
- Extract CreateObject's ModelData parsing into reusable
  CreateObject.ReadModelData(span, ref pos) returning
  (BasePaletteId, SubPalettes, TextureChanges, AnimPartChanges).
- Add ObjDescEvent.cs (parser for 0xF625):
  body = u32 opcode | u32 guid | ModelData | u32 instanceSeq | u32 visualDescSeq.
- WorldSession.AppearanceUpdated event + dispatcher branch.
- GameWindow.OnLiveAppearanceUpdated splices new ModelData onto the
  cached spawn and replays via OnLiveEntitySpawned. The dedup at the
  start of OnLiveEntitySpawnedLocked tears down the old GPU/animated/
  collision state cleanly before rebuild.
- _lastSpawnByGuid cache populated at spawn-end and tracked through
  UpdatePosition so re-applies use current position (no pop-back to
  login spot on equip toggle).
- ACDREAM_DUMP_APPEARANCE=1 env var prints structured SP/TC/APC
  decode for every 0xF625 — replaces the earlier raw-hex preview.
- ACDREAM_DUMP_CLOTHING extended with setup.Parts.Count, flatten.Count,
  and per-part triangle counts for offline polygon-budget audit.

Tests: 4 new ObjDescEvent tests (round-trip + parser drift guard);
269 net tests green. User-verified live: skin/hair colors match
retail's character data; equip/unequip no longer pops position.

Note: a separate "puffy arms / bulky body" geometry issue remains
where base body parts visibly overlap clothing meshes — different
root cause, tracked separately.
2026-05-06 10:46:14 +02:00
Erik
e9e080db8c fix(motion): close #45 — scale local sidestep speedMod by ACE's wire factor
User observed (during fix #5 visual verify of #39): "our own Acdream
client renders sidestep walking too slow". Filed as #45.

Root cause: PlayerMovementController.cs:871 computes localAnimSpeed as
the raw `runRate || 1.0`, while ACE's BroadcastMovement converts
inbound MoveToState SidestepSpeed via
    speed × 3.12 / 1.25 × 0.5
(Network/Motion/MovementData.cs:124-131). Observer-side cycles play at
the ACE-scaled value (~1.248 slow / ~3.0 fast clamped); the local
cycle was playing at the raw 1.0 / runRate — about 80% of retail
cadence for slow strafe.

Fix: in UpdatePlayerAnimation, when animCommand is SideStepLeft / Right
(low byte 0x0F or 0x10), multiply animSpeed by
    WalkAnimSpeed / SidestepAnimSpeed × 0.5 = 3.12 / 1.25 × 0.5 = 1.248
before calling SetCycle. Same factor as ACE; no clamp on the local
side (sequencer handles MultiplyCyclicFramerate naturally).

Forward / backward / turn cycles unchanged — those use WalkAnimSpeed
or RunAnimSpeed as base, where localAnimSpeed = wire ForwardSpeed
already produces the right cadence.

Build clean. Visual verify pending: user reports slow-strafe cadence
should match retail / our own observed-remote rendering after this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:58:41 +02:00
Erik
898d7cd2cf tune(motion): #39 — tighten UmGraceSeconds 0.5 → 0.2
User observed the Shift-toggle cycle transition was "not as fast as
retail" after fixes #3-#5 landed the velocity-fallback path. Worst-case
added latency was the full 500 ms grace window before the first UP
could refine the cycle.

200 ms covers the actual UM/UP race — UMs arrive on direction-key
events, UPs at 5-10 Hz, so the first UP after a fresh UM lands
~100-200 ms behind it. Below that, fallback could prematurely
overwrite a UM's cycle decision; above that adds latency for no
correctness benefit. Direction flips (W↔S, A↔D, Forward↔Strafe)
update via UM directly so they're unaffected by this change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:50:44 +02:00
Erik
349ba65f3e fix(motion): #39 — use SidestepAnimSpeed (1.25) as sidestep mapping base
Fix #4 (commit cc62e1c) divided the observed horizSpeed by WalkAnimSpeed
(3.12 m/s) when computing the sidestep speedMod. That made slow strafe
come out 2.5× too slow because retail's sidestep cycle uses
SidestepAnimSpeed (1.25 m/s) — a smaller base — per
MotionInterpreter.cs:592 `velocity.X = SidestepAnimSpeed * SideStepSpeed`.

User report: "Strafe left and right slowly now is SUPER slow :)".

Replace MotionInterpreter.WalkAnimSpeed with MotionInterpreter.SidestepAnimSpeed
in the sidestep branch only. Forward / backward branches continue using
WalkAnimSpeed (correct for those motions).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:40:50 +02:00
Erik
cc62e1cfde fix(motion): #39 — handle backward sign + sidestep in ApplyPlayerLocomotionRefinement
User report from fix #3 visual verify (commit 2653b30):
- Forward Run↔Walk Shift toggle: WORKS now
- Strafe Shift toggle: no transition (was out of scope)
- "When I shift walk backwards, the retail char gets animated walking
  slow forward but blipping backwards" — REGRESSION

Root cause of the backward regression: ACE encodes WalkBackward as
`WalkForward` motion with NEGATIVE speedMod (MovementData.cs:115
`interpState.ForwardSpeed *= -0.65f`). My fix #1's hysteresis branches
treated lowByte 0x05 / 0x07 as "forward" and computed positive speedMod
from horizSpeed, overwriting the negative sign. Result: animation
played forward-walk while body kept moving backward (the rubber-band).

Strafe gap: sidestep (low byte 0x0F / 0x10) wasn't in fix #1's scope,
so ApplyPlayerLocomotionRefinement returned early for sidestep cycles.
Retail does the same wire-silence on Shift toggle for sidestep, so
observer-side cycle refinement must also fire for it.

Fix:
- Probe `currentSign = sign(CurrentSpeedMod)` to detect backward direction
- For sidestep (lowByte 0x0F or 0x10): keep motion ID, refine
  speedMod magnitude = horizSpeed / WalkAnimSpeed, preserve sign
- For backward (forward-class lowByte AND currentSign < 0): keep
  WalkForward motion (per ACE encoding), refine magnitude, preserve
  negative sign — no "RunBackward" motion exists, only |speedMod|
  changes between Walk-back (~0.65) and Run-back (~1.91 = runRate × 0.65)
- Forward (currentSign >= 0): existing Walk↔Run hysteresis unchanged

Build clean. Diagnostics: [UPCYCLE_PLAYER] line still prints; the
new sidestep / backward branches use the same SetCycle call so
their decisions appear in [SCFULL] / [CURRNODE] for inspection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:36:22 +02:00
Erik
2653b307c7 fix(motion): #39 — wire ApplyServerControlledVelocityCycle into player-remote path
Visual verify with the proper Shift-toggle scenario revealed that fix #1's
ApplyPlayerLocomotionRefinement was UNREACHABLE for player remotes — the
L.3 M2 routing at line 3626+ returns at line 3755, BEFORE the call site
at line 3879. The legacy NPC-only block that compute server velocity +
calls ApplyServerControlledVelocityCycle never runs for players.

[UPCYCLE_PLAYER] count = 0 in launch-39-fix2.log and launch-39-diag2.log
proved the velocity-fallback path was completely dead code for players.

Wire-level evidence (launch-39-diag2.log):

- [FWD_WIRE] for retail actor 0x50000001 over a clean Hold-W-press-Shift-
  release-Shift-release-W test shows ONLY Ready→Run and Run→Ready
  transitions. NO Walk wire transitions for the Shift toggle. So retail's
  outbound MoveToState logic does NOT emit a fresh packet on HoldKey-only
  changes (refutes the launch-39-fix2 hypothesis that both directions
  emit; the earlier fix2 log's many Walk↔Run transitions came from
  W press/release cycles WITH Shift held continuously, not from Shift
  toggling alone).
- [VEL_DIAG] over the same test shows clear walk-pace (~2.5 m/s) and
  run-pace (~11.5 m/s) periods, so the actor's actual physical speed
  IS changing despite the wire silence.

Fix: in OnLivePositionUpdated's L.3 M2 player-remote block, after the
near-enqueue / far-snap routing and before the early `return`, compute
synth velocity from PrevServerPos / LastServerPos and call into
ApplyServerControlledVelocityCycle. The function's internal routing
(commit 8fa04af) sends player remotes through ApplyPlayerLocomotionRefinement
which has the 500 ms UM grace + forward-direction + hysteresis logic
to flip Run↔Walk only when no fresh UM is authoritative.

Build clean. Diagnostics: [UPCYCLE_SRC] now prints `src=synth-player`
when the player-remote path fires (distinct from `src=synth`/`src=wire`
in the legacy NPC path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:25:10 +02:00
Erik
bb026b7991 diag(motion): #39 — per-tick [CURRNODE] for sequencer node identity
Visual-verify of fix #2 (commit 863d96b) showed [SCFULL] correctly reports
currNodeIsCyclic=True after each direct Walk↔Run SetCycle (the link is
removed and _currNode is set to _firstCyclic). User report still:

- Animation continues running visually after Shift toggle to Walk
- Body slows ("speed decreases"), causing rubber-banding
- Adding a turn motion in that state makes the cycle finally transition
  to walking

So either:
- _currNode is reset to a stale node BETWEEN SetCycle and Advance
- _currNode is correctly on the new cycle but its AnimRef is wrong
  (e.g., the same Animation as the previous cycle, dat-side issue)
- BuildBlendedFrame reads from somewhere other than _currNode

Adds CurrentNodeDiag + FirstCyclicAnimRefHash properties on
AnimationSequencer that expose the active node's Animation
identity-hash, IsLooping, Framerate, frame range, and FramePosition.
TickAnimations logs them on every SEQSTATE tick (1 Hz throttle, gated
on ACDREAM_REMOTE_VEL_DIAG=1).

The [CURRNODE] line with animRef vs firstCyclicAnimRef proves whether
_currNode is actually on the new cycle's anim or has drifted to
something else. Compared across SetCycle SCFULL log lines + the
following CURRNODE ticks, we can see the exact moment the cycle
diverges from what SetCycle set.

No code-behavior changes. Pure read-only instrumentation. Per
Phase 4.5 of systematic-debugging — STOP attempting fixes; gather
evidence first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 08:17:56 +02:00
Erik
8fa04af4c7 fix(motion): #39 candidate — un-gate UP velocity-cycle for player remotes (forward only)
Adds a player-remote velocity-fallback path to ApplyServerControlledVelocityCycle
so that when retail (the actor) toggles Shift while holding W and acdream is
the observer, the visible leg cycle switches Run↔Walk within ~200–500 ms even
though no fresh UM arrives. Static analysis (ACE GameActionMoveToState +
MovementData.cs auto-upgrade + acdream's prior diag traces) suggests retail
does NOT broadcast a fresh MoveToState on HoldKey-only changes — acdream's
UMs handle direction-key changes and our local +Acdream's transitions, but
retail-driven actors leave the cycle stuck.

Changes (all in src/AcDream.App/Rendering/GameWindow.cs):
- New RemoteMotion.LastUMTime field, stamped in OnLiveMotionUpdated
- ApplyServerControlledVelocityCycle: removed inner IsPlayerGuid gate;
  routes player remotes to new ApplyPlayerLocomotionRefinement
- ApplyPlayerLocomotionRefinement (forward-direction only):
  - 500 ms UM grace window (UMs win when fresh)
  - Forward-direction-only (low byte 0x05 / 0x07)
  - Hysteresis: Run → Walk demote at < 4.5 m/s; Walk → Run promote > 5.5 m/s
  - Skip SetCycle when neither motion ID nor speedMod changed meaningfully
  - [UPCYCLE_PLAYER] diag gated on ACDREAM_REMOTE_VEL_DIAG=1
- Outer call site in OnLivePositionUpdated un-gated (!IsPlayerGuid removed);
  per-remote routing now lives inside the function

Scope: case #1 (Run↔Walk forward) only. Cases #2–#7 (backward, sidestep
speed-buckets, direction-flips) remain deferred — PlanFromVelocity is
forward-only and its NPC-tuned thresholds (RunThreshold=1.25) do not
separate player Walk (~2.5 m/s) from player Run (~9 m/s); a TTD trace
of retail's per-direction algorithm should ground the wider fix.

ISSUES.md #39 updated with progress; investigation-prompt.md and a new
findings-static.md committed under
docs/research/2026-05-06-locomotion-cycle-transitions/ (the prompt was
authored on a parallel branch in commit 7a38da3 and is brought into this
worktree here so the next session can find it without branch-hopping).

Build clean. The 8 pre-existing test failures on this branch
(BSPStepUpTests.C3_Path6_AirborneMoverHitsSteepSlope, MotionInterpreter
WalkBackward GetMaxSpeed, etc.) are unrelated to this change — verified
by running them with the diff stashed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 06:34:20 +02:00
Erik
9e4772a8f8 fix(motion): project anim root motion onto terrain plane (slope staircase)
Grounded player remotes were showing a ~5 Hz Z staircase when running
up/down slopes — the rate of server UpdatePositions. Body Z stayed flat
between UPs, then ramped over ~100ms during the queue-active chase to
each new server position, then went flat again until the next UP.

Diagnosis (no diagnostic needed — the math is unambiguous):
PositionManager.ComputeOffset has two modes via
InterpolationManager.AdjustOffset:

  - Queue active (body chasing a waypoint): returns
    `(head − body) / dist × min(catchUpSpeed × dt, dist)`. 3D direction,
    Z follows server's reported Z naturally.
  - Queue empty / head-reached (within DESIRED_DISTANCE = 0.05m of the
    most recent UP): returns Vector3.Zero. ComputeOffset falls back to
    `seqVel × dt rotated into world` — pure animation root motion. Every
    locomotion cycle bakes Z=0 in body-local, so the world result has
    Z=0 too. XY advances at the running pace; Z stays at the last UP.

For a runner at maxSpeed ≈ 4 m/s with catchUpSpeed = 2× = 8 m/s and
server UPs at ~5 Hz, body covers ~0.8m per UP, chases for ~100ms
(queue-active 3D path, Z ramps), then sits in seqVel-only mode for
~100ms (Z flat) until the next UP. Visible as a 5 Hz Z staircase.

Fix mirrors retail's CTransition::adjust_offset contact-plane projection
(named-retail acclient_2013_pseudo_c.txt:272296-272346) for grounded
motion, applied at the queue-empty boundary instead of inside the sweep:

  PositionManager.ComputeOffset gains an optional Vector3? terrainNormal.
  When the seqVel-only fallback runs AND a non-trivial terrain normal is
  supplied, project rootMotionWorld onto the plane:

      result = rootMotionWorld − N × dot(rootMotionWorld, N)

  Anim XY motion gains a corresponding Z component proportional to slope
  angle × forward speed, so body Z follows the terrain mesh between UPs.
  No-op on flat ground (N ≈ +Z, dot ≈ 0); cannot regress L.3 M2's
  flat-ground verification.

GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal at the body's current XY each tick
and passes it to ComputeOffset. SampleTerrainNormal is a thin public
wrapper over the existing internal SampleTerrainWalkable that returns
just the plane normal (no need to expose the internal sample shape).

Diagnostic: ACDREAM_SLOPE_DIAG=1 prints a per-tick [SLOPE] line with
guid, body Z before/after, offset, queue active flag, and the sampled
plane Nz so we can grep before/after the fix and confirm Z changes
continuously between UPs on slopes.

Tests: PositionManagerTests gains two cases:
  - slope projection: 30° east-tilted plane, body running due east at
    4 m/s for 1s → expect (3.0, 0, −1.732) (descends along slope, not
    flat). Math: dot(seqVel, N) = 2.0 → result = (4,0,0) − (0.5,0,0.866)
    × 2.0 = (3.0, 0, −1.732).
  - flat-ground no-op: N = +Z, expect identical Y-only motion as the
    pre-fix behavior.

Build green. 357 pass / 6 pre-existing fail (same set as ec59a08;
verified by stashing this change). The pre-existing
`ComputeOffset_BothActive_Combined` failure reflects an outdated
additive-design test docstring; the M2 commit (40d88b9) deliberately
changed the implementation to REPLACE semantics to fix the prior
3×-server-pace overshoot.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:37:42 +02:00
Erik
ec59a08db5 fix(physics): #42 skip self in FindObjCollisions — airborne XY drift
Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.

Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
  the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
  only horizontal contribution and manifests as a clean ~1m drift.

Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).

Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:

    if ((physobj->parent == 0 && physobj != arg2->object_info.object))

`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:

  - ObjectInfo gains a SelfEntityId field (default 0 = no filter).
  - ResolveWithTransition gains an optional `uint movingEntityId = 0`
    parameter that sets it.
  - FindObjCollisions skips entries whose EntityId matches
    SelfEntityId when the id is non-zero.
  - PlayerMovementController gains a LocalEntityId property; GameWindow
    refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
  - GameWindow's airborne-remote ResolveWithTransition call site passes
    `movingEntityId: kv.Key` (kv.Key is the local entity id keying
    `_animatedEntities`, same id used at the spawn-time
    ShadowObjects.Register).

Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.

Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
  - movingEntityId=0 (control)        → unfiltered XY drift > 0.5m
  - movingEntityId=registered id (fix) → XY drift ≈ 0

Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.

Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 19:01:07 +02:00
Erik
086e65dfe6 Merge L.3 motion port — queue-only chase for grounded player remotes
Brings the elated-aryabhata-208d5e branch into main. 7 commits implementing
the L.3 retail-faithful remote-entity motion port:

  de129bc  M1  Fresh InterpolationManager port + retail spec
  40d88b9  M2  Queue routing for player-remote UPs + entity-position sync
  2365c8c  M3  Animation root motion fallback for idle queue
  d57ace0  M6  Cleanup — dead fields + stale env-var references
  c26bbbb  M4  Jump-CellId fix + #42 filed
  b37b713      #42 root cause confirmed via A/B test
  5cc2812      Handoff prompt for #42 PhysicsEngine investigation

User-verified visual checks: smooth body chase on running/walking/strafing,
no per-UP rubber-band, no slope staircase, NPCs pathing correctly, jumps
land cleanly. Two follow-up issues filed:

  #41  sub-decimeter steady-state blips (velocity-synthesis residual; LOW)
  #42  airborne XY drift on jumps (PhysicsEngine.ResolveWithTransition
       depenetration; root cause confirmed; deep-dive prompt at
       docs/research/2026-05-05-issue-42-handoff.md)

Replaces the env-var-gated experimental path (ACDREAM_INTERP_MANAGER=1)
which was marked DO-NOT-ENABLE — the env-var no longer toggles anything.
NPCs and airborne player remotes still use the legacy path; only grounded
player remotes route through the new retail-faithful queue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 15:51:29 +02:00