GameWindow.OnInputAction had an early-return gate dropping every
non-Press activation. With the new InputDispatcher firing
SelectDblLeft as ActivationType.DoubleClick, the case in the switch
was unreachable -- visual test confirmed [input] SelectDblLeft
DoubleClick fired but [B.4b] pick never followed.
Fix: also let DoubleClick through the gate. The existing case label
matches on action (not activation), so SelectDblLeft fires
PickAndStoreSelection(useImmediately: true) as designed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes#57. Adds three OnInputAction switch cases (SelectLeft,
SelectDblLeft, UseSelected) and three private helpers
(PickAndStoreSelection, UseCurrentSelection, SendUse). Single-click
selects but does not Use; double-click selects + Uses; R hotkey
sends Use on the existing _selectedGuid. ImGui mouse-capture
filtering already happens in InputDispatcher — no new guard needed.
Diagnostic lines emitted for log grep:
[B.4b] pick guid=0x{guid:X8} name={label}
[B.4b] use guid=0x{guid:X8} seq={seq}
Also adds a one-line doc comment on _selectedGuid clarifying its
dual-purpose role (combat Q-cycle + interaction click), per the Task 3
review.
Build green; tests 1046/1054 (8 pre-existing-baseline fails
unchanged). Switch-case behavior verified at runtime via the Holtburg
inn doorway visual test (per spec §Testing → Runtime verification).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Retail's selection model is a single "current target" used by combat,
interaction, NPC dialog, and HUD alike - not two parallel selections.
Renames the existing combat-only field on GameWindow so the upcoming
B.4b click handler and the existing Q-cycle SelectClosestCombatTarget
share the same selection state.
Mechanical rename, no behavior change. Build + tests green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes folded into one commit:
1. GameWindow subscribes to WorldSession.StateUpdated and routes the
parsed (guid, newState) pair into
ShadowObjectRegistry.UpdatePhysicsState. End-to-end wiring complete:
server SetState (0xF74B) -> WorldSession dispatcher -> StateUpdated
event -> GameWindow handler -> registry mutation -> next resolver
tick sees the new ETHEREAL bit and CollisionExemption short-circuits
the door cylinder. After this commit the M1 'open the inn door'
scenario is unblocked at the code-path level; visual verification
follows in Task 7 (user-driven).
The handler also emits a [setstate] diagnostic line when
ACDREAM_PROBE_BUILDING is enabled, giving a greppable trail when
the visual test runs.
2. Slice 0.5 freebie folded in: the [entity-source] probe lines now
include state=0x... flags=... so ETHEREAL flips are greppable
end-to-end from spawn through state change. Resolves the 'slice
1.6' suggestion from the L.2d ship handoff
(docs/research/2026-05-13-l2d-slice1-shipped-handoff.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds ACDREAM_PROBE_BUILDING — a read-only per-shadow-entry probe that
captures full BSP collision evidence whenever TransitionTypes.FindObjCollisions
attributes a hit (via the existing L.2a slice 3 chain). One multi-line
[resolve-bldg] entry per attributed hit: partIdx, hasPhys, bspR vs
vAabbR, world-space entOrigin_lb, and the actual hit polygon's vertices
in both object-local and world space.
Paired with a one-time [entity-source] line at every ShadowObjects.Register
call site in GameWindow so entityId from a probe line is greppable to its
WorldEntity source within a single log file.
Plumbing: BSPQuery writes the resolved hit polygon to a new
PhysicsDiagnostics.LastBspHitPoly side-channel at the 5 SetCollisionNormal
sites in Paths 5/6 + CollideWithPt. TransitionTypes clears that field
before each shadow-entry dispatch and reads it back at the L.2a slice 3
attribution site to emit the probe line.
Spec component 4 originally described an out ResolvedPolygon? parameter
on BSPQuery.FindCollisions; the static side-channel achieves the same
observable behavior without plumbing through BSPQuery's recursive private
methods. Deviation noted in PhysicsDiagnostics.LastBspHitPoly's XML doc.
Reframes the plan-of-record's L.2d sub-direction paragraph: the 2026-05-12
handoff proposed porting CBuildingObj + per-cell walkability, but ACE
BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260
show find_building_collisions is one BSP test on Parts[0]. Per-cell
walkability belongs to L.2e, not L.2d. L.2d slice 1 is the diagnostic;
slice 2 is the actual fix scoped from slice 1's evidence (one of three
hypotheses: wrong BSP loaded / over-registered parts / BSPQuery flaw).
Tests: 2 synthetic unit tests in PhysicsDiagnosticsTests.cs pin the
static API contract that the BSPQuery → side-channel → TransitionTypes
emission chain depends on. The multi-line line format itself is verified
by acceptance criterion 2 (live Holtburg-doorway capture) — covering it
here would require a heavy PhysicsEngine + Transition fixture for a
diagnostic-only emission.
Verified: dotnet build green; the 2 new tests pass; the 8 pre-existing
test failures listed in the L.2a handoff (MotionInterpreter GetMaxSpeed_*,
PositionManager.ComputeOffset_BothActive_Combined,
PlayerMovementController.Update_ForwardInput_*, Dispatcher.W_held_*,
BSPStepUpTests.{D4,C3}) remain failing — none introduced by this slice.
Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
Conformance anchors:
- acclient_2013_pseudo_c.txt:701260 (CBuildingObj::find_building_collisions)
- acclient_2013_pseudo_c.txt:323725 (BSPTREE::find_collisions)
- ACE references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs:39-52
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Resolver returns ScriptActivationInfo(ScriptId, PartTransforms) — one
dat lookup per spawn yields both pieces of info. The C.1.5a ServerGuid==0
guard is relaxed: activator now keys by ServerGuid when nonzero, else
entity.Id, so dat-hydrated entities (EnvCell statics, exterior stabs)
flow through the same code path as server-spawned ones. PartTransforms
pushed into ParticleHookSink before scheduling Play, closing the
activator side of #56.
GameWindow resolver lambda upgraded: now constructs ScriptActivationInfo
from setup.DefaultScript.DataId + SetupPartTransforms.Compute(setup),
swallowing dat-lookup throws the same way C.1.5a did.
Tests: 4 existing tests updated for new ScriptActivationInfo signature;
3 new tests cover entity.Id keying for dat-hydrated entities, end-to-end
part-transform pipeline (resolver → sink → particle world position), and
OnRemove with an arbitrary caller-picked key. 77 Vfx+Meshing+Activator
tests green.
GpuWorldState fire-site wiring (Task 4) lands next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-up to 65d833d:
ResolveDefaultScript was closing over its own var capturedDatsForActivator
= _dats, but the sibling SequencerFactory in the same block already
declared var capturedDats = _dats. The two locals pointed at the same
reference and served the same purpose; the alias added no value and
muddied the closure pattern.
Reuse capturedDats. No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the activator into the production lifecycle:
- Construct alongside _wbEntitySpawnAdapter using _scriptRunner +
_particleSink (both built earlier in OnLoad).
- Production resolver lambda hits _dats.Get<Setup>(...) wrapped in
try/catch returning 0 on miss/throw — matches ParticleRenderer's
defensive read pattern.
- Pass into GpuWorldState's new optional ctor parameter.
Closes the wiring half of C.1.5a. Visual verification at the Holtburg
Town network portal is the acceptance gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Capture authoritative CPU+GPU dispatch numbers at Holtburg with the
gpu_us diagnostic now working (commit 25cb147). Three radii (4/8/12)
x two motion modes (standstill/walking) + a surface-format histogram
from ACDREAM_DUMP_SURFACES=1.
Adds env-gated one-shot dump path (TextureCache.TickSurfaceHistogramDumpIfEnabled,
called from GameWindow.OnRender) that fires once after both (a) frame
600 of the session AND (b) the upload-metadata dict reaches 100 entries
-- the cache-size gate prevents the dump from firing during pre-world
GUI ticks where OnRender spins at high rates but no scenery has streamed.
Output writes to %LOCALAPPDATA%\acdream\n6-surfaces.txt with a try/catch
around the I/O so disk-full / permission errors don't crash mid-measurement.
Baseline document at docs/plans/2026-05-11-phase-n6-perf-baseline.md
documents:
- CPU dominates GPU by 30-50x at every radius (strongly CPU-bound)
- GPU wildly under-utilized (max gpu_us p95 ~600us vs 16,600us frame budget)
- CPU scales superlinearly with N1 (Tier 1 cache wins on inner loop but
not outer LB walk)
- Surface atlas opportunity high (59% of textures in top-3 triples) but
win is memory-only since GPU isn't bottlenecked
Recommendation: C.1.5 (PES emitter wiring) next, then a reduced-scope
N.6 slice 2 (drop atlas + persistent-mapped buffers -- not justified by
the GPU under-utilization observed).
Roadmap entry amended to split N.6 into slice 1 (shipped) and slice 2
(planned, reduced scope, deferred until after C.1.5).
Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md.
Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md (Task 4).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GpuWorldState.RemoveEntitiesFromLandblock now invokes an optional
Action<uint> callback before zeroing the entity list. GameWindow wires
this to EntityClassificationCache.InvalidateLandblock so cache entries
get swept on LB demote (Near to Far) and unload. Per spec section 5.3 W3b.
The callback receives the canonicalized landblock id (low 16 bits forced
to 0xFFFF), matching the LandblockHint stored at Populate time. Trace:
GpuWorldState._loaded keys are canonical (set by AppendLiveEntity),
LandblockEntries yields kvp.Key as LandblockId, WalkEntitiesInto
propagates entry.LandblockId into _walkScratch, the dispatcher's
populateLandblockId reads that tuple and stores it as LandblockHint.
Phase 3 (invalidation hooks) complete. The cache now stays correct across
all spec-identified mutation events: despawn, ObjDescEvent (despawn+
respawn), LB demote, LB unload.
Two integration tests added:
- RemoveEntitiesFromLandblock_FiresUnloadCallbackWithCanonicalId asserts
the callback fires once with the canonical id even when called with a
cell-resolved input (low 16 bits non-FFFF).
- RemoveEntitiesFromLandblock_NotLoaded_DoesNotFireCallback asserts the
early-return path doesn't fire the callback for unknown landblocks.
Tests: 1706 passed / 8 failed (baseline). Sentinel: 110/110.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GameWindow.RemoveLiveEntityByServerGuid now invalidates the entity's
cache entry next to the existing _animatedEntities.Remove(). Fires for
DeleteObject (0xF747) and the dedup leg of ObjDescEvent (0xF625).
Adds test #15 (despawn-respawn under reused id repopulates fresh) per
spec section 7.5 — pins the audit's ObjDescEvent-as-despawn-respawn contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the cache as a constructor parameter on WbDrawDispatcher and a
private field on GameWindow. The cache is passed through but not yet
consumed by Draw — that wires up in Task 9 (cache miss / populate) and
Task 10 (cache hit / fast path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Brings in #38 render-interpolation camera work before testing #48
diagnostic dump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>