DTO + TryParse for the GameMessageSetState wire message. The server
broadcasts this when an already-spawned entity's PhysicsState changes
post-CreateObject — chiefly when a door's Ethereal bit toggles on Use.
Wire format per holtburger SetStateData (validated against retail-format
servers): u32 opcode + u32 guid + u32 state + u16 instanceSequence + u16
stateSequence = 16 bytes total. Mirrors the existing VectorUpdate.cs
template.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First Holtburg-doorway capture showed all 191 [resolve-bldg] entries
labeled "n/a (cylinder)" — including hits attributed to the building
0xA9B47900 which [entity-source] confirmed was registered as type=BSP.
The label was a probe bug, not a real cylinder route.
Root cause: BSPQuery's grounded-path (Path 5) returns early via
`StepSphereUp(transition, worldNormal, engine)` when no step is already
in progress. The slice-1 side-channel write at line 1546 came AFTER
that early return, so it never fired for the dominant grounded-player
case. Compounding: StepSphereUp recurses into ResolveWithTransition →
FindObjCollisions, whose per-entity `LastBspHitPoly = null` clear
wiped any earlier write before the outer attribution emitter read it.
Fix:
1. BSPQuery Path 5: move LastBspHitPoly write to the top of
`if (hit0 || hitPoly0 != null)` blocks (both foot- and head-sphere),
BEFORE the StepSphereUp early return. Recursion-safe — the inner
resolve's BSP writes will overwrite with the inner entity's poly,
but for the dominant case (same wall hit on both outer and inner)
that's still the correct attribution.
2. TransitionTypes.FindObjCollisions: drop the per-entity clear of
LastBspHitPoly. With BSPQuery now writing at hit-detection time
instead of response-computation time, the side-channel value is
reliable without per-iteration zeroing.
3. TransitionTypes [resolve-bldg] emission: key the "n/a (cylinder)"
label on `obj.CollisionType` directly, not on LastBspHitPoly being
null. A BSP entity with a null poly now logs "n/a (BSP path —
side-channel not written, missing BSPQuery wire site)" so any
future BSPQuery path that's missing the wire is visible in the
trace rather than being silently mis-labeled.
Verified: build green, the 2 slice-1 tests still pass, 8 pre-existing
failures unchanged.
Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md
First capture (showing the label bug): launch-l2d-slice1.log lines
12086-12120 (representative [resolve-bldg] entries for obj=0xA9B47900).
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>
The CollisionInfo.CollideObjectGuids list + LastCollidedObjectGuid
fields existed but were never written anywhere in the codebase — slice
2's [resolve] probe found this when 85 hit=yes lines came back with
no obj= attribution.
This commit fills the gap at the only place we have the attribution
data: the per-object iteration in Transition.FindObjCollisions, where
obj.EntityId is in scope right after each per-object BSPQuery /
CylinderCollision call. Two cases trigger an Add():
- result != TransitionState.OK (object hard-blocked transition)
- normal flipped invalid→valid during the call (BSPQuery captured
a slide normal without halting — covers wall-slide cases).
Beyond the diagnostic, this also fixes a quiet structural gap — any
future physics behavior that wants "who did I just collide with"
(PvP exemption sanity check, NPC bump rules, etc.) was previously
flying blind on stub fields. Now the data flows.
Build green. Will re-test the doorway with the same trace to get
the wall's entity id.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the existing [resolve] probe line to surface
ci.LastCollidedObjectGuid (hit object) + ci.CollidedWithEnvironment
(terrain hit flag) + ci.CollideObjectGuids.Count (when >1) so the
operator can tell WHICH entity the wall is, not just the wall normal.
Tonight's L.2a slice 1 trace caught a clean wall-slide at the
Holtburg-area doorway (n=(0,1,0), 122 hit=yes lines), but had no way
to attribute the hit to a specific entity — the L.2d sub-direction
call (door collision shape vs building wall mesh) needs the entity id
to pick the right fix. This extension provides it on the next run.
Format change for [resolve] hit field:
Before: hit=yes n=(0.00,1.00,0.00)
After: hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX
hit=yes n=(0.00,1.00,0.00) env
hit=yes n=(0.00,1.00,0.00) obj=0xCC0CXXXX env nObj=3
Pure additive within the existing PhysicsDiagnostics.ProbeResolveEnabled
gate. No new env var, no new file. Build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New static `AcDream.Core.Physics.PhysicsDiagnostics` holds two
runtime-toggleable flags initialized from env vars:
- ACDREAM_PROBE_RESOLVE=1 — emit one [resolve] line per
PhysicsEngine.ResolveWithTransition call: input/target/output
position+cell, ok-vs-partial, grounded-in, contact-plane status,
wall normal if hit, walkable-polygon valid, moving entity id.
- ACDREAM_PROBE_CELL=1 — emit one [cell-transit] line per
PlayerMovementController.CellId change: old → new cell, current
world position, reason tag (resolver / teleport).
Both also exposed as runtime-toggleable checkboxes in the DebugPanel
"Diagnostics" section. Unlike the existing four Dump-* checkboxes
(which only mirror sticky-at-startup env vars), the two new ones
forward directly to PhysicsDiagnostics — toggling on/off takes
effect on the next physics resolve, no relaunch.
Why now: L.2's plan-of-record (docs/plans/2026-04-29-movement-collision-
conformance.md) explicitly says "Land L.2a diagnostics first. Do not
make another physics change blind." This slice closes the most-load-
bearing gap in L.2a — a general-purpose probe on the resolver outcome
and a cell-transit log — so that later L.2b/c/d/e physics changes can
be evidence-driven instead of guessed. Foundation for the indoor /
dungeon walking trajectory (G.3 unblock).
Pure additive: when both flags are off (default), the probes collapse
to a single static-bool read per resolve, zero log cost. PlayerMovement
Controller's two CellId-mutation sites are now routed through a
private UpdateCellId(reason) helper for diag chokepoint.
Build green, 1032/1040 unit tests pass. The 8 failing tests are
pre-existing on the branch base (verified by stash-and-rerun);
none touch resolver or cell-transit code; all fail identically with
this slice stashed. Investigation deferred to a follow-up.
Refs: docs/plans/2026-04-29-movement-collision-conformance.md (L.2a
shipped-slice note added in same commit).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four new foreach blocks in GpuWorldState wire EntityScriptActivator
into the dat-hydration spawn/despawn paths:
- AddLandblock: fires OnCreate for each entity with ServerGuid==0
(live entities filtered out — they got OnCreate at AppendLiveEntity
and would double-fire on pending-bucket merges).
- AddEntitiesToExistingLandblock: fires OnCreate for each entity in
the promoted batch (all dat-hydrated by construction).
- RemoveLandblock: fires OnRemove(entity.Id) for each ServerGuid==0
entity before the loaded record is dropped.
- RemoveEntitiesFromLandblock: fires OnRemove for the demote-tier
entities about to be cleared (Near→Far demotion).
5 new integration tests cover the four fire-sites + the no-double-fire
invariant on pending-bucket merges. Pattern matches existing
GpuWorldStateTests (stub LandBlock heightmap + WorldEntity factory).
Closes#56 end-to-end. Slice A (per-part transforms in Tasks 1-3) +
Slice B (dat-hydrated entity DefaultScript firing, this task) both
ready for visual verification at Holtburg portal + Inn fireplace +
cottage chimney + spell cast.
Note: 8 pre-existing failures in Physics/Input/MotionInterpreter test
families are unrelated to this work (verified by re-running with this
task's changes stashed).
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>
Adds per-entity part-transform side-table mirroring _rotationByEntity.
SpawnFromHook now transforms the hook offset through partTransforms[partIndex]
before rotating to world space. Backwards-compatible: entities without
registered part transforms fall through to identity (pre-C.1.5b behavior),
so the existing C.1.5a rotation-seed test stays green.
Adds SetEntityPartTransforms public method. Cleared on StopAllForEntity
alongside the rotation entry.
2 new xUnit tests:
- SpawnFromHook_AppliesPartTransform_WhenRegistered — part 1 lifted +Z=1,
hook offset (1,0,0), PartIndex=1 → world (1,0,1).
- SpawnFromHook_FallsBackToIdentity_WhenPartIndexOutOfBounds — PartIndex=99
on a 2-part array → offset applied without crash, pre-C.1.5b behavior.
Closes the renderer side of #56. EntityScriptActivator wiring (Task 3)
lands next.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Computes Matrix4x4 per Setup part by walking PlacementFrames[Resting] →
[Default] → first-available, matching SetupMesh.Flatten's priority.
Foundation for #56 fix: ParticleHookSink will use these to apply each
CreateParticleHook's PartIndex-relative offset to the right mesh part.
4 xUnit tests cover Resting-over-Default preference, Default fallback,
empty-PlacementFrames returns empty, DefaultScale application.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Visual verification at the Holtburg Town network portal revealed the swirl
was oriented along world axes (NS) instead of the portal's actual facing
(EW), and partially buried in the ground because the hook's local-frame
Offset.Origin was being applied in world axes too.
Root cause: EntityScriptActivator.OnCreate fired _scriptRunner.Play but
never called _particleSink.SetEntityRotation. When the runner's
CreateParticleHook fires, the sink reads per-entity rotation from
_rotationByEntity (defaults to Quaternion.Identity for unknown entities)
and uses it to transform the hook's Offset.Origin from entity-local to
world space. Without the seed call, the rotation lookup falls through to
Identity and the offset goes off along world XYZ.
Fix is a single SetEntityRotation call before the Play call. Added a 4th
unit test that constructs an entity with a 90 deg yaw and asserts the spawned
particle's world position reflects the rotated offset.
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>
GpuWorldState grows a fourth optional ctor parameter for the activator,
paralleling how EntitySpawnAdapter is plumbed. AppendLiveEntity calls
OnCreate after the existing _wbEntitySpawnAdapter?.OnCreate;
RemoveEntityByServerGuid calls OnRemove after the existing OnRemove.
Symmetric, same order, null-safe.
GameWindow still passes the old 3-arg ctor — activator construction +
wire-through lands in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code-review follow-up to 003c502:
1. Test 3 (OnRemove_StopsScriptsAndEmitters) now wires the runner into
the real ParticleHookSink instead of a RecordingSink, registers a
persistent EmitterDesc, lets the CreateParticleHook actually spawn an
emitter, then asserts the sink killed it after OnRemove. Previously
the test only verified runner-side state — sink.StopAllForEntity was
never observably exercised, so a regression dropping that call would
have passed silently.
2. Removed unused `using System.Numerics` from EntityScriptActivator.cs.
No production code changes. Tests 1 and 2 unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New ~50-line orchestrator that fires Setup.DefaultScript through the
already-shipped PhysicsScriptRunner on entity spawn and stops scripts +
live emitters on despawn. Resolver delegate avoids DatCollection coupling
so the class is fully unit-testable with stubs.
Three xUnit tests cover the three branches: fire-with-script,
no-op-without-script, stop-on-remove. No wiring into the live spawn path
yet -- that lands in the next commit.
Spec: docs/superpowers/specs/2026-05-12-phase-c1.5a-portals-design.md
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>
Code-quality review on Task 1 (commit a7c9800) flagged an asymmetric
diag gate: the read-before-overwrite block at the top of the dispatcher
was not gated on diag, but the frame-counter increment and BeginQuery
calls were. If a maintainer toggled ACDREAM_WB_DIAG from "1" to "" mid-
session, _gpuQueryFrameIndex would freeze (gated inside if(diag)) while
the read kept firing every frame at the same slot — producing duplicate
stale samples.
Add diag to the read block's outer condition so the read/issue/increment
trio is symmetric. One-line change; behavior under the normal usage
pattern (env var set at launch, never toggled) is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The dispatcher's GPU TimeElapsed queries were polled in the same frame
as the indirect draw, so glGetQueryObject(ResultAvailable) always
returned 0 and gpu_us in [WB-DIAG] was stuck at 0m/0p95.
Replace the 2 single-handle queries with ring-of-3 arrays and move the
result read to BEFORE issuing the next frame's queries into the same
slot — at frame N we read slot N%3 which holds frame N-3's queries
(oldest in the ring, ~50ms old at 60fps and definitely done across all
desktop GL drivers). Vendor-neutral: AMD/NVIDIA/Intel desktop GL all
work without driver-specific code.
The gpuQuerySlot variable is hoisted to function scope (just before
Phase 7 opaque pass) so both the opaque and transparent passes
reference the same slot — the plan placed it inside the opaque-pass
if-block, which would have been out of scope for the transparent
BeginQuery; corrected in the implementation.
No new tests — the change is purely a diagnostic readout fix, no
observable behavior in the rendering path. Build green; tests at
baseline (1711 passing, 8 pre-existing physics/MotionInterpreter
failures unchanged). Manual gpu_us verification still pending in-world.
Spec: docs/superpowers/specs/2026-05-11-phase-n6-slice1-design.md (§4).
Plan: docs/superpowers/plans/2026-05-11-phase-n6-slice1.md (Task 1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported (cache enabled, post-c55acdc): drudge statue renders fully
but many trees are missing branches. Cache-disabled A/B run rendered trees
correctly. So the bug is in the cache wiring.
Root cause: c55acdc's `currentEntityIncomplete = false;` reset fired
UNCONDITIONALLY at the top of every iteration. For a tree with MeshRefs
[trunk valid, branches null, leaves valid], the tuple sequence is:
- tuple 0 (trunk): no flag set
- tuple 1 (branches): TryGetRenderData null → set flag, continue
- tuple 2 (leaves): unconditional reset → flag = false (WRONG)
- end-of-entity: flag is false, scratch has trunk+leaves batches but NOT
branches → MaybeFlushOnEntityChange populates a PARTIAL cache entry
- cache hits forever serve trunk+leaves with no branches
Drudge happened to render correctly because its missing MeshRef was at the
END of its MeshRefs list — no later tuple reset the flag.
Adds a per-tuple `prevTupleEntityId` tracker for entity-change detection,
updated UNCONDITIONALLY at end of each tuple (including tuples that skip
via null renderData). The flag-reset block now fires ONLY on actual entity
change. Within the same entity, the flag accumulates across tuples.
Also includes ACDREAM_DISABLE_TIER1_CACHE=1 diagnostic env-var added
inline (was stashed previously) for future A/B testing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User reported: the drudge statue on top of the Foundry (a multi-part
live-spawned entity with AnimPartChange + texChanges) renders only
PARTIALLY — some parts visible, some missing.
Root cause: the dispatcher's slow path skips a MeshRef when
_meshAdapter.TryGetRenderData returns null (mesh still async-decoding
via ObjectMeshManager.PrepareMeshDataAsync). The classified-batches
collector accumulates only the MeshRefs that DID resolve. At entity
boundary, the cache populates with the PARTIAL set. Frame-2 cache hits
serve that partial entry forever — even after the missing mesh loads,
the cache continues to skip those parts because classification never
reruns for cached entities.
Fix: track currentEntityIncomplete during the foreach. Set it true on
any null renderData. At entity boundary (and at end-of-loop), if the
flag is set, DROP the accumulated populate scratch instead of writing
it to the cache. The slow path retries on the next frame; once all
meshes have loaded, the populate fires correctly with the complete
classification.
Adds a regression test pinning the contract — incomplete entities
produce zero cache entries.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User confirmed via A/B test (ACDREAM_DISABLE_TIER1_CACHE=1) that the
visual bug — buildings rendering up in the air outside Holtburg — is in
the cache wiring, not elsewhere. The matrix math (restPose * entityWorld
== model) was provably correct, so the bug had to be cache key collision.
Stabs were namespaced in commit 71d0edc, but scenery (0x80LLBB00 +
localIndex) and interior (0x40LLBB00 + localCounter) still have the
same 256-overflow risk. Dense LBs outside Holtburg (forest, urban) push
localIndex past 255, wrapping into the lbY byte and creating cross-LB
collisions.
Fix: change the cache key from uint entityId to (uint, uint) tuple of
(EntityId, LandblockHint). The cache is now correct-by-construction
regardless of any hydration path's Id-generation strategy. Defensive
against future regressions in any ID namespace.
InvalidateEntity becomes a sweep (was O(1)), but it's called rarely
(only on live-entity despawn). InvalidateLandblock was already a sweep.
Updated 14 existing cache tests + 1 dispatcher integration test to thread
landblockHint through TryGet / DebugCrossCheck calls.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LandblockLoader.BuildEntitiesFromInfo restarted nextId at 1 per landblock,
producing colliding entity.Id values across landblocks. EntityClassificationCache
keys by entity.Id alone, so cross-LB collisions caused cache pollution:
multiple stabs sharing id=1 -> cache entry for id=1 ended up with the
CONCATENATION of multiple entities' batches -> buildings rendered up in the
air with wrong textures (visual gate observation 2026-05-10).
Audit at docs/research/2026-05-10-tier1-mutation-audit.md did not verify
entity.Id uniqueness - that was an unchecked assumption. Cache design
trusted entity.Id was globally unique; for stabs it wasn't.
Fix: optional landblockId parameter on BuildEntitiesFromInfo. When non-zero,
stab Ids are namespaced as 0xC0XXYY00 + nextId, matching the scenery
(0x80XXYY00) and interior (0x40XXYY00) namespacing already in GameWindow.cs.
The 0xC0 top byte distinguishes stabs from those. Existing tests pass
landblockId=0 and keep their legacy starting-from-1 behavior.
Known latent: if any one landblock has >256 stabs, nextId overflows the
low byte. Same pattern + same limitation as scenery/interior. Out of scope
for the immediate Tier 1 cache bug; not affecting current Holtburg play.
Adds 2 regression tests pinning the namespacing + the legacy fallback.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review of f16604b flagged that DebugCrossCheck's XML doc claimed
"called once per static-entity cache hit per frame" — overstated. The
method is currently exercised by unit tests only; the dispatcher's
cache-hit branch fires a simpler predicate assert (!isAnimated) at
production hit time, not the full live-state cross-check. Wiring the
full cross-check is the spec section 6.5 stretch goal, kept open as a
follow-up.
Doc-only change. No behavior change. 1708 / 8 baseline preserved.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds EntityClassificationCache.DebugCrossCheck(entityId, liveBatches) that
asserts cached state matches a live re-classification. Wires a simpler
predicate assert into WbDrawDispatcher's cache-hit branch (asserts
isAnimated == false on cache hit). Tests #13a and #13b cover the
batch-count mismatch and clean-match cases via a custom TraceListener
that captures Debug.Assert calls.
Zero cost in Release. In DEBUG, the assert fires immediately if a future
regression mutates static-entity state outside the audit's known write
sites — the same failure mode that bit the prior Tier 1 attempt.
Phase 4 complete. Cache + invalidation + safety net all in place.
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>
Task 10 (commit 0cbef3c) called ApplyCacheHit inside the per-(entity, partIdx)
foreach loop, but cachedEntry.Batches is flat across all MeshRefs of the
entity. For a 3-MeshRef static building on frame 2: 3 tuples times 6 cached
batches per call = 18 instances drawn instead of 6. Severe Z-fighting and
3x perf hit on every multi-part static entity (buildings, statues, multi-
MeshRef NPCs).
This is the symmetric mirror of the Task 9 bug fixed at 00fa8ae. Both
spec section 5.2 and the plan describe the foreach as per-entity, but
_walkScratch has been per-tuple since Task 6. The implementation
faithfully ported the buggy spec.
Fix: track lastHitEntityId; the cache-hit fast path fires only on the
first tuple of each entity, and subsequent tuples skip the iteration
body via continue. Adds a regression test pinning the per-entity
amplification invariant.
Caught by code review (subagent-driven-development) before Phase 3
dispatched. The bug was invisible in the no-multi-frame-test 1702/8
baseline; would have manifested as visible Z-fighting on every multi-
part building on second-and-subsequent frames once Task 13 perf gate
captured live runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
WbDrawDispatcher.Draw now branches on cache hit before running classification:
on hit, walks the cached flat batch list and appends RestPose times entityWorld
to the matching groups; on miss, runs today's classification and populates
the cache (Task 9). Animated entities skip the cache entirely.
Adds dispatcher integration tests #11 (static entity populates + reuses)
and #12 (animated bypasses) per spec test plan section 7.2, plus the
multi-MeshRef regression test that would have caught the bug fixed in
commit 00fa8ae (cache populate must flush at entity boundary, not per-tuple).
Phase 2 (dispatcher integration) complete. End-to-end caching now live.
Invalidation hooks (Phase 3) ensure correctness across despawns + LB demotes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Task 9 (commit 2f489a8) called _cache.Populate inside the per-tuple
foreach loop, but _walkScratch contains one tuple per (entity, MeshRefIndex)
and the cache is keyed by entity.Id. For multi-MeshRef entities (multi-part
Setup buildings, statues, multi-MeshRef NPCs), each iteration's Populate
OVERWROTE the previous one — only the last MeshRef's batches survived.
The bug was invisible at commit time because Task 10 had not landed
(cache populates but isn't read). It would have manifested the moment
Task 10 wired the cache-hit fast path: every multi-part static building
in Holtburg would render as N stacked copies of its last part.
Fix: restructure the per-entity loop with a flush-on-entity-change pattern.
Track the previous entity's Id; when the iteration moves to a different
entity, flush the previous entity's accumulated _populateScratch via one
Populate call. After the loop, flush the final entity. _populateScratch
is now cleared at flush time, not per-iteration.
Caught by code review (subagent-driven-development) before Task 10 dispatched.
Verified: 1699/8 baseline preserved, sentinel 105/105 unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restructures Draw's per-entity loop: animated entities still skip the
cache entirely, but static entities now collect their classification into
_populateScratch and call cache.Populate at the end of the iteration.
Cache fast-path (skip slow classification on cache hit) lands in Task 10.
This intermediate state is verifiable: behavior unchanged, but the cache
is being populated as entities render. Diagnostic-friendly split.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ClassifyBatches now accepts a restPose parameter (the model-matrix
component without entityWorld baked in) and an optional collector. When
collector is non-null, each classified batch is appended as a CachedBatch
record. Defaults preserve today's behavior. Used in Task 9 to populate
the cache on a static-entity miss.
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>
Extends the walk scratch tuple from (entity, meshRefIndex) to
(entity, meshRefIndex, landblockId). The dispatcher's per-entity loop now
has the landblock id available for EntityClassificationCache.Populate's
landblockHint argument (consumed in Task 9). No behavior change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Idempotent removal of a cached entry by entity id. Tests #4 and #5 from
spec section 7.1 lock in the contract.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements Populate (insert-or-overwrite) and adds 5 tests covering the
populate->TryGet round-trip including the Setup pre-flatten shape. Per
spec test plan section 7.1 tests #2, #3, #9, #10, #14.
Tests use xUnit Assert.* (not FluentAssertions) to match the Task 2
implementer's choice and the existing 149 sibling assertions in the Wb
test directory.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds CachedBatch, EntityCacheEntry, and EntityClassificationCache with
just TryGet (returns false on empty). The skeleton compiles and the first
test (TryGet_EmptyCache_ReturnsFalse) passes. Subsequent tasks add
Populate, InvalidateEntity, InvalidateLandblock, and the dispatcher
integration. Per spec design Section 6.1.
Note: CachedBatch / EntityCacheEntry / EntityClassificationCache are
internal (not public as the plan snippet showed). Their members
transitively reference the internal GroupKey type, so promoting them to
public produces CS0051 inconsistent-accessibility errors. The cache is
dispatcher-internal coordination state anyway, and the AcDream.App
csproj already exposes internals to AcDream.Core.Tests via
InternalsVisibleTo, so the test sees everything it needs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mechanical refactor: GroupKey was a private nested record struct on
WbDrawDispatcher. The upcoming EntityClassificationCache (ISSUE #53) needs
to store GroupKey inside CachedBatch records, so it must be visible to
both the dispatcher and the cache. Promoting to internal at file scope is
the smallest change that achieves this.
No behavior change. 1688 tests pass; 8 pre-existing failures unchanged.
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>
Three root causes regressed the Holtburg lifestone since the WB rendering
migration (Phase N.5 retirement amendment, commit dcae2b6, 2026-05-08).
All confirmed via temporary [LIFESTONE-DIAG] instrumentation and visually
verified by the user through the +Acdream test character.
1. **Alpha-test discard** in mesh_modern.frag transparent pass killed
high-α pixels of dat-flagged transparent surfaces. Native AC
transparent surfaces routinely include effectively-opaque pixels —
e.g. the lifestone crystal core (surface 0x080011DE) — that compose
correctly under (SrcAlpha, 1-SrcAlpha) blending. The original N.5
§2 rationale ("high-α belongs in opaque pass") doesn't hold for
surfaces flagged transparent at the dat level: those pixels can't
reach the opaque pass at all. Fix: remove `α >= 0.95 discard` from
the transparent pass, keep `α < 0.05 discard` as a fragment-cost
optimization (skip totally-empty pixels).
2. **Cull state** for the transparent pass was unset by
WbDrawDispatcher after the N.5 retirement amendment deleted
StaticMeshRenderer.cs (which had the Phase 9.2 setup at commit
6f1971a, 2026-04-11). Closed-shell translucents — lifestone crystal,
glow gems — need GL_CULL_FACE + GL_BACK + GL_CCW in the transparent
pass; otherwise back faces composite over front faces in iteration
order under DepthMask(false). Fix: re-establish Phase 9.2's exact
GL state setup at the top of Phase 8.
3. **uDrawIDOffset uniform** was missing from mesh_modern.vert.
gl_DrawIDARB resets to 0 at the start of each
glMultiDrawElementsIndirect call, so the transparent pass — which
begins later in the indirect buffer — was fetching
Batches[0..transparentCount) instead of its actual section at
Batches[opaqueCount..end). The lifestone crystal ended up reading
the FIRST OPAQUE batch's TextureHandle every frame; as the camera
moved and the front-to-back opaque sort reordered which group
landed at BatchData[0], the crystal's apparent texture flickered to
whatever sat first — typically the player character's body parts.
Fix: add `uniform int uDrawIDOffset` to the vertex shader, change
Batches[gl_DrawIDARB] → Batches[uDrawIDOffset + gl_DrawIDARB], and
set the uniform per-pass in WbDrawDispatcher (0 for opaque,
_opaqueDrawCount for transparent). Mirrors WorldBuilder's
BaseObjectRenderManager.cs line 845.
Tests: 1688/1696 passing (8 pre-existing physics/input failures
unchanged). N.5b conformance sentinel 94/94 clean.
Visual: Holtburg lifestone now renders with the spinning blue crystal
correctly composed over the pedestal. Other transparent content (glass,
particle effects, NPC clothing) is unaffected — the same uniform fix
applies globally and is correct for all transparent draws.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md Tier 1: cache the
per-(entity, meshRef, batch) classification (TextureCache lookup,
GroupKey hash, _groups dict insert) so the per-frame Draw inner loop
becomes "look up cache → walk assignments → append matrix to group's
Matrices list."
For static entities (~95% of world: trees, rocks, buildings, scenery),
the answer never changes between frames. Cache once at first visit;
reuse permanently. Per-frame work for static drops from 4 expensive
operations per (meshRef, batch) to 1 list-append.
Estimated entity dispatcher: 3.5ms → ~1-1.5ms median at radius=12.
Should land inside the 2.0ms spec budget.
Implementation:
- New EntityClassificationCache class (per-meshRef list of cached
(group ref, baked-PartTransform) tuples) keyed by entity.Id.
- ClassifyEntity does the one-time work; result populates _groups and
the cache.
- Draw inner loop: cache lookup → for each assignment, model =
PartTransform × entityWorld; group.Matrices.Add(model).
- Cache miss when ClassifyEntity finds NO mesh loaded yet (Vao == 0)
→ don't store; retry next frame. Avoids cache thrash during the
streaming-in window.
- Public InvalidateEntity(uint id) + ClearEntityCache() for explicit
invalidation hooks. Wiring (palette swap on ObjDescEvent, MeshRefs
hot-swap) is post-A.5 follow-up — for now, cache-stale entities
show their pre-swap appearance until next respawn.
Tier 2 (static/dynamic split with persistent groups) and Tier 3 (GPU
compute culling) tracked in the roadmap doc.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After PlayerDescription is dispatched, the Inventory and Equipped lists
produced by the parser are now fed into ItemRepository via AddOrUpdate +
MoveItem so inventory/paperdoll panels see items after login.
Acceptance test PlayerDescription_RegistersInventoryEntries_InItemRepository
confirms ItemCount goes 0→2 for a synthetic PD with two inventory entries.
282 Net.Tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
T17's WalkEntities helper allocated a fresh List<(WorldEntity, int)>
per frame to hold the (entity, meshRefIndex) pairs that pass visibility
filters. At ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes =
~480 KB / frame of GC pressure on the render thread. The implementer's
self-review flagged this as a future N.6 optimization; the post-T26
diagnostic showed it materially contributing to the perf regression
(though Bug A — far-tier entity load — was the dominant factor).
Refactor: split WalkEntities into two overloads.
- WalkEntities(...) — test-friendly, allocates a fresh ToDraw list per
call. Tests keep using this signature unchanged.
- WalkEntitiesInto(..., scratch, ref result) — no-alloc, clears + populates
a caller-provided scratch list. Draw uses this with a per-dispatcher
_walkScratch field reused across frames.
Test count unchanged (40 streaming + 8 bucketing tests still pass).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase A.5's two-tier streaming spec promised that far-tier landblocks
ship terrain ONLY — no entities, no scenery, no interior cells. T13/T16
wired the controller side (RecenterTo emits ToLoadFar/ToLoadNear/ToPromote;
controller passes JobKind to the worker), but the worker's HandleJob
never branched on Kind: every load called BuildLandblockForStreaming
which runs the full hydration + scenery generation + interior cell path.
Result: at default radii (N₁=4 / N₂=12), 540 far-tier LBs each loaded
their full entity layer (~132 entities/LB → ~71K entities total) into
GpuWorldState. The dispatcher then walked all ~54K entities per frame
(post-frustum-cull), driving the entity dispatcher cpu_us from ~3.6ms
median (T24 baseline) to ~18-21ms (post-T22.5 horizon-test). User-
observed: 40 FPS / 25ms frame time at horizon-safe settings; system
crash at full High preset.
Minimum-diff fix: in LandblockStreamer.HandleJob, after
_loadLandblock returns, strip Entities to empty for LoadFar before
posting Loaded. Worker still does wasted hydration CPU (off the render
thread, harmless). Render-side dispatcher walk drops from ~54K to ~10K
entities/frame.
Math: post-fix entity dispatcher should drop to ~3-4ms median at N₁=4 /
N₂=12 (matches T24's 3.6ms at radius=5 single-tier, since N₁=4 has 33%
fewer near entities than N₁=5).
Future optimization (N.6 / A.6): plumb JobKind through
BuildLandblockForStreaming so the worker also skips the wasted CPU.
Out of A.5 scope.
Bug B (T17 WalkEntities allocation) is a smaller perf hit — defer if
post-Bug-A FPS is acceptable.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>