Compare commits

...

118 commits

Author SHA1 Message Date
Erik
175ad14f8b Merge branch 'claude/friendly-varahamihira-7b8664' — Tier 1 entity-classification cache (closes #53)
Post-A.5 polish Priority 3. EntityClassificationCache keyed by
(entityId, landblockHint) tuple. Entity dispatcher cpu_us median ~1.2 ms,
p95 ~1.5 ms — ~66% reduction vs pre-Tier-1 baseline. Closes the
post-A.5 polish phase entirely (#52, #54, #53 all closed).

See docs/ISSUES.md #53 closure + memory/project_tier1_cache.md for the
24-commit chain, 4 bug-fix iterations, and the per-tuple-vs-per-entity
recurring trap pattern documented for future cache work.
2026-05-11 00:11:42 +02:00
Erik
110fb691a8 ship(post-A.5 #53): Tier 1 entity-classification cache — closes ISSUE #53
EntityClassificationCache keyed by (entityId, landblockHint) tuple lands
per spec docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
+ plan docs/superpowers/plans/2026-05-10-issue-53-tier1-cache.md.

Perf result (horizon-safe preset + High quality, AMD Radeon RX 9070 XT
@ 1440p): entity dispatcher cpu_us median ~1200 us, p95 ~1500 us. Down
from ~3500m / ~4000p95 pre-Tier-1. ~66% / ~63% reduction. Well under
the A.5 spec budget (median <= 2.0 ms, p95 <= 2.5 ms). No BUDGET_OVER
flag across 30s+ standstill captures.

Visual gate cleared after 4 bug-fix iterations:
  - 71d0edc: namespace stab Ids globally (cross-LB id collision)
  - 95ebbf3: key cache by (entityId, landblockHint) tuple (defensive)
  - c55acdc: skip cache populate when classification is incomplete
  - f928e66: incomplete-entity flag must persist across same-entity tuples

User-confirmed visually via +Acdream test character: NPCs animate,
multi-part static buildings render fully (no airborne geometry, no
Z-fighting, no missing parts, no wrong textures), Nullified Statue of
a Drudge on top of the Foundry renders all parts, trees outside
Holtburg render with branches present.

Closes the post-A.5 polish phase. Issues #52, #54, #53 all closed.

Tests: 1711 passing, 8 pre-existing physics/input failures unchanged.
N.5b sentinel: 112/112 throughout.

Memory: ~/.claude/projects/.../memory/project_tier1_cache.md +
feedback_cache_per_tuple_pattern.md capture the audit-gap and per-tuple-
vs-per-entity recurring trap for future cache work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 00:09:57 +02:00
Erik
f928e66119 fix(render #53): incomplete-entity flag must persist across same-entity tuples
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>
2026-05-10 23:56:58 +02:00
Erik
c55acdc3d5 fix(render #53): skip cache populate when classification is incomplete
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>
2026-05-10 23:42:46 +02:00
Erik
95ebbf3004 fix(render #53): key cache by (entityId, landblockHint) to defeat ID collision
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>
2026-05-10 23:02:14 +02:00
Erik
71d0edc3d7 fix(world #53): namespace stab Ids globally for Tier 1 cache safety
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>
2026-05-10 20:07:19 +02:00
Erik
4df19146ff docs(render #53): clarify DebugCrossCheck's wiring status
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>
2026-05-10 19:49:13 +02:00
Erik
f16604b60b feat(render #53): DEBUG cross-check guards against the prior Tier 1 bug class
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>
2026-05-10 19:43:24 +02:00
Erik
489174f21c feat(render #53): wire EntityClassificationCache.InvalidateLandblock at LB demote/unload
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>
2026-05-10 19:32:16 +02:00
Erik
6f7d73f7cf Merge branch 'claude/sharp-sanderson-6b47ae' — sharpen Phase M into design spec + opcode matrix 2026-05-10 19:22:54 +02:00
Erik
1d1afcd562 feat(render #53): wire EntityClassificationCache.InvalidateEntity at despawn
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>
2026-05-10 19:22:50 +02:00
Erik
c7021d8645 docs(phase-m): sharpen Phase M into design spec + opcode coverage matrix
Captures Phase M (Network Stack Conformance) as a fully-formed phase
ready to be picked up later. Three deliverables:

1. Design spec at docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md
   (~700 lines, 8 sections):
   - Bar C completeness target ("wireable on demand"): every wire opcode
     a 2013 EoR retail client receives or sends gets a parser/builder +
     golden-vector test + typed event in the new layered stack.
   - Three-layer architecture: INetTransport / IReliableSession /
     IGameProtocol, with WorldSession as a thin behavior consumer.
     Concrete C# interface signatures, sub-component decomposition.
   - Worktree-branch big-bang migration on claude/phase-m-network-stack;
     weekly rebase cadence; single --no-ff merge ships the phase.
   - Per-sub-phase entry/exit gates, conformance test plan (golden vectors
     + live capture replay + live ACE smoke), 10-row risk register, scope-
     cut order if calendar compresses.
   - Cost: 256 hours / ~6.4 weeks single-developer; 4-6 weeks calendar
     with subagent parallelization on M.1 + M.6.

2. Opcode coverage matrix at docs/research/2026-05-10-phase-m-opcode-matrix.md
   (~284 rows across 5 sections):
   - Section 1: 22 transport flags (14 implemented).
   - Section 2: 12 optional-header fields (10 partial).
   - Section 3: 51 top-level GameMessages (21 implemented).
   - Section 4: 103 GameEvent sub-opcodes inside 0xF7B0 (27 parsed,
     26 wired).
   - Section 5: 96 GameAction sub-opcodes inside 0xF7B1 (24 built,
     8 with live callers).
   - Roll-up: ~34% complete by raw opcode count. Biggest single
     unblocking step is wiring the 16 dead builders in section 5
     (Phase B.4 surface — Use / UseWithTarget / Allegiance / Inventory
     / Social / Cast / Appraise).
   - Sources cited per row: holtburger (629695a), ACE, named retail
     decomp, acdream current state.
   - Produced by 4 parallel research agents (one per class). Spot-check
     pass owed before M.1 closes.

3. Roadmap update: Phase M section trimmed to summary + status + pointer
   to the spec; the previously-tracked M.0 Tier 1 quick-wins are folded
   into M.3 / M.4 / M.6 per the spec; M.1 retained as the matrix
   construction sub-lane with status note.

Why this shape: the user goal is a complete, layered, testable network
stack that can be wired in as gameplay phases need it — independent of
whether each opcode is yet hooked to game state. The matrix is the
source of truth for "done"; the spec is the architecture the matrix
implements against; the roadmap is the index that points at both.

Decisions captured during the design discussion (in case they need
revisiting):
- Bar C ("wireable on demand") chosen over Bar A (holtburger parity)
  or Bar B (named-retail completeness).
- Three layers (INetTransport / IReliableSession / IGameProtocol)
  chosen over holtburger's two-layer split.
- Big-bang on a feature branch (worktree) chosen over strangler
  pattern; preserves live-ACE testing on main throughout the phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 19:22:49 +02:00
Erik
f7e38c214d fix(render #53): cache-hit fast path must fire per-entity, not per-tuple
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>
2026-05-10 19:15:20 +02:00
Erik
0cbef3c8b3 feat(render #53): cache-hit fast path + dispatcher integration tests
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>
2026-05-10 18:56:33 +02:00
Erik
00fa8ae839 fix(render #53): cache Populate must flush at entity boundary, not per-MeshRef tuple
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>
2026-05-10 18:36:57 +02:00
Erik
2f489a83a7 feat(render #53): cache-miss populate on first frame for static entities
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>
2026-05-10 18:24:26 +02:00
Erik
28513eae88 feat(render #53): add optional CachedBatch collector to ClassifyBatches
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>
2026-05-10 18:14:35 +02:00
Erik
a65a241981 feat(render #53): inject EntityClassificationCache into WbDrawDispatcher
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>
2026-05-10 18:05:03 +02:00
Erik
41fe6d0172 Merge branch 'claude/sharp-sanderson-6b47ae' — capture holtburger network study + Phase M.0 2026-05-10 17:56:30 +02:00
Erik
60fbfce8bc refactor(render #53): plumb landblockId through WbDrawDispatcher walkScratch
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>
2026-05-10 17:55:51 +02:00
Erik
b8b9845f50 docs(post-A.5): capture holtburger network-stack study + Phase M.0 quick-wins
Holtburger reference fast-forwarded from 88b19bd to 629695a (+237 commits).
Four parallel research agents produced a parity-first-pass between
holtburger's network stack and acdream's src/AcDream.Core.Net/.

Why captured now: study surfaced six small, high-confidence "Tier 1" fixes
that can ship before the bigger M.1-M.8 layer extraction. Most likely fix
for the longstanding "remote retail observer sees us not perfect" bug
(MoveToState wire-format mismatches). Two transport gaps (no EchoResponse
reply, eager port-switch) match recent holtburger fixes (403bc98, 99974cc).
One latent bug worth a 5-min check (ISAAC search-mode for out-of-order
ENCRYPTED_CHECKSUM packets).

Captured as Phase M.0 in the roadmap so the work survives the session and
can be picked up later. Existing M.1-M.8 lift unchanged; M.1 marked as
partially started since the research note is the parity-map deliverable
in draft form.

Files:
- docs/research/2026-05-10-holtburger-network-stack-study.md (new) — full
  study with ranked port candidates, recent commits worth knowing, and
  acdream-vs-holtburger file map.
- docs/plans/2026-04-11-roadmap.md — Phase M Plan-of-record updated with
  2026-05-10 pointer; M.0 sub-lane added before M.1; M.1 status note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:52:26 +02:00
Erik
a171e7007b feat(render #53): EntityClassificationCache.InvalidateLandblock + tests
Sweep-by-landblock removal for the streaming demote/unload path. Tests
#6, #7, #8 from spec section 7.1 lock in: (a) all matching entries removed,
(b) non-matching entries preserved, (c) idempotent on missing LB.

Phase 1 (cache foundation) complete. 11 cache tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:47:57 +02:00
Erik
aea4460eae feat(render #53): EntityClassificationCache.InvalidateEntity + tests
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>
2026-05-10 17:42:09 +02:00
Erik
694815c499 feat(render #53): EntityClassificationCache.Populate + roundtrip tests
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>
2026-05-10 17:34:48 +02:00
Erik
773e9703da feat(render #53): EntityClassificationCache skeleton + first test
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>
2026-05-10 17:23:37 +02:00
Erik
c02405cbb7 refactor(render): extract WbDrawDispatcher.GroupKey to internal type at namespace scope
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>
2026-05-10 17:13:44 +02:00
Erik
2f8a574b92 docs(post-A.5 #53): Tier 1 cache — implementation plan (writing-plans)
17-task TDD-style plan for the Tier 1 entity-classification cache, sized
~5-7 days. Phases:

  Phase 1 (Tasks 1-5):  Cache foundation — extract GroupKey, build the
                        cache class with TryGet/Populate/InvalidateEntity/
                        InvalidateLandblock, and 11 pure-CPU tests.
  Phase 2 (Tasks 6-10): Dispatcher integration — plumb landblockId
                        through the walk scratch, inject the cache,
                        wire ClassifyBatches collector + cache-miss
                        populate + cache-hit fast path. +2 integration
                        tests.
  Phase 3 (Tasks 11-12): Invalidation hooks — wire InvalidateEntity from
                        RemoveLiveEntityByServerGuid + InvalidateLandblock
                        from GpuWorldState.RemoveEntitiesFromLandblock
                        via callback (W3b per spec §5.3).
  Phase 4 (Task 13):    DEBUG cross-check — assert membership predicate
                        + DebugCrossCheck method + 2 unit tests via
                        TraceListener capture.
  Phase 5 (Tasks 14-16): Verification — full suite + sentinel + visual
                        gate (user) + perf gate (user, ≤2.0 ms median).
  Phase 6 (Task 17):    Ship — ISSUES + CLAUDE.md + memory + final commit.

Plan resolves spec §11 open implementation choices: W3b for LB invalidation,
GroupKey at namespace internal, ResolveLandblockHint plumbed via walk
scratch, _populateScratch as a field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 17:06:42 +02:00
Erik
4abb838729 docs(post-A.5 #53): Tier 1 retry — mutation audit + cache design spec
The audit at docs/research/2026-05-10-tier1-mutation-audit.md enumerates
every entity.MeshRefs write site (5 STATIC at hydration, 1 DYNAMIC at
GameWindow.cs:7580 inside TickAnimations) and verifies that all 7
Position/Rotation write sites only touch entities in _animatedEntities.
Establishes the load-bearing invariant: an entity's renderer state is
stable from spawn to despawn iff entity.Id is NOT in _animatedEntities.

The spec at docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md
locks in the design from brainstorming on 2026-05-10:
- Static-only cache + DEBUG cross-check (option c) — catches future
  regressions of the prior bug class without paying perf cost in Release
- Separate EntityClassificationCache class injected into WbDrawDispatcher
- Cache the rest pose, not the full model matrix (Position/Rotation read
  live each frame so Release stays correct even if the invariant breaks)
- Pre-flatten Setup multi-parts at populate time (the bulk of the win)
- 15 new tests covering all invalidation paths + DEBUG cross-check +
  Setup pre-flatten + lifecycle pin

Closes the audit + design steps of the post-A.5 polish Priority 3 work.
Implementation plan owned by superpowers:writing-plans next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:50:26 +02:00
Erik
f66522cd6b Merge branch 'claude/cranky-varahamihira-fe423f' — Tier 1 retry cold-start handoff 2026-05-10 16:14:24 +02:00
Erik
15376c7a73 docs(post-A.5): cold-start handoff for the Tier 1 retry session (#53)
After the post-A.5 lifestone (#52) + JobKind plumbing (#54) work shipped,
only Priority 3 (Tier 1 entity-classification cache retry, ISSUE #53)
remains. This handoff captures the audit insights gathered during the
#52 investigation that the original post-A.5 handoff didn't have:

- MeshRef is a `readonly record struct` — its fields can NOT be mutated
  in place. The actual per-frame mutation for animated entities is the
  entire MeshRefs LIST replacement at GameWindow.cs:7474-7553. This
  reframes the cache design.
- _animatedEntities dict at GameWindow.cs:160 is the source of truth
  for which entities go through the per-frame rebuild path.
- Static entity = entity.Id NOT in _animatedEntities. Its MeshRefs is
  the same instance from spawn until rare events (ObjDesc / palette
  swap / part hide / scale apply).
- Recommended cache approach: static-only with explicit invalidation
  hooks on the network/spawn-time write sites enumerated in the doc.

Doc covers: where main is, what shipped this session, why the first
Tier 1 attempt failed, the pre-started audit, cache design options,
acceptance criteria, files to read, workflow for the next session, and
things-to-NOT-do.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:14:24 +02:00
Erik
da08490ab0 Merge branch 'claude/cranky-varahamihira-fe423f' — Post-A.5 polish: close #52 (lifestone) + #54 (JobKind plumbing) 2026-05-10 16:08:32 +02:00
Erik
9a55354143 docs(post-A.5 #54): close JobKind plumbing issue + update CLAUDE.md flight status
Move ISSUE #54 to Recently closed referencing commit `bf31e59`. Drop
#54 from CLAUDE.md "Currently in flight" — only #53 (Tier 1 retry)
remains open in the post-A.5 polish phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:04:01 +02:00
Erik
bf31e59805 fix(streaming): close #54 — plumb JobKind through BuildLandblockForStreaming
Bug A's fix (commit `9217fd9`) patched at the worker output by stripping
entities from far-tier `LoadedLandblock`s after the full `LoadNear` path
ran. The worker still wasted CPU on `LandBlockInfo` reads + entity
hydration + `SceneryGenerator` math + interior-cell walks for ~544
far-tier LBs at radius=12, just to throw the work away.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 16:03:16 +02:00
Erik
b19f1d10ec docs(post-A.5 #52): close lifestone issue + update CLAUDE.md flight status
Move ISSUE #52 from Active to Recently closed with full root-cause writeup
referencing commit `e40159f`. Strip lifestone reference from CLAUDE.md
"Currently in flight"; remaining post-A.5 polish scope is #53 (Tier 1
retry) + #54 (JobKind plumbing).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:51:46 +02:00
Erik
e40159f4d6 fix(render): close #52 — lifestone visible (alpha-test + cull + uDrawIDOffset)
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>
2026-05-10 15:49:05 +02:00
Erik
c111312e13 docs(post-A.5): cold-start handoff for the next session
Captures the three deferred items from A.5 ship:
- ISSUE #52: lifestone visual missing (1-3 hours, fast win)
- ISSUE #54: JobKind plumbing through BuildLandblockForStreaming
  (~30 min - 1 hour, worker-thread efficiency cleanup)
- ISSUE #53: Tier 1 entity-classification cache retry (~5-7 days,
  biggest perf win remaining; needs animation-mutation audit before
  designing to avoid the freeze-pose bug from the first attempt)

Doc covers: A.5 final state + 3 high-value gotchas, files to read,
per-priority detail with effort estimates and acceptance criteria,
what NOT to do, the first-30-minute workflow, and the full A.5
commit chain for reference.

Phase is sized ~1 week if all three priorities land. The audit
step on Tier 1 is the highest-leverage investment.

Tier 2 + Tier 3 (static/dynamic split + GPU compute culling) are
explicitly out-of-scope for this phase — separate multi-week phases
per docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:16:10 +02:00
Erik
d3d78fa14f Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system
Phase A.5 — Two-tier Streaming + Horizon LOD shipped.

Headline: 2.3 km terrain horizon (radius=4 near + 12 far) with off-thread
mesh build, fog blend at N₁, mipmaps + 16x AF, MSAA 4x + A2C foliage,
depth-write audit, BUDGET_OVER diag, Quality Preset system (Low/Medium/
High/Ultra) with env-var overrides + F11 mid-session re-apply.

~999 tests pass, 8 pre-existing physics/input failures unchanged.

Two structural-to-A.5 bug fixes shipped post-T26:
- Bug A (9217fd9): far-tier worker strips entities (T13/T16 had only
  wired the controller side; far-tier was loading full entity layers,
  ~71K entities instead of ~10K, 5x perf regression).
- Bug B (0ad8c99): WalkEntities scratch list reused across frames
  (was 480 KB / frame allocation).

Tier 1 entity-classification cache attempted as polish (3639a6f),
reverted (9b49009) — broke animation by caching mutable per-frame
state. Retry deferred to post-A.5 polish phase (ISSUE #53).

Deferred to post-A.5 polish:
- Tier 1 retry with animation-mutation audit (ISSUE #53)
- Lifestone missing visual (ISSUE #52)
- JobKind plumbing through BuildLandblockForStreaming (ISSUE #54)
- Tier 2 (static/dynamic split) + Tier 3 (GPU compute cull) —
  separate multi-week phases. Roadmap at
  docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md.

SHIP commit: 9245db5.
2026-05-10 10:09:03 +02:00
Erik
9245db5b04 phase(A.5): SHIP — two-tier streaming + horizon LOD + Quality Preset system
Final state: A.5 delivers a 2.3 km terrain horizon (radius=4 near + 12
far) with off-thread mesh build, fog blend at the N₁ boundary, mipmaps
+ 16x anisotropic on terrain, MSAA 4x + A2C foliage, depth-write audit
+ lock-in test, BUDGET_OVER diag flag, and a full Quality Preset system
(Low/Medium/High/Ultra) with env-var overrides + F11 mid-session
re-apply.

Acceptance:
- N.5b conformance sentinel: 89+ tests passing (TerrainSlot,
  TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless,
  SplitFormulaDivergence). All clean.
- Build green; ~999 tests passing across all projects; 8 pre-existing
  physics/input failures unchanged (out of A.5 scope).
- Standstill at horizon-safe preset (radius=4/12, MSAA off, A2C off,
  aniso 4x), Holtburg, AMD Radeon RX 9070 XT @ 1440p:
  entity dispatcher cpu_us median ~3.5ms, p95 ~4ms (~200-240 FPS).
  Terrain dispatcher cpu_us median ~21µs (well under 1ms budget).
- Visual gate (partial): horizon visible at ~2.3km; fog blend smooths
  N₁ boundary cleanly; system stable through walking traverse.
  Lifestone missing — known issue from earlier in development chain,
  deferred to post-A.5 (ISSUE #52).

Two post-T26 perf bug fixes that were structural to A.5's promise:
- (Bug A, 9217fd9) Far-tier worker now strips entities. Without this,
  T13/T16 shipped only the controller side of two-tier; the worker
  loaded full entity layers for far-tier LBs. Result was ~71K entities
  in GpuWorldState instead of ~10K — a 5x perf regression. Patch is
  at the worker-output level; cleaner JobKind plumbing through
  BuildLandblockForStreaming is post-A.5 (ISSUE #54).
- (Bug B, 0ad8c99) WalkEntities switched from per-frame fresh-list
  allocation to a caller-provided scratch list reused across frames.
  Eliminated ~480 KB / frame GC pressure on the render thread.

Tier 1 entity-classification cache attempted as ship-prep polish
(commit 3639a6f) but reverted (9b49009) — caching meshRef.PartTransform
froze the per-frame animation pose. Retry is a post-A.5 phase with
animation-mutation audit + animated-bypass design (ISSUE #53).

Decisions (per spec §4):
- N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs).
- Bucketing Change #1 (animated-walk fix in WalkEntities) +
  Change #2 (cached AABB on WorldEntity) shipped. Change #3
  (sub-LB cell cull) NOT shipped — budget hit without it.
- Single-worker off-thread mesh build (Q6 Option A).
- Hysteresis radius+2 on both tiers (Q7 Option A).
- Mipmaps + 16x anisotropic + A2C with MSAA 4x + depth-write audit
  all shipped (Q8 Option C).
- Acceptance gate: refresh-rate-relative + per-preset (Q9 Option B
  reshape after Quality Preset addition).
- Quality Preset system (T22.5, mid-execution scope add): Low /
  Medium / High / Ultra with per-preset radii + MSAA + anisotropic +
  A2C + completions; 6 env-var overrides; settings.json persistence;
  F11 mid-session re-apply.

Deferred to post-A.5 polish phase:
- Tier 1 retry with animation audit (ISSUE #53)
- Lifestone missing (ISSUE #52)
- JobKind plumbing through BuildLandblockForStreaming (ISSUE #54)
- Tier 2 (static/dynamic group split) — multi-week phase
- Tier 3 (GPU compute culling) — multi-week phase
- Re-test full High preset (crashed at original attempt; should work
  post-Bug-A; not retested)

Spec: docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md
Plan: docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md
Perf-tier roadmap: docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md
Memory: ~/.claude/projects/.../memory/project_phase_a5_state.md (5 gotchas)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:08:13 +02:00
Erik
8f43a58037 Merge branch 'claude/bold-proskuriakova-d4fb2c' — ISSUES.md #13 PlayerDescription trailer parser + F.5a roadmap entry
Closes ISSUES.md #13. PlayerDescriptionParser now walks the full
trailer (Options1 / Shortcuts / HotbarSpells / DesiredComps /
SpellbookFilters / Options2 / GameplayOptions blob / Inventory /
Equipped) ported from holtburger events.rs:503-625 +
shortcuts.rs:13-34. The trickiest piece — gameplay_options — uses a
4-byte-aligned forward heuristic (TryHeuristicInventoryStart)
mirroring holtburger's find_inventory_start_after_gameplay_options.

Trailer walk wrapped in its own inner try/catch so a malformed
trailer cannot destroy upstream attribute/skill/spell/enchantment
data; new Parsed.TrailerTruncated flag distinguishes clean parse
from graceful-degradation parse, with diagnostic log under
ACDREAM_DUMP_VITALS=1.

GameEventWiring registers parsed Inventory + Equipped into
ItemRepository at login (acceptance criterion: ItemRepository.Count
> 0 after login, exercised by GameEventWiringTests). 20 PD parser
tests + 1 wiring acceptance test; 282/282 AcDream.Core.Net.Tests
pass.

Plan: docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md.

Roadmap update: F.5a (visible-at-login dev panels) added as the
first deliverable that actually consumes the new trailer data —
ImGui dev panels under ACDREAM_DEVTOOLS=1 binding to
AcDream.UI.Abstractions ViewModels.

13 task commits + 1 review-followup + 1 nit-fix + 1 roadmap = 16
commits on the branch.
2026-05-10 10:07:06 +02:00
Erik
d93d823539 docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship
Roadmap (2026-04-11-roadmap.md):
- Status header updated to 2026-05-10 / A.5 as the shipped phase.
- A.5 row added to shipped table (after A.3): two-tier streaming,
  QualityPreset, Bug A/B fixes, deferred items, plan archive link.
- A.5 sub-piece in Phase A section marked SHIPPED with archive link
  (replaces the old "not yet brainstormed" entry).
- N.6 bullet changed from "Currently in flight" to "Planned
  (post-A.5 polish takes priority)"; A.5's landing means the
  "direct higher-radius comparison once A.5 lands" item is now
  available.

ISSUES.md:
- #52 (A.5/lifestone-missing): Holtburg lifestone not rendering since
  A.5 dev; two root-cause candidates; investigation approach.
- #53 (A.5/tier1-redo): classification cache reverted at 9b49009;
  animation-mutation audit required before retry; 1-week estimate.
- #54 (A.5/jobkind-plumbing): Bug A's post-load strip wastes worker
  CPU; proper fix plumbs JobKind through BuildLandblockForStreaming;
  30 min–1 hour estimate.

CLAUDE.md:
- "Currently in flight" paragraph updated from N.6 to Post-A.5 polish
  (issues #52/#53/#54) with note that N.6 follows.
- A.5 shipped paragraph added (mirrors N.5b/N.5/N.4 format).
- WB integration cribs: new bullet documenting the two-tier streaming
  architecture (StreamingRegion / StreamingController / LandblockStreamer
  / GpuWorldState, N₁/N₂ defaults, QualitySettings, spec pointer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:06:40 +02:00
Erik
68d6898339 roadmap: add F.5a — visible-at-login dev panels (consumes #13)
Sub-phase under existing F.5 (Core panels) capturing the immediate
follow-up to ISSUES.md #13: now that PlayerDescriptionParser surfaces
the full trailer (Inventory / Equipped / Shortcuts / HotbarSpells /
DesiredComps / Options1+2 / SpellbookFilters) and GameEventWiring
populates ItemRepository at login, F.5a wires that data into minimal
ImGui dev panels under ACDREAM_DEVTOOLS=1 so it's observable in-game.

Establishes the binding pattern (AcDream.UI.Abstractions ViewModels →
ImGui renderer) that the eventual D.2b retail-skinned F.5 panels
reuse. Spec to brainstorm before code.
2026-05-10 10:06:30 +02:00
Erik
a28a5b7583 docs(A.5 T27): spec + plan amendments for T22.5 + ship
Spec (2026-05-09-phase-a5-two-tier-streaming-design.md):
- §2 acceptance metrics reshaped from absolute 240 FPS to
  refresh-rate-relative + per-preset (95th-pct ≤ 1000ms/refresh
  standstill; ≤ 1.5× walking) to match the Quality Preset reality.
- New §4.10 Quality Preset System (T22.5): enum Low/Medium/High/Ultra,
  QualitySettings schema, canonical preset values table, env-var
  override table, wiring notes (GameWindow.OnLoad + ReapplyQualityPreset),
  MSAA mid-session unsupported caveat, file list, test count (12).
- New §11 What was deferred: 8 items (Tier 1 cache, lifestone, JobKind
  plumbing, Tier 2/3, ToEntries alloc, InvalidateEntity wiring, High
  preset retest). Former §11 References renumbered to §12.

Plan (2026-05-09-phase-a5-two-tier-streaming.md):
- New Task 22.5 section inserted between T22 and T23: full inline spec
  with schema, preset table, env-var list, wiring steps, acceptance
  criteria, deferred items, commit SHAs. Includes file-name corrections
  (SettingsState → DisplaySettings, DisplayTab → SettingsPanel).
- Self-review cross-check table: new §4.10 row pointing at T22.5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:06:26 +02:00
Erik
9b49009dd5 Revert "feat(perf): Tier 1 entity classification cache"
This reverts commit 3639a6f4ac.
2026-05-10 09:53:26 +02:00
Erik
95aaa6c92e docs: close ISSUES.md #13 — PD trailer parser shipped
Issue #13 closed in `078919c`. Full trailer (Options1 / Shortcuts /
HotbarSpells / DesiredComps / SpellbookFilters / Options2 /
GameplayOptions blob / Inventory / Equipped) now walked by
PlayerDescriptionParser. ItemRepository wired up to receive parsed
Inventory + Equipped at login. 20 PD parser tests + 1 wiring
acceptance test. 282/282 Net.Tests pass.

Plan archived at
docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md.
2026-05-10 09:47:51 +02:00
Erik
3639a6f4ac feat(perf): Tier 1 entity classification cache
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>
2026-05-10 09:45:18 +02:00
Erik
078919cc18 feat(net): #13 register PD trailer inventory+equipped in ItemRepository
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>
2026-05-10 09:43:46 +02:00
Erik
58095d8d4b test(net): #13 end-to-end PD trailer fixture covering every section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:39:36 +02:00
Erik
462f9d6377 docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations
Captured 2026-05-10 during Phase A.5 polish discussion. User asked why
the 9070 XT @ 1440p doesn't hit Unreal-level FPS for an old game like
AC. Answer: architectural — we rebuild the entire draw plan from
scratch every frame instead of caching pre-baked static-world data.

Tier 1 (entity-classification cache) lands as A.5 polish (separate
commit). Tiers 2 + 3 documented here for future scheduling:

- Tier 2 — Static/dynamic split with persistent groups
  ~2-week phase. Static entities (~95% of world) get permanent GPU-
  resident matrix slots, populated at spawn, dirty-tracked for delta
  upload. Per-frame CPU cost for static = LB-cull + dirty-flag check
  only. Estimated entity dispatcher: 3.5ms → 0.5-1ms median.
  400-600 FPS at standstill, radius=12.

- Tier 3 — GPU-side culling (compute pre-pass)
  ~1-month phase. Per-instance frustum cull moves to GPU compute
  shader. Compute writes draw-indirect buffer; rasterizer reads it.
  Estimated CPU dispatcher: ~0.05ms (essentially free).
  600-1000+ FPS at standstill, radius=12.

Doc captures effort estimates, sub-decisions, risks, mitigations, and
scheduling triggers for each tier. Also notes the architectural
ceiling (~800-1500 FPS for a C# + GL client; reaching native engine
performance requires becoming a different engine).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:38:38 +02:00
Erik
91693ea44c feat(net): #13 heuristic inventory locator after gameplay_options blob
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:37:46 +02:00
Erik
0ad8c99c37 fix(A.5): WalkEntities scratch-list pattern (Bug B — T17 GC pressure)
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>
2026-05-10 09:13:20 +02:00
Erik
9217fd93cd fix(A.5): strip far-tier entities in worker (Bug A — far tier optimization)
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>
2026-05-10 09:10:42 +02:00
Erik
d9a5e40203 feat(net): #13 strict inventory+equipped reader (no GAMEPLAY_OPTIONS)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:49:10 +02:00
Erik
98eebef740 feat(net): #13 read options2 gated on CHARACTER_OPTIONS2 flag
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:46:32 +02:00
Erik
b17dc3b152 feat(net): #13 read optional spellbook_filters u32 2026-05-10 08:44:05 +02:00
Erik
28d2c6018e feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2)
GameWindow.OnLoad resolves QualitySettings.From(_persistedDisplay.Quality)
+ WithEnvOverrides() immediately after LoadAndApplyPersistedSettings, stores
result in _resolvedQuality field. All six quality dimensions applied:

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

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

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

991 tests passing, 8 pre-existing failures unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:43:06 +02:00
Erik
75e8e260f2 feat(net): #13 read desired_comps list in PD trailer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:39:31 +02:00
Erik
afa4200107 feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2)
Add QualityPreset enum + QualitySettings readonly record struct with
From(preset) table and WithEnvOverrides() env-var override layer.
Four presets (Low/Medium/High/Ultra) drive NearRadius, FarRadius,
MsaaSamples, AnisotropicLevel, AlphaToCoverage, MaxCompletionsPerFrame.
Env vars (ACDREAM_NEAR_RADIUS, ACDREAM_FAR_RADIUS, ACDREAM_MSAA_SAMPLES,
ACDREAM_ANISOTROPIC, ACDREAM_A2C, ACDREAM_MAX_COMPLETIONS_PER_FRAME)
override individual preset fields for dev spot-testing.

DisplaySettings gains a Quality: QualityPreset field (default High);
SettingsStore persists/loads it under display."quality" as an enum
name string with Enum.TryParse fallback. 12 new QualityPresetTests
cover the preset table (radii, msaa, aniso, a2c, completions) and all
six env-var override paths. 415 UI.Abstractions tests passing.

Wiring into GameWindow / WbDrawDispatcher / TerrainAtlas follows in
commit 2 of this task.

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:55 +02:00
Erik
1488ec62b7 test(A.5 T21): lock in depth-write attribution per translucency kind
Per Phase A.5 spec §4.9.3 audit: opaque + ClipMap pass uses
DepthMask(true); AlphaBlend / Additive / InvAlpha pass uses
DepthMask(false), restored after. Audit confirmed correct in
WbDrawDispatcher.Draw. IsOpaquePublic shim already present.

Add WbDispatcherDepthMaskTests: 5-case Theory that pins the partition
so future regressions surface immediately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:27:03 +02:00
Erik
9a0dfe03da refactor(net): #13 Parsed.TrailerTruncated + diag logging
Code-quality review followup on Task 2 (becbde6) — addresses I1 (the
forward-looking concern that Tasks 3-9's inner-catch will leave partial
lists visible to callers with no signal) and M1 (silent inner catch).

Changes:
  - Parsed gains a trailing `bool TrailerTruncated` field. Both
    construction sites pass `false` by default; the trailer try/catch
    flips a local `trailerTruncated` to `true` on FormatException and
    feeds it into the final return.
  - Inner catch logs `pos`/`payload.Length`/exception message under
    ACDREAM_DUMP_VITALS=1, mirroring the outer catch's diagnostic
    pattern.
  - Task 2 test strengthened to assert defaults on Options2 /
    SpellbookFilters / HotbarSpells / DesiredComps / GameplayOptions /
    Equipped + TrailerTruncated=false (M2 followup — gives Tasks 3-9
    a regression guard if they consume into the wrong field).
  - New test `TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_*`
    documents the contract that <8 bytes after enchantments means the
    trailer is absent (not truncated): TrailerTruncated stays false,
    upstream attribute data survives.
  - Plan updated in lockstep so Tasks 3-11 implementers see the
    `trailerTruncated` local and the new return-arg position.

271/271 AcDream.Core.Net.Tests pass.
2026-05-10 08:26:08 +02:00
Erik
26b2871b10 feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage
Per Phase A.5 spec §4.9.2: ClipMap foliage uses binary alpha-cutoff.
At N₂=12 horizon distance the pixel-stepped silhouettes are visible.
A2C with MSAA 4x produces smooth retail-faithful tree edges.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:25:59 +02:00
Erik
4b84e5650b feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas
Per Phase A.5 spec §4.9.1: at N₂=12 distant terrain LBs occupy a few
pixels on screen and shimmer (texel-swap aliasing) without mipmaps.
Generate mips after atlas upload; sampler trilinear + 16x anisotropic.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:20:20 +02:00
Erik
becbde60a4 feat(net): #13 read OptionFlags + Options1 after enchantments
First step of the PD trailer walk. Wraps trailer reads in their own
try/catch so a malformed trailer does not null out the upstream
attribute/skill/spell/enchantment data we already extracted.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:18:38 +02:00
Erik
003443cd1a feat(A.5 T17): WbDrawDispatcher Change #1 — animated-walk fix + WalkEntities helper
Per Phase A.5 spec §4.6 Change #1: when an LB is invisible AND
animatedEntityIds is non-empty, the inner loop walked every entity
in the LB just to find the few animated ones. At ~10.7K entities
(N1=4) that is wasted iteration cost per frame.

Extracted a pure-CPU internal static WalkEntities helper. When LB
is invisible: iterate animatedEntityIds directly and look each up
in a per-LB AnimatedById dictionary (typically <50 animated vs
~10K total). When LB is visible: walk all entities as before.

GpuWorldState.LandblockEntries now yields an AnimatedById map as a
5th tuple field alongside the AABB tuple. Dictionary is built on
each yield (cheap — ~132 entities/LB max). A caching layer is out
of A.5 scope.

WbDrawDispatcher.Draw signature updated to consume the 5-tuple.
GameWindow.cs call site passes _worldState.LandblockEntries which
now yields the 5-tuple — no change needed there.

8 new tests in WbDrawDispatcherBucketingTests cover T17 Change #1
(invisible LB / animated set / neverCull / null frustum) and
T18 Change #2 guard tests (cached AABB / dirty flag / animated bypass).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:18:02 +02:00
Erik
65870349a8 refactor(net): #13 rename Shortcut → ShortcutEntry, expand doc citations
Code review nit-fix on top of d3b58c9 — addresses two issues from the
quality review of Task 1:

  I1 (Important): the record struct `Shortcut` was a homograph with the
  flag member `CharacterOptionDataFlag.Shortcut`. Both names live inside
  `PlayerDescriptionParser`'s scope. Rename to `ShortcutEntry` aligns
  with `InventoryEntry`/`EquippedEntry` and removes the trap before
  Task 3's walker references both names in the same method body.

  M2 (Minor): `EquippedEntry` had no holtburger source citation; added
  one referencing events.rs:180-190. Also expanded `InventoryEntry`'s
  comment with the strict reader's validation reference.

Plan doc updated in lockstep so Task 3+ implementers see the new name.
8/8 PlayerDescriptionParser tests still pass.
2026-05-10 08:16:01 +02:00
Erik
0de6bc9c96 fix(A.5 T13-T16): canonical LB id in Tick_DrainingPromoted test
Commit 19b4465's new ToPromote test pre-loaded an LB with a non-
canonical id (low 16 bits 0x0FFF instead of 0xFFFF). The new
canonicalization in AddEntitiesToExistingLandblock then key-missed and
parked the entity in the pending bucket instead of merging — assertion
failed.

Use canonical id 0x3232FFFFu directly. The test now exercises the
intended hot-path (merge into existing LB), not the cold pending-bucket
fallback (which is exercised by GpuWorldStateTwoTierTests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:09:53 +02:00
Erik
c2c8a532db fix(A.5 T13-T16): WorldEntity required-member fields in new tests
Commit 19b4465 broke build by omitting required-member init for
SourceGfxObjOrSetupId/Position/Rotation in the new ToDemote/ToPromote
tests. WorldEntity has [required] on those fields (CS9035). The lone
test run that reported 38 passing used pre-existing binaries built
before this break.

Added all three required initializers (zero / Identity defaults — these
test the routing path; entity content doesn't matter).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:09:10 +02:00
Erik
19b4465257 fix(A.5 T13-T16): canonicalize ids; init-only radii; demote/promote tests
Code review on T13-T16 bundle (commits fb10c3f/aff35d2/b8d80fe/c4fd373/31d312a)
flagged 3 Important + 2 test-coverage gaps. Apply all 5:

Important #1: GpuWorldState.AddEntitiesToExistingLandblock didn't
canonicalize landblockId. Streaming callers always pass canonical
0xAAAA0xFFFF ids, but the public API silently key-missed for callers
that mirror AppendLiveEntity's cell-resolved-id pattern. Both new
methods now canonicalize the id on entry.

Important #2: RemoveEntitiesFromLandblock asymmetry with RemoveLandblock
re: persistent-entity rescue. Documented as intentional — demote-tier
entities are atlas-tier only (procedural scenery, dat-static stabs/
buildings; never ServerGuid != 0); the local player and live server
spawns live in their LB via RelocateEntity per frame and aren't
affected by atlas-layer demote.

Important #3: StreamingController.NearRadius / FarRadius were { get; set; }
but mutating them after the first Tick is a no-op (StreamingRegion
snapshots the values). Switched to { get; } only with XML doc warning.

Test gap #1: ToDemote routing through Tick — added test that walks
the player past hysteresis and asserts entities drop while terrain
stays.

Test gap #2: Promoted result routing through Tick — added test that
enqueues a Promoted and asserts AddEntitiesToExistingLandblock fires.

Deferred Minor: dead _streamingRadius write + style consistency on
fully-qualified IReadOnlyList — non-load-bearing, can roll into a later
cleanup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 08:08:23 +02:00
Erik
d3b58c97e0 feat(net): #13 scaffold trailer fields on PlayerDescriptionParser.Parsed
No behavior change yet — adds CharacterOptionDataFlag, Shortcut/Inventory/
EquippedEntry records, and extends Parsed with trailer fields filled with
empty defaults. Sets up the per-section TDD walk in subsequent commits.
2026-05-10 08:06:33 +02:00
Erik
31d312add3 fix(A.5 T16): debug overlay shows _nearRadius instead of legacy _streamingRadius
Cosmetic follow-up flagged by spec compliance review on T13-T16 bundle
(commits fb10c3f / aff35d2 / b8d80fe / c4fd373). The debug overlay's
getStreamingRadius callback was reading _streamingRadius — the legacy
single-tier field that's only updated by ACDREAM_STREAM_RADIUS. Operators
using the new ACDREAM_NEAR_RADIUS / ACDREAM_FAR_RADIUS env vars would
see the overlay frozen at the default 2.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:56:57 +02:00
Erik
aff35d2a76 refactor(A.5 T15): TerrainModernRenderer.AddLandblockWithMesh entry point
T13 routes worker-built meshes from LandblockStreamResult.Loaded.MeshData
into the renderer. AddLandblockWithMesh accepts a prebuilt mesh + origin
and delegates to the existing AddLandblock(uint, LandblockMeshData, Vector3)
so both paths share one upload path (Approach B -- AddLandblock already
takes a prebuilt mesh; no inline build to extract).

GameWindow's T16 lambda captures liveCenterX/Y and passes the derived
origin; the renderer stays origin-agnostic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:54:40 +02:00
Erik
fb10c3fa8c feat(A.5 T14): GpuWorldState RemoveEntitiesFromLandblock + AddEntitiesToExisting
Two new methods on GpuWorldState, used by two-tier streaming (T13):

- RemoveEntitiesFromLandblock(id): drop all entities from an LB while
  keeping the terrain. Used for Near->Far demote (player walks past the
  inner ring; LB stays loaded but entities leave).
- AddEntitiesToExistingLandblock(id, entities): merge new entities into
  an already-loaded LB record. Used for Far->Near promote (terrain is
  already on the GPU; just streaming the entity layer in).

Falls back to the pending bucket if the LB hasn't loaded yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:53:34 +02:00
Erik
774a7070a8 fix(A.5 T10-T12): Start() race + null mesh test + real mesh stub
Code review on T10-T12 bundle (commits 0cf86bb/00bb030/0405947 + audit
fix 76e1a64) found 3 Important issues:

1. LandblockStreamer.Start() had an idempotency race — the XML doc
   claimed thread-safety but the implementation checked _worker != null
   before assigning, allowing two callers to both pass the check and
   spawn duplicate worker threads. Fixed via Interlocked.CompareExchange.

2. No test verified the worker emits Failed when buildMeshOrNull returns
   null. Added Load_WhenBuildMeshReturnsNull_ReportsFailed.

3. StreamingControllerTests.cs:81 used MeshData: default! when
   constructing a Loaded result. If a future test flows MeshData
   through the apply callback, the null reference would NRE rather
   than producing a meaningful assertion failure. Replaced with a real
   empty LandblockMeshData instance.

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

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

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

Wrap both reads in lock (_datLock).

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 07:35:45 +02:00
Erik
00bb030c9f feat(A.5 T11): activate LandblockStreamer worker thread
Phase A.1 reverted to synchronous mode due to DatCollection thread-
safety; T10 documented the lock that makes concurrent reads safe. T11
activates the dedicated worker thread and switches enqueue methods
to non-blocking Channel.Writer.TryWrite.

EnqueueLoad now takes LandblockStreamJobKind (default: LoadNear from
all callers, matching previous full-load semantics). T13/T16 will
route by kind per TwoTierDiff.

Constructor gains optional buildMeshOrNull param (defaults to null-
returning stub); T12 wires the real LandblockMesh.Build factory.

GameWindow construction site updated: Action<uint> enqueueLoad
delegate now wraps a lambda (method group won't bind to Action<uint>
when the method has an optional second param).

LandblockStreamerTests updated: the synchronous-thread-pinning test
replaced by Load_ExecutesLoaderOnWorkerThread which asserts the
loader runs on a different thread; Load_FollowedByDrain now supplies
a stubMesh so the worker can produce Loaded (not Failed) results.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:55:53 +02:00
Erik
a0741bd13a feat(A.5 T8): WorldEntity AABB cache + dirty flag
Adds AabbMin/AabbMax (per-entity world-space bounding box) and AabbDirty
flag to WorldEntity. RefreshAabb() recomputes the box from Position ±5 m
(DefaultAabbRadius). SetPosition() writes Position and marks the cache
dirty so the dispatcher calls RefreshAabb on first read rather than
carrying stale bounds.

AabbDirty defaults to true on construction — freshly-built entities have
zero AabbMin/AabbMax until RefreshAabb is called. Two new conformance tests
verify the ±5 m geometry and the dirty/clean state machine.

Per Phase A.5 spec §4.6 Change #2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:54:25 +02:00
Erik
295bce9bb2 feat(A.5 T7): LandblockStreamResult.Loaded.Tier+MeshData; Promoted variant
Extends the Loaded result record with a LandblockStreamTier discriminator
and a LandblockMeshData payload (default! stub — T13 wires the real
off-thread mesh build). Adds the Promoted variant for Far→Near upgrades
that only need the entity layer, not a mesh rebuild.

LandblockStreamer.HandleJob passes Tier.Near + default! MeshData at the
existing synchronous load site; StreamingControllerTests updated to
match the new positional signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:53:07 +02:00
Erik
1658882439 fix(A.5 T4-T6): bootstrap guard + dead enum + test cleanups
Code review on commits 7bcabab/fb6b61e/326b698 flagged 2 Important +
4 Minor issues. Apply all fixes:

Important:
- Two-tier RecenterTo + MarkResidentFromBootstrap now throw
  InvalidOperationException on misuse — calling RecenterTo before the
  bootstrap silently emitted the entire window as fresh loads (no
  demotes/unloads since _tierResidence was empty), a correctness hazard
  that produced no exception. Calling MarkResidentFromBootstrap twice
  silently dropped accumulated tier state. Both now crash loudly via
  a _bootstrapped flag.
- Dropped TierResidence.None from the enum — never assigned, never
  checked; absence from the dictionary already encodes "not resident."

Minor:
- Renamed test: RecenterTo_FirstTick_* → ComputeFirstTickDiff_FirstTick_*
  (the test calls ComputeFirstTickDiff, not RecenterTo).
- Strengthened RecenterTo_PlayerWalks_NullToFar_* with assertions for
  ToPromote.Count==3 (the x=102 column promoting Far→Near) and
  ToUnload.Empty (everything within hysteresis).
- Replaced System.Math.Abs with Math.Abs in new code to match the
  file's existing `using System;` convention.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:49:35 +02:00
Erik
326b698161 test(A.5 T6): StreamingRegion transitions + hysteresis + oscillation coverage
Adds 5 tests to StreamingRegionTwoTierTests covering all tier-transition paths:
- FarToNear promote (walk 2 east from initial center)
- NullToNear teleport (loads 9 near + 40 far for a fully fresh region)
- NearToFar demote only after NearRadius+2 hysteresis threshold
- FarToNull unload only after FarRadius+2 hysteresis threshold
- oscillation no-thrash: bouncing 1 LB across a near boundary fires 0 demotes
  and at most 5 promotes total (one initial settle of the x=100 near-column)

Oscillation test fix: initialise the region at the oscillation midpoint
(103,100) rather than at a distant starting center (100,100) so the
initial move into the oscillation range doesn't itself trigger legitimate
demotes, isolating the no-thrash invariant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:39:16 +02:00
Erik
fb6b61e8ef feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking
Adds TierResidence enum (None/Far/Near), _tierResidence dictionary seeded
by MarkResidentFromBootstrap, and the canonical two-tier RecenterTo overload
returning TwoTierDiff. Pass 1 walks the new far window and emits ToLoadFar /
ToLoadNear / ToPromote; Pass 2 walks prior residents and emits ToDemote /
ToUnload using Chebyshev hysteresis thresholds (NearRadius+2 / FarRadius+2).
EncodeLandblockIdForTest exposes the encoding rule to test assemblies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:36:20 +02:00
Erik
7bcababf82 feat(A.5 T4): StreamingRegion ComputeFirstTickDiff
Adds the first-tick bootstrap diff: ToLoadNear for the (2*near+1)^2 inner
window, ToLoadFar for the outer annulus up to FarRadius. Uses Chebyshev
distance, matching existing Recenter convention.

Also renames the single-tier RecenterTo → RecenterToSingleTier to free
the canonical name for the upcoming two-tier overload (T5). Updates
StreamingRegionTests and StreamingController to call the renamed method.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:34:55 +02:00
Erik
378f32ac7a fix(A.5 T3): pin Radius==FarRadius invariant in two-tier ctor test
Code review on commit 7fd9c82 flagged that the test asserted NearRadius,
FarRadius, CenterX, CenterY but not the load-bearing alias
Radius == FarRadius. That alias is what makes the existing hysteresis
math (Radius+2 unload threshold) correctly target the far-tier boundary.
Future typos would silently break far-tier hysteresis.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:30:30 +02:00
Erik
7fd9c82954 test(A.5 T3): StreamingRegion two-radius constructor
Add NearRadius/FarRadius properties and a four-arg constructor
(centerX, centerY, nearRadius, farRadius). Radius is set to farRadius
so existing hysteresis math (unload threshold = Radius+2) uses the
outer ring as the bookkeeping boundary. Old three-arg constructor
becomes a thin wrapper: this(cx, cy, radius, radius) — no behaviour
change, 25 pre-existing streaming tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:27:50 +02:00
Erik
21550ecff2 fix(A.5 T2): document Kind placeholder in HandleJob
Code review on commit 90a2027 flagged that HandleJob silently ignores
load.Kind. Add a TODO(A.5 T11/T16) comment at the case arm so the
unused field reads as a planned stub, not a bug.

No semantic change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:25:26 +02:00
Erik
90a2027d14 feat(A.5 T2): TwoTierDiff record + LandblockStreamJob.Load.Kind
Adds TwoTierDiff — the five-list output of StreamingRegion.RecenterTo
(ToLoadFar/Near, ToPromote, ToDemote, ToUnload) per spec §4.2. Used by
T3–T6 (StreamingRegion) and T13 (StreamingController).

Extends LandblockStreamJob.Load with a LandblockStreamJobKind parameter
so the streaming worker can route far vs near vs promote jobs differently
(spec §4.3). Patches the one call site in LandblockStreamer.EnqueueLoad
with LoadNear as a placeholder that preserves today's full-load semantics
until T11 activates the worker thread and T16 routes by tier.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:20:48 +02:00
Erik
d67d16fcfc feat(A.5 T1): LandblockStreamTier + LandblockStreamJobKind enums
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:15:57 +02:00
Erik
b373523f98 docs(A.5): two-tier streaming + horizon LOD implementation plan
28-task TDD-first implementation plan for Phase A.5. Maps each spec
section to concrete bite-sized tasks with failing-test → minimal-impl
→ commit cycles. Self-review at plan footer cross-checks coverage,
type consistency, placeholders.

Plan structure:
- T1-T6: StreamingRegion two-tier + 5-list TwoTierDiff with hysteresis
- T7: LandblockStreamResult.Loaded.Tier + MeshData; Promoted variant
- T8: WorldEntity AABB cache + dirty flag
- T9-T12: off-thread mesh build (ConcurrentDictionary + DatLock + worker activation + DI)
- T13-T16: StreamingController two-tier + GpuWorldState two-tier ops + GameWindow wiring
- T17-T18: WbDrawDispatcher bucketing tightening (Change #1 + Change #2)
- T19-T21: visual quality (mipmaps + A2C + depth-write lock-in)
- T22: fog params from N₁/N₂ + env-var multipliers
- T23: BUDGET_OVER flag in DIAG output
- T24-T26: perf baseline (before/after) + visual user gate
- T27-T28: roadmap/ISSUES/CLAUDE.md updates + memory + SHIP commit

Plan at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md.
Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md (fcaff71).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 22:10:38 +02:00
Erik
fcaff71352 docs(A.5): two-tier streaming + horizon LOD design spec
Brainstorm output for Phase A.5. Locks key decisions:
- Hardware target: 240 Hz / 1440p, 4.166ms vsync budget
- Tier radii: N₁=4 (full detail, 81 LBs), N₂=12 (terrain only, 544 LBs)
- Far-tier strategy: terrain-only + fog blend at N₁ (zero engineering cost)
- Bucketing: tighten existing per-LB walk (Q5 Option A); persistent
  groups deferred to a later phase
- Worker thread: single-thread mesh build off render path (Q6 Option A)
- Hysteresis: existing radius+2 convention applied to both tiers
- Visual ride-alongs: mipmaps + anisotropic + A2C/MSAA + depth-write audit
- Acceptance: 240Hz standstill / 144 FPS walking (Q9 Option B)

Spec at docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md.
Awaiting user review before transitioning to writing-plans.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:52:00 +02:00
Erik
1e6a8123c6 Merge branch 'claude/happy-joliot-f67060' — Phase N.5b SHIP + A.5 handoff
Phase N.5b: terrain on the modern rendering path. TerrainModernRenderer
replaces TerrainChunkRenderer; bindless atlas via uvec2 handle + GLSL
sampler-from-handle constructor; constant-cost dispatch (~6µs/frame)
regardless of radius. Closes issue #51. 114 tests pass; conformance
sentinel max |delta| = 0.015 mm. Honest perf baseline doc captures
that modern is ~4x slower on CPU at radius=5 because legacy's chunked
pattern already collapsed the scene to one draw call; architectural
wins manifest at higher radius (A.5).

Three high-value gotchas captured to memory:
1. uniform sampler2DArray + glProgramUniformHandleARB unreliable
   across drivers; default to uvec2 handle + sampler constructor.
2. Median calc copy[N - nz/2] underflows for nz<2.
3. Visual gate 'go' != 'verified'.

Plus: A.5 cold-start handoff at docs/research/2026-05-10-phase-a5-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:13:55 +02:00
Erik
f7f88674e1 docs(A.5): cold-start handoff for the next session
Records what N.5b shipped, where the actual FPS bottleneck lives
(WbDrawDispatcher entity cull at ~4.3ms/frame, 86% of frame budget;
terrain dispatcher is now <1% of frame), and what A.5 has to do to
make the world look big without falling off a perf cliff.

Three concrete A.5 deliverables:
1. Two-tier streaming (near = full, far = terrain-only)
2. Per-LB entity bucketing in WbDrawDispatcher
3. Off-thread LandblockMesh.Build to avoid streaming hitches at higher
   radius

Eight brainstorm questions for the next session, plus acceptance
criteria, files-to-read list, and explicit "don't do" warnings (don't
raise STREAM_RADIUS without tiering in place; don't put scenery in
far tier without an impostor pipeline; don't break the N.5b conformance
sentinel; etc.).

User's stated goal verbatim: "great smooth HIGH fps visuals. Should
look great. As long as it scales and we get very high FPS." This
reframes priorities away from radius=5 micro-optimization toward
visual scale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:11:46 +02:00
Erik
08b736207c phase(N.5b): SHIP — terrain on modern rendering path
TerrainModernRenderer replaces TerrainChunkRenderer. Single global
VBO/EBO + slot allocator + glMultiDrawElementsIndirect. Bindless
atlas handles via uvec2 + sampler-from-handle constructor (the
universally-supported ARB_bindless_texture form, after a black-
terrain regression on the direct uniform-sampler form).

Path C: WB renderer pattern + acdream's LandblockMesh.Build for
retail's FSplitNESW formula compliance. Closes issue #51.

Captured perf baseline (radius=5, Holtburg, 5+ rollups):
  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)

Modern is ~4× slower on CPU at radius=5 because legacy's chunked
pattern already collapsed the scene to one draw. Architectural wins
(zero glBindTexture/frame; constant-cost dispatch as A.5 raises
radius) manifest at higher scene complexity. Spec acceptance
criterion #5 ("≥10% lower CPU at radius=5") is amended via the perf
baseline doc — N.5b ships on visual identity + structural correctness.

Three high-value gotchas captured to memory:
1. `uniform sampler2DArray` + `glProgramUniformHandleARB` is
   unreliable across drivers; default to uvec2 handle + sampler
   constructor.
2. Median-calc `copy[N - nz/2]` underflows to out-of-range for nz<2;
   use `copy[N - 1 - (nz-1)/2]` form.
3. Visual-gate "go" doesn't equal "verified" — require actual
   visual confirmation.

Visual verification: confirmed at Holtburg town. 114/114 tests pass
in N.5+N.5b filter. Conformance sentinel max ‖Δ‖ = 0.015 mm across
1000 sample points / 10 representative landblocks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 13:05:12 +02:00
Erik
083c10c514 docs(N.5b T10): roadmap + ISSUES + CLAUDE.md + perf baseline updates
Document Phase N.5b shipping (terrain on the modern rendering path via
Path C — `TerrainModernRenderer` mirrors WB's `TerrainRenderManager`
pattern but consumes acdream's `LandblockMesh.Build` so retail's
`FSplitNESW` formula stays in lockstep with physics + visual mesh).

Changes:

- `docs/plans/2026-04-11-roadmap.md` — add N.5b row to the Shipped
  table; promote N.5b's "Phases ahead" entry to ✓ SHIPPED with the
  Path C resolution + perf reality check; refresh N.6 scope to note
  Terrain has joined the modern path (legacy `Texture2D` retirement
  scope narrows to Sky + Debug); update top-of-doc Status line.

- `docs/ISSUES.md` — close issue #51 (WB terrain-split formula
  divergence). Move from OPEN to "Recently closed" with the Path C
  resolution: never adopted WB's formula; modern dispatcher uses
  retail's via `LandblockMesh.Build`. References `da56063` (the
  black-terrain fix that landed within the N.5b ship chain).

- `CLAUDE.md` — add `TerrainModernRenderer.cs` to the WB integration
  cribs list with the GL_INVALID_OPERATION caveat (use uvec2 +
  `sampler2DArray(handle)` constructor, NOT direct
  `uniform sampler2DArray` + `glProgramUniformHandleARB`). Update
  the "Currently in flight" preamble: N.6 builds on N.5 + N.5b;
  add an N.5b shipped paragraph linking the perf baseline doc.

- `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` — new doc
  capturing the radius=5 Holtburg perf measurement (modern 6.4-7.0
  µs median vs legacy 1.5 µs — modern is ~4× SLOWER on CPU at
  radius=5). Documents the spec acceptance criterion #5 amendment,
  the architectural wins that DO hold (zero glBindTexture/frame,
  constant-cost dispatch as A.5 raises radius, per-LB frustum cull),
  and the three high-value gotchas surfaced during implementation.

User-memory updates (outside repo, not in this commit):
- `memory/project_phase_n5b_state.md` — full N.5b state file with
  the three gotchas captured.
- `memory/MEMORY.md` — index entry pointing at the state file.

Build: dotnet build green. No code changes in this commit.

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

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

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

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

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

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

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

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

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

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

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

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

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

Build green; existing tests still green.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:21:32 +02:00
Erik
3418f65462 fix(N.5b T6): index-length validation + document VertsPerLandblock %6 invariant
Code review (Important #1): AddLandblock validated Vertices.Length but
not Indices.Length. The indices loop indexes meshData.Indices[0..383]
unconditionally — out-of-range input would throw IndexOutOfRangeException
instead of the clearer ArgumentException the vertex check raises. Today
LandblockMesh.Build always produces 384/384, so this is defensive
forward-compat for future mesh sources.

Code review (Important #2): The shader (terrain_modern.vert:gl_VertexID
% 6) only correctly picks the cell-corner index because we bake
`slot * VertsPerLandblock` into indices and 384 is a multiple of 6.
That invariant is now documented in a comment near the constant — anyone
changing it must audit the shader.

Build green: 0 errors / 0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:15:51 +02:00
Erik
0a77bd1fd7 phase(N.5b) Task 6: TerrainModernRenderer
The new terrain dispatcher. Single global VBO/EBO with a slot
allocator (one slot per landblock, 384 verts × 40 bytes per slot).
Per-frame: build DEIC array from visible slots, upload, dispatch
via glMultiDrawElementsIndirect. Atlas textures bound via bindless
handles set per-frame as sampler uniforms.

Total ~6-8 GL calls per frame for terrain regardless of visible
landblock count (vs today's per-LB binds at radius=2 → ~25 calls,
radius=5 → ~121 calls).

API mirrors TerrainChunkRenderer so GameWindow integration in T8 is
a drop-in field+ctor swap.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 09:05:28 +02:00
Erik
4ed79207a6 fix(N.5b T7): tighten conformance sample upper bound to 191.975f
Code review identified a latent false-positive flake risk: physics
path clamps fx = localX/24 to (CellsPerSide - 0.001f) = 7.999, which
corresponds to localX <= 191.976. With samples up to 191.999f,
physics computes Z at the clamped position while the mesh sampler
uses the actual position — a difference of up to 23 mm at the upper
edge, which on a steep slope would falsely trip the 1 mm sentinel.

Tighten upper bound to 191.975f (strictly below the clamp boundary)
so both oracles compute Z at the same (cellX, tx). Also restored the
"worst-case from SplitFormulaDivergenceTest" inline comment for
landblock 0x4D96 per code review suggestion #3.

Test still passes: 10/10 landblocks, 1000 samples, max |delta|
= 0.0153 mm (previously 0.0305 mm — confirms the prior worst-case
was indeed at the boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:59:01 +02:00
Erik
e54d5ca2cf phase(N.5b) Task 7: TerrainModernConformanceTests
Z-conformance sentinel for issue #51's bug class. Sweeps 10
representative landblocks x 100 sample points (uniform random in
local 0..192 with fixed seed 42). For each point: compute meshTriZ
via barycentric interpolation in the matching triangle of the
LandblockMesh.Build output; compute physicsZ via
TerrainSurface.SampleZFromHeightmap; assert |delta| < 0.001m.

Catches any silent formula or vertex-layout drift between the
visual and physics paths. Skips gracefully if ACDREAM_DAT_DIR
isn't set (CI without dat data).

Local run with dat data: 10/10 landblocks loaded, 1000 samples,
max |delta| = 0.0305 mm (worst case: Direlands 0xC040).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:49:15 +02:00
Erik
1ea00a075e phase(N.5b) Task 5: terrain_modern.frag
Fragment shader for the modern terrain dispatcher. Bit-identical math
to today's terrain.frag (per-cell maskBlend3 + Phase G fog + lightning
flash). Same #version 460 + GL_ARB_bindless_texture preamble change
as terrain_modern.vert. Sampling syntax unchanged — the bindless-ness
is invisible at the GLSL level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:45:40 +02:00
Erik
3c108a0d68 phase(N.5b) Task 4: terrain_modern.vert
Vertex shader for the modern terrain dispatcher. Bit-identical math
to today's terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
lighting). The only structural change is the version + bindless
extension preamble — sampler access stays a regular sampler2DArray
uniform; bindless-ness is invisible at the GLSL level.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:45:22 +02:00
Erik
ba852993e9 phase(N.5b) Task 2: TerrainSlotAllocator + tests
Pure-CPU slot allocator for the terrain modern dispatcher's global
VBO/EBO. FIFO free-list + monotonic counter, mirroring WB's
TerrainRenderManager pattern. Caller (TerrainModernRenderer) handles
GPU buffer growth when Allocate sets needsGrow=true.

8 unit tests cover: fresh-allocator returns slot 0, sequential
allocs, free+alloc reuse, FIFO ordering, needsGrow signaling on
capacity overflow, GrowTo, LoadedCount tracking, and double-free
detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:44:51 +02:00
Erik
db0f010544 phase(N.5b) Task 1: TerrainAtlas bindless extension
Add optional BindlessSupport ctor parameter + GetBindlessHandles()
method that returns (terrainHandle, alphaHandle) ulongs with both
textures made resident. Two-phase Dispose mirroring TextureCache
(MakeNonResident before DeleteTexture per ARB_bindless_texture spec).

Existing callers pass `Build(gl, dats)` unchanged; bindless = null
default keeps them working until T6/T8 wires the renderer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:37:23 +02:00
Erik
79367d4c15 plan(N.5b): implementation plan for terrain on modern path
Expands spec section 10 into 10 TDD-style tasks with explicit
dependency arrows. Phase A (T1, T2, T4, T5, T7) parallelizable
across 5 subagents; Phase B (T6 dispatcher) serial; Phase C (T8
GameWindow integration) serial; user verification gate; Phase D
(T9 delete legacy + T10 docs/memory) parallelizable.

Each task includes exact file paths, complete code blocks, exact
test/build commands with expected output, and HEREDOC commit
messages. Self-review: no placeholders; type-consistent across
tasks (TerrainSlotAllocator API, GetBindlessHandles signature,
SetSamplerHandleUniform contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:32:19 +02:00
Erik
b35ddf3426 spec(N.5b): design for terrain on the modern rendering path
Brainstormed 2026-05-09. Lifts outdoor terrain rendering onto N.5's
modern primitives (bindless textures + glMultiDrawElementsIndirect)
preserving the visible terrain pixel-for-pixel and preserving
physics-vs-visual Z agreement (issue #51).

Key decisions:
- Path C: WB renderer pattern + acdream's existing LandblockMesh.Build
  (which uses retail's FSplitNESW formula, verified at retail addr
  00531d10). Path A killed by 49.98% measured divergence vs retail.
- Single global VBO/EBO + slot allocator (one slot per landblock),
  uint32 indices with baseVertex baked, mirror WB's pattern.
- Keep TerrainAtlas (palCode-based fragment blending), add bindless
  handles. No LandSurfaceManager adoption.
- Separate terrain_modern.vert/.frag (port of today's terrain.vert/.frag
  with bindless preamble; same blend math, same AdjustPlanes lighting).
- Pure-CPU Z-conformance sentinel: meshTriZ vs TerrainSurface within
  1mm across 10 representative landblocks x 100 sample points.
- Acceptance: build green, conformance test passes, ~6-8 GL calls/frame
  for terrain regardless of scene size, [TERRAIN-DIAG] cpu_ms at
  radius=5 >=10% lower than today's per-LB-binds path.

Files added: TerrainModernRenderer + TerrainSlotAllocator +
terrain_modern.vert/.frag + 2 test files.
Files deleted: TerrainChunkRenderer + TerrainRenderer +
terrain.vert/.frag.

Out of scope: EnvCells/dungeons, sky, particles, A.5 LOD,
LandSurfaceManager adoption, fork-patching WB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:23:09 +02:00
Erik
47f2cea1e8 test(N.5b): quantify WB vs retail terrain split formula divergence
Sweeps all (lbX, lbY, cellX, cellY) tuples for the full 255x255
landblock map (~4.16M cells) and reports both the raw enum-output
disagreement (50.02%) and the diagonal-actually-painted disagreement
(49.98%) between WB's CalculateSplitDirection and acdream's
TerrainBlending.CalculateSplitDirection (which retail uses per
CLandBlockStruct::ConstructPolygons at retail addr 00531d10).

The two formulas behave like independent random hashes. Adopting
WB's pipeline wholesale would mis-render ~half the diagonals on
every landblock (Holtburg 0xA9B0: 29/64 cells = 45.3% wrong). This
data is the foundation for N.5b's Path A vs B vs C decision (kills
Path A).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 08:22:50 +02:00
69 changed files with 18464 additions and 1206 deletions

View file

@ -102,6 +102,29 @@ ourselves".
eventually picks it up finds the hook there; the change is localized:
extend `InstanceData` stride 64→80 bytes, add the field, mix into
fragment color in `mesh_modern.frag`. ~30 min when the time comes.
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs` — terrain dispatcher
on N.5's modern primitives. Mirrors WB's `TerrainRenderManager` pattern
(single global VBO/EBO + slot allocator + `glMultiDrawElementsIndirect`)
but driven by acdream's `LandblockMesh.Build` so retail's `FSplitNESW`
formula is preserved (issue #51 resolved). Atlas handles bound via the
uvec2 + `sampler2DArray(handle)` constructor pattern (NOT the direct
`uniform sampler2DArray` + `glProgramUniformHandleARB` form, which
GL_INVALID_OPERATIONs on at least one driver).
- **Two-tier streaming architecture (Phase A.5, shipped 2026-05-10).**
`src/AcDream.App/Streaming/` owns the full streaming pipeline. Key types:
`StreamingRegion` (two-radius Chebyshev window: N₁=near, N₂=far; produces
`TwoTierDiff` with 5 transition lists per tick), `StreamingController`
(render-thread coordinator: routes `TwoTierDiff` to the worker queue and
drains completions up to `MaxCompletionsPerFrame` per frame),
`LandblockStreamer` (single background worker thread: `LoadFar` = heightmap
+ mesh only, `LoadNear` = heightmap + `LandBlockInfo` + scenery + mesh,
`PromoteToNear` = `LandBlockInfo` + scenery only),
`GpuWorldState` (render-thread entity state: `AddEntitiesToExistingLandblock`
for promotions, `RemoveEntitiesFromLandblock` for demotions).
Default: N₁=4 (81 near LBs, full detail), N₂=12 (544 far LBs, terrain
only). Quality Preset system (`QualitySettings.From(preset)`) controls
both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit.
Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`.
**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
goals, test criteria, and builds on the previous. Don't skip phases.
@ -502,14 +525,56 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user
approval.
**Currently in flight: Phase N.6 — Perf polish.**
Roadmap entry at [`docs/plans/2026-04-11-roadmap.md`](docs/plans/2026-04-11-roadmap.md).
Builds on N.5. Legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`,
`WbFoundationFlag`) were retired in the N.5 ship amendment — N.6 scope is
perf-only: WB atlas adoption, persistent-mapped buffers, GPU-side culling,
GL_TIME_ELAPSED query double-buffering, direct N.4 vs N.5 perf measurement,
legacy `Texture2D`/`sampler2D` TextureCache path retirement (Sky/Terrain/Debug).
Plan + spec written when work begins.
**Currently in flight: NONE — Post-A.5 polish phase COMPLETE 2026-05-11.**
All three post-A.5 issues closed: #52 (lifestone, `e40159f`), #54 (JobKind, `bf31e59`),
#53 (Tier 1 entity cache, `f928e66`). Phase A.5 + post-A.5 polish together comprise
the streaming + rendering perf foundation for the project.
**Tier 1 entity-classification cache (#53) shipped 2026-05-11.** New
`EntityClassificationCache` keyed by `(entityId, landblockHint)` tuple at
`src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`; the dispatcher's static-
entity slow path populates flat `CachedBatch[]` (one entry per (partIdx, batchIdx)
with part-relative `RestPose` + resolved `BindlessTextureHandle`), and the cache-hit
fast path skips classification entirely on subsequent frames. Animated entities
(`_animatedEntities`) bypass the cache. Invalidation fires on live-entity despawn
(`RemoveLiveEntityByServerGuid`) and LB demote/unload (`RemoveEntitiesFromLandblock`).
Entity dispatcher cpu_us **median ~1200 µs / p95 ~1500 µs** at horizon-safe preset
on AMD Radeon RX 9070 XT @ 1440p — down from ~3500m / ~4000p95 pre-Tier-1
(~66% / ~63% reduction). Well under the A.5 spec budget (median ≤ 2.0 ms, p95 ≤ 2.5 ms).
The implementation required 4 bug-fix iterations after the spec landed (stab Id
namespacing → cache tuple-key → drudge incomplete-classification → mid-list null-
renderData); see `docs/ISSUES.md` #53 closure entry for the lessons.
**Next planned phase: N.6 (perf polish) — see `docs/plans/2026-04-11-roadmap.md`.**
Alternative escalation path: Tier 2 (static/dynamic split with persistent groups,
~2 weeks) or Tier 3 (GPU compute culling, ~1 month) per
`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`. With the Tier 1 dispatcher at
~1.2 ms, the project comfortably hits 200-400 FPS at radius=12 standstill;
escalation makes sense only if user wants 500+ FPS sustained.
**Phase A.5 (Two-tier Streaming + Horizon LOD) shipped 2026-05-10.**
N₁=4 near-tier (81 LBs, full detail) + N₂=12 far-tier (544 LBs, terrain only); fog
horizon; QualityPreset system (Low/Medium/High/Ultra) with env-var overrides; F11
mid-session reapply. Two post-ship-prep bugs fixed: Bug A (far-tier worker was loading
full entity layer — ~71K entities, ~5x perf regression vs spec), Bug B (WalkEntities
per-frame list alloc — ~480 KB/frame GC pressure). Tier 1 entity cache reverted (animation
regression; see #53). Plan archived at
[`docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`](docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md).
**Phase N.5b (Terrain on Modern Rendering Path) shipped 2026-05-09.**
`TerrainModernRenderer` mirrors WB's `TerrainRenderManager` pattern
(single global VBO/EBO + slot allocator + bindless atlas +
`glMultiDrawElementsIndirect`) but consumes `LandblockMesh.Build` so
retail's `FSplitNESW` formula is preserved (Path C; closes ISSUE #51).
Path A (substitute WB's `CalculateSplitDirection`) killed by 49.98%
divergence vs retail in
[`tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`](tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs).
At radius=5 in Holtburg modern is ~4× SLOWER on CPU than the legacy
chunked path was; architectural wins manifest at higher radius. Honest
perf baseline at
[`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`](docs/plans/2026-05-09-phase-n5b-perf-baseline.md).
Plan archived at
[`docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`](docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md).
**Phase N.5 (Modern Rendering Path) shipped + amended 2026-05-08.** `WbDrawDispatcher`
on bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher 1.23ms/frame

View file

@ -46,64 +46,6 @@ Copy this block when adding a new issue:
# Active issues
## #51 — WB's terrain-split formula diverges from retail's `FSplitNESW`
**Status:** OPEN
**Severity:** MEDIUM (blocks isolated N.2; affects sequencing of N-phase migration)
**Filed:** 2026-05-08
**Component:** terrain math / Phase N (WorldBuilder rendering migration)
**Description:** WB's `TerrainUtils.CalculateSplitDirection`
([references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44](references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44))
uses a different math expression from retail's `FSplitNESW`
(documented in CLAUDE.md as **the** real AC terrain split formula,
constants `0x0CCAC033` / `0x421BE3BD` / `0x6C1AC587` / `0x519B8F25`).
Ours is a degree-2 polynomial in (x,y); WB's is linear in (x,y).
They cannot be algebraically equivalent and disagree on a meaningful
fraction of cells.
**Concrete impact:** On any cell where the formulas pick different
diagonals, the same world position (X, Y) maps to different terrain
heights — up to ~2m for a sloped cell with one elevated corner. If a
caller mixes "WB-formula path" and "AC2D-formula path" for the same
cell, the player physics floats above or sinks below the visible
ground. This is the bug class fixed in
[src/AcDream.Core/Physics/TerrainSurface.cs:113-120](src/AcDream.Core/Physics/TerrainSurface.cs:113)
(diagonal-direction inversion).
**Files implicated:**
- `src/AcDream.Core/Physics/TerrainSurface.cs` — uses AC2D formula via
`IsSplitSWtoNE`
- `src/AcDream.Core/World/TerrainBlending.cs` — visual mesh, also AC2D
- `references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44`
— WB's diverging formula
- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainGeometryGenerator.cs`
— WB's render mesh (presumably also uses WB's formula in lockstep)
**Sequencing implication:** Phase N.2 (terrain math helpers
substitution) cannot be shipped in isolation — it must land alongside
visual terrain renderer migration (originally N.5, now moved to N.7
scope), at which point both physics and visual mesh switch to WB's
formula together. N.5 shipped entity rendering only; terrain remains
on acdream's own pipeline through N.7.
**Research needed (when N.7 picks this up):**
1. Quantify divergence: run WB's `CalculateSplitDirection` and our
`IsSplitSWtoNE` across all (lbX, lbY, cellX, cellY) tuples for a
representative landblock set; record disagreement rate.
2. Confirm WB's `TerrainGeometryGenerator` uses WB's formula in its
render mesh — if so, switching everything to WB's formula keeps
visual + physics synced. (Highly likely.)
3. Decide whether ANY retail-conformance test (e.g., physics matching
server-authoritative Z within tolerance) is invalidated by the
formula change.
**Acceptance:** Resolved when N.7 lands and both physics + visual
terrain use WB's split formula, OR when we decide to keep the AC2D
formula and patch WB's renderer in our fork.
---
## #50 — Road-edge tree at 0xA9B1 visible in acdream but not retail
**Status:** OPEN
@ -1435,29 +1377,6 @@ one live creature case no longer use the single-cylinder fallback.
---
---
## #13 — PlayerDescription trailer past enchantments (options / shortcuts / hotbars / desired_comps / spellbook_filters / options2 / gameplay_options / inventory / equipped)
**Status:** OPEN
**Severity:** LOW (no current user-visible bug; future panels will need the data)
**Filed:** 2026-04-25
**Component:** net / player-state
**Description:** `PlayerDescriptionParser` walks through enchantments (Phase H, 2026-04-25). The trailer beyond that — Options1 / Shortcuts / HotbarSpells (8 lists) / DesiredComps / SpellbookFilters / Options2 / GameplayOptions blob / Inventory / Equipped — is not yet parsed. Required for future Spellbook UI panel, hotbar UI, inventory UI, character options panel.
**Root cause / status:** Holtburger `events.rs:462-625` has the full layout. The trickiest piece is `gameplay_options` — a variable-length opaque blob; holtburger uses a heuristic forward search (`find_inventory_start_after_gameplay_options`) for plausibly-aligned inventory-count + GUID pairs to find the inventory start. Other sections are well-formed.
**Files:**
- `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs` — extend `Parsed` record + walker.
- `tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs` — add fixtures per section.
- `src/AcDream.Core.Net/GameEventWiring.cs` — route `parsed.Inventory` + `Equipped` to ItemRepository.
**Research:** holtburger `events.rs:462-625`; `references/actestclient/TestClient/messages.xml`.
**Acceptance:** All sections of a real-world PlayerDescription parse to completion (no truncation). New tests cover synthetic fixtures per section. `ItemRepository.Count` after login > 0.
---
---
@ -1758,6 +1677,196 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
## #53 — [DONE 2026-05-11 · f928e66] A.5/tier1-redo: entity-classification cache retry
**Closed:** 2026-05-11
**Commit chain (newest first):**
- `f928e66` — incomplete-entity flag must persist across same-entity tuples (mid-list null-renderData)
- `c55acdc` — skip cache populate when classification is incomplete (drudge fix)
- `95ebbf3` — key cache by `(entityId, landblockHint)` tuple to defeat ID collision
- `71d0edc` — namespace stab Ids globally (`0xC0LLBB01..`) for Tier 1 cache safety
- `4df1914` — clarify `DebugCrossCheck`'s wiring status
- `f16604b` — DEBUG cross-check + tripwire + 2 tests
- `489174f` — wire `InvalidateLandblock` callback at LB demote/unload
- `1d1afcd` — wire `InvalidateEntity` at live-entity despawn
- `f7e38c2` — cache-hit fast path must fire per-entity, not per-tuple
- `0cbef3c` — cache-hit fast path + dispatcher integration tests
- `00fa8ae` — cache `Populate` must flush at entity boundary, not per-MeshRef tuple
- `2f489a8` — cache-miss populate on first frame for static entities
- `28513ea` — optional `CachedBatch` collector + `restPose` param on `ClassifyBatches`
- `a65a241` — inject `EntityClassificationCache` into `WbDrawDispatcher`
- `60fbfce` — plumb `landblockId` through `_walkScratch`
- `a171e70`, `aea4460`, `694815c`, `773e970` — cache `InvalidateLandblock` / `InvalidateEntity` / `Populate` / skeleton+first test
- `c02405c` — extract `GroupKey` to namespace-scope `internal`
- `2f8a574` — implementation plan
- `4abb838` — mutation audit + cache design spec
**Component:** rendering / `WbDrawDispatcher` / `EntityClassificationCache` / `LandblockLoader`
**Resolution.** New `EntityClassificationCache` keyed by `(entityId, landblockHint)` tuple in `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`. The dispatcher routes static entities (NOT in `_animatedEntities`) through the cache — first-frame slow-path populates flat `CachedBatch[]` (one entry per (partIdx, batchIdx) with the part-relative `RestPose` and resolved `BindlessTextureHandle`); subsequent-frame cache hits skip classification entirely and append `cached.RestPose * entityWorld` to each matching group. Animated entities bypass. Invalidation fires from `RemoveLiveEntityByServerGuid` (per-entity, `0xF747`/`0xF625`) and `RemoveEntitiesFromLandblock` (per-LB, Near→Far demote + unload).
**Perf result.** Entity dispatcher cpu_us **median ~1200 µs, p95 ~1500 µs** at horizon-safe + High preset on AMD Radeon RX 9070 XT @ 1440p. Pre-Tier-1 baseline was ~3500m / ~4000p95. ~66% reduction in median, ~63% in p95. Well under the A.5 spec budget (median ≤ 2.0 ms, p95 ≤ 2.5 ms). No `BUDGET_OVER` flag observed.
**Verification.** Build green; full suite 1711 passed / 8 pre-existing physics/input failures unchanged; N.5b sentinel 112/112; visual gate confirmed via `+Acdream` test character (NPCs animate, lifestone renders, multi-part buildings + scenery + Nullified Statue of a Drudge on top of the Foundry all render fully — no airborne geometry, no Z-fighting, no missing parts, no wrong textures).
**Lessons surfaced during implementation (4 bug-fix iterations):**
1. **Audit must verify ID uniqueness for cache keys.** The original mutation audit verified `Position`/`Rotation`/`MeshRefs` stability post-spawn but didn't verify `entity.Id` was globally unique. Stabs from `LandblockLoader.BuildEntitiesFromInfo` restarted at `nextId = 1` per landblock → cross-LB collisions. Scenery (`0x80LLBB00 + localIndex`) and interior (`0x40LLBB00 + localCounter`) overflow at >256 items/LB. Cache key collision produced "buildings up in the air with wrong textures." Fixed by namespacing stab Ids (`71d0edc`) then by changing cache key to `(entityId, landblockHint)` tuple (`95ebbf3`) — defensive against ALL future hydration paths.
2. **Per-tuple iteration with per-entity cache state is a recurring trap.** Three separate bugs caught by code review or visual gate hit this same root cause:
- Populate fired per-tuple → multi-MeshRef entities lost all but the last MeshRef's batches (`00fa8ae`).
- Cache hit fired per-tuple → multi-MeshRef entities drew N× copies, severe Z-fighting (`f7e38c2`).
- Incomplete-flag reset fired per-tuple → mid-list null-MeshRef trees populated partial cache, branches never rendered (`f928e66`).
The fix pattern in all three: track previous entity Id (`prevTupleEntityId` / `lastHitEntityId`); execute per-entity logic only on actual entity-change detected against that tracker, not unconditionally per tuple.
3. **Async mesh loading interacts with cache populate.** WB's `ObjectMeshManager.PrepareMeshDataAsync` decodes meshes off the main thread. If a MeshRef's GfxObj is still decoding at first-frame visibility, `TryGetRenderData` returns null and the slow path skips it. Without the drudge fix (`c55acdc`), the cache populated a partial classification and cache hits served it forever — even after the missing mesh loaded. With the fix, the dispatcher tracks `currentEntityIncomplete` per entity and drops the populate scratch when any MeshRef returned null; the slow path retries every frame until all meshes load.
4. **A/B diagnostic env-var paid for itself.** `ACDREAM_DISABLE_TIER1_CACHE=1` forces every static entity through the slow path. Used twice during debugging to instantly differentiate "bug is in the cache" vs "bug is elsewhere entirely." Kept in tree (read once in `WbDrawDispatcher` ctor) for future cache investigations.
**Memory.** See `~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_tier1_cache.md` for the audit-gap and per-tuple-vs-per-entity pattern documented for future cache work.
---
## #54 — [DONE 2026-05-10 · bf31e59] A.5/jobkind-plumbing: far-tier worker loads full entity layer then strips
**Closed:** 2026-05-10
**Commits:** `bf31e59` (factory signature change to 2-arg + back-compat overload + far-tier early-out)
**Component:** streaming / LandblockStreamer
**Resolution.** `LandblockStreamer.cs` primary ctor now takes `Func<uint, LandblockStreamJobKind, LoadedLandblock?>` so the factory can branch on the job kind. A back-compat overload preserves the old single-arg signature for existing test code (5 ctor sites in `LandblockStreamerTests.cs` resolved to the overload with no test changes). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path (`_dats.Get<LandBlock>(landblockId)` + `Array.Empty<WorldEntity>()`); near-tier path is unchanged. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net. Per-LB worker cost on far-tier dropped from ~tens of ms (LandBlockInfo + scenery + interior) to ~sub-ms (single LandBlock dat read).
**Verification.** Build green; 1688/1696 tests pass (8 pre-existing physics/input failures unchanged); 30 streaming-targeted tests (LandblockStreamer + StreamingController + StreamingRegion) all green via the back-compat overload.
---
## #52 — [DONE 2026-05-10 · e40159f] A.5/lifestone-missing: Holtburg lifestone not rendering
**Closed:** 2026-05-10
**Commits:** `e40159f` (alpha-test discard removal + cull state restoration + uDrawIDOffset uniform)
**Component:** rendering / WbDrawDispatcher / shaders
**Resolution.** Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08). The original ISSUE #52 hypothesis (Bug A far-tier strip catching the lifestone) was wrong — the lifestone is server-spawned (WCID 509, Setup `0x020002EE`) and never goes through the far-tier strip. Real causes:
1. **Alpha-test discard.** `mesh_modern.frag` transparent pass discarded fragments with `α >= 0.95`. The lifestone crystal core surface `0x080011DE` decoded with α≥0.95 across its visible surface, so 100% of the crystal's fragments were discarded — invisible. The original N.5 §2 rationale ("high-α belongs in opaque pass") doesn't hold for surfaces dat-flagged transparent: those pixels can't reach the opaque pass at all. Fix: remove the high-α discard from the transparent pass; keep `α < 0.05` as a fragment-cost optimization.
2. **Cull state regression.** Legacy `StaticMeshRenderer` had Phase 9.2's `Enable(CullFace) + Back + CCW` setup at the top of its translucent pass (commit `6f1971a`, 2026-04-11) — fix for "lifestone crystal one face missing" reported at the time. When `dcae2b6` deleted the legacy renderer, the new `WbDrawDispatcher` never inherited that GL state, so closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's exact setup at the top of Phase 8.
3. **`uDrawIDOffset` indexing bug.** `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect` call. The transparent pass starts at byte offset `_opaqueDrawCount * stride` in the indirect buffer, but the vertex shader read `Batches[gl_DrawIDARB]` directly — so transparent draws read from `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone crystal's apparent texture flickered to whatever opaque batch sorted to index 0 each frame; with the player character in view, this often appeared as a lifestone wearing the player's body / face textures. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, change `Batches[gl_DrawIDARB]` to `Batches[uDrawIDOffset + gl_DrawIDARB]`, and set the uniform per-pass in `WbDrawDispatcher` (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WorldBuilder's `BaseObjectRenderManager.cs:845`.
**Verification.** User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform). Tests 1688/1696 passing (8 pre-existing physics/input failures unchanged). N.5b conformance sentinel 94/94 clean.
**Lesson.** The WB rendering migration's "lift legacy state into the new dispatcher" was incomplete in two non-obvious ways: (a) GL state setup that lived inside legacy per-pass blocks, and (b) shader uniforms that the legacy per-draw flow didn't need but the multi-draw-indirect flow does. Future WB-migration work should systematically diff the legacy renderer's GL setup + shader I/O against the new dispatcher's. The `uDrawIDOffset` bug was particularly hidden because it only manifested for entities that mixed transparent draws with the visible opaque sort order — single-pass content (pure opaque or pure transparent) was unaffected.
---
## #13 — [DONE 2026-05-10 · d3b58c9..078919c] PlayerDescription trailer past enchantments
**Closed:** 2026-05-10
**Commits:** `d3b58c9` (scaffold) → `6587034` (rename nit) → `becbde6` (OptionFlags+Options1) → `9a0dfe0` (TrailerTruncated + diag) → `f7a5eea` (Shortcuts) → `8cbb991` (HotbarSpells) → `75e8e26` (DesiredComps) → `b17dc3b` (SpellbookFilters) → `98eebef` (Options2) → `d9a5e40` (strict Inventory+Equipped) → `91693ea` (heuristic GAMEPLAY_OPTIONS walker) → `58095d8` (combined fixture test) → `078919c` (ItemRepository wiring)
**Component:** net / player-state
**Plan:** [`docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md`](../docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md)
**Resolution.** `PlayerDescriptionParser` now walks every trailer
section through Inventory + Equipped, ported faithfully from holtburger
`events.rs:503-625` + `shortcuts.rs:13-34`. The trickiest piece —
`gameplay_options` — uses a 4-byte-aligned forward heuristic
(`TryHeuristicInventoryStart`) that probes candidate offsets with a
strict `(inventory + equipped consume to EOF)` test, mirroring
holtburger's `find_inventory_start_after_gameplay_options`.
The trailer walk is wrapped in its own inner try/catch (separate from
the outer parse-wide catch) so a malformed trailer cannot destroy the
already-extracted attribute / skill / spell / enchantment data. A new
`Parsed.TrailerTruncated` flag lets callers distinguish a clean parse
from a graceful-degradation parse (set true if the inner catch fires;
log under `ACDREAM_DUMP_VITALS=1`).
`GameEventWiring`'s `PlayerDescription` handler now registers each
inventory entry with `ItemRepository.AddOrUpdate(...)` and applies
`MoveItem(...)` for equipped entries so paperdoll picks up
`CurrentlyEquippedLocation` at login. The acceptance criterion
"`ItemRepository.Count` after login > 0" is now exercised by
`PlayerDescription_RegistersInventoryEntries_InItemRepository` in
`GameEventWiringTests`.
12 tasks, 13 commits, +9 PD parser tests + 1 wiring test (20 PD tests
total, 282 Net.Tests pass). Code-review nits during the run produced
two refactor commits: `Shortcut → ShortcutEntry` rename to avoid a
homograph with the `CharacterOptionDataFlag.Shortcut` flag bit
(`6587034`); `TrailerTruncated` flag + diagnostic logging
(`9a0dfe0`).
Forward-looking notes (low priority, no follow-up issues filed):
- `WeenieClassId = inv.ContainerType` for inventory entries is a
placeholder; `CreateObject` overwrites it with the real weenie class
later in the login sequence.
- The 10,000 count cap throws `FormatException` on validation failure,
which the inner catch treats the same as truncation. If a future
diagnostic UI needs to distinguish "EOF mid-section" from "garbage
count rejected", split `TrailerTruncated` into two flags. For now
the `ACDREAM_DUMP_VITALS=1` log message gives the developer enough
signal.
Files: `src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs`,
`src/AcDream.Core.Net/GameEventWiring.cs`,
`tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs`,
`tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs`.
---
## #51 — [DONE 2026-05-09 · da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's `FSplitNESW`
**Closed:** 2026-05-09
**Commit:** `da56063` (black-terrain fix; landed within Phase N.5b — see
`docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md` for the
ship commit chain)
**Component:** terrain math / Phase N.5b
**Resolution: Path C.** Phase N.5b lifted terrain rendering onto the
modern path (bindless atlas + `glMultiDrawElementsIndirect`) WITHOUT
adopting WB's `TerrainUtils.CalculateSplitDirection`. The pre-implementation
divergence test (`tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`)
confirmed the two formulas disagree on **49.98%** of sweep cells —
fundamentally incompatible with our shared physics + visual mesh, which
both rely on retail's `FSplitNESW` (constants `0x0CCAC033` / `0x421BE3BD` /
`0x6C1AC587` / `0x519B8F25`).
Path C: keep retail's `FSplitNESW` formula via `LandblockMesh.Build`
`TerrainBlending.CalculateSplitDirection`; mirror WB's `TerrainRenderManager`
architectural pattern (single global VBO/EBO + slot allocator + bindless
atlas + multi-draw indirect) but feed it acdream's mesh. Modern dispatcher
(`TerrainModernRenderer`) replaces `TerrainChunkRenderer` (deleted in T9
along with `TerrainRenderer` + `terrain.vert/.frag`).
Path A (substitute WB's formula) was killed by the divergence test.
Path B (fork-patch WB's renderer to use retail's formula) was rejected
for permanent maintenance burden. Path C ships the architectural
pattern while preserving retail-formula compliance.
Visual mesh and physics both still consume retail's `FSplitNESW`; they
remain in lockstep, no triangle-Z hover. The N.6 / N.7 sequencing
implication this issue carried (substitute physics math only when the
visual mesh migrates) is moot — neither side ever switches to WB's
formula.
**Files added:**
- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
- `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`
- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`
- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
- `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs` (the
test that killed Path A)
**Files deleted (T9):**
- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs`
- `src/AcDream.App/Rendering/TerrainRenderer.cs`
- `src/AcDream.App/Rendering/Shaders/terrain.vert`
- `src/AcDream.App/Rendering/Shaders/terrain.frag`
---
## #43 — [DONE 2026-05-05 · 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
**Closed:** 2026-05-05

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-05-08 for Phase N.5 shipping (bindless textures + `glMultiDrawElementsIndirect` on top of N.4's foundation; CPU dispatcher 1.23ms/frame at Holtburg, ~810 fps) + N.6 becomes the new in-flight phase (retire legacy renderers + perf polish).
**Status:** Living document. Updated 2026-05-10 for Phase A.5 shipping (two-tier streaming N₁=4/N₂=12 + QualityPreset system + Bug A/B fixes; closes the two-tier streaming spec). Post-A.5 polish (Tier 1 retry + lifestone fix + JobKind plumbing) is now the in-flight work.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -31,6 +31,7 @@
| A.1 | Streaming landblock loader — runtime-configurable visible window (default 5×5, `ACDREAM_STREAM_RADIUS`), camera-centered offline / player-centered live, hysteresis-based unloads, pending-spawn list for late CreateObject events | Live ✓ |
| A.2 | Frustum culling — per-landblock AABB test (Gribb-Hartmann), terrain + static-mesh renderers skip culled landblocks, perf overlay in window title | Visual ✓ |
| A.3 | Background net receive thread — dedicated daemon thread buffers UDP into Channel, render thread drains | Visual ✓ |
| A.5 | Two-tier streaming + horizon LOD — N₁=4 (full detail, 81 LBs) + N₂=12 (terrain only, 544 LBs); fog blend at N₁; per-LB entity dispatcher walk tightened (Change #1 animated-walk fix + Change #2 cached AABB); single-worker off-thread mesh build; mipmaps + 16x anisotropic on TerrainAtlas; A2C with MSAA 4x on foliage; depth-write audit + lock-in test; **NEW T22.5: QualityPreset system** (Low/Medium/High/Ultra) with per-preset radii + MSAA + anisotropic + A2C + completions; env-var overrides per field; F11 mid-session re-apply. **Bug fixes post-T26 ship-prep**: (Bug A) far-tier worker now strips entities from far-tier loads — without this fix, far-tier LBs were loading their full entity layer (~71K entities) defeating the two-tier optimization; (Bug B) WalkEntities switched from per-frame fresh-list allocation to caller-provided scratch list (eliminated ~480 KB/frame GC pressure). **Deferred to post-A.5**: Tier 1 entity-classification cache (first attempt broke animation; revert + redo with animation-mutation audit), lifestone visual (missing in render), JobKind plumbing through BuildLandblockForStreaming (proper Bug A fix), Tier 2/3 perf optimizations (roadmap at docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md). Plan archived at docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md. | Live ✓ |
| B.3 | Physics MVP resolver foundation — terrain contact, CellSurface prototype, streaming-populated collision inputs, and first `PhysicsEngine` resolver path. Not the complete retail collision system. | Tests ✓ |
| B.2 | Player movement mode — Tab-toggled WASD ground walking, walk/run/idle animations, third-person chase camera, MoveToState + AutonomousPosition outbound, portal entry. Outdoor-only MVP. | Live ✓ |
| D.1 | 2D ortho overlay + font rendering (StbTrueTypeSharp atlas + TextRenderer + DebugOverlay) | Visual ✓ |
@ -61,6 +62,7 @@
| N.3 | WorldBuilder-backed texture decode — `SurfaceDecoder` delegates INDEX16 / P8 / A8R8G8B8 / R8G8B8 / A8(+Additive) to `TextureHelpers.Fill*`; `isAdditive` threaded through (terrain alpha → `FillA8Additive`, non-additive entity surfaces → `FillA8`). R5G6B5 + A4R4G4B4 newly handled (previously magenta). X8R8G8B8, DXT1/3/5, SolidColor remain ours (no WB equivalent). 9 conformance tests prove byte-identical equivalence per format. | Live ✓ |
| N.4 | Rendering pipeline foundation — adopted WB's `ObjectMeshManager` as the production mesh pipeline behind `ACDREAM_USE_WB_FOUNDATION` (default-on). `WbMeshAdapter` is the single seam (owns `ObjectMeshManager`, drains the staged-upload queue per frame, populates `AcSurfaceMetadataTable` with per-batch translucency / luminosity / fog metadata). `WbDrawDispatcher` is the production draw path: groups all visible (entity, batch) pairs, single-uploads the matrix buffer, fires one `glDrawElementsInstancedBaseVertexBaseInstance` per group with `BaseInstance` slicing into the shared instance VBO. `LandblockSpawnAdapter` + `EntitySpawnAdapter` bridge spawn lifecycle to WB ref-counts (atlas tier vs per-instance). Perf wins shipped as part of N.4: per-entity frustum cull, opaque front-to-back sort, palette-hash memoization (compute once per entity, reuse across batches). Visual verification at Holtburg passed: scenery + connected characters with full close-detail geometry (Issue #47 regression resolved). Legacy `InstancedMeshRenderer` retained as `ACDREAM_USE_WB_FOUNDATION=0` escape hatch until N.6 (retired early in N.5 ship amendment). | Live ✓ |
| N.5 | Modern rendering path — lifted `WbDrawDispatcher` onto bindless textures (`GL_ARB_bindless_texture`) + `glMultiDrawElementsIndirect`. Per-frame entity rendering: 3 SSBO uploads (instance matrices @ binding=0, batch data @ binding=1, indirect commands) + 2 indirect draw calls (opaque + transparent). ~12-15 GL calls per frame regardless of group count, down from hundreds-of-per-group in N.4. CPU dispatcher: 1.23 ms/frame median at Holtburg courtyard (1662 groups, ~810 fps sustained). All textures on the WB modern path use 1-layer `Texture2DArray` + `sampler2DArray`. Legacy callers keep `Texture2D` / `sampler2D` via the parallel `TextureCache` path until N.6 retires them. Three gotchas captured in memory: texture target lock-in, bindless Dispose order (two-phase non-resident before delete), GL_TIME_ELAPSED double-buffering. **Ship amendment 2026-05-08:** legacy renderers (`InstancedMeshRenderer`, `StaticMeshRenderer`, `WbFoundationFlag`) retired within N.5 — modern path is mandatory; missing bindless throws `NotSupportedException` at startup. N.6 scope narrowed accordingly. Plan archived at `docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`. | Live ✓ |
| N.5b | Terrain on the modern rendering path — `TerrainModernRenderer` replaces `TerrainChunkRenderer` (the latter plus `TerrainRenderer` + `terrain.vert/.frag` deleted). Single global VBO/EBO with slot allocator (one slot per landblock), per-frame `DrawElementsIndirectCommand[]` upload + `glMultiDrawElementsIndirect`, bindless atlas handles passed as `uvec2` uniforms reconstructed via `sampler2DArray(handle)`. **Path C** chosen: mirrors WB's `TerrainRenderManager` pattern but consumes `LandblockMesh.Build` so retail's `FSplitNESW` formula is preserved (closes ISSUE #51). Path A killed by 49.98% measured divergence between WB's `CalculateSplitDirection` and retail's at addr `00531d10`; Path B (fork-patch WB) rejected for permanent maintenance burden. Perf at Holtburg radius=5 (commit `da56063`): modern 6.4-7.0 µs / 9-14 µs p95 vs legacy 1.5 µs / 3.0 µs — **modern is ~4× SLOWER on CPU at radius=5** because legacy's 16×16-LB chunking collapsed visible LBs to one `glDrawElements`. Architectural wins (zero `glBindTexture`/frame, constant-cost dispatch, per-LB frustum cull) manifest at higher radius (A.5 territory). Spec acceptance criterion 5 ("≥10% lower CPU at radius=5") amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Three gotchas captured in memory: `uniform sampler2DArray` + `glProgramUniformHandleARB` GL_INVALID_OPERATIONs on at least one driver (use `uniform uvec2` + `sampler2DArray(handle)` constructor instead — N.5's mesh_modern pattern); `MaybeFlushTerrainDiag` median-calc underflow on first sample; visual gates need actual visual confirmation, not assent. Plan archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`. | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -81,7 +83,7 @@ Plus polish that doesn't get its own phase number:
- **✓ SHIPPED — A.2 — Frustum culling.** Per-landblock AABB test (Gribb-Hartmann plane extraction + positive-vertex AABB test) in both `TerrainRenderer.Draw` and `StaticMeshRenderer.Draw`. Per-entity culling deferred. LOD deferred to Phase C. Performance overlay in window title shows FPS, frame time, visible/total landblock ratio, entity count, animated count. ~160fps uncapped at 5×5 radius.
- **✓ SHIPPED — A.3 — Background net receive thread.** Dedicated daemon thread continuously pulls raw UDP datagrams from the kernel buffer into a `Channel<byte[]>`. Render thread's `Tick()` drains the channel. All decode, fragment assembly, ISAAC crypto, event dispatch, and ack-sending remain on the render thread — minimal change that prevents packet drops during frame stalls. Thread starts after `EnterWorld()` completes; `PumpOnce()` during handshake still reads the socket directly.
- **A.4 — Async dat decoding.** Folded into the streaming worker — it's the worker's read path, not a separate subsystem. Called out here because regressions in dat caching could land on this surface.
- **A.5 — Two-tier streaming + terrain horizon LOD.** Split `ACDREAM_STREAM_RADIUS` into two: `ACDREAM_TERRAIN_RADIUS` (large, 8-12 cells = 1.5-2.3km) for terrain mesh + `ACDREAM_ENTITY_RADIUS` (small, 2-3 cells, current default) for entities + scenery. Distant landblocks render terrain only — no NPCs, no procedural scenery, no static objects. Tune `SceneLightingUbo`'s `uFogParams` so the far edge fades into sky color (eliminates the hard streaming boundary visible at higher radii). Optional: terrain LOD via mesh decimation for very distant chunks (combine 2×2 landblocks into one decimated mesh; cribs from `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/TerrainRenderManager.cs`). Motivation: at radius=5 today, perf scales from ~810 fps → ~200-300 fps because everything stays full-detail; both retail and WorldBuilder render terrain way out and strip entities/scenery at distance. Enables WB-style horizon visibility. **Estimate: 3-5 days for the radius split + fog tuning; +1 week if terrain LOD is included.** Not yet brainstormed.
- **✓ SHIPPED — A.5 — Two-tier streaming + horizon LOD.** Shipped 2026-05-10. See shipped table above for full description. Plan archived at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md`.
**Acceptance:**
- Walk across 10+ landblocks in any direction, no crashes, no empty voids.
@ -200,6 +202,7 @@ Research: R1 + R2 + R6 + R8 + UI slices 04/05.
- **F.3 — Combat math + damage flow.** Damage formula, per-body-part AL, crit, hit-chance sigmoid. Server broadcasts damage events; client displays + HP bar. See `r02-combat-system.md` + `src/AcDream.Core/Combat/`.
- **F.4 — Spell cast state machine.** `SpellCastStateMachine` + active buff tracking. Buffs + recalls first, projectile spells later. Fizzle sigmoid + mana conversion. See `r01-spell-system.md` + `src/AcDream.Core/Spells/`.
- **F.5 — Core panels.** Attributes / Skills / Paperdoll / Inventory / Spellbook — using the retail-ui framework from Phase D.2. See `05-panels.md` under retail-ui. **(Targets `AcDream.UI.Abstractions`; unblocked by D.2a — ships with ImGui widgets — and reskinned when D.2b lands.)**
- **F.5a — Visible-at-login dev panels.** First deliverable on top of #13 (PD trailer parser shipped 2026-05-10): wire `PlayerDescriptionParser.Parsed.{Inventory, Equipped, Shortcuts, HotbarSpells, DesiredComps, Options1, Options2, SpellbookFilters}` and `ItemRepository.Items` into minimal ImGui dev panels under `ACDREAM_DEVTOOLS=1` so the parsed data is observable in-game without a real retail-skin panel. Establishes the binding pattern (`AcDream.UI.Abstractions` ViewModels → ImGui renderer) the eventual D.2b retail-skinned panels reuse. Acceptance: log in, open dev overlay, see your inventory list / hotbars / shortcuts / character-options bitfields populated from the live PD message. **Targets:** `src/AcDream.UI.Abstractions/` (ViewModels) + `src/AcDream.App/UI/ImGui/` (panels). Spec to brainstorm before code.
**Acceptance:** equip a weapon, swing at a monster, see damage numbers, buff yourself, recall to the lifestone.
@ -429,19 +432,40 @@ EchoRequest/EchoResponse handling, runtime ping/timeout policy, and a typed
protocol/action layer. These gaps will become expensive as movement, dungeons,
inventory, combat, and plugins depend on stable packet semantics.
**Plan of record:** create
`docs/superpowers/specs/2026-05-02-network-stack-conformance.md` before
implementation starts. Treat holtburger as the client-behavior oracle for this
phase; cross-check wire details against named retail, ACE, Chorizite, and AC2D
before porting.
**Plan of record:** Detailed design spec at
[`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
(supersedes the planned-but-never-written `2026-05-02-network-stack-conformance.md`
the original entry referenced). The spec defines: **Bar C** ("wireable on demand")
as the completeness target; a **three-layer architecture** (`INetTransport` /
`IReliableSession` / `IGameProtocol`) with `WorldSession` as a thin behavior
consumer on top; a **worktree-branch big-bang** migration model on
`claude/phase-m-network-stack` with weekly rebase cadence and single-merge ship;
per-sub-phase entry/exit gates with hour estimates; conformance test plan
(golden vectors + live capture replay + live ACE smoke); risk register; and a
**256-hour / ~6.4-week single-developer cost estimate** (46 weeks calendar
with subagent parallelization on M.1 and M.6). Treat holtburger as the
client-behavior oracle, ACE as server-outbound authority, named retail decomp
as wire-format ground truth.
**Sub-lanes:**
- **M.1 — Audit & parity map.** Produce a source-by-source comparison of
acdream `AcDream.Core.Net` and holtburger `holtburger-session`,
`holtburger-protocol`, and `holtburger-core` networking code. Inventory each
packet flag, optional header, session transition, control packet, fragment
path, game message, and game action. Mark each as `parity`, `partial`,
`missing`, or `intentional divergence`.
**2026-05-10 update:** holtburger pulled to `629695a` (+237 commits since
last audit). First parity-pass at
[`docs/research/2026-05-10-holtburger-network-stack-study.md`](../research/2026-05-10-holtburger-network-stack-study.md);
formal opcode coverage matrix (M.1's main deliverable) under construction
at `docs/research/2026-05-10-phase-m-opcode-matrix.md` via parallel
class-by-class agent dispatch. Most relevant recent holtburger commits:
`99974cc` (session crate split + retransmit core), `403bc98` (port-switch
race), `336cbad` (turning + locomotion fix), `797aece` (disconnect
carries client_id). Six "Tier 1" quick-wins identified by the study
(originally tracked as M.0) are folded into M.3 / M.4 / M.6 per the
spec — they no longer ship as a separate sub-phase.
**Sub-lanes:** *(brief summary; the spec has full entry/exit criteria,
conformance gates, and hour estimates for each.)*
- **M.1 — Audit & opcode matrix.** Build the per-opcode coverage table
citing holtburger / ACE / named retail / acdream-today / Phase M target.
Status: parity-pass done; matrix construction in flight via per-class
agent dispatch (transport flags + optional headers, GameMessages,
GameEvents, GameActions). 16h.
- **M.2 — Layer extraction.** Split the low-level stack under `WorldSession`
into testable components: `INetTransport`, `PacketCodec`,
`ReliablePacketSession`, `FragmentSession`, `GameMessageSession`, and the
@ -641,23 +665,43 @@ for our deletions/additions; merge upstream `master` periodically.
lock-in, bindless Dispose two-phase order, GL_TIME_ELAPSED double-
buffering. Plan archived at
`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`.
- **N.5b — Terrain rendering on N.5 path.** Wire WB's
`TerrainRenderManager` + `LandSurfaceManager` + `TerrainGeometryGenerator`
onto the modern rendering path. Closes N.2's deferred terrain math
substitution: visual mesh and physics both switch to WB's
`CalculateSplitDirection` + `GetHeight` + `GetNormal` in lockstep,
resolving ISSUE #51. **Estimate: 1-2 weeks** (was 2-3 — modern path
primitives already in place from N.5).
- **N.6 — Perf polish.** **Currently in flight.**
Builds on N.5. Legacy renderer retirement was pulled forward into N.5
ship amendment — `InstancedMeshRenderer`, `StaticMeshRenderer`, and
`WbFoundationFlag` are already gone. N.6 scope: WB atlas adoption for
memory savings on shared content, persistent-mapped buffers if
`glBufferData` shows up in profiling, GPU-side culling via compute
pre-pass, GL_TIME_ELAPSED query double-buffering (deferred from N.5 —
diagnostic shows `gpu_us=0/0` under `ACDREAM_WB_DIAG=1`), direct N.4
vs N.5 perf measurement, retire the legacy `Texture2D`/`sampler2D` path
in `TextureCache` (currently kept for Sky + Terrain + Debug).
- **✓ SHIPPED — N.5b — Terrain on the modern rendering path.** Shipped
2026-05-09. **Path C** (mirror WB's `TerrainRenderManager` pattern but
consume `LandblockMesh.Build` for retail-formula compliance). Path A
(substitute WB's `CalculateSplitDirection`) killed during pre-implementation
divergence test: WB's formula disagrees with retail's `FSplitNESW`
(addr `00531d10`) on **49.98%** of cells across `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`'s
sweep — wholly incompatible with our shared physics + visual mesh.
Path B (fork-patch WB to use retail's formula) rejected for permanent
maintenance burden. Path C ships the architectural pattern (single
global VBO/EBO + slot allocator + bindless atlas + `glMultiDrawElementsIndirect`)
while keeping retail's formula via `LandblockMesh.Build`
`TerrainBlending.CalculateSplitDirection`. `TerrainModernRenderer` +
`terrain_modern.vert/.frag` shipped, `TerrainChunkRenderer` +
`TerrainRenderer` + legacy `terrain.vert/.frag` deleted in T9.
Closes ISSUE #51. **Perf reality check:** at radius=5 in Holtburg,
modern is ~4× SLOWER on CPU than legacy was (6.4 µs vs 1.5 µs median;
legacy collapsed radius=5's visible LBs into one `glDrawElements`
via 16×16-LB chunking). Architectural wins (zero `glBindTexture`/frame,
constant-cost dispatch as A.5 raises radius, per-LB frustum cull)
manifest at higher radius. Spec acceptance criterion #5 was wrong;
amended via `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`. Plan
archived at `docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md`.
- **N.6 — Perf polish.** **Planned (post-A.5 polish takes priority).**
Builds on N.5 + N.5b. Legacy renderer retirement was pulled forward
into N.5 ship amendment — `InstancedMeshRenderer`, `StaticMeshRenderer`,
`WbFoundationFlag` are gone — and the terrain legacy renderer
(`TerrainChunkRenderer` + `TerrainRenderer` + `terrain.vert/.frag`)
retired in N.5b. N.6 scope: WB atlas adoption for memory savings
on shared content, persistent-mapped buffers if `glBufferData` shows
up in profiling (the modern terrain path's per-frame DEIC `BufferSubData`
is a candidate), GPU-side culling via compute pre-pass (eliminates
the per-frame slot walk + DEIC build entirely), GL_TIME_ELAPSED query
double-buffering (deferred from N.5 — diagnostic shows `gpu_us=0/0`
under `ACDREAM_WB_DIAG=1`), direct higher-radius perf comparison (A.5
has now landed — modern's architectural wins are measurable), retire the
legacy `Texture2D`/`sampler2D` path in `TextureCache` (currently kept
for Sky + Debug + particle paths now that Terrain has migrated).
Plan + spec written when work begins. **Estimate: 1-2 weeks.**
- **N.7 — EnvCells / dungeons.** Replace EnvCell rendering with WB's
`EnvCellRenderManager` + `PortalRenderManager` on top of N.4's

View file

@ -0,0 +1,98 @@
# Phase N.5b — terrain perf baseline
**Captured:** 2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill.
## Methodology
Same build (commit at perf measurement: `da56063`), `ACDREAM_WB_DIAG=1`. The build
included a TEMPORARY `ACDREAM_LEGACY_TERRAIN=1` env-var toggle (since retired in T9
deletion of the legacy renderer) that routed Draw through the legacy renderer for
direct comparison. Both renderers were constructed and fed AddLandblock / RemoveLandblock
in parallel; only one drew per frame; the same Stopwatch wrapped whichever ran.
## Numbers
| Renderer | cpu_us median | cpu_us p95 | draws/frame | Visible LBs |
|---|---|---|---|---|
| **Legacy** (`TerrainChunkRenderer`) | 1.5 | 3.0 | 1 (1 chunk) | 132-143 (whole chunk) |
| **Modern** (`TerrainModernRenderer`) | 6.4-7.0 | 9-14 | ~36-51 | 36-51 (per-LB cull) |
(Legacy `draws=1` because its 16×16-LB chunking collapses radius=5's 121 visible
landblocks into a single chunk, dispatched as one `glDrawElements`. Modern issues
one `glMultiDrawElementsIndirect` with N=36-51 sub-commands.)
## Acceptance criterion
The N.5b spec acceptance criterion 5 read: "CPU dispatcher time at radius=5 ≥10%
lower than today's per-LB-binds path." The captured numbers show modern is ~4×
HIGHER on CPU at radius=5. **The criterion was wrong** — at radius=5 in Holtburg,
legacy's chunked path was already collapsed to one draw call. The architectural
wins of multi-draw indirect manifest at higher chunk counts (A.5 territory).
The spec is amended via this doc: ship N.5b on visual identity + structural
correctness rather than CPU savings at radius=5.
## Architectural wins of the modern path (real, even when CPU is higher)
1. **Zero `glBindTexture` per frame.** Bindless atlas handles are made resident
once at startup; the modern shader samples via `sampler2DArray(uvec2 handle)`.
Legacy issued 2 `glBindTexture(Texture2DArray)` calls per frame.
2. **Constant-cost dispatch.** As A.5 raises the streaming radius (next phase),
the visible chunk count grows. Legacy scales linearly: at radius=10 (4× chunks)
it's 4 `glDrawElements` calls; at radius=15 (≥9 chunks) it's 9+ calls. Modern
stays at exactly 1 `glMultiDrawElementsIndirect` regardless.
3. **Per-LB frustum culling.** Legacy culled at chunk granularity (16×16 LBs);
modern culls per-LB. At a typical Holtburg view, ~36-51 of 132 loaded LBs are
actually visible; legacy drew the entire 132-LB chunk (3.5× the visible work
pushed to GPU vertex/fragment stages, even though CPU dispatch was cheap).
## Why modern's CPU was higher at radius=5
Per-frame work in modern (in microseconds-ish budget on this scene):
- Walk all loaded slots checking visibility (~120 slots) → AABB test each
- Build DEIC array (51 entries × 20 bytes = 1020 bytes)
- `glBufferSubData(DRAW_INDIRECT_BUFFER, ...)` — driver memcpy
- 2× `glProgramUniform2(..., handle.low, handle.high)` for atlas handles
- `glBindVertexArray` + `glMemoryBarrier(GL_COMMAND_BARRIER_BIT)` + `glMultiDrawElementsIndirect`
Legacy's per-frame work:
- Bind 2 textures
- Bind one VAO (the chunk)
- One `glDrawElements`
The DEIC array build + buffer upload alone is ~3-5µs at radius=5 on this hardware,
which is the bulk of the modern overhead. At higher radius, this overhead amortizes:
the buffer is similar size, but the alternative (legacy's N draws) grows.
## Follow-up work
- **A.5 (next phase)** will exercise the higher-radius case where modern wins.
Capture a fresh baseline at radius=8 / 10 once A.5 lands.
- **N.6 perf polish** can investigate persistent-mapped buffers for the indirect
buffer, which would eliminate the per-frame `glBufferSubData`. Likely small win
at radius=5 (single ~1KB upload), bigger at higher radii.
- **GPU-side culling** (compute shader generating the DEIC array directly into
the indirect buffer) eliminates the CPU slot walk + DEIC build entirely. N.6 or
later territory; only worth it if profiling shows the CPU walk is hot.
## Lessons captured to memory
`memory/project_phase_n5b_state.md` records the high-value gotchas surfaced
during N.5b implementation. Three particularly bitable ones:
1. **`uniform sampler2DArray` + `glProgramUniformHandleARB` is unreliable.** Some
drivers (NVIDIA Windows in this case) reject the combination with
`GL_INVALID_OPERATION`. Use the `uniform uvec2` + `sampler2DArray(handle)`
constructor pattern instead — N.5's mesh_modern uses this, and N.5b's
terrain_modern adopted it after the black-terrain regression.
2. **`MaybeFlushTerrainDiag` underflow.** A naive median calc (`copy[N - nz/2]`)
underflows to `copy[N]` when only one sample has been recorded. Use
`copy[N - 1 - (nz - 1) / 2]` instead.
3. **Visual gate must actually be visually confirmed.** "Go" doesn't mean
"verified." During N.5b's gate the user said "go" without launching, which
masked the black-terrain regression for hours. The gate must include the
user reporting actual visual confirmation, not assent to proceed.

View file

@ -0,0 +1,195 @@
# Performance Tiers 2 + 3 — Future Roadmap
**Created:** 2026-05-10 during Phase A.5 polish.
**Status:** Future planning — not for current execution.
**Context:** A.5 shipped two-tier streaming with the entity dispatcher landing at ~3.5ms median (post-Bug-A and Bug-B fixes). Tier 1 (entity-classification cache) lands as A.5 polish and brings the dispatcher inside the 2.0ms spec budget. Tiers 2 + 3 are the "next big perf wins" beyond Tier 1.
---
## Background — why this exists
Discussion captured 2026-05-10: user observed 200-240 FPS at radius=12 on a Radeon 9070 XT @ 1440p and asked why an "old game like AC" doesn't deliver Unreal-level (1000+ FPS) on this hardware.
The honest answer: the bottleneck is *architectural*, not hardware. The CPU is single-threaded and rebuilds the entire draw plan from scratch every frame. Modern engines pre-bake static-world batches at content-cook time and rebuild only what changes.
AC's design — server-spawned per-entity world streamed at runtime — doesn't naturally batch the way Unreal's pre-cooked content does. Closing the gap requires backporting modern techniques while preserving AC's data model. Tiers 2 and 3 are that backporting work.
---
## Tier 2 — Static/dynamic split with persistent groups
**Estimated effort:** ~10-15 days (2-week phase).
**Estimated win:** entity dispatcher ~3.5ms → **~0.5-1ms median** at radius=12.
**Total frame time:** ~4-5ms → **~2-3ms = 400-600 FPS at standstill.**
### The core idea
Today, `WbDrawDispatcher._groups` (the dictionary of "(mesh + texture + blend) → list of instances to draw") is cleared and rebuilt from scratch every frame.
For trees, rocks, buildings, and other static entities (~95% of the world), the answer is identical every frame forever. Tier 2 makes the static-group instance buffers **persistent GPU-resident data**, just like Unreal's pre-baked world. The CPU only orchestrates "which groups are visible" per frame.
### Architectural shift
```csharp
class StaticInstancedGroup
{
public GroupKey Key;
public Matrix4x4[] Matrices; // grown as entities spawn
public BitArray ActiveSlots; // for free-list reuse
public bool NeedsGpuUpload; // dirty flag for delta upload
public Dictionary<uint, int> EntityToSlot; // for despawn lookup
public uint InstanceBufferOffset; // start of group's slice in global SSBO
}
```
**On entity spawn (atlas-tier static):** allocate a slot in each relevant group, write the matrix, mark dirty.
**On entity despawn:** free the slot, mark dirty.
**Per frame:**
- Static groups: LB-cull each group (cheap). For visible groups, flag for draw. **No matrix copy. No list rebuild.**
- Dynamic entities (~50 NPCs/players): today's per-frame walk-and-classify. Keeps the existing slow path for things that legitimately change every frame.
- Upload only the dirty groups' matrix slices (delta upload, not full reupload).
- Issue 2 multi-draw-indirect calls.
### Sub-decisions
**Frustum cull granularity at the group level:** at group level you can't reject individual instances; you draw the whole group or none of it. Two strategies:
- **Per-LB subgroups:** split each group into per-landblock subgroups. LB-frustum-culls reject subgroups whose LB is invisible. ~2K groups × ~5 LBs per group on average = ~10K subgroups. Each subgroup AABB cull is ~0.3 µs → ~3 ms per frame. Roughly a wash with today's per-entity cull.
- **Per-instance GPU cull (Tier 3):** compute pre-pass on the GPU writes which instances are visible to a draw-indirect buffer. ~0.05ms CPU. The right long-term answer.
For Tier 2 alone, per-LB subgroups are the recommended approach — keep CPU culling, just at coarser granularity than per-entity.
**Dynamic entities crossing LB boundaries:** when an NPC walks across a landblock boundary, it stays in the same group key but its "spatial bucket" changes. Solution: dynamic entities are tracked in a single global "dynamic group" outside the per-LB structure; they don't need spatial bucketing because there are only ~50 of them.
**Palette override invalidation:** server event swaps an NPC's clothing color → group key changes. Treat as despawn-from-old + spawn-into-new. NPCs are dynamic so this just rebuckets them.
**Animation overrides on static entities:** static entities don't animate. Trees don't bend (foliage wave is a vertex shader effect, not a group-key change). Buildings don't move. So the static path never invalidates.
**EnvCell visibility:** dungeon entities are gated by per-cell visibility state. Need to track which group instances are tied to which cell, and during visibility cull, gate per-cell. Keep using existing `ParentCellId` field on WorldEntity.
**Streaming load/unload integration:** when an LB unloads, all its static entity matrices need to be removed from their groups. Free-list management. Matches existing `LandblockSpawnAdapter` lifecycle.
### Effort breakdown
| Task | Days |
|---|---|
| Design + invariants document | 2 |
| Spawn-time slot allocator + free-list | 3 |
| Per-frame visibility + dirty-flag delta upload | 2 |
| Dynamic entity path (NPCs, projectiles) | 2 |
| Invalidation (palette/ObjDesc events) | 2 |
| EnvCell visibility integration | 1 |
| Streaming load/unload integration | 1 |
| Conformance testing | 2-3 |
| **Total** | **~10-15 days** |
### Risks
- **Slot management bugs** = double-frees or leaks (entities draw at random positions — visible).
- **Invalidation bugs** = stale matrices (entity teleports back to spawn point when palette changes).
- **Dynamic entity tracking** adds complexity around the static/dynamic boundary.
### Mitigations
- **Conformance test:** render a fixed scene through both pipelines, compare draw output. Adds CI infrastructure.
- **Per-frame validation in debug:** walk all groups, assert no orphan slots.
- **Hash invariant test:** static entities should produce stable group keys frame-over-frame. Add a debug assertion that fires once per frame in Debug builds.
---
## Tier 3 — GPU-side culling (compute pre-pass)
**Estimated effort:** ~1 month (longer phase).
**Estimated win:** entity dispatcher ~0.5-1ms (post-Tier-2) → **~0.05ms median.**
**Total frame time:** ~2-3ms → **~1.5-2ms = 600-1000+ FPS at standstill.**
### The core idea
Today (and after Tier 2), the CPU does per-LB or per-subgroup frustum culling and tells the GPU which groups to draw.
Tier 3 moves per-instance frustum cull to the GPU via a compute shader pre-pass. The CPU just uploads "here are all 1M instance matrices" once; the GPU compute shader writes which ones are visible to a draw-indirect buffer; the rasterizer draws only those.
This is the level Unreal is at. With this, per-frame CPU work for the entity dispatcher becomes essentially "tell the GPU what to do" + a tiny scratch upload.
### Why Tier 3 needs Tier 2 first
Without Tier 2's persistent group structure, GPU culling has nothing stable to operate on. The compute shader needs an addressable "here are the static instances" buffer to read from; that buffer only exists after Tier 2.
### Sub-decisions to be made
**Compute shader API:** OpenGL 4.3+ compute shaders are sufficient. We're already at GL 4.3+ for bindless. No additional capability requirement.
**Indirect draw command generation:** the compute shader writes a `DrawElementsIndirectCommand[]` buffer per pass. Render thread issues `glMultiDrawElementsIndirect` reading from that buffer. No CPU readback.
**LOD selection:** opportunity to add per-instance LOD selection in the compute shader (distance-based mesh detail). Not needed for A.5's scope; could be a Tier 4 follow-up.
**Per-light shadow map culling:** if shadows ship, GPU culling extends naturally to per-light frustum cull. Significant win for shadow rendering.
### Effort breakdown
| Task | Days |
|---|---|
| Compute shader design + GLSL implementation | 4 |
| Buffer layout coordination with Tier 2 | 2 |
| Silk.NET compute dispatch integration | 3 |
| Indirect command compaction logic | 4 |
| LOD selection (optional, ~stretch) | 4 |
| Validation: per-instance cull matches CPU cull within epsilon | 3 |
| Conformance + regression testing | 5 |
| **Total** | **~21-25 days, ~1 month** |
### Risks
- **GPU stalls** if the compute shader takes longer than expected (esp. on lower-end GPUs).
- **Sync overhead** between compute pre-pass and rasterizer pass.
- **Debugging difficulty** — GPU compute bugs are harder to diagnose than CPU bugs.
### Mitigations
- **Profile-driven design:** measure compute shader runtime on target hardware before committing.
- **Fallback path:** keep CPU cull as a runtime-toggleable option (env var) so we can A/B compare.
- **GPU debugging tools:** RenderDoc captures + frame-by-frame compute shader inspection.
---
## When to schedule these
**Tier 2:**
- Best fit: dedicated 2-week phase after a SHIP cycle. Treat it like a Phase B/C/N (i.e., name it Phase A.6 or N.7).
- Trigger: user wants to push radius beyond 12 (e.g., to 15 or 20 for true continent-scale horizon).
- Trigger: user wants to add 100+ active NPCs in a city without dropping below 240Hz.
**Tier 3:**
- Best fit: after Tier 2 has been live and stable for at least one cycle.
- Trigger: shadow map work begins (GPU cull + shadow cull share the same compute pre-pass infrastructure).
- Trigger: user wants 500+ FPS sustained for very-high-refresh scenarios (360Hz monitors, future hardware).
**Both:**
- Don't bundle with other phases. These are dedicated perf phases with their own brainstorm + spec + plan + SHIP cycles.
---
## What's "free" or smaller (out of Tier 1/2/3 scope but worth noting)
- **Plumb `JobKind` properly through `BuildLandblockForStreaming`** (~30 min). Today's Bug A patch wastes worker-thread CPU on hydration that gets thrown away for far-tier. Cleaner code, slight CPU savings on worker.
- **Eliminate `ToEntries` adapter allocation in `Draw`** (~15 min). Tiny win (~25 KB / frame). Could fold into Tier 1.
- **Persistent-mapped indirect buffer** (~2 days). Today's `glBufferData` per frame becomes a pre-mapped persistent buffer. Marginal win on RDNA 4; meaningful on lower-end GPUs.
- **Multi-thread mesh-build worker pool** (~1 day). 2.7s first-traversal horizon-fill drops to 0.7s with 4 workers. UX win on first walk-into-region.
These are good candidates for a "perf polish" mini-phase or to backfill into Tier 2.
---
## The architectural ceiling
Even with all three tiers, **a faithful AC client written in C# with bindless OpenGL tops out around 800-1500 FPS at radius=12 on RDNA 4 hardware**. Beyond that requires:
- Native C++ rendering core (eliminate .NET GC + JIT overhead)
- DX12/Vulkan API (eliminate driver state validation)
- Offline content cooking (eliminate runtime mesh/texture decode)
Each of those is a several-month undertaking and represents "becoming a different engine." The realistic target for acdream is 240-500 FPS at the user's monitor refresh, comfortably ahead of the visible-stutter threshold. Tier 1 + Tier 2 alone should deliver that for radius=12-15.
For "Unreal-level FPS at full quality," that's a different project.

View file

@ -0,0 +1,177 @@
# Holtburger network stack — study & port candidates for acdream
**Date:** 2026-05-10
**Holtburger reference:** github.com/merklejerk/holtburger, vendored at `references/holtburger/`, fast-forwarded from `88b19bd``629695a` (237 commits, ~3 months of work).
**Method:** Four parallel research agents — three over holtburger's transport, handshake, and movement; one inventorying acdream's current `src/AcDream.Core.Net/`. Findings cross-referenced and ranked by ROI.
## TL;DR
Holtburger has shipped real, citeable fixes since our last pin that we should adopt. The biggest tactical wins are:
1. **A handful of one-line MoveToState fixes** that are likely candidates for the "remote retail observer sees acdream's player not perfect" issue (#L.X).
2. **Three small handshake/transport corrections** — LoginComplete-on-teleport, EchoResponse reply, port-switch race — each <1 hour and each measurable.
3. **A real retransmit subsystem we're missing entirely.** Our `WorldSession` parses retransmit requests, doesn't honor them, has no resend buffer, and never asks for a resend. Lost packets just vanish. Holtburger's `session/reliability.rs` is the reference-quality pattern.
Separately, the audit surfaced one painful finding about acdream itself: **roughly half of our outbound `Messages/` library is dead code** — InteractRequests, InventoryActions, SocialActions, AllegianceRequests, CastSpellRequest, AppraiseRequest, and most of CharacterActions are built and unit-tested but have no `WorldSession.Send*` wrapper and no live caller. Phase B.4 (Use/UseWithTarget) per memory shipped, but the audit found no in-app caller. Either we left wiring on the table or there's an integration drift to investigate.
The remainder of this doc is organized as: ranked port candidates → confirmations of what we got right → traps (where holtburger is wrong or stubbed) → recent commits worth knowing → recommended sequencing → cross-reference file map.
---
## 1. Ranked port candidates (highest ROI first)
### 1.1 Outbound MoveToState audit — concrete suspects for the "observer not perfect" bug
Five specific items where holtburger's wire format is likely tighter than ours. Each is a small change in our `Messages/MoveToState.cs` builder; together they're the most likely cause of remote retail observers reporting our player "lagging forward" or "walking when running."
| # | Suspect | Holtburger reference |
|---|---------|----------------------|
| a | **`current_hold_key` always set on non-stop MoveToState.** Holtburger's drive emit seeds `flags = CURRENT_HOLD_KEY` and writes `current_hold_key = HoldKey::Run`(2) for run, `HoldKey::None`(1) for walk. ACE's relay code may treat its absence as "unknown" and broadcast Walk to observers. | `crates/holtburger-core/src/client/movement/common.rs:151-153` |
| b | **`commands[]` array MUST be empty on held WASD.** Holtburger never puts a `MotionItem` in `commands[]` for held movement — only for transient slash commands like `/dance`. If acdream is putting one in for held W (or letting movement_sequence bump per-frame), every observer's `apply_self_update_motion` re-applies the same sequence as a fresh interpolation start — exactly the symptom. | `system.rs:743-766` (`execute_transient_motion_at`) |
| c | **`turn_speed` always emitted alongside `TURN_COMMAND`.** Holtburger writes 1.5 rad/s for Run, 1.0 rad/s for Walk; the `TURN_SPEED` flag is *always* set whenever `TURN_COMMAND` is. Omitting it lets ACE default to 0 → "smoothly but slowly" turn observed. | `common.rs:184-186, 226-231` |
| d | **Dedup gate must include gait.** Holtburger's `should_send_motion_state_pulse` compares the full `(MotionState, MotionStyle)`. If acdream's dedup is keyed on only `(forward_command, hold_key)` it would suppress the Run→Walk transition (since `forward_command = WalkForward = 0x45000005` for both), explaining the Run↔Walk observer bug specifically. | `system.rs:916-926` |
| e | **Don't emit `turning` field when locomotion is non-zero.** Recent fix in commit `336cbad`: `autonomous_wire_motion_state` no longer emits `turning` when locomotion ≠ 0 (avoids server-side double-correction where it interpolates turn AND locomotes). | `crates/holtburger-core/src/client/movement/common.rs` |
**Recommended action:** a side-by-side audit of [WorldSession.cs:6067-6089](src/AcDream.Core.Net/WorldSession.cs:6067) (MoveToState builder) and [Messages/MoveToState.cs](src/AcDream.Core.Net/Messages/MoveToState.cs) against holtburger `common.rs:122-186` and `system.rs:710-1000`. File whichever items don't already match as `#L.X.a-e` issues.
### 1.2 LoginComplete on every PlayerTeleport, not just first PlayerCreate
Holtburger sends `GameAction::LoginComplete` (0x00A1) **both** on first `PlayerCreate` (0xF746) AND on every `PlayerTeleport` (0xF74A) — no de-dup, server tolerates multiples. acdream sends it only on first PlayerCreate. Likely explains some portal-transition glitches.
References: holtburger `messages.rs:433-467` (PlayerCreate), `messages.rs:480-487` (PlayerTeleport). acdream sends only at [WorldSession.cs:648](src/AcDream.Core.Net/WorldSession.cs:648).
**Cost:** ~5 lines.
### 1.3 EchoRequest → EchoResponse reply
We parse `EchoRequest` from the optional header but never reply. ACE pings periodically; the missing response is a likely contributor to Network Timeout drops in long sessions. Holtburger handles it inline in the recv-message dispatcher.
Reference: holtburger `crates/holtburger-session/src/session/receive.rs::finalize_ordered_server_packet` and the optional-header iterator at `crates/holtburger-session/src/optional_header.rs:59-141`.
**Cost:** ~30 lines (parse the EchoRequest payload, build EchoResponse with mirrored time, send as control packet).
### 1.4 Port-switch race fix (commit `403bc98`)
On `ConnectRequest`, our `WorldSession` eagerly sets `_connectEndpoint = port+1`. Holtburger's recent fix introduces `pending_server_source_addr`: the new port is staged but `server_source_addr` is only updated when an actual packet arrives from the new port. ACE deployments occasionally send one more packet from `port` after the activation, and our code drops them.
References: holtburger `session/auth.rs:42-47` (stage), `session/receive.rs:17-51` (confirm on first packet from new port).
**Cost:** ~20 lines, one new field on `WorldSession`.
### 1.5 Non-blocking 200 ms handshake delay
We use `Thread.Sleep(200)` between receiving ConnectRequest and sending ConnectResponse on `port+1`. Holtburger queues ConnectResponse with `ready_at = Instant::now() + 200ms` and lets the recv loop keep draining during the gap (handles any inbound TimeSync that arrives in the window).
Reference: holtburger `session/auth.rs:42-66`, queued via `pending_control_packets` flushed by the recv loop. (Their old form, deleted in `99974cc`, used `tokio::time::sleep` and matched our blocking pattern.)
**Cost:** ~40 lines (small "deferred control packet" queue + flush check).
### 1.6 AutonomousPosition cadence audit
We have **three policies** in play, and at least two are wrong:
- **acdream:** fixed 200 ms heartbeat (per `memory/project_retail_motion_outbound`)
- **holtburger:** fixed 1 s heartbeat, unconditional regardless of motion (`common.rs:22`, `system.rs:858-893`)
- **cdb retail trace (memory):** AutoPos appears gated on actual motion
Most likely retail wins (cdb is observing real client behavior). If retail truly suppresses AutoPos when stationary, our 5× over-emission triggers ACE-side over-validation and may contribute to the observer-side jitter. **Recommended:** another cdb idle trace to confirm retail's exact behavior, then converge to it.
### 1.7 Retransmit machinery (entire subsystem)
Largest delta from holtburger. We are missing:
- **A retransmit cache.** Holtburger's `MAX_CACHED_PACKETS=512`, LRU-style, drops oldest when full (`reliability.rs:32-37`).
- **Server-requested retransmits.** When the server asks for resends, holtburger re-encrypts with current ISAAC + RETRANSMISSION flag and replays from cache (`reliability.rs:135-186`).
- **Client-issued retransmit requests.** When inbound seq has gaps, holtburger sends `RequestRetransmit` for up to 115 seqs in a 256-seq window, rate-limited to once per second (`MAX_RETRANSMIT_SEQUENCE_IDS=115`, `MAX_RETRANSMIT_SEQUENCE_WINDOW=256`, `REQUEST_RETRANSMIT_INTERVAL=1s`).
- **`Iteration` field handling.** Our `PacketHeader.Iteration` is always 0; holtburger increments on retransmit.
- **`ISAAC::search` for out-of-order ENCRYPTED_CHECKSUM packets.** Out-of-order packets have ISAAC keys that have already advanced. Holtburger scans forward up to 256 keys, stashing each skipped key in `xors: HashSet<u32>` for later out-of-order packets to consume via `consume_key_value` (`crypto.rs:73-93`). **A naive port either drops the out-of-order packet or corrupts the ISAAC stream.** If our IsaacRandom doesn't have a search-and-stash mode, this is a latent bug waiting for any UDP loss event.
Our `WorldSession` class doc explicitly defers this work (`WorldSession.cs:29` "ACK pump, retransmit handling … deferred"). Symptoms when it's missing: any packet loss → silent state divergence, eventual desync, "purple haze" / Network Timeout drops.
**Cost:** 1-2 days. The whole pattern is in holtburger's `reliability.rs` (196 lines) plus the ISAAC search-mode in `crypto.rs:73-93`.
### 1.8 Fragment assembler TTL + outbound multi-fragment split
Two smaller correctness gaps:
- **Inbound:** Our `FragmentAssembler` has no TTL. If a multi-fragment server message loses its middle fragment, the partials sit forever. Memory leak in any long session that sees UDP loss. Holtburger's reassembler tracks completion per `(sequence, id)` and lives inside `process_fragment` in `send.rs`.
- **Outbound:** Our `GameMessageFragment.BuildSingleFragment` throws on body > 448 bytes. Anything that needs splitting (long /tells, big inventory queries, large appraisals) silently can't be sent. Note: **holtburger doesn't do outbound fragmentation either** (`send_message` always emits `count: 1`, `send.rs:298`) — they're betting on UDP-level fragmentation. So this isn't a holtburger crib; it's a hole in both. AC2D + Chorizite are the better references when we get there.
---
## 2. Confirmations — we're doing it right
Three places where the audit confirmed our existing approach matches the reference:
- **Run/walk encoding via WalkForward + HoldKey.Run/None.** Holtburger sends `forward_command = 0x45000005 (WalkForward)` for **both** walk and run; the distinction is in `forward_hold_key` (Run=2 vs None=1) and `forward_speed`. ACE upgrades server-side. Test pinning this contract: `holtburger system/tests.rs:404-428`.
- **Two-step EnterWorld** (`0xF7C8 CharacterEnterWorldRequest` → wait for `0xF7DF ServerReady``0xF657 CharacterEnterWorld`).
- **ACK on every received packet with seq > 0.** Holtburger's `recv_packet_with_addr` queues an ack for every received packet with `sequence > 0 && flags != ACK_SEQUENCE`. Outbound `send_message` auto-piggybacks the latest server seq onto the next data packet; standalone ACKs flush only when nothing naturally goes out. (Worth double-checking that our `SendAck` is called automatically on `ProcessDatagram`, not as a separate periodic pump.)
One thing **worth re-verifying** because it's easy to invert: ISAAC seeding direction. Holtburger uses `isaac_c2s = Isaac::new(crd.client_seed)` and `isaac_s2c = Isaac::new(crd.server_seed)` — i.e. the wire field labelled `client_seed` seeds the C2S keystream, and vice versa. Worth a 30-second check that our `WorldSession` does the same.
---
## 3. Don't crib these (holtburger gaps / wrong)
- **Outbound fragmentation:** holtburger doesn't do it. Hole in both projects. Use AC2D + Chorizite when needed.
- **Jump (0xF61B):** holtburger never sends Jump. The TUI client can't jump. `JumpActionData` is decoder-only. Use cdb retail trace + Chorizite.ACProtocol for jump format reference.
- **Initial run_rate_scalar fallback:** holtburger uses 4.5 (max-cap formula, run_skill ≥ 800); acdream uses 2.4-2.94 default. Retail formula: `(load_mod * (run_skill / (run_skill + 200) * 11) + 4) / 4`. The right pre-PlayerDescription default depends on what retail does — cdb trace will settle it.
- **AutoPos cadence:** holtburger's 1-second unconditional heartbeat is probably wrong (cdb retail trace says gated on motion). Don't copy this verbatim; investigate first.
---
## 4. Recent commits worth knowing (last 237)
| Commit | Date | Intent | Relevance |
|--------|------|--------|-----------|
| `99974cc` | 2026-04-06 | "Fix/session issues" — splits 673-line `lib.rs` into `session/{api,auth,receive,send,reliability,types}`. **Adds the missing C↔S retransmit logic.** Replaces `tokio::sleep(200ms)` with deferred control-packet queue. | Read this diff if you read only one. |
| `403bc98` | 2026-04-21 | "do not switch ports prematurely" (#158). Pending vs confirmed source-port. | Apply same pattern to `WorldSession`. |
| `336cbad` | 2026-04-?? | "fix: more movement fixes". `autonomous_wire_motion_state` no longer emits `turning` when locomotion ≠ 0. | Likely also a bug class in our outbound MoveToState. |
| `797aece` | 2026-04-06 | DISCONNECT now carries `id = client_id` instead of 0. | One-line fix on our `Dispose` path. |
| `854c1bb` | (older) | "Feat/simulation system" (#105) — added the entire 2222-LOC `client/movement/{common,system}.rs`. | Foundation everything else builds on. |
Nothing in 237 commits changes LoginRequest payload, ConnectRequest parse, ISAAC seeding, or EnterWorld message ordering. The wire format is unchanged from what acdream targets — the deltas are internal architecture and bug fixes.
---
## 5. Recommended sequencing
**Tier 1 — Quick wins (under an hour each, high signal-to-noise):**
1. MoveToState audit fixes (1.1.a-e) — file as `#L.X.a-e`, batch into one PR
2. LoginComplete on PlayerTeleport (1.2)
3. EchoRequest → EchoResponse reply (1.3)
4. Port-switch race fix (1.4)
5. Non-blocking handshake delay (1.5)
6. Disconnect carries client_id (`797aece` finding)
**Tier 2 — Investigation, then fix:**
7. AutoPos cadence — cdb idle trace, then converge (1.6)
8. Audit "dead outbound builders" (Phase B.4 wiring drift) — separate from holtburger but surfaced by this study
**Tier 3 — Bigger investment:**
9. Retransmit subsystem (1.7) — port `reliability.rs` wholesale, including ISAAC search-mode (1-2 days)
10. Fragment assembler TTL (1.8 inbound)
The Tier 1 group is a cohesive "post-A.5 network polish" pass — cheap, high-confidence, and several of them are likely candidates for the longstanding observer-not-perfect issue.
---
## 6. File map for cross-reference
| acdream | holtburger | Role |
|---------|-----------|------|
| `src/AcDream.Core.Net/WorldSession.cs:411-521` | `crates/holtburger-session/src/session/{api,auth}.rs` | Handshake driver |
| `src/AcDream.Core.Net/WorldSession.cs:556-924` | `crates/holtburger-core/src/client/runtime.rs:91-200` + `messages.rs` | Recv loop + dispatch |
| `src/AcDream.Core.Net/WorldSession.cs:1096-1156` | `crates/holtburger-session/src/session/send.rs` | Outbound transport (encode + ack piggyback) |
| `src/AcDream.Core.Net/Cryptography/IsaacRandom.cs` | `crates/holtburger-protocol/src/crypto.rs` | ISAAC (we likely lack `search`-mode) |
| `src/AcDream.Core.Net/Packets/PacketCodec.cs` | `session/{send,receive}.rs` + `optional_header.rs` | Encode/decode + optional header iteration |
| `src/AcDream.Core.Net/Packets/FragmentAssembler.cs` | `session/send.rs::process_fragment` | Inbound reassembly |
| `src/AcDream.Core.Net/Messages/MoveToState.cs` | `crates/holtburger-protocol/src/messages/movement/actions.rs:53-69` + `client/movement/common.rs:122-186` | MoveToState builder |
| `src/AcDream.Core.Net/Messages/AutonomousPosition.cs` | `messages/movement/actions.rs:175-189` + `system.rs:858-893` | AutoPos builder + cadence |
| **(missing)** | `crates/holtburger-session/src/session/reliability.rs` | **Retransmit machinery — entirely absent in acdream** |
---
## Method note
This study used four parallel general-purpose agents on the day-of pull (2026-05-10, holtburger HEAD `629695a`). All citations are file paths + line numbers in that exact tree. If holtburger moves forward, line numbers will drift; commit hashes (especially `99974cc`, `403bc98`, `336cbad`, `797aece`) are stable anchors.

View file

@ -0,0 +1,376 @@
# Phase A.5 — Two-tier Streaming + Horizon LOD — Cold-Start Handoff
**Created:** 2026-05-10, immediately after N.5b ship.
**Audience:** the next agent picking up streaming + horizon-LOD work.
**Purpose:** brief you on where N.5b left things, what A.5 actually has to do
to make the world look and feel great, and the load-bearing facts the
brainstorm should be informed by.
---
## TL;DR
N.5b just shipped: outdoor terrain rendering is on bindless + multi-draw
indirect via `TerrainModernRenderer`. Constant-cost dispatch as the
visible landblock count grows — radius=5 vs radius=15 are the same number
of GL calls for terrain.
**A.5's actual goal — verbatim from the user, 2026-05-09:**
> "I just want great smooth HIGH fps visuals. Should look great. As long
> as it scales and we get very high FPS"
That reframes priorities. We are NOT optimizing the inner loop at radius=5
(it's solved). We're scaling visual reach + scene density without the
client falling off a perf cliff.
**Concretely, A.5 ships three things:**
1. **Two-tier streaming.** Near tier (≤ N₁ landblocks) loads everything as
today (terrain + scenery + EnvCells + collision). Far tier (N₁ < r N)
loads terrain mesh ONLY. No scenery generation, no collision, no
entity registration for the far tier.
2. **Per-LB entity bucketing for the WB dispatcher.** Today the entity
dispatcher walks every loaded entity each frame for AABB cull —
~16K entities @ ~1µs/test = 4.3ms/frame, dominating the frame budget.
Bucket entities by landblock so the cull is hierarchical: cull the LB
first, then only walk entities inside surviving LBs.
3. **Off-thread mesh build.** `LandblockMesh.Build` currently runs on the
render thread when a new LB streams in. At today's radius=5 this is
invisible; at A.5's higher N₂ it becomes a visible frame-time spike
when 4-5 LBs stream simultaneously. Move the build to a worker pool;
hand finished `LandblockMeshData` back via a queue.
The headline win you're shooting for: **radius=15 sustains the user's
target FPS in Holtburg with no streaming hitches.**
---
## Where N.5b left things
### Branch state (relative to main)
After N.5b ships:
- N.5b SHIP at `08b7362` (final commit; appended SHIP record to plan)
- Roadmap entry, issue #51 closure, perf baseline doc all in place at `083c10c`
- Legacy `TerrainChunkRenderer` + `TerrainRenderer` + `terrain.vert/.frag`
deleted at `7dfa2af`. **The modern path is the only path.**
### Captured perf baseline (load-bearing for A.5's "what's actually hot")
From `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`, measured
2026-05-09 at Holtburg town dueling field, radius=5, ~30s standstill:
| Subsystem | cpu_us median per frame | Notes |
|---|---|---|
| **Entity dispatcher** (`WbDrawDispatcher`) | **~4,300** | 86% of frame budget. ~16K entities walked for AABB cull. THIS is the bottleneck. |
| Terrain dispatcher (`TerrainModernRenderer`) | ~6.4 | <1% of frame. Constant-cost regardless of radius (proved in N.5b). |
| Everything else (sky, particles, ImGui, swap, audio) | ~700 | Small. |
**Actual FPS at radius=5 in Holtburg: ~200 fps** (frame time ≈ 5ms).
NOT the "810 fps" inferred from the N.5 ship doc (that was 1/dispatcher_ms,
which is only the WB dispatcher CPU cost in isolation, not real frame time).
### What naive radius increase does
If you simply raised `ACDREAM_STREAM_RADIUS` to 15 today without A.5:
- Loaded landblocks: 121 → ~961 (8× more). Acceptable.
- Loaded entities: ~16K → ~125K (linear scaling with LB count). **NOT
acceptable.** At ~1µs per AABB cull, the entity dispatcher would take
~125ms/frame = 8 FPS. Slideshow.
- Memory footprint: similar 8× explosion in scenery instance buffers.
So the perf cliff is real and immediate. A.5 has to address it BEFORE
the radius can be safely raised.
### What N.5b set up that A.5 inherits
- **Modern terrain dispatcher.** `TerrainModernRenderer` is O(1) GL calls
in radius. As you add far-tier LBs (terrain only), the terrain
dispatcher cost stays flat (~6µs/frame). This is the one subsystem
that doesn't need any A.5 work — it just scales.
- **Slot allocator for terrain GPU buffers.** Already grows by power-of-two
doubling. Will absorb radius=15 (~961 slots × ~15 KB each = ~14 MB)
without manual tuning.
- **`[TERRAIN-DIAG]` instrumentation.** Reports per-frame median + p95 in
microseconds. Use this to confirm A.5 doesn't regress terrain perf.
- **Conformance sentinel.** `TerrainModernConformanceTests` proves visual
mesh Z agrees with `TerrainSurface.SampleZFromHeightmap` to 0.015 mm.
Don't break this — physics ↔ visual agreement must hold across both
tiers.
- **Bindless atlas.** `TerrainAtlas.GetBindlessHandles()`. The far tier
shares the atlas (it's region-wide). Zero atlas-related per-LB cost.
---
## The brainstorm questions (the hard calls A.5 has to make)
These are the questions to resolve in the brainstorm step. Bring them to
the user with options + recommendation; don't prejudge.
### 1. Tier radii: what are N₁ and N₂?
- **N₁** = near-tier radius (everything loads). Today's default `STREAM_RADIUS`.
Probably stays at 5 (or maybe 4; maybe 3).
- **N₂** = far-tier radius (terrain mesh only). Could be 8, 12, 15, 20.
Tradeoffs: bigger N₂ = more world visible = looks better. But each far-tier
LB still costs ~16 KB GPU memory + a frustum cull AABB + a slot allocation.
At N₂=15, that's ~961 LBs × 16 KB = ~15 MB GPU mem (cheap) + ~961 cull
tests (cheap, ~1ms total at 1µs each — and we'll do this per-LB cull
anyway as part of #2 below).
Verify against retail: cdb attach + check how many landblocks retail keeps
loaded at a given vantage point. Probably around 10-12 per the AC2D
references and the holtburger client's behavior.
### 2. Far tier: terrain only? Or also impostor scenery?
Two options:
- **Terrain only** (cleanest). Beyond N₁, no trees, no rocks. Skyline is the
terrain mesh against the sky.
- **Impostor scenery** (more retail-like). Beyond N₁, generate flat
billboards or low-poly trees instead of full meshes. Adds substantial
complexity (billboard pipeline, mesh-LOD generation, per-camera-angle
rotation).
Recommendation: start with terrain-only. Add impostors only if the
horizon looks wrong (too bare). Retail definitely has SOME distant
scenery but the cutoff is gradual; we can match it later if needed.
### 3. Entity bucketing structure
Today: `WbDrawDispatcher` keeps a flat dictionary of all entities and
walks all of them per frame. To bucket by LB, we need:
- A `Dictionary<uint, List<EntityHandle>>` keyed by landblock ID
- On `AddEntity(...)`, also stash it in the LB bucket (the spawn flow
already knows the LB context)
- On `RemoveEntity(...)`, remove from the LB bucket too
- Per frame: cull at LB granularity first; then cull entities only inside
surviving LBs
LB-level AABBs are already computed (per the existing `_visibleSlots`
logic in `TerrainModernRenderer` — the same AABB applies to entities,
modulo a Z-range bump for trees/buildings).
Open question: do entities outside a known LB exist? (Items dropped on the
ground? Ephemeral effects? Player projectiles?) If yes, they need a
fallback "unknown LB" bucket that's still walked every frame. Probably
small.
### 4. Where does the off-thread mesh build land?
Today `LandblockMesh.Build` runs synchronously inside `OnLandblockLoaded`
on the render thread. To move it off:
- `StreamingLoader` worker thread (already async for dat reads) signals
"LB X is ready"
- A new worker pool consumes that signal, builds the mesh on a worker
thread, posts the finished `LandblockMeshData` to a `ConcurrentQueue`
- Render thread drains the queue at the start of each frame, calling
`_terrain.AddLandblock(...)` for each ready mesh
Gotcha: the `TerrainBlendingContext` is shared. Need to confirm it's
read-only (it is — built once at startup). Also `_surfaceCache`
currently a plain `Dictionary` populated lazily by `TerrainBlending.BuildSurface`.
Either lock it, replace with `ConcurrentDictionary`, or pre-populate with
all known palCodes at startup.
### 5. Streaming hysteresis at the tier boundary
When the player crosses N₁ → near-tier shrinks, far-tier grows.
LBs that were near-tier need to:
- Drop their scenery (unregister entities)
- Drop their EnvCells
- Keep the terrain mesh (still in far tier)
When the player crosses back: the LB needs scenery + EnvCells re-loaded.
Hysteresis (don't churn at the exact boundary) is needed.
The streaming loader already has hysteresis for full LB load/unload. A.5
extends that: a separate hysteresis radius for the scenery/entity layer.
### 6. Visual quality wins to ride along
A.5 is the natural place to land 2-3 nearly-free quality wins:
- **Mipmapped terrain atlas + anisotropic 16x.** Today the atlas is
`GL_LINEAR` no mipmaps; distant terrain shimmers. ~half-day fix.
Big visible improvement at far tier.
- **Tree alpha-test → alpha-to-coverage with MSAA.** Today tree edges are
binary cutoff and pixel-edged. A2C with MSAA fixes them. ~one day.
- **Correct depth-write for transparent foliage.** Some scenery passes
may be writing depth incorrectly; confirm + fix.
These are not strictly required for A.5 to ship, but they amplify the
"looks great" payoff.
### 7. Acceptance metrics
The user's goal is "smooth + high FPS + great-looking + scales." Pin
this concretely:
- Target FPS at radius (whatever final N₁ + N₂): ≥ user's monitor refresh
(probably 144 or 240 Hz). Capture before/after numbers in a perf
baseline doc parallel to N.5b's.
- No frame-time spikes > 5ms during streaming (record a 60-second
trace running through Holtburg → North Yanshi).
- Visual horizon visible at the new N₂. Capture screenshots from the
same vantage point at the start of A.5 (before) and at ship (after)
for the SHIP record.
### 8. What's NOT in A.5
A.5 does not need to ship:
- GPU-side culling (compute-shader cull). Bigger lift; N.6 territory.
- Persistent-mapped indirect buffer. N.6 territory.
- Sky / particles / EnvCells migration. Separate N.7+ phases.
- Shadow mapping. Separate visual phase.
Don't let scope creep pull these in.
---
## Files to read before brainstorming
In rough order of relevance:
1. **`docs/research/2026-05-09-phase-n5b-handoff.md`** — N.5b's handoff
(read for context on what was just shipped + the structure of these
handoff docs).
2. **`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`** — captured
perf numbers + the architectural reasoning for what A.5 inherits.
3. **`memory/project_phase_n5b_state.md`** — three high-value gotchas
captured during N.5b (especially #1: bindless uniform-sampler driver
quirk; A.5 won't directly need this, but it's the prior art for any
new shader code in the phase).
4. **`docs/plans/2026-04-11-roadmap.md`** A.5 entry — the original A.5
description.
5. **The streaming loader**`src/AcDream.Core/World/StreamingLoader.cs`
(or wherever it lives; grep for `OnLandblockLoaded`). Understand the
existing ring + hysteresis logic before extending it.
6. **WB dispatcher entity flow**
`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` lines covering
`Draw` (the per-entity walk) and `EntitySpawnAdapter` (where entities
get registered). The bucketing change lands here.
7. **`LandblockMesh.Build`** — `src/AcDream.Core/Terrain/LandblockMesh.cs`.
Its inputs (heightmap, ctx, surfaceCache) determine what the worker
thread needs. ~150 lines.
8. **WB's `SceneryRenderManager`**
`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/SceneryRenderManager.cs`.
Has a render-distance cap; informs N₁ vs N₂ defaults.
9. **`TerrainModernRenderer`** —
`src/AcDream.App/Rendering/TerrainModernRenderer.cs`. Don't modify;
confirm the slot allocator handles radius=15 cleanly.
---
## Acceptance criteria for the whole phase
1. Build green; existing tests stay green; N.5b's conformance sentinel
still passes (visual mesh Z = TerrainSurface Z within 1mm).
2. **Far-tier LBs render terrain visibly past N₁** in user-driven visual
verification.
3. **Per-frame entity-dispatcher cpu_us at radius=N₁ drops** vs today
(the bucketing should help even at the current radius).
4. **Per-frame entity-dispatcher cpu_us at radius (N₁+N₂) is bounded**
— does NOT scale linearly with total loaded LBs. Specifically:
bucketed cull should be < 1.5× today's cost despite far-tier LBs
loading.
5. **No streaming hitch > 5ms** when running at run-speed across N₁/N₂
tier boundaries simultaneously (capture a 60s trace).
6. **`[TERRAIN-DIAG]` cpu_us stays flat** as N₂ grows — the terrain
dispatcher proven O(1) (regression check).
7. Visual identity at near-tier (no scenery missing inside N₁; no
z-fighting; no cell-boundary wobble — N.5b sentinel still applies).
8. SHIP record + perf baseline + memory entry written, mirroring N.5b's
pattern.
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Read `docs/research/2026-05-09-phase-n5b-handoff.md` for the structural
pattern.
3. Read `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` for the captured
numbers A.5 inherits.
4. Read `memory/project_phase_n5b_state.md` for gotchas.
5. Verify build is green: `dotnet build`.
6. Verify N.5b ship is intact: `dotnet test --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless"` (target ≥114 passing, 0 failures).
7. Capture a baseline radius=5 frame trace yourself (one launch, 30s
standstill at Holtburg dueling field) so you have a "before" number
in your own measurement environment, not just trusting N.5b's number.
8. Invoke `superpowers:brainstorming` with the user. Walk through the
8 brainstorm questions above. Present each with options + my
recommendation; don't prejudge.
9. After agreement, write the spec; then the plan; then execute
task-by-task using `superpowers:subagent-driven-development`.
Don't skip the brainstorm. The N₁/N₂ values, the bucketing structure
trade-offs, and the worker-thread design are real decisions with
downstream consequences that need user input — not "the agent makes a
call and goes."
---
## Things to NOT do
- **Don't raise `ACDREAM_STREAM_RADIUS` without A.5's tiered loading
in place.** The entity-cull cliff is immediate and severe (8 FPS at
naive radius=15).
- **Don't put scenery in the far tier just to "look more retail" without
a billboard/impostor pipeline.** Full-detail scenery in the far tier
is what causes the cull cliff.
- **Don't move `LandblockMesh.Build` to a worker thread without first
auditing `TerrainBlendingContext` + `_surfaceCache` for thread
safety.** Concurrent writes to the surfaceCache will produce
silently-wrong terrain blending.
- **Don't break the N.5b conformance sentinel.** If A.5 changes how
meshes are built (e.g., for the worker thread), the conformance
test must still pass — it's the load-bearing physics ↔ visual Z
agreement guard.
- **Don't bundle GPU-side culling, persistent-mapped buffers, or shadow
mapping into A.5.** Those are N.6+ territory; A.5 is "make the world
look big and not stutter."
- **Don't ship without honest perf numbers.** If A.5 doesn't actually
hit its FPS target, document why and ship N.6 next instead of
papering over it. The N.5b precedent is honest reporting.
- **Don't skip the visual verification gate.** Same lesson from N.5b's
black-terrain regression: "go" doesn't mean "verified." User must
actually launch the client at radius=N₂ and confirm the horizon
looks great + FPS hits target.
---
## Reference: where the FPS budget actually goes today
For brainstorming purposes, the per-frame breakdown at radius=5 / Holtburg
(real measurement, 2026-05-09):
```
~5,000 µs total frame time (= 200 fps)
├── 4,300 µs WbDrawDispatcher entity cull + dispatch ← THE BOTTLENECK
│ ~16K entity AABB tests / frame
│ A.5's entity bucketing attacks this directly
├── 6 µs TerrainModernRenderer
│ O(1) in radius. Won't grow with A.5. Already solved.
├── ~700 µs Sky, particles, ImGui, audio, swap-buffers, misc
│ Mostly fixed cost; some VSync-related
└── rest GPU side (we don't measure this — query plumbing
deferred to N.6). Could be substantial.
```
The first action of A.5 is to recognize that the perf claim "810 fps"
from N.5 was misleading. Don't repeat the mistake — measure the actual
frame time, not just one subsystem.
---
Good luck. The phase is meaty (~2 weeks) but the structural work is
well-shaped: tiered streaming has clear boundaries, entity bucketing is
an isolated dispatcher change, off-thread mesh build is a well-understood
worker pattern. The hard call is the N₁/N₂ values, and that's a
brainstorm question — bring it to the user with data.

View file

@ -0,0 +1,543 @@
# Phase M — Network Opcode Coverage Matrix
**Date:** 2026-05-10
**Status:** Initial population complete (4 parallel research agents). Spot-check pass + intentional-divergence ratification owed before M.1 closes.
**Companion spec:** [`docs/superpowers/specs/2026-05-10-phase-m-network-stack-design.md`](../superpowers/specs/2026-05-10-phase-m-network-stack-design.md)
**Companion study:** [`docs/research/2026-05-10-holtburger-network-stack-study.md`](2026-05-10-holtburger-network-stack-study.md)
This matrix is the **source of truth for Phase M completeness**. Every row defines: what the opcode is, who currently sends/receives it across our three reference sources, what acdream does today, and what Phase M must do. The spec's M.6 work plan reduces to "for every row where `acdream today``Phase M target`, implement the delta and add tests."
---
## Roll-up
| Section | In-scope | Acdream today | Phase M delta |
|---------|----------|---------------|---------------|
| 1 — Transport flags | 22 | 14 parse / 5 build | 8 |
| 2 — Optional-header fields | 12 | 10 partial | builder + decoder gaps |
| 3 — GameMessage opcodes (top-level) | 51 | 21 implemented | 30 |
| 4 — GameEvent sub-opcodes (inside 0xF7B0) | 103 | 27 parsed / 26 wired | 76 new (~50 deferable to gameplay phases) |
| 5 — GameAction sub-opcodes (inside 0xF7B1) | 96 | 24 built / 8 live callers | 72 new + 16 dead-builder wirings |
| **Total** | **~284** | **~96** | **~190** |
Roughly **34% complete by raw opcode count.** The biggest single Phase-M unblocking step is wiring the 16 dead builders in section 5 (Phase B.4 surface — Use / UseWithTarget / Allegiance / Inventory / Social / Cast / Appraise / etc.).
---
## Cell-value vocabulary
| Code | Meaning |
|------|---------|
| `P` | Parses inbound |
| `B` | Builds outbound |
| `PB` | Parses + builds (both directions) |
| `W` | Wired — typed handler exists AND state is updated by it |
| `H` | (ACE only) Server has a handler that processes this client-sent opcode |
| `` | Not implemented |
| `N/A` | Not applicable for this side (e.g., server-only message in ACE column) |
| `?` | Could not determine — needs verification |
**Phase M target column:**
| Target | Meaning |
|--------|---------|
| `PB+W` | Must parse, build (if outbound), wire to typed event by phase end |
| `PB` | Must parse + build, no wiring required |
| `P+W` | Inbound only, must parse + dispatch typed event |
| `B+W` | Outbound only, must build + have a live caller |
| `B` | Build only, no live caller required (typed for future use) |
| `defer:<phase>` | Explicitly deferred to a named gameplay phase |
| `skip:<reason>` | Out of scope, with justification |
---
## Section 1 — Transport flags
In-scope: 22. Implemented in acdream: 14 (parse path + 5 build path). Phase M target delta: 8 (4 inbound parse gaps to wire, 4 outbound builders, plus 6 to retire/skip-justify).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| `0x00000000` | N/A | None | | N/A | N/A | N/A | N/A | Identity flag value [^t-a] |
| `0x00000001` | both | Retransmission | | P (set on retx) | PB+W | | PB+W | We never echo/honor this bit [^t-b] |
| `0x00000002` | both | EncryptedChecksum | `FlowQueue::EncryptChecksum` | PB | PB+W | PB+W | PB+W | Codec covers in/out + ISAAC |
| `0x00000004` | both | BlobFragments | `MessageFragment` group | PB+W | PB+W | PB+W | PB+W | Fragment list parsed/built |
| `0x00000100` | inbound | ServerSwitch | `ClientNet::HandleServerSwitch` | P (size-skip) | PB | P (size-skip) | P+W | Handler missing; just consumes 8 bytes |
| `0x00000200` | inbound | LogonServerAddr | | | | | defer:M2 | Login-server bounce; no client logic yet [^t-c] |
| `0x00000400` | inbound | EmptyHeader1 | `CEmptyHeader<0x400,2>` | | | | skip:dead-flag | Retail struct exists, never sent |
| `0x00000800` | inbound | Referral | `ClientNet::HandleReferral` | | B only (server) | | defer:M2 | Server-only path until login bounce |
| `0x00001000` | both | RequestRetransmit | `FlowQueue::TransmitNaks` | PB+W | PB+W | P (size-skip) | PB+W | NAK list parsed but ignored [^t-d] |
| `0x00002000` | both | RejectRetransmit | `FlowQueue::EnqueueEmptyAck` | P+W | PB | P (size-skip) | P+W | Inbound only (server tells us "no") |
| `0x00004000` | both | AckSequence | `FlowQueue::EnqueueAcks` | PB+W | PB+W | PB+W | PB+W | Per-packet ack pump shipped |
| `0x00008000` | both | Disconnect | `Client::Disconnect` | | P+W | B only | P+W | Inbound parse-and-tear-down missing [^t-e] |
| `0x00010000` | outbound | LoginRequest | `ClientNet::SendLoginRequest` | B | P+W | B | B | Auth-only, parsed by server [^t-f] |
| `0x00020000` | inbound | WorldLoginRequest | `CEmptyHeader<0x20000,1>` | | P (8 bytes) | P (size-skip) | P (size-skip) | Server-only on relay [^t-g] |
| `0x00040000` | inbound | ConnectRequest | `ClientNet::HandleConnectionRequest` | P+W | B | P+W | P+W | Handshake oracle, ISAAC seeded |
| `0x00080000` | outbound | ConnectResponse | `ClientNet::SendConnectAck` | B | P+W | B | B | 8-byte cookie echo |
| `0x00100000` | inbound | NetError | `NetError::UnPack` | | | | P+W | Drop session + surface error to UI |
| `0x00200000` | inbound | NetErrorDisconnect | `NetError::UnPack` | | P+W | | P+W | Same parse, hard-disconnect variant |
| `0x00400000` | inbound | CICMDCommand | | P (size-skip) | P+W | P (size-skip) | defer:M3 | Server-debug only; not honored by retail clients |
| `0x01000000` | inbound | TimeSync | `ClientNet::HandleTimeSynch` | P+W | P+W | P+W | P+W | Drives `WorldTimeService` |
| `0x02000000` | inbound | EchoRequest | `CEchoRequestHeader::CreateFromData` | P+W (mirrors out) | P+W | P (no reply) | PB+W | Must build EchoResponse mirror [^t-h] |
| `0x04000000` | outbound | EchoResponse | | B | B | | B | Reply path for incoming EchoRequest |
| `0x08000000` | both | Flow | `FlowQueue::TransmitNewPackets` | P (size-skip) | P+W | P (size-skip) | defer:M2 | Throttle hint; safe to ignore until M2 |
**Footnotes:**
[^t-a]: The `None=0` value isn't a wire bit, but it's in our enum so callers can default-initialize headers — keep it.
[^t-b]: ACE sets `Retransmission` when re-sending a cached packet; clients should accept it as informational. We currently treat the bit as a no-op (works because we don't dedupe on it).
[^t-c]: A login-server-side handshake step; only relevant when ACE adds login-bounce, which it doesn't today.
[^t-d]: We need to actually retransmit on inbound NAK and need to send NAKs for our own missing inbound. M3 reliability-core phase.
[^t-e]: Inbound `Disconnect` must close the session cleanly and notify upper layers; right now the connection just times out on client side too.
[^t-f]: `LoginRequest` is a server-decode case but our codec consumes it on encode for hashing.
[^t-g]: Retail server uses this for world-server entry confirmation; the holtburger ref has no parse, ACE writer-side is `Pack`. Our consumer just skips 8 bytes for hashing.
[^t-h]: Servers do periodically EchoRequest to the client; we must mirror the 4-byte client-time as an `EchoResponse` per `FlowQueue::DequeueAck` semantics.
---
## Section 2 — Optional-header fields
In-scope: 12. Implemented in acdream: 12 of 12 sized-skip; 6 of 12 surface decoded fields. Phase M target delta: needs (a) builders for the ones we only parse, (b) ConnectRequest + EchoRequest builder paths for symmetric tests, (c) golden-vector test file.
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| `0x100` | inbound | ServerSwitch (8 bytes) | `UCServerSwitchStruct` | P (skip) | PB | P (skip) | P (decode)+W | Decode `serverIp:u32, port:u16, pad:u16` [^o-a] |
| `0x1000` | inbound | RequestRetransmit (4+N\*4) | `FlowQueue::TransmitNaks` | PB | PB | P (parsed list) | PB+W | List stored; build path missing |
| `0x2000` | inbound | RejectRetransmit (4+N\*4) | `FlowQueue::CompileEmptyAcks` | P | PB | P (size-skip) | P (decode)+W | List currently consumed without storage |
| `0x4000` | both | AckSequence (4 bytes) | `FlowQueue::EnqueueAcks` | PB | PB | PB | PB | Stored as `AckSequence:u32` |
| `0x10000` | outbound | LoginRequest (rest of pkt) | `ClientNet::SendLoginRequest` | B | P (full) | B (via `LoginRequest.Build`) | B | Variable-length tail; raw bytes hashed |
| `0x20000` | inbound | WorldLoginRequest (8 bytes) | `CEmptyHeader<0x20000,1>` | | P (8B peek) | P (size-skip) | P (decode)+W | Decode purpose unknown, store raw |
| `0x40000` | inbound | ConnectRequest (32 bytes) | `CConnectHeader` | P+W | B (server) | P+W | PB | We need encode path for round-trip tests |
| `0x80000` | outbound | ConnectResponse (8 bytes) | | B | P (8B peek) | B | PB | Decode on inbound test fixtures |
| `0x400000` | inbound | CICMDCommand (8 bytes) | | P (skip) | P (8B) | P (size-skip) | defer:M3 | Decode + handler deferred |
| `0x1000000` | inbound | TimeSync (8 bytes) | `CTimeSyncHeader` | P+W | P+W | P+W | PB | Add build for symmetry; double LE |
| `0x2000000` | inbound | EchoRequest (4 bytes) | `CEchoRequestHeader` | P+W | P+W | P (no reply) | PB+W | Wire to `SendEchoResponse` builder |
| `0x8000000` | both | Flow (6 bytes) | `UCFlowStruct` | P (skip) | P+W | P (decode) | defer:M2 | `FlowBytes:u32, FlowInterval:u16` decoded |
**Footnotes:**
[^o-a]: ServerSwitch struct layout per retail `UCServerSwitchStruct` — confirmed via named-retail symbol `?CreateFromData@?$COnePrimHeader@$0BAA@$0GA@UCServerSwitchStruct@@@@`. M3 needs the IP/port to actually re-target the socket; today we'd silently drop traffic from a relocated server.
**Cross-cutting Phase M deliverables for sections 1+2:**
1. **Goldens fixture file**`tests/AcDream.Core.Net.Tests/Packets/PacketHeaderOptionalTests.cs` does not exist; only indirect coverage via `PacketCodecTests` and `ConnectRequestTests`. M needs one fixture per non-skip flag covering parse + build symmetry.
2. **Typed events** — currently the only `WorldSession`-side flag-driven event is `ServerTimeUpdated` (from `TimeSync`). Phase M target adds: `ServerSwitchRequested(ip, port)`, `ServerDisconnect(reason)`, `ServerNetError(NetErrorCode, message)`, `EchoRequested(clientTime)` (internal), `RetransmitRequested(seqs)`, `RetransmitRejected(seqs)`.
3. **`PacketHeaderOptional` storage gaps** — `RejectRetransmit` list is consumed but discarded; `WorldLoginRequest` 8-byte body is skipped; `CICMDCommand` 8-byte body is skipped; `ConnectResponse` 8-byte cookie is decoded only inside `Connect()`'s send path, not on inbound parse. M target: lift each into a typed property on `PacketHeaderOptional`.
4. **Builder-side parity**`PacketHeaderOptional.Parse` exists; there is no `PacketHeaderOptional.Build` — every outbound flag's body bytes are hand-rolled at the call site (`SendAck`, `Connect`, `Dispose`). Phase M should add a single `Build(PacketHeaderFlags, body fields)` to mirror parse.
---
## Section 3 — GameMessage opcodes (top-level)
In-scope: 51. Implemented in acdream: 21. Phase M target delta: 30.
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
| 0x0000 | both | None | | PB | N/A | | skip:heartbeat-only | Internal/heartbeat sentinel |
| 0x0024 | inbound | InventoryRemoveObject | | P | B | | P+W | Out of bubble or destroyed |
| 0x0197 | inbound | SetStackSize | | P | B | | P+W | Container stack size delta |
| 0x019E | inbound | PlayerKilled | | PB | B | P+W | P+W | victim+killer broadcast |
| 0x01E0 | inbound | EmoteText | `CM_Communication::DispatchUI_HearEmote` | PB | B | P+W | P+W | Server-driven 3rd-person emote |
| 0x01E2 | inbound | SoulEmote | `CM_Communication::DispatchUI_HearSoulEmote` | PB | B | P+W | P+W | Complex emote w/ animation |
| 0x02BB | inbound | HearSpeech | `ClientCommunicationSystem::Handle_Communication__HearSpeech` | PB | B | P+W | P+W | Local chat |
| 0x02BC | inbound | HearRangedSpeech | `ClientCommunicationSystem::Handle_Communication__HearRangedSpeech` | PB | B | P+W | P+W | Shouts; same parser as 0x02BB |
| 0x02CD | inbound | PrivateUpdatePropertyInt | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt` | PB | B | | P+W | Owner-only int property |
| 0x02CE | inbound | PublicUpdatePropertyInt | | PB | B | | P+W | Broadcast int property |
| 0x02CF | inbound | PrivateUpdatePropertyInt64 | | PB | B | | P+W | Owner-only int64 |
| 0x02D0 | inbound | PublicUpdatePropertyInt64 | | PB | B | | P+W | Broadcast int64 |
| 0x02D1 | inbound | PrivateUpdatePropertyBool | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateBool` | PB | B | | P+W | Owner-only bool |
| 0x02D2 | inbound | PublicUpdatePropertyBool | | PB | B | | P+W | Broadcast bool |
| 0x02D3 | inbound | PrivateUpdatePropertyFloat | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateFloat` | PB | B | | P+W | Owner-only float |
| 0x02D4 | inbound | PublicUpdatePropertyFloat | | PB | B | | P+W | Broadcast float |
| 0x02D5 | inbound | PrivateUpdatePropertyString | | PB | B | | P+W | Owner-only string |
| 0x02D6 | inbound | PublicUpdatePropertyString | | PB | B | | P+W | Broadcast string |
| 0x02D7 | inbound | PrivateUpdatePropertyDataID | | PB | B | | P+W | Owner-only DataID |
| 0x02D8 | inbound | PublicUpdatePropertyDataID | | PB | B | | P+W | Broadcast DataID |
| 0x02D9 | inbound | PrivateUpdatePropertyInstanceID | `CM_Qualities::DispatchUI_PrivateUpdateInstanceID` | PB | B | | P+W | Owner-only InstanceID |
| 0x02DA | inbound | PublicUpdateInstanceID | | PB | B | | P+W | Broadcast InstanceID |
| 0x02DB | inbound | PrivateUpdatePosition | `CM_Qualities::DispatchUI_PrivateUpdatePosition` | PB | B | | defer:F.x | Owner-only position; redundant with 0xF748 |
| 0x02DC | inbound | PublicUpdatePosition | | PB | B | | defer:F.x | Public position; redundant with 0xF748 |
| 0x02DD | inbound | PrivateUpdateSkill | | PB | B | | P+W | Owner-only skill XP |
| 0x02DE | inbound | PublicUpdateSkill | | PB | B | | P+W | Public skill |
| 0x02DF | inbound | PrivateUpdateSkillLevel | | PB | B | | P+W | Owner-only skill base level |
| 0x02E0 | inbound | PublicUpdateSkillLevel | | PB | B | | P+W | Public skill base level |
| 0x02E3 | inbound | PrivateUpdateAttribute | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute` | PB | B | | P+W | Strength/Stamina/etc base |
| 0x02E4 | inbound | PublicUpdateAttribute | | PB | B | | P+W | Public attribute |
| 0x02E7 | inbound | PrivateUpdateVital | | PB | B | P+W | P+W | Max HP/Stam/Mana — vitals panel |
| 0x02E8 | inbound | PublicUpdateVital | | PB | B | | P+W | Public vital |
| 0x02E9 | inbound | PrivateUpdateAttribute2ndLevel | `ClientObjMaintSystem::Handle_Qualities__PrivateUpdateAttribute2ndLevel` | PB [^m-1] | B | P+W | P+W | Current-only vital delta |
| 0xEA60 | inbound | AdminEnvirons | `CPlayerSystem::Handle_Admin__Environs` | | B | P+W | P+W | Fog presets / sound cues |
| 0xF625 | inbound | ObjDescEvent | `SmartBox::HandleObjDescEvent` | PB | B | P+W | P+W | Per-entity appearance update |
| 0xF643 | inbound | CharacterCreateResponse | | PB | B | | defer:char-creation | Char-creation flow not yet built |
| 0xF653 | outbound | CharacterLogOff | | PB | P | B | PB+W | Sent on Dispose; ACE accepts |
| 0xF655 | both | CharacterDelete | | PB | P | | defer:char-mgmt | Char-management UI deferred |
| 0xF656 | outbound | CharacterCreate | | PB | P | | defer:char-creation | Char-creation flow not yet built |
| 0xF657 | outbound | CharacterEnterWorld | `CM_Login::SendNotice_BeginEnterWorld` [^m-2] | PB | P | B | PB+W | Built; sent during handshake |
| 0xF658 | inbound | CharacterList | `CPlayerSystem::Handle_Login__CharacterSet` | PB | B | P+W | P+W | Login char picker |
| 0xF659 | inbound | CharacterError | `CPlayerSystem::Handle_CharacterError` | PB | B | | P+W | Login/restore failures |
| 0xF6EA | both | ForceObjectDescSend | | PB | P | | defer:F.x | Server requests client re-send ObjDesc; rare |
| 0xF745 | inbound | CreateObject (ObjectCreate) | `SmartBox::HandleCreateObject` | PB | B | P+W | P+W | Spawn entity in bubble |
| 0xF746 | inbound | PlayerCreate | `SmartBox::HandleCreatePlayer` | PB | B | P+W [^m-3] | P+W | Triggers LoginComplete |
| 0xF747 | inbound | DeleteObject (ObjectDelete) | `SmartBox::HandleDeleteObject` | PB | B | P+W | P+W | Despawn |
| 0xF748 | inbound | UpdatePosition | `CM_Qualities::DispatchUI_UpdatePosition` | PB | B | P+W | P+W | Periodic position sync |
| 0xF749 | inbound | ParentEvent | `SmartBox::HandleParentEvent` | PB | B | | P+W | Equip/wield parent change |
| 0xF74A | inbound | PickupEvent | `SmartBox::HandlePickupEvent` | PB | B | | P+W | Pickup confirmation |
| 0xF74B | inbound | SetState | `SmartBox::HandleSetState` | PB | B | | P+W | Door open/close, container state |
| 0xF74C | inbound | UpdateMotion (Motion) | | PB | B | P+W | P+W | Animation cycle change |
| 0xF74E | inbound | VectorUpdate | `SmartBox::HandleVectorUpdate` | PB | B | P+W | P+W | Remote jump velocity, missile arc |
| 0xF750 | inbound | Sound | `SmartBox::HandleSoundEvent` | PB | B | | P+W | Positional sound trigger |
| 0xF751 | inbound | PlayerTeleport | `SmartBox::HandlePlayerTeleport` | PB | B | P+W | P+W | Portal/teleport screen |
| 0xF752 | inbound | AutonomyLevel | `CommandInterpreter::SetAutonomyLevel` | P [^m-4] | | | P+W | Server tells client physics-trust level |
| 0xF753 | both | AutonomousPosition | `CM_Movement::Event_AutonomousPosition` | PB | | B | PB+W | Outbound built; inbound parser missing |
| 0xF754 | inbound | PlayScript (PlayScriptId) | `SmartBox::HandlePlayScriptID` | | | P+W [^m-5] | P+W | Inline parser; lightning, spell FX, emotes |
| 0xF755 | inbound | PlayEffect | | PB | B | | P+W | Particle/visual scripts; ACE uses for PlayScript wrapper |
| 0xF7B0 | inbound | GameEvent (envelope) | | PB | B | P+W | P+W | Envelope for sub-opcodes (see §4) |
| 0xF7B1 | outbound | GameAction (envelope) | | PB | P | B+W | PB+W | Envelope for sub-opcodes (see §5) |
| 0xF7C1 | inbound | AccountBanned | | | B | | defer:F.x | ACE-only, rarely seen |
| 0xF7C8 | outbound | CharacterEnterWorldRequest | | PB | P | B | PB+W | Built; sent before 0xF657 |
| 0xF7CC | both | GetServerVersion | `Proto_UI::SendAdminGetServerVersion` | | P | | defer:F.x | Admin-only |
| 0xF7CD | both | FriendsOld | | | P | | defer:F.x | Obsolete; ACE drops it |
| 0xF7D9 | outbound | CharacterRestore | | PB | P | | defer:char-mgmt | Char-management UI deferred |
| 0xF7DB | inbound | UpdateObject | `SmartBox::HandleUpdateObject` | PB | B | | P+W | Heavy re-send of object visual+physics |
| 0xF7DC | inbound | AccountBoot | `CPlayerSystem::Handle_AccountBooted` | PB | B | | P+W | Kicked from server |
| 0xF7DE | both | TurbineChat | `CCommunicationSystem::IsUsingTurbineChat` | PB | PB [^m-6] | PB+W | PB+W | Global community chat |
| 0xF7DF | inbound | CharacterEnterWorldServerReady | | P [^m-7] | B | P+W [^m-8] | P+W | Handshake gate during enter-world |
| 0xF7E0 | inbound | ServerMessage | | PB | B | P+W | P+W | System message / announcements |
| 0xF7E1 | inbound | ServerName | `ECM_Login::SendNotice_WorldName` | PB | B | | P+W | Shard name during login |
| 0xF7E2 | both | DDD_DataMessage | | | | | defer:dat-streaming | DDD download channel (we ship dats locally) |
| 0xF7E3 | both | DDD_RequestDataMessage | | | P | | defer:dat-streaming | Client requests dat data |
| 0xF7E4 | both | DDD_ErrorMessage | | | | | defer:dat-streaming | DDD error channel |
| 0xF7E5 | inbound | DDD_Interrogation | `DDD_InterrogationMessage::Serialize` | PB [^m-9] | B | P+W | P+W | Server asks "what dat versions?" |
| 0xF7E6 | outbound | DDD_InterrogationResponse | | PB | P | B | PB+W | Built; sent in response to 0xF7E5 |
| 0xF7E7 | both | DDD_BeginDDD | | | | | defer:dat-streaming | DDD start |
| 0xF7E8 | both | DDD_BeginPullDDD | | | | | defer:dat-streaming | DDD pull start |
| 0xF7E9 | both | DDD_IterationData | | | | | defer:dat-streaming | DDD chunk iteration |
| 0xF7EA | inbound | DDD_EndDDD | | | P | | defer:dat-streaming | DDD end signal |
**Footnotes:**
[^m-1]: ACE calls 0x02E9 `PrivateUpdateAttribute2ndLevel`; holtburger calls it `PrivateUpdateVitalCurrent` (current-only delta).
[^m-2]: Retail-side trigger of the enter-world flow; the wire opcode 0xF657 is constructed from the request.
[^m-3]: PlayerCreate fires LoginComplete when guid matches own char; CreateObject body is parsed for the player too.
[^m-4]: AutonomyLevel is in holtburger's `GameMessage` enum + unpack/pack, but its enum value (0xF752) is mapped via opcode dispatch.
[^m-5]: 0xF754 PlayScript is parsed inline in `WorldSession.cs:850` (no dedicated `Messages/PlayScript.cs`); routed to `PlayScriptReceived` event for VFX runtime.
[^m-6]: ACE handles inbound TurbineChat via `TurbineChatHandler` and emits outbound via `GameMessageTurbineChat`, hence both directions.
[^m-7]: CharacterEnterWorldServerReady is unit variant in holtburger (no payload); only an opcode marker.
[^m-8]: acdream uses 0xF7DF as a handshake gate (`WorldSession.cs:495`), no dedicated parser file.
[^m-9]: DddInterrogation in holtburger is a unit variant — opcode marker only, no payload to parse.
**Caveats and unknowns:**
- `0xF7C1 AccountBanned` is in ACE's enum + has a `GameMessageAccountBanned.cs`, but holtburger has it commented out. Marked `defer` since the channel exists in retail but rarely fires.
- `0xF7CC GetServerVersion`, `0xF7CD FriendsOld`: ACE has handlers for them (i.e. accepts them inbound from a client that sends them), but no acdream sends them today. Listed as `defer`.
- `0xF619 PositionAndMovement`: holtburger documents this as a "ghost" opcode (defined but never emitted by ACE/retail). Excluded from the table — confirmed dead code per holtburger comment + grep on ACE shows no `Writer.Write` site.
- `0xF754 PlayScriptId` vs `0xF755 PlayEffect`: ACE has the `Script.cs` GameMessage tagged with `PlayEffect (0xF755)`, while retail's `SmartBox::HandlePlayScriptID` is the 0xF754 handler. acdream's inline parser at `WorldSession.cs:850` reads `[u32 opcode][u32 guid][u32 scriptId]` matching the 0xF754 layout.
---
## Section 4 — GameEvent sub-opcodes (inside 0xF7B0 envelope)
In-scope: 103. Implemented (parsed) in acdream today: 27. Wired (`W`) in acdream today: 26. Phase M target delta: 76 new parsers + ~50 deferred to later phases.
All rows are `inbound` direction (GameEvents are server→client only).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|---|---|---|---|---|---|---|---|---|
| 0x0003 | inbound | AllegianceUpdateAborted | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdateAborted` | | W | | defer:Allegiance | scope deferred — no allegiance UI yet |
| 0x0004 | inbound | PopupString | `ClientCommunicationSystem::Handle_Communication__PopUpString` | W | W | W | W | modal text → ChatLog.OnPopup |
| 0x0013 | inbound | PlayerDescription | `CPlayerSystem::Handle_PlayerDescription` | W | W | W | W | full local-player snapshot at login [^e-a] |
| 0x0020 | inbound | AllegianceUpdate | `ClientAllegianceSystem::Handle_Allegiance__AllegianceUpdate` | | W | | defer:Allegiance | needs CAllegianceProfile parser |
| 0x0021 | inbound | FriendsListUpdate | `CM_Social::SendNotice_UpdateFriendsList` | | W | | P+W | FriendDataList; small parser, high UX value |
| 0x0022 | inbound | InventoryPutObjInContainer | (CM_Inventory) | W | W | W | W | (item, container, slot) — items.MoveItem |
| 0x0023 | inbound | WieldObject | (CM_Inventory) | W | W | W | W | server-driven equip |
| 0x0029 | inbound | CharacterTitle | `CM_Social::SendNotice_AddCharacterTitle` | | W | | defer:Social | gmCharacterTitleUI |
| 0x002B | inbound | UpdateTitle | `CM_Social::SendNotice_SetDisplayCharacterTitle` | | W | | defer:Social | titles UI not yet built |
| 0x0052 | inbound | CloseGroundContainer | (gmInventoryUI) | W | W | P | P+W | parser exists, needs ItemRepository wiring |
| 0x0062 | inbound | ApproachVendor | (CM_Vendor) | W | W | | defer:VendorPanel | needs VendorProfile + ItemProfile list parser |
| 0x0075 | inbound | StartBarber | `ClientUISystem::Handle_Character__StartBarber` | | W | | defer:Barber | gmBarberUI not yet built |
| 0x00A0 | inbound | InventoryServerSaveFailed | (CM_Inventory) | W | W | P | P+W | parser exists; needs revert hook |
| 0x00A3 | inbound | FellowshipQuit | `ClientFellowshipSystem::Handle_Fellowship__Quit` | W | W | | defer:Fellowship | scope deferred — no fellowship state |
| 0x00A4 | inbound | FellowshipDismiss | `ClientFellowshipSystem::Handle_Fellowship__Dismiss` | W | W | | defer:Fellowship | scope deferred |
| 0x00B4 | inbound | BookDataResponse | `CM_Writing::Event_BookData` | W | W | | defer:Books | gmBookUI not yet built |
| 0x00B5 | inbound | BookModifyPageResponse | `CM_Writing::Event_BookModifyPage` | | W | | defer:Books | |
| 0x00B6 | inbound | BookAddPageResponse | `CM_Writing::SendNotice_BookAddPageResponse` | | W | | defer:Books | |
| 0x00B7 | inbound | BookDeletePageResponse | `CM_Writing::SendNotice_BookDeletePageResponse` | | W | | defer:Books | |
| 0x00B8 | inbound | BookPageDataResponse | `CM_Writing::SendNotice_BookPageDataResponse` | W | W | | defer:Books | |
| 0x00C3 | inbound | GetInscriptionResponse | | | W | | defer:Books | inscription on caster items |
| 0x00C9 | inbound | IdentifyObjectResponse | `ClientUISystem::Handle_Item__AppraiseDone` [^e-b] | W | W | W | W | AppraiseInfoParser feeds ItemRepository |
| 0x0147 | inbound | ChannelBroadcast | `ClientCommunicationSystem::Handle_Communication__ChannelBroadcast` | W | W | W | W | (channelId, sender, msg) → ChatLog |
| 0x0148 | inbound | ChannelList | `ClientCommunicationSystem::Handle_Communication__ChannelList` | | W | | P+W | PackableList<PStringBase>; admin/list response |
| 0x0149 | inbound | ChannelIndex | `ClientCommunicationSystem::Handle_Communication__ChannelIndex` | | W | | P+W | PackableList<PStringBase> |
| 0x0196 | inbound | ViewContents | `ClientUISystem::OnViewContents` | W | W | | P+W | server view of remote container — needed for sidepacks |
| 0x019A | inbound | InventoryPutObjectIn3D | (CM_Inventory) | W | W | P | P+W | parser exists; needs spawn-into-world wiring |
| 0x01A7 | inbound | AttackDone | | W | W | W | W | combat seq complete |
| 0x01A8 | inbound | MagicRemoveSpell | `ClientMagicSystem::Handle_Magic__RemoveSpell` | W | W | W | W | spell removed from spellbook |
| 0x01AC | inbound | VictimNotification | `ClientCombatSystem::HandleVictimNotificationEvent` | W | W | W | W | death msg for victim |
| 0x01AD | inbound | KillerNotification | `ClientCombatSystem::HandleKillerNotificationEvent` | W | W | W | W | death msg for killer |
| 0x01B1 | inbound | AttackerNotification | `ClientCombatSystem::HandleAttackerNotificationEvent` | W | W | W | W | "you hit X" |
| 0x01B2 | inbound | DefenderNotification | `ClientCombatSystem::HandleDefenderNotificationEvent` | W | W | W | W | "X hit you" |
| 0x01B3 | inbound | EvasionAttackerNotification | `ClientCombatSystem::HandleEvasionAttackerNotificationEvent` | W | W | W | W | "X evaded" |
| 0x01B4 | inbound | EvasionDefenderNotification | `ClientCombatSystem::HandleEvasionDefenderNotificationEvent` | W | W | W | W | "you evaded X" |
| 0x01B8 | inbound | CombatCommenceAttack | | W | W | W | W | empty payload |
| 0x01C0 | inbound | UpdateHealth | `CM_Combat::SendNotice_UpdateObjectHealth` | W | W | W | W | (guid, healthPct) → CombatState |
| 0x01C3 | inbound | QueryAgeResponse | `ClientCommunicationSystem::Handle_Character__QueryAgeResponse` | | W | | P | small string parser; chat panel display |
| 0x01C7 | inbound | UseDone | `ClientUISystem::Handle_Item__UseDone` | W | W | P | P+W | parser exists; needs InteractionState wiring |
| 0x01C8 | inbound | AllegianceUpdateDone | | | W | | defer:Allegiance | |
| 0x01C9 | inbound | FellowshipFellowUpdateDone | `ClientFellowshipSystem::Handle_Fellowship__FellowUpdateDone` | W | W | | defer:Fellowship | empty payload |
| 0x01CA | inbound | FellowshipFellowStatsDone | `ClientFellowshipSystem::Handle_Fellowship__FellowStatsDone` | W | W | | defer:Fellowship | empty payload |
| 0x01CB | inbound | ItemAppraiseDone | `ClientUISystem::Handle_Item__AppraiseDone` | | W | | P | post-IdentifyObjectResponse signal |
| 0x01E2 | inbound | Emote | `ClientCommunicationSystem::Handle_Communication__HearEmote` [^e-c] | | W | | P | "*X waves*" — chat broadcast |
| 0x01EA | inbound | PingResponse | `ClientUISystem::Handle_Character__ReturnPing` | W | W | P | P+W | parser exists; needs latency/heartbeat wiring |
| 0x01F4 | inbound | SetSquelchDB | `ClientCommunicationSystem::Handle_Communication__SetSquelchDB` | | W | | defer:SquelchUI | SquelchDB blob; ignore-list state |
| 0x01FD | inbound | RegisterTrade | `ClientTradeSystem::Handle_Trade__Recv_RegisterTrade` | W | W | | defer:TradePanel | (guid, accepterGuid, ackTimer) |
| 0x01FE | inbound | OpenTrade | `ClientTradeSystem::Handle_Trade__Recv_OpenTrade` | W | W | | defer:TradePanel | initiator guid |
| 0x01FF | inbound | CloseTrade | `ClientTradeSystem::Handle_Trade__Recv_CloseTrade` | W | W | | defer:TradePanel | closer guid |
| 0x0200 | inbound | AddToTrade | `ClientTradeSystem::Handle_Trade__Recv_AddToTrade` | W | W | P | defer:TradePanel | parser exists; needs TradeState |
| 0x0201 | inbound | RemoveFromTrade | `ClientTradeSystem::Handle_Trade__Recv_RemoveFromTrade` | | W | | defer:TradePanel | (initiatorGuid, itemGuid) |
| 0x0202 | inbound | AcceptTrade | `ClientTradeSystem::Handle_Trade__Recv_AcceptTrade` | W | W | P | defer:TradePanel | parser exists |
| 0x0203 | inbound | DeclineTrade | `ClientTradeSystem::Handle_Trade__Recv_DeclineTrade` | W | W | | defer:TradePanel | initiator guid |
| 0x0205 | inbound | ResetTrade | `ClientTradeSystem::Handle_Trade__Recv_ResetTrade` | W | W | | defer:TradePanel | reset to-trade list |
| 0x0207 | inbound | TradeFailure | `ClientTradeSystem::Handle_Trade__Recv_TradeFailure` | W | W | P | defer:TradePanel | parser exists |
| 0x0208 | inbound | ClearTradeAcceptance | `ClientTradeSystem::Handle_Trade__Recv_ClearTradeAcceptance` | W | W | | defer:TradePanel | empty payload |
| 0x021D | inbound | HouseProfile | `ClientHousingSystem::Handle_House__Recv_HouseProfile` | | W | | defer:Housing | HouseProfile blob |
| 0x0225 | inbound | HouseData | `ClientHousingSystem::Handle_House__Recv_HouseData` | | W | | defer:Housing | HouseData blob |
| 0x0226 | inbound | HouseStatus | `ClientHousingSystem::Handle_House__Recv_HouseStatus` | | W | | defer:Housing | scalar status code |
| 0x0227 | inbound | UpdateRentTime | `ClientHousingSystem::Handle_House__Recv_UpdateRentTime` | | W | | defer:Housing | i32 timestamp |
| 0x0228 | inbound | UpdateRentPayment | `ClientHousingSystem::Handle_House__Recv_UpdateRentPayment` | | W | | defer:Housing | HousePaymentList |
| 0x0248 | inbound | HouseUpdateRestrictions | `ClientHousingSystem::Handle_House__Recv_UpdateRestrictions` | | W | | defer:Housing | RestrictionDB blob |
| 0x0257 | inbound | UpdateHAR | `ClientHousingSystem::Handle_House__Recv_UpdateHAR` | | W | | defer:Housing | HAR blob |
| 0x0259 | inbound | HouseTransaction | `ClientHousingSystem::Handle_House__Recv_HouseTransaction` | | W | | defer:Housing | scalar txn code |
| 0x0264 | inbound | QueryItemManaResponse | `ClientUISystem::Handle_Item__QueryItemManaResponse` | W | W | P | P+W | parser exists; needs ItemRepository wiring |
| 0x0271 | inbound | AvailableHouses | `ClientHousingSystem::Handle_House__Recv_AvailableHouses` | | W | | defer:Housing | PackableList<u32> + flag |
| 0x0274 | inbound | CharacterConfirmationRequest | `ClientUISystem::Handle_Character__ConfirmationRequest` | W | W | P | P+W | parser exists; needs modal-confirm wiring |
| 0x0276 | inbound | CharacterConfirmationDone | `ClientUISystem::Handle_Character__ConfirmationDone` | W | W | | P+W | (type, contextId); confirms client ACK |
| 0x027A | inbound | AllegianceLoginNotification | `ClientAllegianceSystem::Handle_Allegiance__AllegianceLoginNotificationEvent` | | W | | defer:Allegiance | (guid, login/logout flag) |
| 0x027C | inbound | AllegianceInfoResponse | `ClientAllegianceSystem::Handle_Allegiance__AllegianceInfoResponseEvent` | | W | | defer:Allegiance | CAllegianceProfile |
| 0x0281 | inbound | JoinGameResponse | `ClientMiniGameSystem::Handle_Game__Recv_JoinGameResponse` | | W | | defer:MiniGame | chess/dice/etc — minimal value |
| 0x0282 | inbound | StartGame | `ClientMiniGameSystem::Handle_Game__Recv_StartGame` | W | W | | defer:MiniGame | empty payload |
| 0x0283 | inbound | MoveResponse | `ClientMiniGameSystem::Handle_Game__Recv_MoveResponse` | | W | | defer:MiniGame | minigame move ack |
| 0x0284 | inbound | OpponentTurn | `ClientMiniGameSystem::Handle_Game__Recv_OpponentTurn` | | W | | defer:MiniGame | GameMoveData blob |
| 0x0285 | inbound | OpponentStalemate | `ClientMiniGameSystem::Handle_Game__Recv_OppenentStalemateState` | | W | | defer:MiniGame | typo preserved (retail name) |
| 0x028A | inbound | WeenieError | `ClientCommunicationSystem::Handle_Communication__WeenieError` | W | W | W | W | error code → ChatLog.OnWeenieError |
| 0x028B | inbound | WeenieErrorWithString | `ClientCommunicationSystem::Handle_Communication__WeenieErrorWithString` | W | W | W | W | (code, interp) → ChatLog |
| 0x028C | inbound | GameOver | `ClientMiniGameSystem::Handle_Game__Recv_GameOver` | | W | | defer:MiniGame | (gameId, winner) |
| 0x0295 | inbound | SetTurbineChatChannels | `ClientCommunicationSystem::Handle_Communication__Recv_ChatRoomTracker` [^e-d] | W | W | W | W | per-room ids → TurbineChatState |
| 0x02AE | inbound | AdminQueryPluginList | (admin tooling) | | W | | skip:admin-only | server-admin path; not retail-emitted to player |
| 0x02B1 | inbound | AdminQueryPlugin | | | W | | skip:admin-only | |
| 0x02B3 | inbound | AdminQueryPluginResponse | | | W | | skip:admin-only | |
| 0x02B4 | inbound | SalvageOperationsResult | `ClientUISystem::Handle_Inventory__Recv_SalvageOperationsResultData` | | W | | defer:SalvageUI | SalvageOperationsResultData blob |
| 0x02BD | inbound | Tell | (CM_Communication) | W | W | W | W | direct whisper → ChatLog |
| 0x02BE | inbound | FellowshipFullUpdate | `ClientFellowshipSystem::Handle_Fellowship__FullUpdate` | W | W | | defer:Fellowship | CFellowship blob |
| 0x02BF | inbound | FellowshipDisband | `ClientFellowshipSystem::Handle_Fellowship__Disband` | W | W | | defer:Fellowship | empty payload |
| 0x02C0 | inbound | FellowshipUpdateFellow | `ClientFellowshipSystem::Handle_Fellowship__UpdateFellow` | W | W | | defer:Fellowship | (memberGuid, Fellow, flag) |
| 0x02C1 | inbound | MagicUpdateSpell | `ClientMagicSystem::Handle_Magic__UpdateSpell` | W | W | W | W | learned spellId → Spellbook |
| 0x02C2 | inbound | MagicUpdateEnchantment | `ClientMagicSystem::Handle_Magic__UpdateEnchantment` | W | W | W | W | Enchantment blob → Spellbook |
| 0x02C3 | inbound | MagicRemoveEnchantment | `ClientMagicSystem::Handle_Magic__RemoveEnchantment` | W | W | W | W | (layerId, spellId) |
| 0x02C4 | inbound | MagicUpdateMultipleEnchantments | `ClientMagicSystem::Handle_Magic__UpdateMultipleEnchantments` | W | W | | P+W | PackableList<Enchantment> |
| 0x02C5 | inbound | MagicRemoveMultipleEnchantments | `ClientMagicSystem::Handle_Magic__RemoveMultipleEnchantments` | W | W | | P+W | PackableList<u32> |
| 0x02C6 | inbound | MagicPurgeEnchantments | `ClientMagicSystem::Handle_Magic__PurgeEnchantments` | W | W | W | W | empty payload → Spellbook.OnPurgeAll |
| 0x02C7 | inbound | MagicDispelEnchantment | `ClientMagicSystem::Handle_Magic__DispelEnchantment` | W | W | W | W | shared parser w/ MagicRemoveEnchantment |
| 0x02C8 | inbound | MagicDispelMultipleEnchantments | `ClientMagicSystem::Handle_Magic__DispelMultipleEnchantments` | W | W | | P+W | PackableList<u32> |
| 0x02C9 | inbound | PortalStormBrewing | `ClientUISystem::Handle_Misc__PortalStormBrewing` | | W | | P+W | float intensity → ChatLog system message |
| 0x02CA | inbound | PortalStormImminent | `ClientUISystem::Handle_Misc__PortalStormImminent` | | W | | P+W | float intensity |
| 0x02CB | inbound | PortalStorm | `ClientUISystem::Handle_Misc__PortalStorm` | | W | | P+W | empty payload — actual storm trigger |
| 0x02CC | inbound | PortalStormSubsided | `ClientUISystem::Handle_Misc__PortalStormSubsided` | | W | | P+W | empty payload |
| 0x02EB | inbound | CommunicationTransientString | `ClientCommunicationSystem::Handle_Communication__TransientString` | W | W | W | W | (msg, chatType) → ChatLog system msg |
| 0x0312 | inbound | MagicPurgeBadEnchantments | `ClientMagicSystem::Handle_Magic__PurgeBadEnchantments` | W | W | | P+W | empty payload |
| 0x0314 | inbound | SendClientContractTrackerTable | `ClientUISystem::Handle_Social__SendClientContractTrackerTable` | | W | | defer:Quests | CContractTrackerTable blob |
| 0x0315 | inbound | SendClientContractTracker | `ClientUISystem::Handle_Social__SendClientContractTracker` | | W | | defer:Quests | (CContractTracker, flag, flag) |
**Footnotes:**
[^e-a]: PlayerDescription has its own dedicated parser (`PlayerDescriptionParser.TryParse`) rather than living in `GameEvents.cs`. Wires into `LocalPlayerState` (vitals 7/8/9), `Spellbook` (learned spells + enchantments), `ItemRepository` (inventory + equipped), and the `onSkillsUpdated` callback (Run/Jump skills for movement).
[^e-b]: IdentifyObjectResponse uses `AppraiseInfoParser.TryParse` (separate file) rather than the simple header-only parser in `GameEvents.cs`. Returns full property bundle (int / int64 / bool / float / string / DID tables) plus SpellBook list. The retail handler `Handle_Item__AppraiseDone` (0x01CB) is the post-arrival completion signal, not the data carrier itself.
[^e-c]: 0x01E2 Emote sub-opcode is distinct from `HearEmote` (top-level GameMessage 0x02BC); the sub-opcode form is documented in ACE's `GameEventType.cs` but the named-retail decomp doesn't expose a dedicated handler — likely re-routed through the chat broadcast path.
[^e-d]: Named retail's `Recv_ChatRoomTracker` is the underlying handler symbol; ACE/Holtburger renamed to `SetTurbineChatChannels` for clarity. Same wire payload (per-room session ids for General/Trade/LFG/Roleplay/Society/Olthoi/Allegiance).
---
## Section 5 — GameAction sub-opcodes (inside 0xF7B1 envelope)
In-scope: 96. Implemented (built) in acdream: 24. Live callers in acdream: 8. Phase M target delta: 72 new builders + golden-vector tests.
All rows are `outbound` direction (GameActions are client→server only).
| Code | Direction | Name | Named-retail symbol | Holtburger | ACE | acdream today | Phase M target | Notes |
|------|-----------|------|---------------------|------------|-----|---------------|----------------|-------|
| 0x0005 | outbound | SetSingleCharacterOption | | W | H | | B | Per-option toggle; sibling of 0x01A1 bitmap |
| 0x0008 | outbound | TargetedMeleeAttack | `CM_Combat::Event_TargetedMeleeAttack` | W | H | W | B+W | Wired in WorldSession.SendMeleeAttack |
| 0x000A | outbound | TargetedMissileAttack | `CM_Combat::Event_TargetedMissileAttack` | W | H | W | B+W | Wired in WorldSession.SendMissileAttack |
| 0x000F | outbound | SetAfkMode | `CM_Communication::Event_SetAFKMode` | | H | | B | Toggle AFK |
| 0x0010 | outbound | SetAfkMessage | `CM_Communication::Event_SetAFKMessage` | | H | | B | Custom AFK string |
| 0x0015 | outbound | Talk | `CM_Communication::Event_Talk` | W | H | W | B+W | Wired in WorldSession.SendTalk |
| 0x0017 | outbound | RemoveFriend | `CM_Social::Event_RemoveFriend` | | H | | B | Friends list mutation |
| 0x0018 | outbound | AddFriend | `CM_Social::Event_AddFriend` | | H | | B | Friends list mutation |
| 0x0019 | outbound | PutItemInContainer | `CM_Inventory::Event_PutItemInContainer` | W | H | | B | Inventory move; high priority |
| 0x001A | outbound | GetAndWieldItem | `CM_Inventory::Event_GetAndWieldItem` | W | H | | B | Equip item |
| 0x001B | outbound | DropItem | `CM_Inventory::Event_DropItem` | W | H | | B | Drop to ground |
| 0x001D | outbound | SwearAllegiance | `CM_Allegiance::Event_SwearAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
| 0x001E | outbound | BreakAllegiance | `CM_Allegiance::Event_BreakAllegiance` | W | H | B | B+W | AllegianceRequests dead [^a-1] |
| 0x001F | outbound | AllegianceUpdateRequest | | | H | | B | Refresh allegiance tree |
| 0x0025 | outbound | RemoveAllFriends | | | H | | B | Clear friends list |
| 0x0026 | outbound | TeleToPklArena | | W | H | | B | PK-lite arena recall |
| 0x0027 | outbound | TeleToPkArena | | | H | | B | PK arena recall |
| 0x002C | outbound | TitleSet | | | H | | B | Equip title |
| 0x0030 | outbound | QueryAllegianceName | `CM_Allegiance::Event_QueryAllegianceName` | | H | | B | |
| 0x0031 | outbound | ClearAllegianceName | `CM_Allegiance::Event_ClearAllegianceName` | | H | | B | Officer-only |
| 0x0032 | outbound | TalkDirect | `CM_Communication::Event_TalkDirect` | | H | | B | Targeted /say (rarely used) |
| 0x0033 | outbound | SetAllegianceName | `CM_Allegiance::Event_SetAllegianceName` | | H | | B | Monarch-only |
| 0x0035 | outbound | UseWithTarget | `CM_Inventory::Event_UseWithTargetEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
| 0x0036 | outbound | Use | `CM_Inventory::Event_UseEvent` | W | H | B | B+W | InteractRequests dead [^a-1] |
| 0x003B | outbound | SetAllegianceOfficer | `CM_Allegiance::Event_SetAllegianceOfficer` | | H | | B | |
| 0x003C | outbound | SetAllegianceOfficerTitle | `CM_Allegiance::Event_SetAllegianceOfficerTitle` | | H | | B | |
| 0x003D | outbound | ListAllegianceOfficerTitles | `CM_Allegiance::Event_ListAllegianceOfficerTitles` | | H | | B | |
| 0x003E | outbound | ClearAllegianceOfficerTitles | `CM_Allegiance::Event_ClearAllegianceOfficerTitles` | | H | | B | |
| 0x003F | outbound | DoAllegianceLockAction | `CM_Allegiance::Event_DoAllegianceLockAction` | | H | | B | Lock recruitment |
| 0x0040 | outbound | SetAllegianceApprovedVassal | `CM_Allegiance::Event_SetAllegianceApprovedVassal` | | | | B | |
| 0x0041 | outbound | AllegianceChatGag | `CM_Allegiance::Event_AllegianceChatGag` | | H | | B | |
| 0x0042 | outbound | DoAllegianceHouseAction | `CM_Allegiance::Event_DoAllegianceHouseAction` | | H | | B | |
| 0x0044 | outbound | RaiseVital | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0045 | outbound | RaiseAttribute | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0046 | outbound | RaiseSkill | | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0047 | outbound | TrainSkill | `CM_Train::Event_TrainSkill` | W | H | B | B+W | CharacterActions builder dead [^a-1] |
| 0x0048 | outbound | CastUntargetedSpell | `CM_Magic::Event_CastUntargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x004A | outbound | CastTargetedSpell | `CM_Magic::Event_CastTargetedSpell` | W | H | B | B+W | CastSpellRequest builder dead [^a-1] |
| 0x0053 | outbound | ChangeCombatMode | `CM_Combat::Event_ChangeCombatMode` | W | H | W | B+W | Wired in WorldSession.SendChangeCombatMode |
| 0x0054 | outbound | StackableMerge | `CM_Inventory::Event_StackableMerge` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0055 | outbound | StackableSplitToContainer | `CM_Inventory::Event_StackableSplitToContainer` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0056 | outbound | StackableSplitTo3D | `CM_Inventory::Event_StackableSplitTo3D` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x0058 | outbound | ModifyCharacterSquelch | `CM_Communication::Event_ModifyCharacterSquelch` | | H | | B | Mute one player |
| 0x0059 | outbound | ModifyAccountSquelch | `CM_Communication::Event_ModifyAccountSquelch` | | H | | B | Mute account |
| 0x005B | outbound | ModifyGlobalSquelch | `CM_Communication::Event_ModifyGlobalSquelch` | | H | | B | Mute pattern |
| 0x005D | outbound | Tell | | W | H | W | B+W | Wired in WorldSession.SendTell [^a-2] |
| 0x005F | outbound | Buy | `CM_Vendor::Event_Buy` | W | H | | B | Vendor purchase |
| 0x0060 | outbound | Sell | `CM_Vendor::Event_Sell` | W | H | | B | Vendor sell |
| 0x0063 | outbound | TeleToLifestone | `CM_Character::Event_TeleToLifestone` | W | H | B | B+W | InteractRequests builder dead [^a-1] |
| 0x00A1 | outbound | LoginComplete | `CM_Character::Event_LoginCompleteNotification` | W | H | W | B+W | Wired in GameWindow.cs:4423 |
| 0x00A2 | outbound | FellowshipCreate | `CM_Fellowship::Event_Create` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A3 | outbound | FellowshipQuit | `CM_Fellowship::Event_Quit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A4 | outbound | FellowshipDismiss | `CM_Fellowship::Event_Dismiss` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A5 | outbound | FellowshipRecruit | `CM_Fellowship::Event_Recruit` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00A6 | outbound | FellowshipUpdateRequest | `CM_Fellowship::Event_UpdateRequest` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x00AA | outbound | BookData | `CM_Writing::Event_BookData` | | H | | B | Open book contents |
| 0x00AB | outbound | BookModifyPage | `CM_Writing::Event_BookModifyPage` | | H | | B | Edit page text |
| 0x00AC | outbound | BookAddPage | `CM_Writing::Event_BookAddPage` | | H | | B | |
| 0x00AD | outbound | BookDeletePage | `CM_Writing::Event_BookDeletePage` | | H | | B | |
| 0x00AE | outbound | BookPageData | `CM_Writing::Event_BookPageData` | W | | | B | Read one page |
| 0x00B1 | outbound | TeleToPoi | | | | B | B | InventoryActions builder dead; ACE handler unclear [^a-1][^a-3] |
| 0x00BF | outbound | SetInscription | `CM_Writing::Event_SetInscription` | | | | B | Inscribe item |
| 0x00C8 | outbound | IdentifyObject | `CM_Item::Event_Appraise` | W | H | B | B+W | AppraiseRequest builder dead [^a-1] |
| 0x00CD | outbound | GiveObjectRequest | `CM_Inventory::Event_GiveObjectRequest` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x00D6 | outbound | AdvocateTeleport | | | H | | B | GM-only teleport |
| 0x0140 | outbound | AbuseLogRequest | `CM_Character::Event_AbuseLogRequest` | | | | B | Player report tool |
| 0x0145 | outbound | AddChannel | `CM_Communication::Event_ChannelList` | | H | B | B+W | SocialActions builder dead [^a-1][^a-4] |
| 0x0146 | outbound | RemoveChannel | | | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x0147 | outbound | ChatChannel | `CM_Communication::Event_ChannelBroadcast` | W | H | W | B+W | Wired in WorldSession.SendChannel; same code as inbound 0x0147 [^a-5] |
| 0x0148 | outbound | ListChannels | | | | | B | |
| 0x0149 | outbound | IndexChannels | `CM_Communication::Event_ChannelIndex` | | | | B | |
| 0x0195 | outbound | NoLongerViewingContents | `CM_Inventory::Event_NoLongerViewingContents` | W | H | | B | Container UI close |
| 0x019B | outbound | StackableSplitToWield | `CM_Inventory::Event_StackableSplitToWield` | W | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x019C | outbound | AddShortcut | `CM_Character::Event_AddShortCut` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x019D | outbound | RemoveShortcut | `CM_Character::Event_RemoveShortCut` | | H | B | B+W | InventoryActions builder dead [^a-1] |
| 0x01A1 | outbound | SetCharacterOptions | | | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x01A8 | outbound | RemoveSpellC2S | `CM_Magic::Event_RemoveSpell` | | H | | B | Self-cancel buff |
| 0x01B7 | outbound | CancelAttack | `CM_Combat::Event_CancelAttack` | W | H | W | B+W | Wired in WorldSession.SendCancelAttack |
| 0x01BF | outbound | QueryHealth | `CM_Combat::Event_QueryHealth` | W | H | B | B+W | SocialActions builder dead [^a-1] |
| 0x01C2 | outbound | QueryAge | `CM_Character::Event_QueryAge` | | H | | B | |
| 0x01C4 | outbound | QueryBirth | `CM_Character::Event_QueryBirth` | | H | | B | |
| 0x01DF | outbound | Emote | `CM_Communication::Event_Emote` | W | H | | B | Custom /e text |
| 0x01E1 | outbound | SoulEmote | `CM_Communication::Event_SoulEmote` | W | H | | B | /soulemote |
| 0x01E3 | outbound | AddSpellFavorite | `CM_Character::Event_AddSpellFavorite` | | H | | B | Spellbook pin |
| 0x01E4 | outbound | RemoveSpellFavorite | `CM_Character::Event_RemoveSpellFavorite` | | | | B | Spellbook unpin |
| 0x01E9 | outbound | PingRequest | | W | H | B | B+W | SocialActions builder dead; keepalive [^a-1] |
| 0x01F6 | outbound | OpenTradeNegotiations | `CM_Trade::Event_OpenTradeNegotiations` | W | H | | B | Begin trade |
| 0x01F7 | outbound | CloseTradeNegotiations | `CM_Trade::Event_CloseTradeNegotiations` | W | H | | B | Cancel trade |
| 0x01F8 | outbound | AddToTrade | `CM_Trade::Event_AddToTrade` | W | H | | B | Add item to trade |
| 0x01FA | outbound | AcceptTrade | `CM_Trade::Event_AcceptTrade` | W | H | | B | Confirm trade |
| 0x01FB | outbound | DeclineTrade | `CM_Trade::Event_DeclineTrade` | W | H | | B | Reject trade |
| 0x0204 | outbound | ResetTrade | `CM_Trade::Event_ResetTrade` | W | H | | B | Clear pending items |
| 0x0216 | outbound | ClearPlayerConsentList | `CM_Character::Event_ClearPlayerConsentList` | | H | | B | Resurrection consent |
| 0x0217 | outbound | DisplayPlayerConsentList | `CM_Character::Event_DisplayPlayerConsentList` | | H | | B | |
| 0x0218 | outbound | RemoveFromPlayerConsentList | `CM_Character::Event_RemoveFromPlayerConsentList` | | | | B | |
| 0x0219 | outbound | AddPlayerPermission | `CM_Character::Event_AddPlayerPermission` | W | H | | B | Storage / consent perm |
| 0x021A | outbound | RemovePlayerPermission | `CM_Character::Event_RemovePlayerPermission` | W | H | | B | |
| 0x021C | outbound | BuyHouse | `CM_House::Event_BuyHouse` | | H | | defer:Phase Q | Housing — out of M baseline scope |
| 0x021E | outbound | HouseQuery | | | H | | defer:Phase Q | Housing |
| 0x021F | outbound | AbandonHouse | `CM_House::Event_AbandonHouse` | | H | | defer:Phase Q | Housing |
| 0x0221 | outbound | RentHouse | `CM_House::Event_RentHouse` | | | | defer:Phase Q | Housing |
| 0x0224 | outbound | SetDesiredComponentLevel | | | | | B | Component-buy preference |
| 0x0245 | outbound | AddPermanentGuest | `CM_House::Event_AddPermanentGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x0246 | outbound | RemovePermanentGuest | `CM_House::Event_RemovePermanentGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x0247 | outbound | SetOpenHouseStatus | `CM_House::Event_SetOpenHouseStatus_Event` | | H | | defer:Phase Q | Housing |
| 0x0249 | outbound | ChangeStoragePermission | `CM_House::Event_ChangeStoragePermission_Event` | | H | | defer:Phase Q | Housing |
| 0x024A | outbound | BootSpecificHouseGuest | `CM_House::Event_BootSpecificHouseGuest_Event` | | H | | defer:Phase Q | Housing |
| 0x024C | outbound | RemoveAllStoragePermission | `CM_House::Event_RemoveAllStoragePermission` | | H | | defer:Phase Q | Housing |
| 0x024D | outbound | RequestFullGuestList | `CM_House::Event_RequestFullGuestList_Event` | | | | defer:Phase Q | Housing |
| 0x0254 | outbound | SetMotd | `CM_Allegiance::Event_SetMotd` | | | | B | Allegiance message-of-the-day |
| 0x0255 | outbound | QueryMotd | `CM_Allegiance::Event_QueryMotd` | | | | B | |
| 0x0256 | outbound | ClearMotd | `CM_Allegiance::Event_ClearMotd` | | H | | B | |
| 0x0258 | outbound | QueryLord | `CM_House::Event_QueryLord` | | | | defer:Phase Q | Housing |
| 0x025C | outbound | AddAllStoragePermission | `CM_House::Event_AddAllStoragePermission` | | | | defer:Phase Q | Housing |
| 0x025E | outbound | RemoveAllPermanentGuests | `CM_House::Event_RemoveAllPermanentGuests_Event` | | H | | defer:Phase Q | Housing |
| 0x025F | outbound | BootEveryone | `CM_House::Event_BootEveryone_Event` | | H | | defer:Phase Q | Housing |
| 0x0262 | outbound | TeleToHouse | `CM_House::Event_TeleToHouse_Event` | | | | defer:Phase Q | Housing |
| 0x0263 | outbound | QueryItemMana | `CM_Item::Event_QueryItemMana` | W | H | | B | Mana-meter check |
| 0x0266 | outbound | SetHooksVisibility | `CM_House::Event_SetHooksVisibility` | | H | | defer:Phase Q | Housing |
| 0x0267 | outbound | ModifyAllegianceGuestPermission | `CM_House::Event_ModifyAllegianceGuestPermission` | | | | defer:Phase Q | Housing |
| 0x0268 | outbound | ModifyAllegianceStoragePermission | `CM_House::Event_ModifyAllegianceStoragePermission` | | | | defer:Phase Q | Housing |
| 0x0269 | outbound | ChessJoin | | | H | | skip:minigame | Chess |
| 0x026A | outbound | ChessQuit | | | H | | skip:minigame | Chess |
| 0x026B | outbound | ChessMove | | | H | | skip:minigame | Chess |
| 0x026D | outbound | ChessMovePass | | | H | | skip:minigame | Chess |
| 0x026E | outbound | ChessStalemate | | | H | | skip:minigame | Chess |
| 0x0270 | outbound | ListAvailableHouses | `CM_House::Event_ListAvailableHouses` | | | | defer:Phase Q | Housing |
| 0x0275 | outbound | ConfirmationResponse | `CM_Character::Event_ConfirmationResponse` | W | H | | B | Yes/No popups |
| 0x0277 | outbound | BreakAllegianceBoot | `CM_Allegiance::Event_BreakAllegianceBoot` | | H | | B | Officer kick |
| 0x0278 | outbound | TeleToMansion | `CM_House::Event_TeleToMansion_Event` | W | | | defer:Phase Q | Housing recall |
| 0x0279 | outbound | Suicide | `CM_Character::Event_Suicide` | W | | | B | /suicide cmd |
| 0x027B | outbound | AllegianceInfoRequest | `CM_Allegiance::Event_AllegianceInfoRequest` | | H | | B | Tree info |
| 0x027D | outbound | CreateTinkeringTool / SalvageItemsWith | `CM_Inventory::Event_CreateTinkeringTool` | W | H | | B | Salvage UI [^a-6] |
| 0x0286 | outbound | SpellbookFilter | `CM_Character::Event_SpellbookFilterEvent` | | | | B | School filter |
| 0x028D | outbound | TeleToMarketPlace | | W | | | B | MP recall |
| 0x028F | outbound | EnterPkLite | | W | | | B | PK-lite toggle |
| 0x0290 | outbound | FellowshipAssignNewLeader | `CM_Fellowship::Event_AssignNewLeader` | W | H | | B | |
| 0x0291 | outbound | FellowshipChangeOpenness | `CM_Fellowship::Event_ChangeFellowOpeness` | | H | | B | |
| 0x02A0 | outbound | AllegianceChatBoot | `CM_Allegiance::Event_AllegianceChatBoot` | | | | B | Officer chat-mute |
| 0x02A1 | outbound | AddAllegianceBan | `CM_Allegiance::Event_AddAllegianceBan` | | H | | B | |
| 0x02A2 | outbound | RemoveAllegianceBan | `CM_Allegiance::Event_RemoveAllegianceBan` | | | | B | |
| 0x02A3 | outbound | ListAllegianceBans | `CM_Allegiance::Event_ListAllegianceBans` | | | | B | |
| 0x02A5 | outbound | RemoveAllegianceOfficer | `CM_Allegiance::Event_RemoveAllegianceOfficer` | | H | | B | |
| 0x02A6 | outbound | ListAllegianceOfficers | `CM_Allegiance::Event_ListAllegianceOfficers` | | | | B | |
| 0x02A7 | outbound | ClearAllegianceOfficers | `CM_Allegiance::Event_ClearAllegianceOfficers` | | | | B | |
| 0x02AB | outbound | RecallAllegianceHometown | `CM_Allegiance::Event_RecallAllegianceHometown` | | | | B | Bind to monarch lifestone |
| 0x02AF | outbound | QueryPluginListResponse | `CM_Admin::Event_QueryPluginListResponse` | | | | skip:plugin-c2s | Decal-era plugin probe |
| 0x02B2 | outbound | QueryPluginResponse | `CM_Admin::Event_QueryPluginResponse` | | | | skip:plugin-c2s | Decal-era plugin probe |
| 0x0311 | outbound | FinishBarber | `CM_Character::Event_FinishBarber` | | H | | B | Char appearance commit |
| 0x0316 | outbound | AbandonContract | `CM_Social::Event_AbandonContract` | | H | | B | Drop quest |
**Footnotes:**
[^a-1]: "Builder dead" = the byte-array builder is implemented in `src/AcDream.Core.Net/Messages/<file>.cs` but no caller in `src/AcDream.App/` or a `WorldSession.Send*` wrapper invokes it. Phase M wires these to game-state actions (UI clicks, command bus, key bindings) and adds golden-vector tests against holtburger fixtures.
[^a-2]: ACE's wire field order for Tell is `message FIRST then target` (see `ChatRequests.BuildTell` doc comment). Sept-2013 PDB has no `Event_Tell` symbol — it routes through `CM_Communication::Event_TalkDirectByName` plus a server-side rename.
[^a-3]: TeleToPoi (0x00B1) is listed in `InventoryActions.cs` but not in ACE's `GameActionType` enum. Cross-reference holtburger to confirm; may be a dead-letter opcode that retail's vendored 2013 ACE branch dropped. Verify before shipping the test vector.
[^a-4]: AddChannel (0x0145) — named-retail's matching symbol is `Event_ChannelList` (0x0148 according to retail enum), so the symbol mapping is approximate; AddChannel in pseudo-C may be unsymbolicated. Confirm by greping `acclient_2013_pseudo_c.txt` before publishing.
[^a-5]: 0x0147 ChannelBroadcast is the same numeric code in both directions (outbound GameAction = client sends to channel; inbound GameEvent = server broadcasts to channel members). Listed under outbound here per Section-5 scope; inbound version is in §4.
[^a-6]: ACE GameActionType lists 0x027D as `CreateTinkeringTool`; holtburger names the same opcode `SalvageItemsWith`. Both behaviors funnel through the salvage UI in retail. Either name is acceptable in acdream; pick one and leave the other as an alias constant.
---
## Source attribution
- **Holtburger**`references/holtburger/` at `629695a` (2026-05-10). Primary client-behavior oracle.
- **ACE**`references/ACE/Source/ACE.Server/Network/`. Server-side authority for GameMessages, GameEvents, GameActions, and accept rules.
- **Named retail decomp**`docs/research/named-retail/` (Sept 2013 EoR PDB + Binary Ninja pseudo-C). Wire-format ground truth for the 2013 client.
- **acdream current state**`src/AcDream.Core.Net/` and `src/AcDream.App/`. Inventoried by parallel agents on 2026-05-10.
## Caveats
This is the **initial population**, produced by four parallel research agents (one per opcode class) on 2026-05-10. Spot-check pass + intentional-divergence ratification is owed before M.1 closes. Specifically:
- A handful of named-retail symbol citations are tentative (marked in footnotes); spot-check by greping `acclient_2013_pseudo_c.txt` and `symbols.json`.
- Holtburger / ACE / acdream cells were determined by reading the actual code (not guessing); when an agent couldn't determine a value, it used `?`. The `?` cells need a follow-up read.
- "Dead builder" calls (rows where acdream `B` but Phase M target is `B+W`) are based on a grep for `WorldSession.Send*` patterns and `worldSession.Send` calls in `src/AcDream.App/`. Edge cases (call sites in test code, command-bus indirection) may have been missed.
- Total opcode count in scope (~284) is approximate; deduplication of cross-section codes (e.g., 0x0147 in §4 and §5) is tracked in footnotes but the headline count treats them as distinct rows.
This matrix lives on as a long-term reference. Phase M.6 implementation tracks progress against it; gameplay phases consuming Phase M will reference the rows they wire as part of their phase acceptance.

View file

@ -0,0 +1,307 @@
# Phase Post-A.5 Polish — Cold-Start Handoff
**Created:** 2026-05-10, immediately after A.5 SHIP + merge to main (`d3d78fa`).
**Audience:** the next agent picking up post-A.5 polish work.
**Purpose:** give you everything you need to start the polish phase cold, without spelunking through the A.5 session's 200+ messages.
---
## TL;DR
A.5 just shipped. Two-tier streaming is live (N₁=4 near, N₂=12 far) with a 2.3 km fog horizon, off-thread mesh build, entity dispatcher tightening, mipmaps + 16x AF, MSAA 4x + A2C foliage, depth-write audit, BUDGET_OVER diag, and a full Quality Preset system (Low/Medium/High/Ultra) with env-var overrides + F11 mid-session re-apply.
**A.5 was an enormous phase** (29 numbered tasks + T22.5 mid-execution scope add + Bug A + Bug B post-T26 fixes). Spec at `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` (~700 lines). Plan at `docs/superpowers/plans/2026-05-09-phase-a5-two-tier-streaming.md` (~2400 lines).
**Three things were intentionally deferred to this phase:**
1. **Lifestone visual missing (ISSUE #52).** The Holtburg lifestone — a known visual landmark — hasn't been rendering since earlier in A.5 development. User confirmed they noticed it earlier but didn't flag it; deferred to post-ship. **Highest user-perception value to fix.**
2. **JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54).** Bug A's fix patches at the worker output by stripping entities from far-tier `LoadedLandblock`s after the full load runs. The worker still wastes CPU on hydration + scenery generation that gets thrown away. Cleaner fix: make the worker SKIP that work for far-tier loads. ~30 min - 1 hour. **Smallest cleanup, biggest worker-thread efficiency win.**
3. **Tier 1 entity-classification cache retry (ISSUE #53).** First attempt (commit `3639a6f`, reverted at `9b49009`) cached `meshRef.PartTransform` which is mutated per frame for animated entities — froze animations. Retry needs a careful read of `AnimationSequencer` + `AnimationHookRouter` first to map ALL the per-frame mutations of MeshRef state, then design a cache that bypasses animated entities OR caches only the animation-invariant subset. **Biggest perf headroom available** — math says it should drop the entity dispatcher from 3.5ms to 1-1.5ms, hitting the spec's 2.0ms budget.
The phase is sized ~1 week if all three land cleanly. Could be longer if Tier 1's animation audit reveals something subtle.
---
## Where A.5 left things
### Branch state
- `main` is at `d3d78fa` ("Merge branch 'claude/hopeful-darwin-ae8b87' — Phase A.5 SHIP + Quality Preset system").
- A.5 SHIP commit at `9245db5` (one commit before the merge bubble).
- Roadmap entry: A.5 moved from "Phases ahead" → "Phases already shipped" table.
- CLAUDE.md "Currently in flight" updated to "Post-A.5 polish — Tier 1 retry + lifestone fix + JobKind plumbing".
### What works in A.5 (final post-fix state)
- **Two-tier streaming end-to-end:** `StreamingRegion` with `RecenterTo` returning a 5-list `TwoTierDiff` (ToLoadFar/ToLoadNear/ToPromote/ToDemote/ToUnload) with hysteresis radius+2 on both tiers; `StreamingController.Tick` routes by `LandblockStreamJobKind`; `LandblockStreamer` worker thread does dat reads + mesh build off the render thread.
- **Bug A fixed:** `LandblockStreamer.HandleJob` strips entities for `LoadFar` results before posting Loaded. Far-tier ships terrain only as the spec promised.
- **Bug B fixed:** `WalkEntities` uses `_walkScratch` field reused across frames, no per-frame List allocation.
- **Quality Preset system:** Low / Medium / High / Ultra presets with per-preset radii + MSAA + anisotropic + A2C + max-completions. 6 env-var overrides per field. F11 → Display tab dropdown for mid-session change. `DisplaySettings.Quality` persists in settings.json. `GameWindow.ReapplyQualityPreset` rebuilds the streaming pipeline for radius changes.
- **Visual quality stack:** mipmaps + 16x anisotropic on TerrainAtlas. MSAA 4x + alpha-to-coverage on foliage shader. Depth-write audit + lock-in test (5 cases).
- **Fog horizon:** FogStart = N₁ × 192m × 0.7 ≈ 538m. FogEnd = N₂ × 192m × 0.95 ≈ 2188m. Tunable via `ACDREAM_FOG_START_MULT` / `ACDREAM_FOG_END_MULT`.
- **DIAG:** `[WB-DIAG]` and `[TERRAIN-DIAG]` flag `BUDGET_OVER` when median exceeds the per-subsystem spec budget (entity 2.0ms, terrain 1.0ms).
### Final perf state at A.5 SHIP (horizon-safe Quality preset)
User hardware: AMD Radeon RX 9070 XT, 240 Hz @ 2560×1440.
Settings tested: `NEAR_RADIUS=4, FAR_RADIUS=12, MSAA=0, A2C=0, ANISOTROPIC=4, MAX_COMPLETIONS=2`.
| Subsystem | cpu_us median | cpu_us p95 |
|---|---|---|
| Entity dispatcher | ~3500 µs (3.5 ms) | ~4000 µs |
| Terrain dispatcher | ~21 µs | ~26 µs |
Total frame time math: ~4-5 ms = ~200-240 FPS at standstill. User reported "Better now" — not the 240Hz spec target but a 5× improvement from the broken pre-Bug-A state (~40 FPS).
The 1.5ms gap to the 2.0ms entity dispatcher budget is what Tier 1 closes (per ISSUE #53 + the perf-tier roadmap).
### What was NOT validated at SHIP
- **Full High preset (radius=4/12, MSAA 4x, A2C on, anisotropic 16x).** Crashed the entire OS at first attempt earlier in A.5 development. Bug A was likely the trigger (CPU dispatcher saturating + GPU command queue overflowing). With Bug A fixed, this likely works — but never re-tested. **Re-testing is part of this phase's stretch goal.**
- **Visual gate at full quality.** Same — only validated at horizon-safe settings.
- **Walking trace at any preset.** Brief walking observed but not metric-captured.
### Three high-value gotchas captured in A.5 memory
These are at `~/.claude/projects/.../memory/project_phase_a5_state.md`:
1. **Worker-side JobKind routing was the load-bearing far-tier optimization.** T13/T16 wired the controller side; the worker never branched on Kind. ~5x perf regression that wasn't caught by spec/code reviews.
2. **WalkEntities's "extract a list-producing helper" pattern is a perf antipattern.** ~480 KB / frame allocation. Implementer flagged "future N.6 optimization" in self-review; review should have caught that "future" was actually "now."
3. **Caching mutable per-frame state silently breaks animation.** Tier 1's first attempt. The "trust MeshRefs as the source of truth" comment in the dispatcher is true but misleading — MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
(Full memory entry has 5 gotchas; these three are the load-bearing ones for post-A.5.)
---
## Files to read before brainstorming
In rough order:
1. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** — A.5 spec, full design rationale + Quality Preset system (§4.10) + acceptance criteria reshape (§2). Skim for vocabulary; read §4.10 in full.
2. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** — Tier 2 (static/dynamic split) + Tier 3 (GPU compute culling) roadmap. Read for context on where Tier 1 fits in the perf optimization tower.
3. **`docs/ISSUES.md` issues #52, #53, #54** — the three deferred items in tactical-list form.
4. **`memory/project_phase_a5_state.md`** — the 5 gotchas. Critical for avoiding the same traps in this phase.
5. **`src/AcDream.App/Streaming/LandblockStreamer.cs`** — `HandleJob` is where Bug A's patch lives + where ISSUE #54's cleaner fix will go.
6. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** — `WalkEntities` + `Draw`'s inner loop. Where Tier 1's retry will operate.
7. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — the per-frame animation engine. Read this BEFORE designing Tier 1's retry. Pay specific attention to anywhere it touches `meshRef.PartTransform` or any other field that the dispatcher reads.
8. **`src/AcDream.App/Animation/AnimationHookRouter.cs`** (or similar) — the hook fan-out from animation events. Same audit reason as #7.
---
## Per-priority detail
### Priority 1 — Lifestone missing (ISSUE #52)
**Estimated effort:** 1-3 hours. Could be a 1-line fix or could surface a deeper issue.
The Holtburg lifestone is a Setup-multi-part entity (the spinning blue crystal pillar). User reports it hasn't been rendering since earlier in A.5 development. They noticed but didn't flag during the session.
Hypotheses:
- **Bug A's strip caught a near-tier entity.** The current strip in `LandblockStreamer.HandleJob` only fires when `tier == LandblockStreamTier.Far`. Holtburg's lifestone is in a near-tier LB (Holtburg's center, ~LB 0xA9B4). Should NOT have been stripped. But verify — maybe the LB's tier resolution at first-tick is wrong.
- **Earlier visual regression from a different commit.** User said it was missing in earlier runs too. Could be from N.5b, an N.5b follow-up, or even older. Requires a `git log -- docs/ISSUES.md` correlation with visible state.
- **Setup-rendering edge case.** The lifestone has unusual properties (animated rotation, particle effects on top). Maybe it's a Setup with some sub-mesh that the dispatcher's `SetupParts` walk filters out.
- **Dat-state mismatch.** The lifestone's GfxObj id might be in a part of the dat that's failing decode.
**Investigation steps:**
1. Launch the client + walk to Holtburg lifestone position.
2. Check `[WB-DIAG]` for `meshMissing` count — if non-zero, some entity's mesh isn't loading.
3. Use the cdb attach toolchain (per CLAUDE.md "Retail debugger toolchain") if needed to compare vs retail's lifestone rendering.
4. Compare to ACViewer / WorldBuilder to see if the lifestone renders there. If yes, our renderer has a regression. If no, the issue is dat-side or in shared decode logic.
5. Identify the GfxObj/Setup id for the lifestone (likely well-known retail ID; check `docs/research/named-retail/` or ACViewer reference).
6. Trace: does `_meshAdapter.TryGetRenderData(lifestoneId)` return non-null? Does the resulting `renderData.Batches` have entries?
**Acceptance:** lifestone renders correctly (visible spinning blue crystal at the Holtburg town center).
### Priority 2 — JobKind plumbing through `BuildLandblockForStreaming` (ISSUE #54)
**Estimated effort:** 30 min - 1 hour.
Currently `LandblockStreamer.HandleJob` strips entities POST-load for far-tier:
```csharp
case LandblockStreamJob.Load load:
var lb = _loadLandblock(load.LandblockId); // full load
var mesh = _buildMeshOrNull(load.LandblockId, lb);
var tier = load.Kind == LandblockStreamJobKind.LoadFar ? Far : Near;
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
{
// Strip entities — far-tier ships terrain only.
lb = new LoadedLandblock(...empty entities...);
}
_outbox.Writer.TryWrite(new Loaded(... lb, mesh ...));
break;
```
The full `_loadLandblock` does:
1. Read `LandBlock` heightmap (cheap).
2. Read `LandBlockInfo` (medium).
3. `LandblockLoader.BuildEntitiesFromInfo` (extract stabs/buildings).
4. Hydrate stab/building meshRefs (medium).
5. Run scenery generation (heavy — ~50-200 procedural entities × meshRef hydration).
6. Build interior cell entities.
For far-tier, only step 1 is needed. Steps 2-6 are wasted CPU on the worker thread.
**Refactor plan:**
1. Change the streamer's `_loadLandblock` factory to take `LandblockStreamJobKind`:
```csharp
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock;
```
2. In `GameWindow`, the factory closure branches:
```csharp
loadLandblock: (id, kind) => kind == LandblockStreamJobKind.LoadFar
? BuildLandblockHeightmapOnly(id)
: BuildLandblockForStreaming(id),
```
3. New `BuildLandblockHeightmapOnly` returns a `LoadedLandblock` with the heightmap dat record + empty entity list. Cheap — no LandBlockInfo read, no scenery generation.
4. Remove the post-load strip in `HandleJob` (no longer needed).
5. Worker-thread CPU drops measurably; horizon fill on first traversal speeds up.
**Acceptance:**
- Build green; existing 999+ tests pass.
- Streaming worker thread is measurably faster on first-traversal (the user can validate with `[WB-DIAG]` worker queue depth or just feel the responsiveness when walking into virgin region).
- Visible behavior unchanged — far tier looks the same as before.
### Priority 3 — Tier 1 entity-classification cache retry (ISSUE #53)
**Estimated effort:** ~5-7 days. Substantial because the audit step is critical.
This is the BIG perf win remaining for A.5's CPU dispatcher. Math says entity dispatcher 3.5ms → 1-1.5ms = ~300-400 FPS at standstill. Drops the dispatcher inside the spec's 2.0ms budget.
**The first attempt's failure (commit 3639a6f, reverted at 9b49009):**
Cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities, this is stable forever. For animated entities, `meshRef.PartTransform` is updated EVERY FRAME by `AnimationSequencer` to apply the current skeletal pose. The cache froze the pose.
User-visible symptoms:
- NPCs / players stop animating.
- Some buildings (likely those mistakenly in `animatedEntityIds`) draw at wrong positions.
**The retry's audit step (do this BEFORE designing the cache):**
Read `src/AcDream.Core/Physics/AnimationSequencer.cs` and trace EVERY assignment to `meshRef.PartTransform` (and any other field on `MeshRef`, `WorldEntity`, or related state that the dispatcher reads). Likely write sites:
- `AnimationSequencer.TickAnimations` per-frame skeletal pose update
- `AnimationHookRouter` for hooks like `AnimSetPose`
- Live network handlers that mutate `entity.Position` / `entity.Rotation` (T18 already migrated these to `SetPosition` for AABB invalidation; double-check)
- `EntitySpawnAdapter` for ObjDescEvent / palette swap
For each write site, decide: is this entity STATIC (write only at spawn) or DYNAMIC (write per-frame or in response to network events)?
**Cache design options after the audit:**
(a) **Static-only cache.** Only cache entities where `animatedEntityIds.Contains(entity.Id) == false`. Animated entities use today's per-frame classification path. Cleanest, but requires `animatedEntityIds` to be a stable signal (it is — `_animatedEntities` dict in GameWindow is the source).
(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` for the dispatcher's invalidation. Wire from the network layer (palette swap fires invalidation; ObjDesc event fires invalidation). More complex but might let animated entities also benefit.
(c) **Static-only + animated-bypass + diagnostic check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `meshRef.PartTransform` differs from the cached value (catches mis-classified dynamics). Belt-and-suspenders.
Recommendation: start with (a). Ship Tier 1 for static entities only. Animated path stays slow but correct. If perf gate finds the static-only Tier 1 isn't enough, escalate to (c) for safety + (b) later.
**Acceptance:**
- Build green; existing 999+ tests pass.
- 1-3 new tests covering: cache hit for static entity, cache bypass for animated entity, cache invalidation on entity remove.
- Visual gate: launch + walk Holtburg → North Yanshi at horizon-safe preset; confirm:
- Animation works (NPCs, player character animate normally)
- Buildings at correct positions
- Lifestone (still depending on Priority 1 fix) renders correctly
- No new visual regressions
- Perf gate (with `[WB-DIAG]`):
- Entity dispatcher cpu_us median drops from ~3.5ms to ≤2.0ms (matches spec budget).
- p95 stays ≤ 2.5ms.
---
## What's NOT in this phase
- **Tier 2 (static/dynamic split with persistent groups).** Separate ~2-week phase. See `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`.
- **Tier 3 (GPU compute culling).** Separate ~1-month phase. Same roadmap.
- **Full High preset crash investigation beyond casual retest.** Stretch goal: re-test the High preset with Bug A + B fixed, see if it's stable now. If it crashes, file a new issue and continue. Don't deep-dive in this phase.
- **EnvCell modern path migration, Sky/Particles modern path, Shadow mapping** — all later phases.
- **N.6 perf polish (the previously-flagged "next phase").** N.6 was the original CLAUDE.md "Currently in flight" target before A.5. Most of N.6's scope was rolled into A.5 (perf-tier work). What's left of N.6 (persistent-mapped indirect buffer, GPU-side culling) overlaps with Tier 2/3 and should be re-scoped after Tier 1 lands.
---
## Acceptance criteria (whole phase)
- All three priorities (Lifestone, JobKind, Tier 1) shipped or one is explicitly deferred with documented reasoning.
- Build green throughout. ~999+ tests pass; 8 pre-existing physics/input failures stay at 8.
- N.5b conformance sentinel intact (TerrainSlot, TerrainModernConformance, Wb*, MatrixComposition, TextureCacheBindless, SplitFormulaDivergence — all clean).
- Visual gate: lifestone renders; animation works; horizon visible at ~2.3km; smooth walking trace; no new artifacts.
- Perf gate (post-Tier-1): entity dispatcher cpu_us median ≤ 2.0ms at horizon-safe preset, ~250-300 FPS at standstill.
- Memory entry written + roadmap "shipped" row updated for the polish phase.
---
## What you'll be doing in the first 30 minutes
1. Read this handoff in full.
2. Verify build green: `dotnet build`. Verify ~999 tests pass: `dotnet test --no-build`.
3. Read `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md` §2, §4.10, §11 (deferred section).
4. Read `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` Tier 1 section.
5. Read `docs/ISSUES.md` issues #52, #53, #54 in full.
6. Read `memory/project_phase_a5_state.md` (5 gotchas).
7. Read `src/AcDream.App/Streaming/LandblockStreamer.cs` HandleJob method.
8. Read `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` Draw + WalkEntitiesInto methods.
9. Skim `src/AcDream.Core/Physics/AnimationSequencer.cs` for write-sites of `meshRef.PartTransform` (Tier 1 retry's audit prerequisite).
10. Decide: which priority to start with? Recommendation order: 1 (lifestone, fast win), 2 (JobKind, easy cleanup), 3 (Tier 1, biggest perf win + most complex).
11. Brainstorm with the user on the chosen priority before writing code.
12. Write a small spec or just the implementation if the priority is small (1 + 2 are small enough to skip a formal spec). Tier 1 (priority 3) needs a spec because of the audit + invalidation design.
Don't skip the audit step on Tier 1. The first attempt failed because of an incomplete read of the animation mutation graph; the second attempt should not repeat that.
---
## Things to NOT do
- **Don't rush Tier 1.** Audit first. Write down which entities are static vs dynamic. Write tests that specifically verify animated entities still animate after caching is enabled.
- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases with their own brainstorm + spec + plan cycles.
- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
```
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```
Expect 89+ passing, 0 failures.
- **Don't skip the visual gate.** Lifestone fix specifically requires looking at the lifestone in-game. Tier 1 retry requires confirming animation works on a moving NPC.
- **Don't delete the `_walkScratch` field** added in Bug B fix. It's load-bearing — without it, Tier 1 retry would re-introduce the per-frame allocation bug.
- **Don't re-add the `Tier1` cache that was reverted.** Start the retry with a fresh design after the animation audit. Cherry-picking the reverted code will re-introduce the bug.
---
## Reference: A.5's commit chain
Final A.5 commit chain on `claude/hopeful-darwin-ae8b87` (merged into main at `d3d78fa`):
| SHA | Subject |
|---|---|
| 9245db5 | phase(A.5): SHIP — two-tier streaming + horizon LOD + Quality Preset system |
| d93d823 | docs(A.5 T27): roadmap + ISSUES + CLAUDE.md updates for A.5 ship |
| a28a5b7 | docs(A.5 T27): spec + plan amendments for T22.5 + ship |
| 9b49009 | Revert "feat(perf): Tier 1 entity classification cache" |
| 3639a6f | feat(perf): Tier 1 entity classification cache (REVERTED) |
| 462f9d6 | docs(perf): roadmap for Tier 2 + Tier 3 entity-dispatcher optimizations |
| 0ad8c99 | fix(A.5): WalkEntities scratch-list pattern (Bug B — T17 GC pressure) |
| 9217fd9 | fix(A.5): strip far-tier entities in worker (Bug A — far tier optimization) |
| 28d2c60 | feat(A.5 T22.5): wire QualityPreset into renderer + streaming (commit 2/2) |
| afa4200 | feat(A.5 T22.5): QualityPreset schema + tests (commit 1/2) |
| c473fee | feat(A.5 T23): BUDGET_OVER flag in [WB-DIAG] / [TERRAIN-DIAG] |
| 3b684db | feat(A.5 T22): fog wired from N₁/N₂ + ACDREAM_FOG_*_MULT env vars |
| 1488ec6 | test(A.5 T21): lock in depth-write attribution per translucency kind |
| 26b2871 | feat(A.5 T20): MSAA 4x + alpha-to-coverage on foliage |
| 4b84e56 | feat(A.5 T19): mipmaps + 16x anisotropic on TerrainAtlas |
| (...60+ commits earlier in the chain through T1-T18) | (see full log on the merge bubble) |
The merge bubble preserves the full chain. To inspect any A.5 commit:
```
git log d3d78fa^..d3d78fa
git show <sha>
```
---
Good luck. The phase is well-bounded; the audit step on Tier 1 is the single highest-leverage thing to invest in. The lifestone and JobKind cleanup should be quick wins. After this phase ships, the project is in a great position — A.5 + polish + Tier 2/3 roadmap covers the rendering + perf work for the next several months.
Holler at the user if any of the three priorities reveals scope you didn't expect.

View file

@ -0,0 +1,246 @@
# Tier 1 entity-classification cache — mutation audit
**Created:** 2026-05-10, opening move of the ISSUE #53 retry session.
**Purpose:** enumerate every code path that writes to `WorldEntity.MeshRefs` (the dispatcher's load-bearing per-entity input) and every adjacent state read by `WbDrawDispatcher.ClassifyBatches` / model-matrix composition, classify each as STATIC or DYNAMIC, and design the cache invalidation surface BEFORE touching renderer code.
This audit is the load-bearing prerequisite the prior Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) skipped. Cache design follows from the audit, not the other way around.
---
## TL;DR — the invariant
> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**
That is the invariant the cache rides on. Animated entities (player + remote NPCs/players + animated dat scenery like the lifestone crystal) get a fresh `MeshRefs` list every frame from `TickAnimations` plus per-frame `Position`/`Rotation` writes from physics/dead-reckoning. Everything else — stabs, scenery, cell-mesh entities, interior static objects — touches none of those fields after construction.
The cache should hold per-entity classification ONLY for entities whose `Id` is not in `_animatedEntities` at lookup time. Animated entities go through today's per-frame classification path unchanged.
---
## §1. `entity.MeshRefs = ...` write sites (the core question)
`WorldEntity.MeshRefs` is `IReadOnlyList<MeshRef>` with a `set` accessor (see [src/AcDream.Core/World/WorldEntity.cs:28](../../src/AcDream.Core/World/WorldEntity.cs#L28)). `MeshRef` itself is a `readonly record struct` ([src/AcDream.Core/World/MeshRef.cs:15](../../src/AcDream.Core/World/MeshRef.cs#L15)) — its fields cannot be mutated in place. So every "MeshRefs change" is a whole-list replacement, not a per-element edit.
Six write sites total in `src/`. Five STATIC, one DYNAMIC.
### Site 1 — `OnLiveEntitySpawnedLocked` (server-spawned entity hydration)
**[src/AcDream.App/Rendering/GameWindow.cs:2578](../../src/AcDream.App/Rendering/GameWindow.cs#L2578)** — `MeshRefs = meshRefs` in the `WorldEntity { … }` constructor.
**Classification:** **STATIC** at first spawn.
**Trigger:** server's `0xF745 CreateObject` for any entity (NPC, monster, player, item, statue, lifestone). Also re-runs from `OnLiveAppearanceUpdated` (server's `0xF625 ObjDescEvent`) → spawn dedup at top of `OnLiveEntitySpawnedLocked` invokes `RemoveLiveEntityByServerGuid`, then re-spawns. Each invocation gets a NEW local `entity.Id` from `_liveEntityIdCounter++` (line 2573).
**Implication for cache:** ObjDescEvent isn't a "mutate existing entity" event — it's a despawn+respawn pair. The despawn path (next subsection) clears the cache for the old Id; the respawn populates fresh under the new Id. The cache never sees a stale entry for a still-active Id from this path.
**Pre-construction `parts[…]` mutations** at lines 2333 and 2365 (AnimPartChanges + DIDDegrade resolver) edit the *local* `parts` list before it becomes the `meshRefs` argument; they're not separate write sites.
### Site 2 — `BuildLandblockForStreaming` (stab hydration)
**[src/AcDream.App/Rendering/GameWindow.cs:4748](../../src/AcDream.App/Rendering/GameWindow.cs#L4748)** — `MeshRefs = meshRefs` constructing dat-stab entities.
**Classification:** **STATIC** at hydration. Worker-thread only.
**Trigger:** streaming worker's near-tier load path (`LandblockStreamJobKind.LoadNear` or `PromoteToNear`). Single-GfxObj stabs use `Matrix4x4.Identity`; multi-part Setups go through `SetupMesh.Flatten` to produce per-part MeshRefs.
**Lifetime:** lives until the entity's owning landblock is demoted (Near→Far) or unloaded — see Site invalidation §3.2.
### Site 3 — `BuildSceneryEntitiesForStreaming` (procedural scenery)
**[src/AcDream.App/Rendering/GameWindow.cs:4951](../../src/AcDream.App/Rendering/GameWindow.cs#L4951)** — `MeshRefs = meshRefs` for trees / rocks / bushes / fences.
**Classification:** **STATIC** at hydration. Worker-thread only.
**Lifetime:** identical to Site 2.
### Site 4 — Interior cell-mesh entity
**[src/AcDream.App/Rendering/GameWindow.cs:5023](../../src/AcDream.App/Rendering/GameWindow.cs#L5023)** — `MeshRefs = new[] { cellMeshRef }` for the EnvCell's own room geometry as a renderable entity.
**Classification:** **STATIC** at hydration.
### Site 5 — Interior static-object entity
**[src/AcDream.App/Rendering/GameWindow.cs:5083](../../src/AcDream.App/Rendering/GameWindow.cs#L5083)** — `MeshRefs = meshRefs` for static objects placed inside an EnvCell (furniture, fixtures).
**Classification:** **STATIC** at hydration.
### Site 6 — `TickAnimations` per-frame rebuild
**[src/AcDream.App/Rendering/GameWindow.cs:7580](../../src/AcDream.App/Rendering/GameWindow.cs#L7580)** — `ae.Entity.MeshRefs = newMeshRefs;` after constructing a fresh `List<MeshRef>(partCount)` at line 7501 from `sequencer.Advance(dt)` output.
**Classification:** **DYNAMIC** every frame.
**Trigger:** per-frame iteration over `_animatedEntities.Values` inside `TickAnimations`. If `entity.Id ∈ _animatedEntities`, this loop runs for that entity every frame (subject to motion-table presence). If `entity.Id ∉ _animatedEntities`, this loop never runs for it.
**Consequence:** any cache that captures `entity.MeshRefs[i].PartTransform` for an entity in `_animatedEntities` will freeze the pose. **This is exactly what the prior Tier 1 attempt did.**
---
## §2. `_animatedEntities` membership transitions
`_animatedEntities` at [GameWindow.cs:160](../../src/AcDream.App/Rendering/GameWindow.cs#L160) is the gating dict. The cache's "static" predicate is `! _animatedEntities.ContainsKey(entity.Id)`.
### Population
- **[GameWindow.cs:2724](../../src/AcDream.App/Rendering/GameWindow.cs#L2724)** — `_animatedEntities[entity.Id] = new AnimatedEntity { … }` at server-spawn for entities with a non-empty motion table + a resolvable idle cycle.
- **[GameWindow.cs:7685](../../src/AcDream.App/Rendering/GameWindow.cs#L7685)** — `_animatedEntities[pe.Id] = ae;` in `UpdatePlayerAnimation` to *re-add* the local player entity if a prior `UpdateMotion` removed it (the "Phase 6.8 stationary remove" pattern). This is the only path that can flip an entity from STATIC to ANIMATED mid-life.
### Removal
- **[GameWindow.cs:2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2935)** — `_animatedEntities.Remove(existingEntity.Id)` inside `RemoveLiveEntityByServerGuid`. Fires for `0xF747 DeleteObject` and as the dedup leg of `OnLiveAppearanceUpdated`.
### Cache implication
Membership IS NOT cached by the dispatcher. The cache lookup checks `_animatedEntities.ContainsKey(entity.Id)` at lookup time. If the player flips STATIC→ANIMATED mid-session (Site 7685 above), a stale cache entry would still exist for the player Id but never be read; the next despawn (Site 2935) clears it. No special-casing needed.
The reverse flip (ANIMATED→STATIC, e.g. a ground-state demote) leaves no cache entry; the dispatcher takes the cache-miss path on the first frame and populates fresh. Also no special-casing needed.
---
## §3. Position / Rotation write sites (matters for the cached model matrix)
The dispatcher composes `model = meshRef.PartTransform * entityWorld` for non-Setup entities, and `model = restPose * meshRef.PartTransform * entityWorld` for Setup multi-parts (with `entityWorld = Rotation × Translation`). If `Position` or `Rotation` changes for a STATIC entity, a cached model matrix would be stale.
Audit shows: **every Position/Rotation write site in `GameWindow.cs` operates on entities that are in `_animatedEntities`.** Static entities never have these fields touched after construction.
| Line | Context | Animated? |
|---|---|---|
| 3992-3993 | `entity.SetPosition(worldPos); entity.Rotation = rot;` (player physics snap-on-arrival) | YES — `entity` is the local player |
| 4116 | `entity.SetPosition(rmState.Body.Position);` (remote dead-reckon snap branch) | YES — remote NPC/player |
| 4230 | same context, near-enqueue branch | YES |
| 4362-4363 | remote dead-reckon physics tick body sync | YES |
| 4407-4408 | local player position snap (teleport / GoHome) | YES |
| 7045-7046 | `ae.Entity.SetPosition(rm.Body.Position); ae.Entity.Rotation = rm.Body.Orientation;` (TickAnimations body sync) | YES — `ae.Entity` is in `_animatedEntities` by definition |
| 7373-7374 | same body-sync context, fall-through path | YES |
No Position/Rotation writes happen on entities that are NOT in `_animatedEntities`. Confirmed via grep.
---
## §4. Other entity fields read by the dispatcher
`WbDrawDispatcher.Draw` and `ClassifyBatches` read these `WorldEntity` fields beyond `MeshRefs`, `Position`, `Rotation`:
| Field | Mutability | Cache impact |
|---|---|---|
| `PaletteOverride` | `init`-only ([WorldEntity.cs:37](../../src/AcDream.Core/World/WorldEntity.cs#L37)) | Stable post-spawn → safe to fold into cache key / texHandle resolution |
| `HiddenPartsMask` | `init`-only ([WorldEntity.cs:73](../../src/AcDream.Core/World/WorldEntity.cs#L73)) | Stable; doesn't apply to dispatcher anyway (animation tick handles part-hide via `s_hidePartIndex` debug global, animated path only) |
| `ParentCellId` | `init`-only ([WorldEntity.cs:45](../../src/AcDream.Core/World/WorldEntity.cs#L45)) | Stable; visibility filter input |
| `AabbMin/AabbMax/AabbDirty` | Mutated lazily by `RefreshAabb` ([WorldEntity.cs:79-91](../../src/AcDream.Core/World/WorldEntity.cs#L79)) on `AabbDirty` flag, set by `SetPosition` | Read by `WalkEntitiesInto`, NOT used by classification. AABB stays static for static entities (Position never changes → never marked dirty after first refresh) |
| `MeshRefs[i].SurfaceOverrides` | `init`-only on the MeshRef record struct | Stable for the lifetime of the MeshRef list (Sites 1-5) |
| `MeshRefs[i].GfxObjId` | Stable (`readonly record struct`) | Forms part of the cache key |
| `MeshRefs[i].PartTransform` | Stable for STATIC entities (the list is replaced atomically in Site 6 only for ANIMATED entities) | Cacheable for STATIC entities |
No hidden mutability surface. The cache is safe for entities outside `_animatedEntities`.
---
## §5. Cache invalidation events (the wire-up)
The cache is keyed by `entity.Id`. Only TWO event sources can invalidate a cached entry:
### §5.1 Per-entity despawn (live server entities)
**[GameWindow.cs:2933-2935](../../src/AcDream.App/Rendering/GameWindow.cs#L2933)** — `_worldState.RemoveEntityByServerGuid(serverGuid); _worldGameState.RemoveById(...); _animatedEntities.Remove(...);`
This block fires for:
- `0xF747 DeleteObject` (server explicitly says entity is gone).
- `0xF625 ObjDescEvent` (dedup leg before respawn).
**Hook:** add `_wbDrawDispatcher.InvalidateEntity(existingEntity.Id)` to this block.
### §5.2 Landblock demote / unload (static dat entities)
**[src/AcDream.App/Streaming/GpuWorldState.cs:373](../../src/AcDream.App/Streaming/GpuWorldState.cs#L373)** — `RemoveEntitiesFromLandblock(landblockId)` clears the entity list for a landblock. Called from `StreamingController.Tick` at [StreamingController.cs:116](../../src/AcDream.App/Streaming/StreamingController.cs#L116) for `ToDemote` (Near→Far) and via `_enqueueUnload` for `ToUnload`.
**Hook:** add `_wbDrawDispatcher.InvalidateLandblock(landblockId)` adjacent to the `RemoveEntitiesFromLandblock` call. Walk the LB's pre-removal entity list; invalidate each Id.
Implementation note: `RemoveEntitiesFromLandblock` already has the entity list in scope before zeroing it — adding the invalidation walk inside the method (or via a callback) is cheap. Alternative: `StreamingController` walks the LB's entries before invoking `RemoveEntitiesFromLandblock`. Either works; brainstorming will pick.
### §5.3 No other invalidation paths needed
Confirmed:
- `MarkPersistent` ([GameWindow.cs:2024](../../src/AcDream.App/Rendering/GameWindow.cs#L2024)) — keeps player Id pinned across LB unloads. No MeshRefs change.
- `DrainRescued` ([GameWindow.cs:5885](../../src/AcDream.App/Rendering/GameWindow.cs#L5885)) — re-attaches rescued persistent entities. No MeshRefs change.
- `RelocateEntity` ([GameWindow.cs:6026](../../src/AcDream.App/Rendering/GameWindow.cs#L6026)) — moves entity between landblocks. Doesn't change MeshRefs/Position/Rotation. Safe.
- `AddEntitiesToExistingLandblock` ([GpuWorldState.cs:401](../../src/AcDream.App/Streaming/GpuWorldState.cs#L401)) — Far→Near promotion adds entities. New entries get cache-miss naturally.
`AnimationSequencer` ([src/AcDream.Core/Physics/AnimationSequencer.cs](../../src/AcDream.Core/Physics/AnimationSequencer.cs)) does NOT write to `entity.MeshRefs` or `entity.Position`/`entity.Rotation` directly. It produces `PartTransform[]` frames consumed by `TickAnimations`. Confirmed via grep — only docstring mention of `MeshRef`. Sequencer is safe to ignore for cache design.
`Core` library has zero `entity.MeshRefs = ...` writes. All writes are in the App layer, all in `GameWindow.cs`. Confirmed via grep.
---
## §6. Recommended cache shape (for brainstorming, not yet committed)
Pre-spec recommendation; final design picks settle in the brainstorming session.
```csharp
// Per-(entity, partIdx, batchIdx) classification result.
private readonly record struct CachedBatch(
GroupKey Key, // bucket identity
ulong BindlessTextureHandle, // resolved texture (via palette + override)
Matrix4x4 RestPose); // meshRef.PartTransform (or restPose * meshRef.PartTransform for Setup)
// Per-entity cache value.
private sealed class EntityCache
{
public List<CachedBatch> Batches = new(); // ordered: (part, batch) flat
public uint LandblockHint; // for InvalidateLandblock
}
// Cache state.
private readonly Dictionary<uint /*entityId*/, EntityCache> _entityCache = new();
// Hot path:
// if (_animatedEntities.ContainsKey(entity.Id)) → today's path (full ClassifyBatches)
// else if (_entityCache.TryGetValue(entity.Id, out var cached)) →
// for each batch: append (cached.RestPose * entityWorld) to its group's matrices
// else → ClassifyBatches once, populate cache, then same fast path next frame.
```
**Per-frame static cost:** dictionary lookup + per-batch matrix multiply + matrices.Add. No texture resolution, no group-key construction, no metaTable lookup.
**Worst case:** if every entity is animated (e.g. a city full of NPCs), the cache adds one `ContainsKey` lookup per visible entity vs today's path. Negligible overhead. In practice ~10K entities total at radius=12 with ~50 animated → 99.5% cache hit rate on the static path.
**Risk surface:** the cache invariant rests on TWO claims, both verified in the audit above:
1. STATIC entity Position / Rotation never mutate post-spawn. Verified §3.
2. STATIC entity MeshRefs reference never changes post-spawn. Verified §1 (only Site 6 writes, only for animated entities).
If either claim breaks in a future change (e.g. someone adds an "earthquake" effect that mutates static-tree positions), the cache will quietly serve stale matrices. Defense:
- **DEBUG-only assertion** in the cache hit path: `Debug.Assert(!_animatedEntities.ContainsKey(entity.Id))`.
- **DEBUG-only cross-check**: in DEBUG builds, in the cache-hit path, also recompute the live model matrix and compare against `cached.RestPose * entityWorld`. Log a warning if they differ. Catches the "someone added a new mutation site" failure mode without paying the cost in Release.
(Belt-and-suspenders option (c) from the original handoff. Recommended for the first retry given the prior bug.)
---
## §7. What does NOT need to be in the cache design
- **Texture invalidation on bindless handle change.** Bindless handles are issued on first texture upload and remain valid for the texture's lifetime. `TextureCache` doesn't evict entries during normal play (only on shutdown). Static-entity texture handles never change.
- **GfxObj re-decode.** `WbMeshAdapter.TryGetRenderData` returns the same `ObjectRenderData` instance for a given `gfxObjId` for the session. Static-entity batches never change.
- **`SurfaceOverrides` reactivation.** Init-only on `MeshRef`, set at Site 1's hydration time, stable for the MeshRef's lifetime.
- **Per-frame `Time` / `dt` inputs.** The dispatcher doesn't read time. Texture animation (e.g. animated UV scrolls) happens in the shader from `gl_Time`-equivalent uniforms, not from cached state.
---
## §8. Open questions for brainstorming
These need a user decision before I write the spec:
1. **Where do `InvalidateEntity` / `InvalidateLandblock` live?** On `WbDrawDispatcher` (cache lives there)? On a new `EntityClassificationCache` class injected into the dispatcher (separation of concerns; testable in isolation)? My lean: separate class, dispatcher gets it via ctor.
2. **Static-only (option a) vs static-only + DEBUG cross-check (option c)?** Cross-check costs nothing in Release and catches the exact bug class that bit us last time. My lean: option (c).
3. **Cache the full model matrix or the rest pose?** Full matrix saves a per-frame multiply but bakes Position/Rotation into the cache (theoretically violatable). Rest pose is safer + costs ~one mat4 mult per batch. My lean: rest pose.
4. **Setup multi-part flattening: cache the per-part `setupPart.PartTransform * meshRef.PartTransform` product?** Today's `Draw` walks `renderData.SetupParts` per-frame even though that list is per-GfxObj-immutable. The cache could pre-flatten into the batch list. My lean: yes — that's where the visible CPU win is.
5. **Test plan: where do new tests live?** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`? Pure-CPU tests on the cache class without GL state? My lean: yes, separate test file in the existing Wb test directory.
---
## §9. Sentinel + baseline (verified at audit start, 2026-05-10)
- `dotnet build`: green (after `git submodule update --init` for the WorldBuilder ref tree, which was missing in this fresh worktree).
- `dotnet test --no-build`: 1688 passing, 8 pre-existing failures in `AcDream.Core.Tests`. Matches the post-#52/#54 baseline in the handoff.
- N.5b sentinel filter (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`): 94/94 passing. Matches the post-#52/#54 baseline.
These are the floors the Tier 1 retry must keep clean throughout.

View file

@ -0,0 +1,203 @@
# Phase Post-A.5 — Tier 1 Retry (ISSUE #53) — Cold-Start Handoff
**Created:** 2026-05-10, immediately after closing ISSUES #52 (lifestone) + #54 (JobKind plumbing) and merging to main.
**Audience:** the next agent picking up Priority 3 of the Post-A.5 polish phase.
**Purpose:** drop straight into the Tier 1 entity-classification cache retry without re-litigating what the prior session settled.
---
## TL;DR
Post-A.5 polish was sized at three priorities. **2 of 3 shipped to main** during the 2026-05-10 session; **only Priority 3 (Tier 1 retry, ISSUE #53) remains.** Tier 1 is the biggest perf headroom in the post-A.5 phase: it should drop the entity dispatcher cpu_us median from ~3.5 ms to ~1-1.5 ms, putting the dispatcher inside the spec's 2.0 ms budget and unlocking ~300-400 FPS at standstill.
The first Tier 1 attempt (commit `3639a6f`, reverted at `9b49009`) broke animation. The next attempt MUST start with an animation-mutation audit. **This handoff has the audit pre-started** — there's specific evidence captured below that the previous handoff didn't have.
Sized: ~5-7 days including audit + design + spec + implementation + visual gate.
---
## Where main is
- **`main` HEAD: `da08490`** — Merge of `claude/cranky-varahamihira-fe423f`. Includes the lifestone fix + JobKind plumbing.
- **CLAUDE.md "Currently in flight"** updated to *"Post-A.5 polish — Tier 1 retry (only remaining priority)"*.
- **`docs/ISSUES.md`** has both #52 and #54 in *Recently closed* with full root-cause writeups; only #53 remains in *Active issues*.
- **N.5b conformance sentinel: 94/94.** Full suite: 1688/1696 passing (8 pre-existing physics/input failures unchanged across all session work).
Recent commit chain on main (newest first):
| SHA | Subject |
|---|---|
| `da08490` | Merge branch 'claude/cranky-varahamihira-fe423f' — Post-A.5 polish: close #52 (lifestone) + #54 (JobKind plumbing) |
| `9a55354` | docs(post-A.5 #54): close JobKind plumbing issue + update CLAUDE.md flight status |
| `bf31e59` | fix(streaming): close #54 — plumb JobKind through BuildLandblockForStreaming |
| `b19f1d1` | docs(post-A.5 #52): close lifestone issue + update CLAUDE.md flight status |
| `e40159f` | fix(render): close #52 — lifestone visible (alpha-test + cull + uDrawIDOffset) |
| `c111312` | docs(post-A.5): cold-start handoff for the next session (the prior handoff this work used) |
---
## What shipped this session
### Priority 1 — ISSUE #52 (lifestone missing) — closed by `e40159f`
Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit `dcae2b6`, 2026-05-08):
1. **Alpha-test discard** in `mesh_modern.frag` transparent pass killed high-α pixels of dat-flagged transparent surfaces. The lifestone crystal core (surface `0x080011DE`) decoded with α≥0.95, so 100% of fragments were discarded. Fix: remove `α >= 0.95 discard` from transparent pass; keep `α < 0.05 discard` as a fragment-cost optimization.
2. **Cull state regression**: `WbDrawDispatcher.Draw` Phase 8 had no GL cull state — Phase 9.2's `Enable(CullFace) + Back + CCW` setup (commit `6f1971a`, 2026-04-11) was lost when the legacy `StaticMeshRenderer` was deleted. Closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's GL state at the top of Phase 8.
3. **`uDrawIDOffset` indexing bug**: `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect`, so the transparent pass was reading `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[opaqueCount..end)`. The lifestone flickered to whatever opaque batch sorted to index 0 each frame. Fix: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, set per-pass in dispatcher (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WB's `BaseObjectRenderManager.cs:845`.
User-confirmed visually via `+Acdream` test character at the Holtburg outdoor lifestone (Z=94 platform).
### Priority 2 — ISSUE #54 (JobKind plumbing) — closed by `bf31e59`
`LandblockStreamer.cs` primary ctor signature changed from `Func<uint, LoadedLandblock?>` to `Func<uint, LandblockStreamJobKind, LoadedLandblock?>`. A back-compat overload preserves the old signature for the 5 ctor sites in `LandblockStreamerTests.cs` (no test changes needed). `BuildLandblockForStreaming(uint, JobKind)` in `GameWindow.cs` early-outs for `LoadFar` with a heightmap-only path. The Bug A post-load entity strip in `LandblockStreamer.HandleJob` is retained as a `Debug.Assert` + Release safety net.
Per-LB worker cost on far-tier dropped from ~tens of ms (full hydration including `LandBlockInfo` + `SceneryGenerator` + interior cells) to ~sub-ms (single `LandBlock` dat read).
### Memory entry from this session
`feedback_wb_migration_state_audit.md` — captures the meta-lesson that WB-migration phases need a systematic GL-state and shader-uniform diff vs the legacy renderer being replaced. Future phases at risk: Sky/Particles modern path migration, EnvCell modern path, Shadow mapping. Also captures the workflow lesson: when the user says *"we had this nailed down before"*, the first move is `git log -- <legacy file>` BEFORE adding new diagnostic instrumentation.
---
## Priority 3 — ISSUE #53 — Tier 1 entity-classification cache retry
### What the first attempt was and why it failed
Commit `3639a6f` (reverted at `9b49009`) cached `meshRef.PartTransform` baked into per-(entity, batch) classification at first-frame visit. For static entities this is stable; for animated entities the cache froze the pose and NPCs/players stopped animating. Some buildings also showed at wrong positions (likely entities incorrectly flagged as animated).
The "trust MeshRefs as the source of truth" comment in the dispatcher gave false confidence. MeshRefs IS the source of truth, but it's mutated EVERY frame for animated entities.
### The audit (PRE-STARTED in the prior session — read this carefully)
The previous handoff and ISSUE #53 describe the bug as *"AnimationSequencer mutates `meshRef.PartTransform` every frame to apply the current skeletal pose."* **That framing is technically wrong** in a way that matters for the retry design. Discovered during the post-A.5 lifestone session:
- `MeshRef` at `src/AcDream.Core/World/MeshRef.cs:15` is a `readonly record struct` — its fields **cannot be mutated in place**:
```csharp
public readonly record struct MeshRef(uint GfxObjId, Matrix4x4 PartTransform)
```
- The actual per-frame mutation for animated entities is the **entire `MeshRefs` LIST replacement** at `src/AcDream.App/Rendering/GameWindow.cs:7474-7553`:
```csharp
var newMeshRefs = new List<AcDream.Core.World.MeshRef>(partCount);
// ... loop building per-part transforms from sequencer.Advance(dt) ...
ae.Entity.MeshRefs = newMeshRefs;
```
- The source of truth for *which* entities go through that per-frame path is the `_animatedEntities` dictionary at `GameWindow.cs:160`:
```csharp
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
```
Population: `_animatedEntities[entity.Id] = new AnimatedEntity{...}` at GameWindow.cs:2724 (spawn). Removal: `_animatedEntities.Remove(...)` at GameWindow.cs:2935 (despawn).
**Therefore: a static entity is one whose `Id` is NOT in `_animatedEntities`.** Its MeshRefs list is the same instance from spawn until rare events (ObjDesc / palette swap / part hide). Other static-entity write sites that must be invalidation-aware:
- `src/AcDream.App/Rendering/GameWindow.cs:2333` and `:2365` — ObjDescEvent / AnimPartChange events rebuild a `MeshRef` element. Network-driven, infrequent.
- `src/AcDream.App/Rendering/GameWindow.cs:2524` — entity scale apply at spawn (one-shot).
- Lines 4682-4924, 4996-5074 — dat-side hydration paths in OnLoad / scenery / interior. Spawn-time only.
### What this means for cache design
The cleanest design is now clearer than the original handoff suggested:
**Recommended approach (option a from the original handoff): static-only cache with explicit invalidation hooks.**
1. Cache the (entity, batch) → InstanceGroup-key + model-matrix mapping for entities where `_animatedEntities.ContainsKey(entity.Id) == false`.
2. Animated entities skip the cache entirely; they go through today's per-frame `ClassifyBatches` path.
3. Invalidate the cache for an entity on:
- **ObjDesc / AnimPartChange events** (`GameWindow.cs:2333, 2365`) — rebuild that entity's cache entry.
- **Palette override changes** (rare; usually only on initial server spawn or a re-equip event).
- **Entity despawn** — drop the cache entry.
4. Static entities never animate. The dispatcher's per-frame work for cached entities reduces from "walk + classify all batches" to "walk + lookup-and-emit-pre-classified".
Why this is safer than the first attempt: the first attempt cached the POSE (model matrix). This attempt would cache only the (group key, texture handle, blend mode, per-part `meshRef.PartTransform * entityWorld` for the spawn-time stable subset). Animation never enters the cache surface.
### Cache design options reconsidered
(a) **Static-only cache (recommended).** As described above. Clean invariant: animated entities skip the cache; static entities go through it. Requires careful enumeration of all writes to `entity.MeshRefs` for static entities (see audit list above) so each one fires invalidation.
(b) **Dynamic-aware cache with invalidation hooks.** Cache everything but expose `InvalidateEntity(uint)` / `RefreshEntityPalette(uint)` hooks; wire from network handlers. More complex but might let some animated entities also benefit if their per-frame mutations are localized. NOT RECOMMENDED for a first retry — error-prone and the first attempt already failed at this scope.
(c) **Static-only + animated-bypass + DEBUG cross-check.** Like (a), but in DEBUG builds, log a warning every frame if a cached entity's `MeshRefs` reference no longer matches the cached snapshot (catches mis-classified dynamics). Belt-and-suspenders. Recommended IF you're nervous about the audit being incomplete.
### Acceptance criteria (from the original handoff, refined)
- Build green; existing 999+ tests pass; 8 pre-existing physics/input failures stay at 8.
- 1-3 new tests covering: cache hit for static entity (lookup), cache bypass for animated entity (no-op), cache invalidation on entity despawn, cache invalidation on ObjDesc/palette event.
- N.5b conformance sentinel intact (89+ tests; in this session it's 94/94 — must stay clean).
- Visual gate: launch + walk Holtburg → North Yanshi at horizon-safe preset; confirm:
- Animation works (NPCs, player character animate normally — including the lifestone crystal closed by #52).
- Buildings at correct positions.
- No new visual regressions.
- Perf gate (with `[WB-DIAG]` under `ACDREAM_WB_DIAG=1`):
- Entity dispatcher cpu_us median drops from ~3.5 ms to ≤2.0 ms (matches spec budget).
- p95 stays ≤2.5 ms.
---
## Files to read before brainstorming
In rough order:
1. **This handoff** end-to-end — captures audit insights from the prior session that the original handoff didn't have.
2. **`docs/research/2026-05-10-post-a5-polish-handoff.md`** — the prior handoff. §"Priority 3" has the original (slightly outdated) framing of the bug. Read for context but trust THIS handoff's audit insights over its.
3. **`docs/ISSUES.md` issue #53** — the issue's own description (now updated post-#52/#54 close).
4. **`docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`** — A.5 spec for the entity dispatcher's data-flow context (esp. §4.10 Quality Preset and §11 deferred items).
5. **`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`** — the perf-tier roadmap. Tier 1 is in scope; Tier 2 + Tier 3 are explicitly NOT (those are dedicated multi-week phases).
6. **`memory/feedback_wb_migration_state_audit.md`** — the new memory entry on WB migration state-loss patterns. Tier 1 doesn't touch the WB migration directly, but the meta-lesson "audit before assume" is exactly what this priority needs.
7. **`memory/project_phase_a5_state.md`** — the 5 gotchas. **Critical for avoiding the same traps**, especially #3 (caching mutable per-frame state breaks animation silently) — the exact bug the first Tier 1 attempt hit.
8. **`src/AcDream.Core/World/MeshRef.cs`** — confirm the `readonly record struct` shape; understand that "mutating PartTransform" actually means "replacing the whole MeshRef record."
9. **`src/AcDream.App/Rendering/GameWindow.cs:7340-7560`** — the per-frame animation rebuild loop. Read this end-to-end for the audit. Find every line that writes to `entity.MeshRefs` for animated entities.
10. **`src/AcDream.App/Rendering/GameWindow.cs:160` + lines 2710-2760, 2920-2940** — `_animatedEntities` declaration + spawn/despawn population.
11. **`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`** — `Draw` and `ClassifyBatches`. Where the cache will land.
12. **`src/AcDream.Core/Physics/AnimationSequencer.cs`** — the per-frame animation engine. Audit any field it mutates that the dispatcher reads.
13. **`src/AcDream.Core/Physics/AnimationHookRouter.cs`** — secondary mutation source via animation hooks.
---
## Workflow for the next session
1. **Read this handoff in full.**
2. **Verify build green:** `dotnet build`. Verify ~1688 tests pass: `dotnet test --no-build`. Verify N.5b sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` → expect 94 passing.
3. **Read the files above** in order. Especially deep on §"Files to read" #8-#13.
4. **Audit step (1-2 days):** open a fresh research note `docs/research/2026-05-10-tier1-mutation-audit.md` and write down:
- Every code path that writes `entity.MeshRefs = ...` for any entity.
- Tag each as **STATIC** (one-shot at spawn or rare event) or **DYNAMIC** (per-frame).
- For each STATIC write, identify the trigger (network event, scale apply, etc.) and design the invalidation hook.
- For each DYNAMIC write, confirm it fires only for entities in `_animatedEntities` (which means cache bypass is the right answer).
5. **Spec (~1 day):** brainstorm the cache design with the user (use `superpowers:brainstorming`). Write `docs/superpowers/specs/2026-05-10-issue-53-tier1-cache-design.md`. Include the audit findings, the chosen cache approach (probably option (a)), the invariants, the invalidation API, the test plan, the perf-gate measurement plan.
6. **Implement (~2-3 days):** TDD via `superpowers:test-driven-development`. Tests first for cache hit/miss/invalidation, then implementation in `WbDrawDispatcher`. Wire invalidation hooks into the relevant write sites in `GameWindow.cs`.
7. **Visual gate:** launch + walk; confirm animation works on a moving NPC; confirm static buildings/scenery still render at correct positions; confirm lifestone (closed by #52) still renders.
8. **Perf gate:** capture `[WB-DIAG]` cpu_us median + p95 with `ACDREAM_WB_DIAG=1` at horizon-safe preset (NEAR=4, FAR=12). Compare to today's ~3.5 ms baseline; expect ≤2.0 ms.
9. **Ship:** commit, close #53 in ISSUES.md, update CLAUDE.md "Currently in flight" (this would close out the post-A.5 polish phase entirely), update memory with any new gotchas captured during the audit/implementation.
10. **Next phase after #53 ships:** N.6 (perf polish) per the roadmap. Or escalate to Tier 2 (static/dynamic split with persistent groups) per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md` if Tier 1 alone doesn't hit the perf target.
---
## Things to NOT do
- **Don't skip the audit.** The whole reason the first attempt failed was that the audit was implicit and incomplete. The audit step should produce a written list of every MeshRefs write site, classified static vs dynamic, before any cache code is written.
- **Don't bundle Tier 2 or Tier 3 into this phase.** Those are dedicated multi-week phases per `docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`. If the audit reveals Tier 1 alone can't hit the perf target, file a follow-up issue and escalate as a separate phase.
- **Don't re-add the `Tier1` cache that was reverted.** Start fresh after the audit. Cherry-picking commit `3639a6f` reintroduces the animation freeze.
- **Don't break the N.5b conformance sentinel.** Run the filter on every commit:
```
dotnet test --no-build --filter "FullyQualifiedName~TerrainSlot|FullyQualifiedName~TerrainModernConformance|FullyQualifiedName~Wb|FullyQualifiedName~MatrixComposition|FullyQualifiedName~TextureCacheBindless|FullyQualifiedName~SplitFormulaDivergence"
```
Expect 94 passing, 0 failures.
- **Don't skip the visual gate.** Animation has been the highest-risk regression in this codebase repeatedly (Tier 1 first attempt, the lifestone crystal in this session, the foundry statue earlier). Confirm visually with a moving animated NPC, a stationary building, and the lifestone before declaring done.
- **Don't trust "it was working in prod before."** That was the first Tier 1 attempt's posture. The audit is what makes it actually safe.
---
## Reference: Tier 1 perf math
Per the perf-tier roadmap and A.5 final state:
- **Today** (post-A.5 ship + #52/#54): entity dispatcher cpu_us median ~3.5 ms at radius=12 on Radeon RX 9070 XT @ 1440p. ~200-240 FPS at standstill.
- **After Tier 1**: ~1.0-1.5 ms median expected. ~300-400 FPS at standstill. Inside the spec's 2.0 ms budget.
- **After Tier 2 (separate phase)**: ~0.5-1.0 ms. ~400-600 FPS.
- **After Tier 3 (GPU compute culling, separate phase)**: ~0.05 ms. ~600-1000+ FPS.
Tier 1 is the lowest-risk, highest-leverage perf win remaining for the post-A.5 polish phase.
---
Good luck. The audit is the load-bearing thing — invest in it. The implementation is mechanical once the audit is solid.
Holler at the user if any of the audit reveals a write site that doesn't fit the static/dynamic dichotomy cleanly.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,829 @@
# Phase A.5 — Two-tier Streaming + Horizon LOD — Design
**Created:** 2026-05-09 (immediately after N.5b ship + brainstorm).
**Status:** Spec — awaiting user review before plan-writing.
**Branch:** `claude/hopeful-darwin-ae8b87` (worktree under `.claude/worktrees/hopeful-darwin-ae8b87`).
**Predecessor:** Phase N.5b SHIP at `08b7362`. A.5 handoff at `f7f8867`.
---
## 1. Goal
Scale acdream's visible reach from radius=5 (~1 km) to radius=12 (~2.3 km horizon)
while sustaining 240 FPS at standstill on a 240 Hz / 1440p monitor.
Delivered through:
1. Two-tier streaming (near = full detail, far = terrain only).
2. Tightening the existing per-LB entity dispatcher walk.
3. Off-thread mesh build (single worker).
4. Fog blend at the near-tier boundary to mask the scenery cutoff.
5. Three nearly-free visual quality wins (terrain mipmaps + anisotropic, A2C
with MSAA on foliage, depth-write audit).
The headline win: walking around Holtburg, the user sees a real horizon
(2.3 km of visible terrain) without the client falling off a perf cliff.
**User goal verbatim (2026-05-09):**
> "I just want great smooth HIGH fps visuals. Should look great. As long as
> it scales and we get very high FPS"
---
## 2. Hardware target + acceptance metrics
### Target hardware
- AMD Radeon RX 9070 XT (RDNA 4, ~December 2025).
- 240 Hz @ 2560×1440 (verified via `Get-CimInstance Win32_VideoController`).
- Frame budget: **4.166 ms** at vsync.
### Acceptance metrics (as shipped — revised with Quality Preset system)
1. **Build green; existing tests still green.** N.5b conformance sentinel
passes (visual mesh Z = TerrainSurface.SampleZ within 1 mm).
2. **Standstill at user's selected preset on user's hardware:**
- 95% of frames hit ≤ (1000ms / monitor refresh rate).
- No absolute FPS number is required — the Quality Preset system (§4.10)
is the user's knob for trading quality vs frame budget.
3. **Walking at user's selected preset:**
- 95% of frames hit ≤ 1.5× (1000ms / monitor refresh rate).
4. **First traversal into virgin region (cold mesh cache):**
- Render thread frame time stays within 2× the standstill budget while
the worker fills the far-tier horizon (~2.7 s of "horizon filling in" is OK).
5. **Visual gate (user-driven, same on all presets):** user launches the
client, walks Holtburg → North Yanshi, and confirms:
- Horizon visible at ~2.3 km.
- Fog blend at N₁ smooths the scenery boundary (no harsh cliff).
- Distant terrain does not shimmer (mipmaps work).
- Tree edges are smooth (A2C works).
- No new z-fighting / depth artifacts (depth-write audit).
6. **Per-subsystem regression budgets** (added to `[WB-DIAG]` /
`[TERRAIN-DIAG]` output):
- Entity dispatcher cpu_us median ≤ **2.0 ms** at standstill.
- Terrain dispatcher cpu_us median ≤ **1.0 ms** at standstill (all 625 LBs).
7. **N.5b sentinel intact:** TerrainSlot, TerrainModernConformance, Wb*,
MatrixComposition, TextureCacheBindless, SplitFormulaDivergence — all
pass clean.
8. **SHIP record + perf baseline doc + memory entry** mirroring N.5b's pattern.
A failure on (5) is a SHIP-blocker. A failure on (3) walking-FPS criterion
escalates to "fix or document the tradeoff and ship N.6 next" — not a
direct blocker but pushes the gate to user discretion.
---
## 3. Two-tier streaming model
### Tier definitions
| Tier | Radius | LB count | Loads | GPU mem |
|---|---|---|---|---|
| **Near** (N₁ = 4) | 9×9 = 81 LBs | terrain mesh + LandBlockInfo (stabs/buildings) + scenery generation + EnvCells + collision data + entity registration with WB dispatcher | scenery instance buffers + per-entity textures (depends on PaletteOverrides) |
| **Far** (N₂ = 12) | 25×25 - 9×9 = 544 LBs | terrain mesh ONLY (LandBlock heightmap + atlas blend) | ~14 MB shared atlas slots |
| **Total** | 25×25 = 625 LBs | combined | ~30 MB total estimated |
### Hysteresis (Q7 Option A — match existing radius+2 convention)
- **Near-tier:** entity load at distance 4, demote (entity unload) at distance 6.
- **Far-tier:** terrain load at distance 12, terrain unload at distance 14.
Both boundaries get the same 2-LB buffer. Phase A.1's existing hysteresis
mechanism in `StreamingRegion.RecenterTo` is the reference pattern; A.5
extends it from one radius to two.
### Tier transitions
| Transition | Trigger | Action |
|---|---|---|
| `null → far` | LB enters far window from outside | Worker reads LandBlock heightmap, builds mesh, posts `LandblockStreamResult.Loaded { Tier = Far }`. Render thread adds slot in `TerrainModernRenderer`. No entity work. |
| `null → near` | LB jumps null → near in one tick (first-tick bootstrap; teleport into virgin region) | Worker reads LandBlock heightmap + `LandBlockInfo`, generates scenery, builds entity list, builds mesh. Posts `LandblockStreamResult.Loaded { Tier = Near }`. Render thread adds terrain slot AND merges entities. |
| `far → near` | LB enters near window from far-resident | Worker reads `LandBlockInfo`, generates scenery, builds entity list. Posts `LandblockStreamResult.Promoted`. Render thread merges entities into `GpuWorldState` for the existing LB (terrain already loaded). |
| `near → far` | LB leaves near window past hysteresis (distance > 6) | Render thread drops the LB's entities from `GpuWorldState` (which fires `_wbSpawnAdapter.OnLandblockUnloaded`). Terrain stays. |
| `far → null` | LB leaves far window past hysteresis (distance > 14) | Render thread removes the terrain slot from `TerrainModernRenderer`. |
The order matters: when a player walks outward, the same LB goes
`near → far → null` over time. Each transition is one event per LB per
crossing.
### Why the player crossing the N₁ boundary works
The player is always at radius=0 from the streaming center (the streaming
center IS the player). The boundary effects are about LBs at the edge of N₁
crossing inward/outward as the player moves. Server-spawned NPCs are
delivered by ACE's broadcast (radius typically 5-7 LBs ≥ N₁), so when an
LB promotes back to near, ACE will already have its NPCs broadcast or
re-broadcast as the player moves through. Dat-static entities (stabs,
buildings) are reloaded from `LandBlockInfo` on promotion. Scenery is
re-generated from the deterministic seed at the same time.
---
## 4. Component-by-component design
### 4.1 `LandblockStreamTier` — new enum
```csharp
namespace AcDream.App.Streaming;
public enum LandblockStreamTier
{
Far, // terrain only
Near, // full detail (terrain + entities + scenery + EnvCells)
}
```
### 4.2 `StreamingRegion` — extended to two radii
```csharp
public sealed class StreamingRegion
{
public int CenterX { get; }
public int CenterY { get; }
public int NearRadius { get; } // N₁ (default 4)
public int FarRadius { get; } // N₂ (default 12)
public IReadOnlyCollection<uint> NearVisible { get; } // 9×9 window
public IReadOnlyCollection<uint> FarVisible { get; } // 25×25 window minus near
public IReadOnlyCollection<uint> Resident { get; } // hysteresis-retained
public TwoTierDiff RecenterTo(int newCx, int newCy);
}
public readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (need terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (need terrain + entities — first-tick bootstrap, teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (need entities only — terrain already loaded)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)
```
The hysteresis math:
- Near-unload threshold: `NearRadius + 2` = 6.
- Far-unload threshold: `FarRadius + 2` = 14.
A landblock is "near-resident" if its distance ≤ 6; "far-resident" if its
distance is in (6, 14]. Beyond 14, it unloads entirely.
### 4.3 `StreamingController` — routes by tier
```csharp
public sealed class StreamingController
{
public int NearRadius { get; set; } = 4;
public int FarRadius { get; set; } = 12;
public int MaxCompletionsPerFrame { get; set; } = 4;
// Action signatures change to carry the tier.
private readonly Action<uint, LandblockStreamTier> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
// ...
public void Tick(int observerCx, int observerCy)
{
// First-tick bootstrap: every near-window LB → ToLoadNear; every
// far-window-only LB → ToLoadFar.
// Steady-state RecenterTo: produces 5 transition lists.
// - ToLoadFar → _enqueueLoad(id, JobKind.LoadFar)
// - ToLoadNear → _enqueueLoad(id, JobKind.LoadNear)
// - ToPromote → _enqueueLoad(id, JobKind.PromoteToNear)
// - ToDemote → _state.RemoveEntities(id) on render thread (no worker job)
// - ToUnload → _enqueueUnload(id)
// Drain completions and route by result variant.
}
}
public enum LandblockStreamJobKind { LoadFar, LoadNear, PromoteToNear }
```
The render thread decides the job kind up-front based on its own knowledge
of which LBs are currently terrain-resident; the worker never peeks at
render-thread state. Three distinct worker paths:
- **`LoadFar`:** read `LandBlock` heightmap only. Skip `LandBlockInfo`,
skip `LandblockLoader.BuildEntitiesFromInfo`, skip
`SceneryGenerator`/`WbSceneryAdapter`. Build `LandblockMesh`. Post
`LandblockStreamResult.Loaded(Tier=Far, Entities=[], MeshData=mesh)`.
- **`LoadNear`:** read `LandBlock` + `LandBlockInfo` + scenery generation
+ build mesh. Post `LandblockStreamResult.Loaded(Tier=Near, Entities=...,
MeshData=mesh)`. Used for first-tick bootstrap of the inner ring and
for the rare null→Near jump (teleport into virgin region).
- **`PromoteToNear`:** read `LandBlockInfo` + scenery generation only.
Skip `LandBlock` heightmap (mesh already on GPU). Skip
`LandblockMesh.Build`. Post `LandblockStreamResult.Promoted(id, entities)`.
### 4.4 `LandblockStreamResult` — new variants
```csharp
public abstract record LandblockStreamResult
{
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LandBlock Heightmap,
IReadOnlyList<WorldEntity> Entities, // empty for Far
LandblockMeshData MeshData // built off-thread
) : LandblockStreamResult;
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities // entity layer for an already-loaded far-tier LB
) : LandblockStreamResult;
// Existing:
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult;
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult;
public sealed record WorkerCrashed(string Error) : LandblockStreamResult;
}
```
`Loaded` carries `MeshData` — the mesh is built on the worker thread, NOT
in `_applyTerrain` on the render thread. `Promoted` only carries entities;
the mesh is already in `TerrainModernRenderer`.
### 4.5 `LandblockStreamer` — single worker, mesh-build on-worker
Existing `LandblockStreamer` (today on a single background thread) gets
extended to:
1. Read dat as today (`DatCollection.Get<LandBlock>` etc.).
2. Build `LandblockMesh` on the same thread:
```csharp
var meshData = LandblockMesh.Build(
block, lbX, lbY, heightTable, _ctx, _surfaceCache);
```
3. Post `LandblockStreamResult.Loaded(... MeshData = meshData)` to the
completion queue.
Thread-safety implications:
- `_ctx` (TerrainBlendingContext) is read-only after init — no change.
- `_surfaceCache`: today a plain `Dictionary<uint, SurfaceInfo>`,
populated lazily by `LandblockMesh.Build`. Currently safe because
Build runs on the render thread; A.5 moves Build to the worker, so
the cache must be thread-safe. **Swap to
`ConcurrentDictionary<uint, SurfaceInfo>`** with `GetOrAdd` for the
populate path. The factory inside `GetOrAdd` may run twice for the
same key under contention (acceptable — the result is deterministic).
### 4.6 `WbDrawDispatcher` — entity bucketing tightening (Q5 Option A)
Three targeted changes inside the existing `Draw` flow:
#### Change 1: Animated-entity walk fix
Today (at lines 197-204 of `WbDrawDispatcher.cs`):
```csharp
foreach (var entry in landblockEntries) {
bool landblockVisible = ...;
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities) {
...
if (!landblockVisible && !isAnimated) continue;
```
The `if (!landblockVisible && ...) continue;` only skips if there are NO
animated entities. When `animatedEntityIds` is non-empty, the inner loop
walks every entity in the invisible LB just to find the few animated
ones. With ~10.7K entities at N₁=4, this is wasted iteration.
**Fix:** when an LB is invisible, iterate `animatedEntityIds` directly
and look each up in a per-LB `Dictionary<uint, WorldEntity>` map (added
to `LoadedLandblock` or kept in a parallel structure).
```csharp
foreach (var entry in landblockEntries) {
bool landblockVisible = ...;
if (!landblockVisible) {
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
// Walk only animated entities in this invisible LB.
foreach (var animatedId in animatedEntityIds) {
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
// ... draw the entity
}
continue;
}
foreach (var entity in entry.Entities) { ... }
}
```
#### Change 2: Per-entity AABB cache at register time
Today: `Draw` recomputes `aMin = position - 5`, `aMax = position + 5` per
entity per frame. Cheap individually, but ~16K × per frame = measurable.
**Fix:** add `Vector3 AabbMin, AabbMax` fields to `WorldEntity` (or a
parallel struct keyed by entity id). Populate at `EntitySpawnAdapter.OnCreate`
(server-spawned) and `LandblockLoader.BuildEntitiesFromInfo` (dat-static)
time. Static entities never invalidate. Dynamic entities (NPCs, players)
update on position change — add `WorldEntity.PositionDirty` flag set by
the live position update path; AABB recompute happens lazily on first
read after dirty.
The AABB radius today is hard-coded `PerEntityCullRadius = 5.0f` — keep
that as a per-mesh-bucket fallback; future improvement is to compute the
real AABB from the mesh, but defer that to a later phase (it's a
cross-cutting change).
#### Change 3: 4×4 sub-LB cell cull for partially-visible LBs
When an LB is fully visible (its AABB entirely inside the frustum), all
its entities are drawn — no per-entity cull needed. Today's per-entity
cull is wasted work in this case.
When an LB is partially visible, today's per-entity cull is the right
work — but it walks all ~132 entities. Cheap with the AABB-cache fix
(memory read), so the win here is small. Worth doing only if the cache
fix alone isn't enough to hit the 2.0ms budget.
**Add only if needed:** bucket each LB's entities into 4×4 sub-cells
(each 48 m). Compute a sub-cell AABB at register time. Per frame: for
partially-visible LBs, cull at sub-cell granularity first; walk
entities only inside surviving sub-cells.
Ship change #1 and #2 unconditionally; ship #3 only if the budget
isn't hit by #1 + #2.
### 4.7 `TerrainModernRenderer` — no structural change
The slot allocator (`TerrainSlotAllocator`) already grows by power-of-two
doubling. At N₂=12 worst case, ~961 slots × ~15 KB per slot = ~14 MB.
Allocator handles it without modification.
Per-LB frustum cull stays per-slot — at ~961 slots × ~0.3 µs/AABB-test
the worst-case cull pass is ~0.3 ms. Acceptable inside the 1.0 ms terrain
dispatcher budget.
The DEIC (`DrawElementsIndirectCommand`) array grows accordingly. The
existing per-frame `BufferSubData` upload absorbs a 961-entry array
without issue (~19 KB).
### 4.8 Fog tuning (`SceneLightingUbo`)
Existing fields (Phase G.1+):
- `FogStart` — distance at which fog begins (today: somewhere outside the
visible terrain range).
- `FogEnd` — distance at which fog reaches full opacity.
- `FogColor` — sourced from current sky state.
A.5 change: dynamically tune `FogStart` and `FogEnd` based on the
current N₁/N₂:
- `FogStart = N₁ × LandblockSize × 0.7``4 × 192 × 0.7` = **~538 m**.
- `FogEnd = N₂ × LandblockSize × 0.95``12 × 192 × 0.95` = **~2188 m**.
The fog color matches the current sky color (already provided by
`SkyStateProvider`) — at the far horizon, fog blends terrain into
sky, hiding the N₂ edge.
The 0.7 / 0.95 multipliers are tuning knobs. Iterate during user gate.
**Expose as env vars during development** (`ACDREAM_FOG_START_MULT`,
`ACDREAM_FOG_END_MULT`) to allow fast iteration without a recompile.
### 4.9 Visual quality wins (Q8 Option C — all three)
#### 4.9.1 Mipmaps + 16x anisotropic on `TerrainAtlas`
Today: `TerrainAtlas.Upload` uses `GL_LINEAR` minification, no mipmaps.
A.5 change: after upload, call `glGenerateMipmap(GL_TEXTURE_2D_ARRAY)`.
Sampler state: `GL_LINEAR_MIPMAP_LINEAR` (trilinear) +
`GL_TEXTURE_MAX_ANISOTROPY = 16`.
Affects only `TerrainAtlas`. Mesh atlas (entity textures) and other
texture caches stay as-is.
Verification: at N₂=12, walk to a vantage point looking at terrain at
range 2 km. With the fix, no shimmer. Without, "moving sparkles" visible
at distance.
#### 4.9.2 Alpha-to-coverage with MSAA on foliage
Today: `mesh_modern.frag` uses `if (alpha < cutoff) discard;` for ClipMap
translucency. Produces hard, pixel-edged tree silhouettes.
A.5 change:
- Enable MSAA 4x on the GL render target (window framebuffer).
- In `mesh_modern.frag`, for ClipMap pass: write
`gl_SampleMask[0]` based on alpha threshold instead of binary discard.
Risk: MSAA framebuffer interaction with sky / particles / UI overlay.
Audit:
- `SkyRenderer` — clears its own framebuffer? If so, must clear the MSAA
attachment instead. Investigate.
- `ParticleRenderer` — billboards already use alpha-blend; MSAA-friendly.
- ImGui overlay — drawn after the 3D pass; must not interact with MSAA
resolve.
If the audit finds blocking issues, ship 4.9.1 + 4.9.3 only and defer
4.9.2 to a later phase. Document the result either way.
#### 4.9.3 Depth-write audit on translucent batches
Walk all translucent batch paths in `WbDrawDispatcher.Draw` and verify:
- Alpha-blend (`AlphaBlend`, `Additive`, `InvAlpha`): `glDepthMask(false)`.
- Clip-map (binary alpha): `glDepthMask(true)` (foliage casts depth).
- Opaque: `glDepthMask(true)`.
Today's code at lines 401-433 sets `DepthMask(true)` for opaque,
`DepthMask(false)` for transparent. Confirm ClipMap is in the opaque
pass (it is, per `IsOpaque` returning true for ClipMap at line 738).
If audit finds nothing wrong, ship a comment + a unit test that locks in
the partition. Cheap insurance against future regression.
### 4.10 Quality Preset System (T22.5 — added mid-execution)
**Background:** Added between T22 (fog wiring) and T23 (DIAG budgets) at
user's direction. The original spec had no preset concept; §2 was written
against absolute 240 FPS on fixed N₁/N₂. T22.5 makes both radii and every
quality knob user-controllable via a single enum. §2 was amended above to
reflect the per-preset, refresh-rate-relative acceptance criteria.
#### Schema
```csharp
public enum QualityPreset { Low, Medium, High, Ultra }
public readonly record struct QualitySettings(
int NearRadius,
int FarRadius,
int MsaaSamples,
int AnisotropicLevel,
bool AlphaToCoverage,
int MaxCompletionsPerFrame);
```
`QualitySettings.From(preset)` returns the canonical values:
| Preset | NearRadius | FarRadius | MsaaSamples | AnisotropicLevel | AlphaToCoverage | MaxCompletionsPerFrame |
|---|---|---|---|---|---|---|
| Low | 2 | 5 | 0 | 4 | false | 2 |
| Medium | 3 | 8 | 2 | 8 | false | 3 |
| High | 4 | 12 | 4 | 16 | true | 4 |
| Ultra | 5 | 15 | 4 | 16 | true | 6 |
`QualitySettings.WithEnvOverrides(baseSettings)` applies per-field env-var
overrides (see §4.10.3).
#### Persistence and UI
`DisplaySettings.Quality` (type `QualityPreset`) persists via the existing
`settings.json` infrastructure (Phase L.0). The Settings panel (F11) exposes
a Quality dropdown in its Display tab (`SettingsPanel.RenderDisplayTab`).
#### Wiring (GameWindow.OnLoad + ReapplyQualityPreset)
1. `GameWindow.OnLoad` resolves the active `QualitySettings`:
`QualitySettings.From(displaySettings.Quality).WithEnvOverrides(...)`.
2. `StreamingController` and `LandblockStreamer` are built with the preset's
`NearRadius` / `FarRadius`.
3. `TerrainAtlas.SetAnisotropic(settings.AnisotropicLevel)` called once at
load and again on reapply.
4. `WindowOptions.Samples = settings.MsaaSamples` applied at window creation
time only (MSAA mid-session change is structurally unsupported by OpenGL).
5. `WbDrawDispatcher.AlphaToCoverage = settings.AlphaToCoverage`.
6. `StreamingController.MaxCompletionsPerFrame = settings.MaxCompletionsPerFrame`.
Mid-session quality change (F11 dropdown change → Save):
- `GameWindow.ReapplyQualityPreset` rebuilds `StreamingController` +
`LandblockStreamer` with the new radii, re-applies anisotropic and
AlphaToCoverage.
- If `MsaaSamples` changed, logs a warning that MSAA sample count cannot be
changed mid-session; requires restart.
#### Env-var overrides (§4.10.3)
Applied by `QualitySettings.WithEnvOverrides` after the base preset is resolved.
Each field has one env var; all are optional. Logged at startup.
| Env var | Field overridden |
|---|---|
| `ACDREAM_NEAR_RADIUS` | `NearRadius` |
| `ACDREAM_FAR_RADIUS` | `FarRadius` |
| `ACDREAM_MSAA_SAMPLES` | `MsaaSamples` |
| `ACDREAM_ANISOTROPIC` | `AnisotropicLevel` |
| `ACDREAM_A2C` | `AlphaToCoverage` (1/0/true/false) |
| `ACDREAM_MAX_COMPLETIONS_PER_FRAME` | `MaxCompletionsPerFrame` |
#### Tests
12 tests in `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs`
cover: canonical preset values per enum member; `WithEnvOverrides` no-op when
no env vars set; `WithEnvOverrides` each override individually; invalid env-var
value falls back to base setting.
#### Files
- `src/AcDream.UI.Abstractions/Settings/QualityPreset.cs` — new
- `src/AcDream.UI.Abstractions/Settings/DisplaySettings.cs``Quality` field added
- `src/AcDream.UI.Abstractions/Panels/Settings/SettingsPanel.cs` — Display tab
Quality dropdown (`RenderDisplayTab` method)
- `src/AcDream.App/Rendering/GameWindow.cs``ReapplyQualityPreset`,
`OnLoad` preset wiring
- `tests/AcDream.UI.Abstractions.Tests/Settings/QualityPresetTests.cs` — new (12 tests)
#### Out of scope (deferred)
- Auto-detect preset on first launch (Phase A.6 / N.6.5).
- Adaptive runtime preset drop on budget miss.
- Per-feature toggles below preset level.
Commits: `afa4200` (schema + tests), `28d2c60` (wiring).
---
## 5. Data flow
### Per-frame (steady state)
```
GameWindow.OnUpdate(dt)
└─ StreamingController.Tick(playerCx, playerCy)
├─ region.RecenterTo(...) // produces TwoTierDiff if center changed
├─ for each ToLoadFar: _enqueueLoad(id, LoadFar)
├─ for each ToLoadNear: _enqueueLoad(id, LoadNear)
├─ for each ToPromote: _enqueueLoad(id, PromoteToNear)
├─ for each ToDemote: _state.RemoveEntities(id) // on render thread
├─ for each ToUnload: _enqueueUnload(id)
└─ drainCompletions(MaxCompletionsPerFrame=4)
├─ Loaded.Far: _terrain.AddLandblock(meshData); _state.AddLandblock(...)
├─ Loaded.Near: _terrain.AddLandblock(meshData); _state.AddLandblock(... entities)
├─ Promoted: _state.AddEntitiesToExisting(id, entities)
├─ Unloaded: _terrain.RemoveLandblock(id); _state.RemoveLandblock(id)
└─ Failed/Crash: log
GameWindow.OnRender
├─ TerrainModernRenderer.Draw(camera, frustum)
│ └─ glMultiDrawElementsIndirect across all near + far slots that pass cull
└─ WbDrawDispatcher.Draw(camera, gpuWorldState.LandblockEntries, frustum, visibleCellIds, animatedEntityIds)
├─ for each LB entry:
│ ├─ if invisible: walk only animatedEntityIds (Change #1)
│ └─ if visible: walk entities, AABB cache lookup (Change #2)
├─ classify into groups, build SSBO, multi-draw indirect
└─ flush DIAG every ~5 s
```
### Worker thread
```
LandblockStreamer.WorkerLoop
while running:
job = jobQueue.dequeue()
switch job.Kind:
LoadFar:
block = dats.Get<LandBlock>(id)
meshData = LandblockMesh.Build(block, ..., _surfaceCache)
completionQueue.enqueue(Loaded(id, Far, block, [], meshData))
LoadNear:
block = dats.Get<LandBlock>(id)
info = dats.Get<LandBlockInfo>(...)
entities = LandblockLoader.BuildEntitiesFromInfo(info)
scenery = WbSceneryAdapter.GenerateScenery(block, ...)
meshData = LandblockMesh.Build(block, ..., _surfaceCache)
completionQueue.enqueue(Loaded(id, Near, block, entities scenery, meshData))
PromoteToNear:
info = dats.Get<LandBlockInfo>(...)
// Heightmap not re-read; scenery generation needs LandBlock for height
// sampling — read it again from disk cache (DatCollection caches the
// last-read block; cheap second access) OR pass through from render
// thread's terrain-slot snapshot (deferred plan-level decision).
block = dats.Get<LandBlock>(id)
entities = LandblockLoader.BuildEntitiesFromInfo(info)
scenery = WbSceneryAdapter.GenerateScenery(block, ...)
completionQueue.enqueue(Promoted(id, entities scenery))
```
---
## 6. Threading model
- **Render thread:** drives `StreamingController.Tick`, drains the
completion queue, calls `TerrainModernRenderer.AddLandblock` /
`RemoveLandblock`, mutates `GpuWorldState`. All GL calls on this thread.
- **One streaming worker thread:** dat reads, mesh build, scenery generation.
Owns `_surfaceCache` (now `ConcurrentDictionary`) — render thread does
not access it directly.
- **Network thread:** unchanged from Phase A.3 — drains UDP into the
channel; render thread decodes.
Synchronization:
- Job queue: `Channel<LbStreamJob>` (writer = render thread via
`_enqueueLoad`; reader = worker).
- Completion queue: `ConcurrentQueue<LandblockStreamResult>` (writer =
worker; reader = render thread).
- `_surfaceCache`: `ConcurrentDictionary<uint, SurfaceInfo>` populated by
`LandblockMesh.Build` on the worker; read by future paths if any
(none today).
- `TerrainBlendingContext`: read-only post-init. No lock.
---
## 7. Error handling
- **Worker crash:** caught in worker loop, posts
`LandblockStreamResult.WorkerCrashed`. Render thread logs to console.
(Existing pattern.)
- **Dat read failure:** posts `LandblockStreamResult.Failed`. Render
thread logs. Streaming continues with the LB skipped — region still
tracks it as resident so we don't retry forever, but the slot stays empty.
- **AABB cache invalidation race:** dynamic entity moves while the
dispatcher is walking. Acceptable — at worst, the entity culls or
draws based on the previous frame's position. Position is updated in
the network handler (also render-thread today) so no actual race.
- **Promotion timing:** if the player crosses N₁ inward, we enqueue a
`Near` load on the worker. Until it completes, the LB has terrain but
no scenery / entities. Frame budget is unaffected (only `LoadedLandblock`
changes, and the dispatcher already handles missing entities by walking
zero-length lists).
- **Unload during in-flight load:** enqueue an unload while a load is
in flight. When the load completes, render thread sees the LB is no
longer resident — drop the result silently. Same pattern as today.
---
## 8. Testing strategy
### Unit tests (offline, no GL)
Add to `tests/AcDream.Core.Tests/Streaming/`:
- `StreamingRegion_TwoTier_FirstTick_LoadsNearAndFarSeparately` — first
call produces `ToLoadNear` populated for inner ring, `ToLoadFar`
populated for outer ring, `ToPromote` empty (nothing was previously
resident).
- `StreamingRegion_TwoTier_NullToFar_OnFarRingEntry` — LB rolls into
far window from null. Asserts entry in `ToLoadFar`, not
`ToLoadNear`.
- `StreamingRegion_TwoTier_FarToNear_OnNearRingEntry` — LB was
far-resident, player walks toward it, LB enters near window. Asserts
entry in `ToPromote`, not `ToLoadNear`.
- `StreamingRegion_TwoTier_NullToNear_OnTeleport` — observer center
jumps far enough that an LB goes from null → Near in one frame
(e.g., teleport). Asserts entry in `ToLoadNear`, not `ToPromote`.
- `StreamingRegion_TwoTier_NearToFar_OnNearBoundaryExitPlusHysteresis`
asserts entry in `ToDemote` only after distance exceeds
`NearRadius + 2`.
- `StreamingRegion_TwoTier_FarToNull_OnFarBoundaryExitPlusHysteresis`
asserts entry in `ToUnload` only after distance exceeds
`FarRadius + 2`.
- `StreamingRegion_TwoTier_HysteresisHoldsAcrossOscillation` — walk
back-and-forth across N₁ five times within the hysteresis radius;
assert no demote events fire.
- `StreamingController_TwoTier_DrainsRoutedByVariant``Loaded.Far`,
`Loaded.Near`, and `Promoted` each route to the right state mutation
on the render thread.
Add to `tests/AcDream.Core.Tests/Rendering/Wb/`:
- `WbDrawDispatcher_AnimatedEntities_InInvisibleLb_NoFullEntityWalk`
verify Change #1 (only iterates `animatedEntityIds`, not `Entities`).
- `WbDrawDispatcher_PerEntityAabbCached_NotRecomputed` — assert AABB
fields are read, not recomputed, for static entities.
### Conformance tests
- `TerrainModernConformanceTests` (existing) — must still pass. The
visual mesh Z must agree with `TerrainSurface.SampleZFromHeightmap`
to within 1 mm across both tiers.
- `LandblockMeshTests` (existing) — must still pass. Worker-thread
mesh build produces byte-identical results to render-thread build
for the same inputs.
### Perf gate (manual, with `[WB-DIAG]` + `[TERRAIN-DIAG]`)
- **Standstill bench:** launch with `ACDREAM_WB_DIAG=1`, stand at
Holtburg dueling field for 60 s. Read median + p95 + p99 from log.
- **Walking bench:** launch with diag, run from Holtburg to North
Yanshi, ~60 s. Same metrics.
- **First traversal bench:** clear OS file cache (or reboot), launch
with diag, walk into a region not previously visited, capture the
worker-thread fill duration + render-thread frame time during fill.
### Visual gate (manual, user-driven)
User launches the client, walks the standard route, confirms:
1. Horizon visible at 2.3 km.
2. Fog blend is smooth (no scenery cliff at N₁).
3. No shimmer on distant terrain.
4. Smooth tree edges (foliage A2C).
5. No new z-fighting / depth artifacts.
---
## 9. Out of scope (explicitly deferred)
Per the brainstorm Q10 confirmation:
- **GPU-side culling** (compute pre-pass) — N.6.
- **Persistent-mapped indirect buffer** — N.6.
- **Multi-thread mesh-build worker pool** — N.6 if first-traversal fill
feels too slow at gate.
- **Static/dynamic persistent groups** (Q5 Option B — the "compute the
group key once at spawn" architecture change) — separate later phase
(likely A.6 or N.6.5).
- **Billboard / impostor scenery** at far tier — escalation only if the
fog'd terrain horizon looks too bare at gate.
- **Wider N₁ hysteresis** (Option C, radius+3) — single-line tweak only
if gate finds entity pop-in along the boundary.
- **Far-tier terrain mesh LOD** (decimating 2×2 LBs) — not needed at
N₂=12; revisit only if N₂ grows beyond 15.
- **Sky / particles modern path migration** — N.7+ phases.
- **EnvCell modern path migration** — separate phase.
- **Shadow mapping** — separate visual phase, later.
- **Strict 240 Hz during walking** (Q9 Option A) — graduate to in a
perf-polish phase if we want to commit to it.
---
## 10. Risks
1. **Fog tuning visual gate** *(highest risk).* Hardest non-engineering
risk. The 0.7 / 0.95 multipliers in §4.8 are first-cut numbers. If
the fog band is too thin (visible scenery cliff at N₁) or too thick
(terrain looks washed out), iterate on the multipliers. Mitigation:
expose `FogStart` / `FogEnd` as tunable env vars during A.5
development for fast iteration.
2. **A2C / MSAA framebuffer interaction** *(moderate risk).* MSAA on
the GL render target may break sky / particles / UI rendering.
Audit during implementation. **Fallback: ship Q8 Option B (mipmaps
+ depth-audit only) if A2C goes sideways.** Document the result.
3. **Worker starvation on first-traversal** *(low-moderate risk).*
~2.7 s of sequential mesh build on first walk into virgin region.
Render thread frame time stays in budget; the visible effect is the
horizon visibly filling. Acceptable per Q9 Option B; graduate to
multi-worker pool in N.6 if user complains.
4. **Tier-boundary churn** *(low risk).* When player crosses N₁ both
directions, demote→promote→demote fires. Hysteresis (radius+2) is
the buffer. If thrash visible, widen to radius+3.
5. **Entity AABB cache invalidation** *(low risk).* Dynamic entities
must recompute AABB on position change. Single-threaded render
thread means no concurrent mutation; the dirty-flag pattern is
straightforward.
6. **Server broadcast radius mismatch** *(low risk).* If ACE's broadcast
radius is < N=4, NPCs in outer near-tier LBs won't be
server-broadcast (they don't exist in our state). Mitigation:
N₁=4 is conservative — typical ACE configs broadcast at 5-7 LBs.
If observed, drop N₁ to 3.
---
## 11. What was deferred (post-A.5)
The following items were identified during A.5 development but deferred to
post-A.5 phases. They are tracked as OPEN issues in `docs/ISSUES.md`.
1. **Tier 1 entity-classification cache** (commit `3639a6f` reverted at
`9b49009`): First attempt cached `meshRef.PartTransform` which is mutated
per frame for animated entities (skeletal pose). Next attempt needs:
(a) audit AnimationSequencer + AnimationHookRouter to identify ALL
per-frame mutations of MeshRef state; (b) redesign cache to bypass
animated entities OR cache only the animation-invariant subset; (c) test
specifically with a moving animated NPC on screen. (`docs/ISSUES.md` #53)
2. **Lifestone missing visual**: The Holtburg lifestone has not rendered since
earlier in A.5 development. Possibly Bug A's far-tier strip incorrectly
catching a near-tier entity, or a separate earlier regression.
(`docs/ISSUES.md` #52)
3. **Plumb JobKind through BuildLandblockForStreaming**: Bug A's fix (commit
`9217fd9`) strips entities post-load in the worker. Proper fix: skip the
`LandBlockInfo` + scenery load entirely for far-tier jobs. ~30 min.
(`docs/ISSUES.md` #54)
4. **Tier 2 — Static/dynamic split with persistent groups**: ~2-week phase.
Avoids per-frame entity re-classification by maintaining stable groups
keyed at spawn time. Roadmap doc at
`docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md`.
5. **Tier 3 — GPU-side culling via compute pre-pass**: ~1-month phase.
Same roadmap doc.
6. **Eliminate ToEntries adapter allocation**: tiny win (~25 KB/frame).
7. **InvalidateEntity wiring on palette/ObjDesc events**: needed by the
Tier 1 retry.
8. **Visual gate at full High preset**: never validated due to the
GPU+CPU stack-up OS crash earlier in A.5. With Bug A fixed the crash
likely won't recur; defer retest to post-A.5 perf polish.
---
## 12. References (formerly §11)
- **Handoff (cold-start):** [`docs/research/2026-05-10-phase-a5-handoff.md`](../../research/2026-05-10-phase-a5-handoff.md)
- **N.5b handoff (predecessor):** [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md)
- **N.5b perf baseline:** [`docs/plans/2026-05-09-phase-n5b-perf-baseline.md`](../../plans/2026-05-09-phase-n5b-perf-baseline.md)
- **Roadmap A.5 entry:** [`docs/plans/2026-04-11-roadmap.md`](../../plans/2026-04-11-roadmap.md)
- **N.5b memory state:** `memory/project_phase_n5b_state.md` (three high-value
gotchas — bindless uniform-sampler driver quirk, MaybeFlushTerrainDiag
underflow, visual gate confirmation requirement).
- **Existing streaming files:**
- [`src/AcDream.App/Streaming/StreamingController.cs`](../../../src/AcDream.App/Streaming/StreamingController.cs)
- [`src/AcDream.App/Streaming/StreamingRegion.cs`](../../../src/AcDream.App/Streaming/StreamingRegion.cs)
- [`src/AcDream.App/Streaming/GpuWorldState.cs`](../../../src/AcDream.App/Streaming/GpuWorldState.cs)
- [`src/AcDream.App/Streaming/LandblockStreamer.cs`](../../../src/AcDream.App/Streaming/LandblockStreamer.cs)
- **Existing dispatcher:** [`src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs)
- **Existing terrain renderer:** [`src/AcDream.App/Rendering/TerrainModernRenderer.cs`](../../../src/AcDream.App/Rendering/TerrainModernRenderer.cs)
- **Mesh builder (will move off render thread):** [`src/AcDream.Core/Terrain/LandblockMesh.cs`](../../../src/AcDream.Core/Terrain/LandblockMesh.cs)

View file

@ -0,0 +1,438 @@
# Phase N.5b — Terrain on the Modern Rendering Path — Design Spec
**Status:** Brainstormed 2026-05-09; not yet implemented.
**Author:** acdream lead engineer + Claude.
**Builds on:** Phase N.5 (`WbDrawDispatcher` on bindless + multi-draw indirect, shipped 2026-05-08).
**Predecessor docs (read first if you're new to this phase):**
- [`docs/research/2026-05-09-phase-n5b-handoff.md`](../../research/2026-05-09-phase-n5b-handoff.md) — cold-start briefing.
- [`docs/superpowers/plans/2026-05-08-phase-n5-modern-rendering.md`](../plans/2026-05-08-phase-n5-modern-rendering.md) — N.5 plan + ship record.
- [`docs/superpowers/specs/2026-05-08-phase-n5-modern-rendering-design.md`](2026-05-08-phase-n5-modern-rendering-design.md) — N.5 spec; the substrate N.5b consumes.
- [`docs/ISSUES.md`](../../ISSUES.md) issue #51 — the load-bearing constraint this phase resolves.
---
## 1. Problem statement
N.5 lifted **entity** rendering onto bindless textures + `glMultiDrawElementsIndirect`. CPU dispatcher is 1.23 ms/frame median at Holtburg courtyard; ~810 fps sustained; ~12-15 GL calls/frame for entities regardless of scene complexity. Terrain is still on the older per-landblock pipeline (`TerrainChunkRenderer` at [src/AcDream.App/Rendering/TerrainChunkRenderer.cs](../../../src/AcDream.App/Rendering/TerrainChunkRenderer.cs)) — bind a per-chunk VAO + IBO, issue `glDrawElements` per visible chunk. At radius=2 that's ~25 GL calls/frame for terrain; at radius=5 it scales to ~121.
**N.5b's goal:** lift terrain rendering onto the same modern primitives N.5 just delivered, preserving the visible terrain pixel-for-pixel and preserving physics-vs-visual Z agreement (issue #51 / the cell-boundary wobble bug class).
The work is straightforward in shape — N.5's substrate (bindless wrapper, `DrawElementsIndirectCommand` struct, `[WB-DIAG]` instrumentation, two-phase Dispose pattern) is already built. The non-trivial decision is how to handle the formula divergence between WorldBuilder and retail.
---
## 2. The formula divergence (why Path A is dead)
WorldBuilder's `TerrainUtils.CalculateSplitDirection` ([references/WorldBuilder/.../TerrainUtils.cs:44-53](../../../references/WorldBuilder/WorldBuilder.Shared/Modules/Landscape/Lib/TerrainUtils.cs:44)) and acdream's `TerrainBlending.CalculateSplitDirection` ([src/AcDream.Core/Terrain/TerrainBlending.cs:56](../../../src/AcDream.Core/Terrain/TerrainBlending.cs:56)) use mathematically distinct formulas:
| | Formula | Source |
|---|---|---|
| acdream | `dw = x*y*0x0CCAC033 - x*0x421BE3BD + y*0x6C1AC587 - 0x519B8F25; bit31` | AC2D `Landblocks.cpp:346-350` |
| WB | `(seedA + 1813693831) - seedB - 1369149221 >= 0.5` (rescaled) where `seedA = (lbX*8+cellX)*214614067; seedB = (lbY*8+cellY)*1109124029` | clean-room reverse engineering |
**Verified retail authority:** the named retail decomp at [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt) lines 316042-316144 (function `CLandBlockStruct::ConstructPolygons` at retail address `00531d10`) contains the constants `0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD / 0x519B8F25` verbatim. **Retail uses AC2D's formula.** acdream matches retail. **WB does not.**
**Quantified divergence** (per `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs`, sweep across 255×255 landblocks × 64 cells = 4,161,600 cells):
| Comparison | Disagreement rate |
|---|---|
| Raw enum output (WB enum vs acdream enum) | **50.02%** |
| Diagonal-actually-painted (post-correcting for WB's inverted enum semantics) | **49.98%** |
| Holtburg town (0xA9B0) | 29/64 cells (45.3%) wrong if using WB |
| Worst landblock (0x4D96) | 47/64 cells (73.4%) wrong if using WB |
| Best landblock (0x0478) | 17/64 cells (26.6%) wrong if using WB |
The two formulas behave like independent random hashes. Adopting WB's pipeline wholesale (Path A) would visibly mis-render ~half the diagonals on every landblock — the cell-boundary wobble bug class would be present everywhere.
**Path A is dead.** N.5b commits to Path C (see Decision 1 below): use WB's *renderer* pattern (single global VBO/EBO + slot allocator + multi-draw indirect), driven by acdream's existing `LandblockMesh.Build` which uses retail's formula.
---
## 3. Decisions log
The eight brainstorm outcomes, locked.
| # | Decision | Choice | Reason |
|---|---|---|---|
| 1 | Formula source for cell split direction | **Path C — WB renderer pattern, acdream's `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`** (retail's formula) | Path A measured 49.98% diagonal-painted divergence vs retail. Path B (fork-patch WB) is permanent maintenance burden. Path C keeps a known-working asset and avoids fork friction. Same per-frame perf as either alternative. |
| 2 | Atlas model | **Keep `TerrainAtlas` (palCode-based fragment blending) + add bindless handles** | Visual correctness already locked in. Bindless wrapper is ~50 lines, cookie-cutter from N.5's `TextureCache.MakeResidentHandle` pattern. No perf win from adopting WB's `LandSurfaceManager`. |
| 3 | Mesh ownership | **Single global VBO/EBO + slot allocator, one slot per landblock** | Required for `glMultiDrawElementsIndirect` to actually win — per-LB IBOs would force per-LB binds, defeating the point. Mirrors N.5's pattern + WB's pattern. |
| 4 | Index format | **uint32 + baseVertex baked into indices on upload** | Matches WB's pattern verbatim ("maximum driver compatibility"). 192 KB extra IBO at 256 slots — rounding error vs vertex bytes. Future-proofs A.5's higher radius. |
| 5 | Shader unification | **Separate `terrain_modern.vert/.frag`** | Vertex layouts are meaningfully different (terrain: 6 attribs incl. palCode; entities: position+UV+normal+per-instance matrix). Unifying forces dead code on both sides; no perf win. |
| 6 | Streaming integration | **Mirror WB's slot allocator (free-list `Queue<int>` + power-of-two grow). Skip WB's 15s unload delay.** | Free-list standard; grow-by-doubling matches N.5 buffer growth pattern. The 15s delay would compete with `StreamingLoader`'s existing hysteresis — let one component own lifecycle policy. |
| 7 | Conformance test | **Pure-CPU sweep: visual mesh Z = `TerrainSurface.SampleZFromHeightmap` within 1mm, 10 representative landblocks × 100 sample points** | The exact issue #51 sentinel. ~1,000 assertions/run, <100ms, no GL infrastructure needed. Catches any silent formula or vertex-layout drift. |
| 8 | Visual verification gate | **4 outdoor scenes (Holtburg flat + sloped, Foundry-area, sloped LB) × 6 visual checks** | Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet. The wobble check is the load-bearing #51 sentinel. |
---
## 4. Architecture overview
### Per-frame draw flow
```
TerrainModernRenderer.Draw(camera, frustum, neverCullId):
1. Walk all loaded slots → per-slot frustum cull (AABB test).
Build _visibleSlots list (in-place reuse, no per-frame alloc).
2. If _visibleSlots.Count == 0: early-out.
3. Build per-frame DEIC array, one entry per visible slot:
DrawElementsIndirectCommand {
Count = 384, // verts/landblock
InstanceCount= 1,
FirstIndex = slot.FirstIndex, // baked offset into global IBO
BaseVertex = 0, // already baked into indices
BaseInstance = 0
}
4. If _drawIndirectCapacity < _visibleSlots.Count:
delete + re-allocate _indirectBuffer (power-of-two grow).
glBufferSubData(DRAW_INDIRECT_BUFFER, 0, sizeof(DEIC) * _visibleSlots.Count, deicArray)
5. shader.Use() // terrain_modern
6. Bind global VAO (_globalVao)
7. Set bindless handle uniforms: glProgramUniformHandleARB for uTerrain + uAlpha
8. Bind DRAW_INDIRECT_BUFFER (_indirectBuffer)
9. glMemoryBarrier(GL_COMMAND_BARRIER_BIT)
10. glMultiDrawElementsIndirect(Triangles, UnsignedInt, indirect=0,
drawcount=_visibleSlots.Count, stride=sizeof(DEIC))
11. Unbind VAO.
GL calls per frame for terrain: ~6-8 fixed.
- 1× shader.Use
- 1× BindVertexArray
- 2× ProgramUniformHandleARB (atlas handles)
- 1× BindBuffer for DRAW_INDIRECT_BUFFER
- 1× BufferSubData for DEIC array
- 1× MemoryBarrier
- 1× MultiDrawElementsIndirect
- 1× BindVertexArray(0)
```
### Per-landblock-load flow (streaming integration)
```
TerrainModernRenderer.AddLandblock(id, meshData, worldOrigin):
1. If id already present: RemoveLandblock(id) first (replaces).
2. Bake worldOrigin into vertex positions (CPU; ~12µs per landblock).
3. Acquire slot:
if _freeSlots.TryDequeue: reuse
else: slot = _nextFreeSlot++; if needed, EnsureCapacity(_nextFreeSlot).
4. Compute slot offsets:
slotByteOffset_VBO = slot * 384 * 40 bytes (15,360 bytes per slot)
slotByteOffset_IBO = slot * 384 * 4 bytes (1,536 bytes per slot)
firstIndex = slot * 384
baseVertex = slot * 384
5. Bake baseVertex into indices on CPU (indices[i] += baseVertex).
6. glBufferSubData(VBO, slotByteOffset_VBO, vertBytes, vertData).
7. glBufferSubData(IBO, slotByteOffset_IBO, idxBytes, bakedIndices).
8. Compute slot AABB (worldOrigin.x, worldOrigin.y, minZ, +192, +192, maxZ).
9. Store SlotData {id, worldOrigin, firstIndex, indexCount, aabbMin, aabbMax}.
10. _idToSlot[id] = slot.
TerrainModernRenderer.RemoveLandblock(id):
1. _idToSlot.TryGetValue(id) → slot.
2. _freeSlots.Enqueue(slot); _idToSlot.Remove(id); _slots[slot] = null.
(No GPU clear — DEIC list won't reference unused slots.)
EnsureCapacity(requiredSlots):
newCap = max(initialCapacity, currentCap * 2)
while newCap < requiredSlots: newCap *= 2.
Allocate new VBO + IBO at new size.
glCopyBufferSubData old → new (preserve loaded slot data).
Delete old; recreate VAO pointing at new VBO+IBO.
```
### Relation to N.5's existing dispatcher
`TerrainModernRenderer` is structurally **parallel** to `WbDrawDispatcher`, not nested under it. They share:
- `BindlessSupport` wrapper for `ARB_bindless_texture` calls
- `DrawElementsIndirectCommand` struct (20-byte layout)
- `[WB-DIAG]` instrumentation pattern (CPU `Stopwatch` + GPU `GL_TIME_ELAPSED` queries)
- `SceneLighting` UBO at binding=1
But they're separate dispatchers with separate global buffers, separate VAOs, separate shaders. Per frame, `GameWindow.Draw` calls them in sequence:
1. `_wbDrawDispatcher.Draw(...)` — entities (opaque + transparent passes)
2. `_terrainModern.Draw(...)` — terrain (single opaque pass)
3. Sky / particles / debug / UI on legacy paths until later phases retire them.
---
## 5. Component changes
### Files added
| File | Purpose | Approx. size |
|---|---|---|
| `src/AcDream.App/Rendering/TerrainModernRenderer.cs` | The new dispatcher. Owns global VBO/EBO + slot allocator + per-frame DEIC build + `glMultiDrawElementsIndirect` dispatch. | ~400-500 lines |
| `src/AcDream.App/Rendering/TerrainSlotAllocator.cs` | Pure-CPU helper extracted for unit testing: free-list slot management + DEIC array builder. | ~150 lines |
| `src/AcDream.App/Rendering/Shaders/terrain_modern.vert` | Vertex shader. Same per-cell layout as today's `terrain.vert` (locations 0-5). Reads bindless atlas handles via uniform. Same `SceneLighting` UBO at binding=1. Same per-vertex AdjustPlanes lighting bake. | ~150 lines |
| `src/AcDream.App/Rendering/Shaders/terrain_modern.frag` | Fragment shader. Same `combineOverlays` + `combineRoad` + `maskBlend3` as today's `terrain.frag`. Samples bindless `sampler2DArray` handles via `GL_ARB_bindless_texture` extension. Same fog + lightning flash + atmosphere. | ~150 lines |
| `tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs` | The Z-conformance sentinel for issue #51's bug class. ~10 representative landblocks × ~100 sample points; asserts `\|meshTriZ - TerrainSurface.SampleZFromHeightmap\| < 0.001m`. | ~150 lines |
| `tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs` | Unit tests for the slot allocator (free-list correctness, capacity grow, AABB tracking) + DEIC build correctness. Pure CPU; no GL. | ~200 lines |
### Files modified
| File | Change |
|---|---|
| `src/AcDream.App/Rendering/TerrainAtlas.cs` | Add `GetBindlessHandles()` returning `(ulong terrain, ulong alpha)`. Mirrors N.5's `TextureCache.MakeResidentHandle` pattern: generate handle once at first call, make resident, cache. The existing `GlTexture` / `GlAlphaTexture` `uint` properties stay (no legacy callers to migrate yet, but the path is preserved). |
| `src/AcDream.App/Rendering/GameWindow.cs` | Field declaration ([line 21](../../../src/AcDream.App/Rendering/GameWindow.cs:21)): `_terrain` field type `TerrainChunkRenderer? → TerrainModernRenderer?`. Construction ([line 1391](../../../src/AcDream.App/Rendering/GameWindow.cs:1391)): `new TerrainChunkRenderer(gl, shader, atlas)``new TerrainModernRenderer(gl, bindless, shader, atlas)`. Wire the `[TERRAIN-DIAG]` rollup callback (mirror the existing `[WB-DIAG]` callback wiring). |
| `docs/plans/2026-04-11-roadmap.md` | N.5b → "Shipped" row on completion; N.6 entry refreshed to remove "terrain on modern path" from scope. |
| `docs/ISSUES.md` | Issue #51 → "Recently closed" with the SHIP commit SHA. |
| `CLAUDE.md` "WB integration cribs" section | Add the N.5b crib: terrain dispatcher mirror of WB's pattern, retail-formula preserved via `LandblockMesh.Build` + `TerrainBlending.CalculateSplitDirection`. |
| `memory/project_phase_n5b_state.md` (new memory file) | Captures any high-value gotchas discovered during N.5b implementation (analogous to `project_phase_n5_state.md`'s three gotchas). |
### Files deleted
| File | Reason |
|---|---|
| `src/AcDream.App/Rendering/TerrainChunkRenderer.cs` (454 lines) | Replaced by `TerrainModernRenderer`. |
| `src/AcDream.App/Rendering/TerrainRenderer.cs` (247 lines) | Older sibling — already not wired in production. Has no users. Goes away in the same commit as `TerrainChunkRenderer`. |
| `src/AcDream.App/Rendering/Shaders/terrain.vert` (147 lines) | Replaced by `terrain_modern.vert`. |
| `src/AcDream.App/Rendering/Shaders/terrain.frag` (149 lines) | Replaced by `terrain_modern.frag`. |
### Net diff
- Adds: ~6 files, ~1,200 lines (renderer + slot-allocator + 2 shaders + 2 test files)
- Removes: ~4 files, ~1,000 lines (2 old renderers + 2 old shaders)
- Net: ~+200 lines for the same visual output, with the dispatcher collapsed to ~6-8 GL calls/frame regardless of scene size
### Public API of `TerrainModernRenderer`
```csharp
public sealed class TerrainModernRenderer : IDisposable
{
public TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader terrainModernShader,
TerrainAtlas atlas,
int initialSlotCapacity = 64);
public void AddLandblock(uint landblockId, LandblockMeshData mesh, Vector3 worldOrigin);
public void RemoveLandblock(uint landblockId);
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null);
public int LoadedSlots { get; } // for [TERRAIN-DIAG]
public int VisibleSlots { get; } // for [TERRAIN-DIAG]
public int CapacitySlots { get; } // for [TERRAIN-DIAG]
public void Dispose();
}
```
Same external interface as today's `TerrainChunkRenderer` (`AddLandblock` + `RemoveLandblock` + `Draw`). Drop-in at `GameWindow.cs:1391`.
---
## 6. Vertex format & shader
### Vertex format: `TerrainVertex` stays as-is (40 bytes)
```csharp
[StructLayout(LayoutKind.Sequential)]
public readonly record struct TerrainVertex(
Vector3 Position, // 12 bytes — world-space (worldOrigin baked in by AddLandblock)
Vector3 Normal, // 12 bytes — per-vertex from central-difference (Phase 3b)
uint Data0, // 4 bytes — base+ovl0 tex/alpha indices
uint Data1, // 4 bytes — ovl1+ovl2 tex/alpha indices
uint Data2, // 4 bytes — road0+road1 tex/alpha indices
uint Data3); // 4 bytes — rotations + splitDir bit
// total: 40 bytes
```
Already correct, already debugged. Per-vertex normal is preserved because retail bakes AdjustPlanes lighting at the vertex stage — losing it would re-introduce the "warmer / less blue than retail" regression researched in [`docs/research/2026-04-24-lambert-brightness-split.md`](../../research/2026-04-24-lambert-brightness-split.md).
VAO attribute layout (locations 0-5, unchanged from today's `terrain.vert`):
| Loc | Type | Source | Purpose |
|---|---|---|---|
| 0 | vec3 (3 floats) | Position offset 0 | world-space position |
| 1 | vec3 (3 floats) | Normal offset 12 | per-vertex normal |
| 2 | uvec4 (4 bytes) | Data0 offset 24 | base+ovl0 tex/alpha |
| 3 | uvec4 (4 bytes) | Data1 offset 28 | ovl1+ovl2 tex/alpha |
| 4 | uvec4 (4 bytes) | Data2 offset 32 | road0+road1 tex/alpha |
| 5 | uvec4 (4 bytes) | Data3 offset 36 | rotations + splitDir |
### Shader: `terrain_modern.vert/.frag`
The structural change vs today's `terrain.vert/.frag` is small. The blend math, lighting bake, fog, lightning flash all stay verbatim. The only change is how textures are bound:
```glsl
// terrain_modern.frag — preamble
#version 460 core
#extension GL_ARB_bindless_texture : require
uniform sampler2DArray uTerrain; // 64-bit bindless handle, set per-frame
uniform sampler2DArray uAlpha; // 64-bit bindless handle, set per-frame
// SceneLighting UBO at binding=1 (unchanged from today)
layout(std140, binding = 1) uniform SceneLighting { ... };
// rest is unchanged from today's terrain.frag — combineOverlays, combineRoad,
// maskBlend3, applyFog, lightning flash are line-for-line identical
```
C# side per frame:
```csharp
// once at startup or first Draw, after atlas is built:
var (terrainHandle, alphaHandle) = atlas.GetBindlessHandles();
// MakeTextureHandleResidentARB called inside GetBindlessHandles, mirror N.5's pattern
// per frame:
shader.Use();
gl.ProgramUniformHandleARB(shader.Program, uTerrainLoc, terrainHandle);
gl.ProgramUniformHandleARB(shader.Program, uAlphaLoc, alphaHandle);
// ... bind global VAO + DEIC + glMultiDrawElementsIndirect
```
The bindless extension makes texture access syntactically identical to today's `sampler2DArray` uniform — the only difference is *how* the sampler is set on the C# side. GLSL doesn't know it's bindless.
### SSBO/UBO binding map (cross-checked with N.5)
| Binding | Type | Owner | Used by |
|---|---|---|---|
| SSBO=0 | `Instances[]` (mat4) | `WbDrawDispatcher` | `mesh_modern.vert` |
| SSBO=1 | `Batches[]` (handle+layer+flags) | `WbDrawDispatcher` | `mesh_modern.vert/.frag` |
| **SSBO=2** | (reserved) | — | future per-batch terrain data when A.5 wants per-LB atlas variation |
| UBO=1 | `SceneLighting` | `GameWindow` (set once/frame) | `mesh_modern.frag`, `terrain_modern.vert/.frag`, `sky.frag`, etc. |
N.5b doesn't introduce a new SSBO. The atlas handles are uniforms, not SSBO entries — atlas is region-wide so per-frame upload is two `uvec2`s (16 bytes), not worth the SSBO machinery. SSBO=2 stays available for future per-batch terrain data.
### What's preserved bit-for-bit from today's shaders
- `unpackOverlayLayer(...)` (rotation logic for overlays)
- The `gl_VertexID % 6 → corner` table for both SWtoNE and SEtoNW splits (the geometry mapping that was debugged 2026-04-21 to match ACE's `ConstructPolygons`)
- `MIN_FACTOR = 0.0` for the AdjustPlanes Lambert floor (the brightness research)
- `combineOverlays` + `combineRoad` + `maskBlend3` fragment math
- `applyFog` distance-blend
- Lightning flash additive overlay
- Per-vertex sun + ambient bake into `vLightingRGB`
---
## 7. Conformance + verification
### CPU unit tests (no GL required)
**`tests/AcDream.Core.Tests/Rendering/TerrainSlotAllocatorTests.cs`** — exercises the dispatcher's pure-CPU pieces in isolation:
| Test | Asserts |
|---|---|
| `Add_FirstLandblock_GetsSlotZero` | `_nextFreeSlot` starts at 0; first add uses slot 0 |
| `Add_SecondLandblock_GetsSlotOne` | Sequential adds use sequential slots |
| `RemoveThenAdd_ReusesFreedSlot` | Free-list FIFO: remove slot 0, add new LB → slot 0 again |
| `Add_BeyondInitialCapacity_DoublesCapacity` | After 64 adds, 65th triggers grow to 128 |
| `AddSameId_ReplacesExistingSlot` | Re-adding an LB id replaces in same slot (no leak) |
| `Build_DeicArray_VisibleSlotsOnly` | DEIC array has one entry per visible slot, `firstIndex = slot * 384`, `count = 384` |
| `Build_DeicArray_EmptyVisible` | No visible → empty array |
| `Aabb_StoredFromWorldOrigin` | Slot's AABB is `(origin.x, origin.y, minZ)..(origin.x+192, origin.y+192, maxZ)` |
**`tests/AcDream.Core.Tests/Terrain/TerrainModernConformanceTests.cs`** — the Z-conformance sentinel for issue #51's bug class.
Pattern modeled on the existing `ClientConformanceTests.cs`. For each landblock:
1. Load real dat heightmap data (10 representative landblocks: Holtburg flat 0xA9B0, Holtburg sloped 0xA9B1, Foundry 0x8080, Cragstone 0xCB99, Direlands sample 0xC040, plus 5 randomly-chosen sloped landblocks from a fixed seed for variety).
2. Build mesh via `LandblockMesh.Build(...)` (the source-of-truth generator that `TerrainModernRenderer` calls internally).
3. For 100 (localX, localY) sample points uniformly distributed in `[0, 192] × [0, 192]`:
- Compute `meshTriZ`: find the triangle in the built mesh containing the point, barycentric-interpolate Z from its three vertex Zs.
- Compute `physicsZ = TerrainSurface.SampleZFromHeightmap(heights, heightTable, lbX, lbY, localX, localY)`.
- Assert `|meshTriZ - physicsZ| < 0.001m` (1 mm tolerance — well below visible threshold).
4. Total: 10 landblocks × 100 points = 1,000 assertions per run; runs in <100 ms.
If this test fires, the pipeline has silently drifted (different formula somewhere, swapped vertex order, baseVertex baked wrong, etc.) — the exact bug class issue #51 names.
### Existing tests stay green
| Test file | Proves | N.5b impact |
|---|---|---|
| `TerrainBlendingTests.cs` | `CalculateSplitDirection` returns retail's formula | unchanged — still passes |
| `LandblockMeshTests.cs` | `LandblockMesh.Build` produces correct triangles | unchanged — still passes |
| `ClientConformanceTests.cs` | Existing conformance sweep | unchanged — still passes |
| `SplitFormulaDivergenceTest.cs` | WB↔retail divergence is real (49.98%) | unchanged — runs as data documentation; passes |
| All 71 tests in N.5 filter (Wb+MatrixComposition+TextureCacheBindless) | N.5 ship intact | unchanged — terrain is a separate dispatcher |
### `[TERRAIN-DIAG]` instrumentation
A new dedicated `[TERRAIN-DIAG]` log line, parallel to the existing `[WB-DIAG]` line, so terrain perf is observable independent of entity perf. Two parallel dispatchers, two parallel diag lines:
```
[TERRAIN-DIAG] cpu_ms=avg/95th draws=N/frame visible=N loaded=N capacity=N
```
- `cpu_ms``Stopwatch` around `TerrainModernRenderer.Draw`. Median + 95th percentile over the 5-second rollup window.
- `draws` — DEIC drawcount param (number of visible landblocks dispatched per `glMultiDrawElementsIndirect` call). Should be 6-8 GL calls fixed per frame regardless of `draws` value.
- `visible` / `loaded` / `capacity` — slot accounting; for spotting growth or leaks.
- `gpu_ms``GL_TIME_ELAPSED` query around the indirect dispatch. Same double-buffering caveat as N.5 (deferred to N.6 perf polish; will report `0/0` until then).
### Visual verification gate (user runs the client)
**Scenes** (drive the character through each):
1. **Holtburg town** (~0xA9B0 area) — flat terrain + roads
2. **Holtburg sloped landblock** (~0xA9B1) — slopes + cell-boundary diagonal transitions
3. **Foundry-area** (~0x80xx) — different blend palette
4. **Any visibly-sloped outdoor landblock** — Direlands or wherever you regularly test slope behavior
**Checks** at each scene:
1. **No cell-boundary wobble** — the load-bearing #51 sentinel
2. **No missing chunks / black holes** — slot allocator or DEIC misalignment
3. **No texture seams at landblock edges** — pre-N.5b regression check
4. **No z-fighting** — pre-N.5b regression check
5. **`[TERRAIN-DIAG] draws=N` ~6-8 GL calls/frame regardless of N**
6. **`[TERRAIN-DIAG] cpu_ms` at radius=5 is ≥10% lower** than the pre-N.5b baseline (recorded in `docs/plans/2026-05-09-phase-n5b-perf-baseline.md`)
Acceptance: all six checks pass in all four scenes. **Outdoor-only — interiors / dungeons / EnvCells are out of scope and not testable yet**.
---
## 8. Acceptance criteria
1. Build green; existing tests stay green; new conformance test passes (`|deltaZ| < 1mm` across the sweep).
2. Visual identity to today confirmed at the four user-verification scenes.
3. `[TERRAIN-DIAG]` shows terrain at ~6-8 GL calls/frame regardless of scene size (vs today's 25-121).
4. No cell-boundary wobble at any visited landblock (the #51 sentinel).
5. **CPU dispatcher time at radius=5 ≥10% lower** than today's `TerrainChunkRenderer` per-LB-binds path. Measured via the `[TERRAIN-DIAG] cpu_ms` median over a 5-second rollup at the Holtburg test scene with radius=5; before/after numbers captured into `docs/plans/2026-05-09-phase-n5b-perf-baseline.md` (mirror N.5's perf baseline doc convention).
6. Issue #51 closed in `docs/ISSUES.md` with the SHIP commit SHA.
---
## 9. Out-of-scope (explicit boundaries)
N.5b does **not** ship any of these. Each is a separate phase or backlog item:
- **EnvCells / interior cells / dungeons** — different mesh source (cell-bound static geometry, not heightmap). Future phase, not currently scoped on the roadmap.
- **Sky rendering** (`SkyRenderer.cs`) — N.8 territory.
- **Particle rendering** (`ParticleRenderer.cs`) — N.8 territory.
- **Two-tier streaming + horizon LOD** (A.5) — separate brainstorm. Different streaming primitive (visible window split into "near tier" full-detail and "far tier" coarse-LOD). N.5b deliberately doesn't touch streaming radius or LOD machinery.
- **WB's `LandSurfaceManager` adoption** — Decision 2 explicitly keeps `TerrainAtlas`. Revisit only if a specific feature requires per-landblock alpha-mask bake.
- **WB's `TerrainGeometryGenerator` adoption** — Path C explicitly keeps acdream's `LandblockMesh.Build` as the source of truth. Don't call into WB's generator.
- **Fork-patching WB upstream** — Path C avoids this entirely. The WB submodule stays clean.
- **Persistent-mapped buffers / GPU-side culling / GL_TIME_ELAPSED double-buffering** — N.6 perf polish territory; not in N.5b scope.
- **Per-instance terrain "highlight" or per-LB tint** — no analogue need today; defer to backlog if a use case appears.
- **Removing `Texture2D` / `sampler2D` legacy texture path** — N.6 cleanup once Sky/Terrain/Debug/particle paths all migrate. N.5b only adds the `Texture2DArray` bindless path; legacy stays for non-terrain consumers.
- **Visual changes** — terrain renders pixel-for-pixel identical to today (same vertex layout, same blend math, same lighting bake). The phase is purely a dispatch-mechanism upgrade. Any visible diff means a bug, not a feature.
---
## 10. Implementation guidance
The phase is sized at ~1 week. Tasks decompose into ~10 mostly-parallel chunks:
1. **`TerrainAtlas` bindless extension** — add `GetBindlessHandles()` method. ~50 lines. Independent of dispatcher.
2. **`TerrainSlotAllocator`** — pure-CPU helper class. ~150 lines. Independent of GL.
3. **`TerrainSlotAllocatorTests`** — unit tests for #2. ~200 lines. Depends on #2.
4. **`terrain_modern.vert`** — port of today's `terrain.vert` with bindless preamble. ~150 lines. Independent.
5. **`terrain_modern.frag`** — port of today's `terrain.frag` with bindless preamble. ~150 lines. Independent.
6. **`TerrainModernRenderer`** — dispatcher class wiring slot allocator + GL state + bindless handle uniforms + DEIC dispatch. ~400 lines. Depends on #1, #2.
7. **`TerrainModernConformanceTests`** — Z-conformance sentinel. ~150 lines. Depends on `LandblockMesh.Build` (existing).
8. **`GameWindow` integration** — swap `TerrainChunkRenderer``TerrainModernRenderer` at field+construction; add `[TERRAIN-DIAG]` rollup. ~30 lines. Depends on #6.
9. **Delete legacy**`TerrainChunkRenderer.cs`, `TerrainRenderer.cs`, `terrain.vert`, `terrain.frag`. Depends on #8 working in production.
10. **Roadmap + ISSUES.md + memory** — close issue #51, update CLAUDE.md "WB integration cribs", write `memory/project_phase_n5b_state.md`. Depends on #8 + visual verification.
Tasks 1, 2, 4, 5, 7 can land in parallel. Task 6 depends on 1+2. Task 8 depends on 6. Tasks 9 and 10 are post-verification cleanup.
The plan document (next step after this spec) breaks each task into TDD-style subtasks with clear acceptance gates per subagent dispatch.

View file

@ -0,0 +1,451 @@
# ISSUE #53 — Tier 1 entity-classification cache (design)
**Created:** 2026-05-10.
**Status:** approved design, ready for implementation plan.
**Audit foundation:** [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).
**Originating issue:** [docs/ISSUES.md](../../ISSUES.md) §#53.
**Phase context:** Phase Post-A.5 polish, Priority 3 (only remaining priority after #52 + #54 closed).
---
## §1. Problem
`WbDrawDispatcher.Draw` runs full per-frame entity classification at radius=12: walk every visible entity → resolve textures (palette + override) → bucket into groups by `(IBO, FirstIndex, BaseVertex, IndexCount, textureHandle, layer, translucency)`. At ~10K visible entities × ~3 batches average = ~30K classification ops/frame, this dominates the dispatcher's CPU at ~3.5 ms median (post-#52/#54 baseline) — 75% over the Phase A.5 spec's 2.0 ms entity dispatcher budget.
For ~99.5% of entities (stabs, scenery, cell-mesh, interior fixtures, lifestone), the classification result is *identical* every frame from spawn to despawn. The classification work for those entities is pure waste.
A first attempt to cache this state — commit `3639a6f`, reverted at `9b49009` — froze NPC animation by caching `meshRef.PartTransform`, which is mutated every frame for entities in `_animatedEntities`. ([memory entry on the failure mode](../../../../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_phase_a5_state.md))
This spec is the audit-driven retry.
---
## §2. Goals and non-goals
### Goals
1. Drop entity dispatcher CPU median from ~3.5 ms to ≤ 2.0 ms (matches A.5 spec budget) at the horizon-safe preset (radius=4/12).
2. Hold p95 at ≤ 2.5 ms.
3. Hold animation correctness — NPCs animate, the lifestone crystal animates, the player animates, no frozen poses.
4. Hold N.5b conformance sentinel: 94/94 passing (`TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence`) throughout.
5. Hold full test baseline: 1688 passing, 8 pre-existing physics/input failures unchanged.
6. Surface a defensive guard against the prior bug class so the next regression of "static entity gets per-frame mutation snuck in" fails fast instead of silently freezing visuals.
### Non-goals
- Tier 2 (static/dynamic split with persistent groups) — separate multi-week phase per [docs/plans/2026-05-10-perf-tiers-2-3-roadmap.md](../../plans/2026-05-10-perf-tiers-2-3-roadmap.md). DO NOT bundle.
- Tier 3 (GPU compute culling) — same roadmap; depends on Tier 2 first.
- Caching for animated entities. Animated entities use today's per-frame classification path, unchanged.
- Persistent-mapped indirect buffer or any other rendering perf work outside the entity classification path.
---
## §3. Design decisions (from brainstorming, 2026-05-10)
| # | Decision | Rationale |
|---|---|---|
| Q1 | **Static-only cache + DEBUG cross-check** (option `c`) | The prior failure mode was "we silently cached mutable state." DEBUG cross-check converts that class of regression from "user notices a frozen NPC" to "Debug.Assert fires in any dev/test run." Zero Release cost. |
| Q2 | **Separate `EntityClassificationCache` class** (option `B`) at `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`, injected into `WbDrawDispatcher` via ctor | Pure-CPU testable in isolation. The single invariant ("static entity = `entity.Id ∉ _animatedEntities`") lives at the top of one ~200-line file rather than scattered through the 940-line dispatcher. |
| Q3 | **Cache the rest pose, not the full model matrix** (option `P`) | Full-matrix would save ~50 µs/frame of mat4 mults at the cost of baking `Position`/`Rotation` into the cache. With rest pose, `Position`/`Rotation` are read live every frame; if a future regression introduces a static-entity Position write, Release builds still produce correct visuals (just with unused cache entries). DEBUG cross-check catches the regression either way. Marginal perf delta dominated by safety. |
| Q4 | **Pre-flatten Setup multi-parts at populate time** (option `F`) | The bulk of the visible CPU win lives here. Today the dispatcher walks `renderData.SetupParts` per frame even though that list is per-GfxObj-immutable. Pre-flattening makes the per-frame hot path branchless: walk one flat list per entity regardless of Setup-vs-non-Setup. Populate cost: one extra mat4 mult per subPart, run once per entity per session. |
| Q5 | **Thorough test coverage** (option `T`): ~10 tests in a new `EntityClassificationCacheTests.cs`, +2 integration tests in `WbDrawDispatcherBucketingTests.cs` | The prior bug would have been caught by the DEBUG cross-check test. The "ObjDescEvent treated as despawn-respawn" test pins a contract from the audit so it can't quietly change. Setup pre-flattening test verifies the per-batch product math without the GL stack. ~150-200 lines of test code. |
---
## §4. The invariant
The cache rests on this single rule, verified in the audit:
> **An entity's `MeshRefs` reference, `Position`, `Rotation`, `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, and `Scale` are stable from spawn to despawn IF AND ONLY IF the entity is NOT in `GameWindow._animatedEntities`.**
Six write sites in `src/`, five static (one-shot at hydration), one dynamic (per-frame in `TickAnimations`, only for entities in `_animatedEntities`). All seven `Position`/`Rotation` write sites operate on entities in `_animatedEntities`. `PaletteOverride`, `HiddenPartsMask`, `ParentCellId`, `Scale` are `init`-only on `WorldEntity`. `MeshRef` is a `readonly record struct` — no in-place mutation possible. See [audit §1, §3, §4](../../research/2026-05-10-tier1-mutation-audit.md#1-entitymeshrefs---write-sites-the-core-question).
The DEBUG cross-check (§6.5) is the safety net for any future regression that violates this rule.
---
## §5. Architecture
```
┌─────────────────────────────────┐
│ GameWindow │
│ └─ _animatedEntities (dict) │ ← gating predicate
│ └─ _classificationCache (NEW) ─┼──┐
│ └─ _wbDrawDispatcher │ │
└──────────────────┬──────────────┘ │
│ │
▼ │
┌─────────────────────────────────┐ │
│ WbDrawDispatcher (MODIFIED) │ │
│ └─ Draw(...) │ │
│ └─ per (entity, partIdx): │ │
│ ├─ animated? → slow path │ │
│ ├─ cache hit? → fast path┼──┤
│ └─ cache miss? → slow │ │
│ path + populate ──────┼──┘
└─────────────────────────────────┘
│ ctor injection
┌─────────────────────────────────┐
│ EntityClassificationCache (NEW)│
│ └─ Dictionary<uint, Entry>
│ └─ TryGet(id, out CachedBatch[])│
│ └─ Populate(id, partIdx, ...) │
│ └─ InvalidateEntity(id) │
│ └─ InvalidateLandblock(lbId) │
│ └─ [DEBUG] CrossCheck(...) │
└─────────────────────────────────┘
│ invalidation calls
┌─────────────────────────────────┐
│ GameWindow.RemoveLiveEntity… ──┘
│ GpuWorldState.RemoveEntities… │ (or wired via callback)
└─────────────────────────────────┘
```
### §5.1 Cache shape
```csharp
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result. Stored flat in
/// EntityCacheEntry.Batches — one entry per (logical-part, batch), where
/// for a Setup MeshRef each subPart contributes its own entries.
/// </summary>
public readonly record struct CachedBatch(
GroupKey Key, // bucket identity (matches the dispatcher's private GroupKey)
ulong BindlessTextureHandle, // resolved texture (post-palette + override)
Matrix4x4 RestPose); // meshRef.PartTransform (or subPart.PartTransform * meshRef.PartTransform for Setup)
internal sealed class EntityCacheEntry
{
public required uint EntityId;
public required uint LandblockHint; // for InvalidateLandblock sweep
public required CachedBatch[] Batches; // flat across (partIdx, batchIdx); ordered as classification produced them
}
public sealed class EntityClassificationCache
{
private readonly Dictionary<uint, EntityCacheEntry> _entries = new();
public bool TryGet(uint entityId, out EntityCacheEntry entry);
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
public void InvalidateEntity(uint entityId);
public void InvalidateLandblock(uint landblockId);
public int Count => _entries.Count; // diag
#if DEBUG
public void DebugCrossCheck(
uint entityId,
Matrix4x4 entityWorld,
IReadOnlyList<MeshRef> liveMeshRefs,
// …enough live state to recompute model matrices and assert match
);
#endif
}
```
`GroupKey` is defined privately inside `WbDrawDispatcher` today (lines 923-930); promote to internal or pass an opaque payload through. Implementation detail; settle in writing-plans.
### §5.2 Dispatcher integration (the per-entity branch)
```csharp
// Inside WbDrawDispatcher.Draw, replacing today's per-(entity, partIdx) body
// at lines 367-423.
foreach (var (entity, partIdx) in _walkScratch)
{
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!isAnimated && _cache.TryGet(entity.Id, out var entry))
{
// Fast path: cache hit on a static entity.
foreach (var cached in entry.Batches)
{
if (!_groups.TryGetValue(cached.Key, out var grp))
{
grp = new InstanceGroup { /* …materialize from key… */ };
_groups[cached.Key] = grp;
}
grp.Matrices.Add(cached.RestPose * entityWorld);
}
#if DEBUG
_cache.DebugCrossCheck(entity.Id, entityWorld, entity.MeshRefs, /*…*/);
#endif
if (diag) _entitiesDrawn++;
continue;
}
// Slow path: animated entity, OR cache miss.
// Run today's classification, optionally collecting into a populate buffer
// when !isAnimated.
var collector = isAnimated ? null : _populateScratch;
collector?.Clear();
// …today's TryGetRenderData / SetupParts walk / ClassifyBatches …
// ClassifyBatches now also writes (key, texHandle, restPose) into
// `collector` when collector is non-null.
if (collector is not null && collector.Count > 0)
{
_cache.Populate(entity.Id, /*landblockHint*/ ResolveLandblockHint(entity),
collector.ToArray());
}
}
```
`ClassifyBatches` is extended to optionally append into a caller-supplied `List<CachedBatch>`. When the collector is null (animated path), behavior is unchanged from today. When non-null (cache-miss path on static entities), each emitted batch also produces a `CachedBatch` record.
### §5.3 Invalidation wiring
Two invalidation events:
1. **Per-entity despawn** at [GameWindow.cs:2933-2935](../../../src/AcDream.App/Rendering/GameWindow.cs#L2933) — add `_classificationCache.InvalidateEntity(existingEntity.Id);` next to `_animatedEntities.Remove(...)`.
2. **Landblock demote / unload**`GpuWorldState.RemoveEntitiesFromLandblock` is the choke point. Wire one of:
- **(W1)** Add an `Action<uint /*entityId*/>?` callback parameter; `GameWindow` wires it to `_classificationCache.InvalidateEntity`. Cleaner separation.
- **(W2)** Pass the cache directly into `GpuWorldState`. Less ceremony.
- **(W3)** Call `_classificationCache.InvalidateLandblock(landblockId)` from `StreamingController.Tick` before invoking `RemoveEntitiesFromLandblock`. Requires the cache to maintain `LandblockHint` correctly per entry.
Implementation plan picks one. My lean: **(W3)** — the cache already needs `LandblockHint` for the sweep, and `StreamingController` is the natural lifecycle owner.
### §5.4 Failure modes and recovery
| Failure mode | Detection | Recovery |
|---|---|---|
| Future regression adds `MeshRefs` write site for static entity | DEBUG cross-check `Debug.Assert` fires in dev runs | Audit + fix source. Cross-check stays as guard. |
| Future regression adds `Position`/`Rotation` write site for static entity | DEBUG cross-check (compares `RestPose * liveEntityWorld` against live `meshRef.PartTransform * liveEntityWorld`) | Same. |
| Despawn fires but invalidation not wired | Despawn test asserts `cache.TryGet(id, …) == false` post-call | TDD test catches in CI. |
| Landblock unload misses cache invalidation | `RemoveEntitiesFromLandblock` test asserts every entry with matching `LandblockHint` is gone | TDD test catches in CI. |
| Animated→static membership flip leaves stale entry | No-op (membership predicate skips cache for animated entries; if entity later flips static, cache miss → populate fresh) | None needed. |
| Static→animated membership flip leaves stale entry | No-op (predicate now skips cache; entry sits unused until despawn) | None needed. |
| Cache memory growth | At radius=12: ~10K static entities × ~3-10 batches × ~64 bytes = ~2-6 MB total | None needed. |
| Cache hit on a `_meshAdapter.TryGetRenderData` mesh that subsequently becomes unavailable (theoretical — adapter is session-stable) | N/A — adapter doesn't evict during play | N/A |
---
## §6. Components and their contracts
### §6.1 `EntityClassificationCache` (NEW)
**File:** `src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs`
**Public surface:**
```csharp
public sealed class EntityClassificationCache
{
public bool TryGet(uint entityId, out EntityCacheEntry entry);
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches);
public void InvalidateEntity(uint entityId);
public void InvalidateLandblock(uint landblockId);
public int Count { get; } // for diag
}
```
**Invariants:**
- `Populate` overwrites any existing entry for `entityId` (defensive: handles a populate that races with a partial despawn).
- `InvalidateEntity` is idempotent (no-throw on missing id).
- `InvalidateLandblock` walks all entries; entries whose `LandblockHint == landblockId` are removed.
- `TryGet` is read-only; never mutates.
**Threading:** dispatcher runs on the render thread. All cache operations are render-thread only. No locking needed.
### §6.2 `WbDrawDispatcher` (MODIFIED)
**File:** `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
**Constructor change:** add `EntityClassificationCache classificationCache` parameter; assign to a private `readonly` field.
**`Draw` change:** the per-entity body at lines ~367-423 is restructured per §5.2. The `WalkEntitiesInto` walk and the GL state setup phases (sort, upload, two `glMultiDrawElementsIndirect` calls) are unchanged.
**`ClassifyBatches` change:** add optional `List<CachedBatch>? collector` parameter. When non-null, every classified `(key, texHandle, restPose)` triple is also appended to the collector. Today's behavior preserved for animated entities (collector is null).
**`ResolveLandblockHint(entity)`:** small helper that returns the landblock id the cache should associate with the entity, for `InvalidateLandblock` sweeps. For dat-loaded entities, this is the landblock the entity was hydrated into. For live-spawned entities, it's the entity's current `Position`-implied landblock at spawn time (or `0` if landblock-invalidation isn't expected to fire — live entities are invalidated by `InvalidateEntity` on despawn).
### §6.3 `GameWindow` (MODIFIED)
**File:** `src/AcDream.App/Rendering/GameWindow.cs`
**Construction:** instantiate `EntityClassificationCache`, pass to dispatcher ctor.
**Despawn hook:** at line 2935 (inside `RemoveLiveEntityByServerGuid`), add `_classificationCache.InvalidateEntity(existingEntity.Id);` adjacent to `_animatedEntities.Remove(...)`.
### §6.4 `GpuWorldState` and/or `StreamingController` (MODIFIED, exact split per W1/W2/W3)
Implementation plan picks one of W1/W2/W3 from §5.3. The wiring lands invalidation calls at the LB demote / unload boundary.
### §6.5 DEBUG cross-check
```csharp
#if DEBUG
public void DebugCrossCheck(
uint entityId,
Matrix4x4 entityWorld,
IReadOnlyList<MeshRef> liveMeshRefs,
Func<uint, ObjectRenderData?> tryGetRenderData,
AcSurfaceMetadataTable metaTable,
Func<WorldEntity, MeshRef, ObjectRenderBatch, ulong, ulong> resolveTexture,
WorldEntity entity,
ulong palHash)
{
if (!_entries.TryGetValue(entityId, out var entry)) return;
// Re-classify from live state and compare against cached batches one-by-one.
int idx = 0;
foreach (var meshRef in liveMeshRefs)
{
var renderData = tryGetRenderData(meshRef.GfxObjId);
if (renderData is null) continue;
var setupParts = renderData.IsSetup ? renderData.SetupParts : OnePart(meshRef);
foreach (var (subGfxId, subTransform) in setupParts)
{
var subData = tryGetRenderData(subGfxId);
if (subData is null) continue;
var liveRestPose = renderData.IsSetup
? subTransform * meshRef.PartTransform
: meshRef.PartTransform;
for (int b = 0; b < subData.Batches.Count; b++)
{
var batch = subData.Batches[b];
var liveTex = resolveTexture(entity, meshRef, batch, palHash);
Debug.Assert(idx < entry.Batches.Length,
$"cache size mismatch for entity {entityId}");
var cached = entry.Batches[idx];
Debug.Assert(MatrixApproxEqual(cached.RestPose, liveRestPose, 1e-5f),
$"RestPose drift for entity {entityId} batch {idx}");
Debug.Assert(cached.BindlessTextureHandle == liveTex,
$"texture drift for entity {entityId} batch {idx}");
idx++;
}
}
}
Debug.Assert(idx == entry.Batches.Length,
$"cache batch count mismatch for entity {entityId}");
}
#endif
```
The cross-check duplicates the slow-path classification against live state and compares to cached. If any drift is detected, the assert fires in dev runs with an actionable message. Zero cost in Release.
---
## §7. Test plan
### §7.1 New tests — `EntityClassificationCacheTests.cs`
**File:** `tests/AcDream.Core.Tests/Rendering/Wb/EntityClassificationCacheTests.cs`
| # | Test | What it verifies |
|---|---|---|
| 1 | `TryGet_EmptyCache_ReturnsFalse` | Baseline. |
| 2 | `Populate_ThenTryGet_ReturnsBatchesInOrder` | Round-trip. |
| 3 | `Populate_OverridesExistingEntry` | Defensive overwrite. |
| 4 | `InvalidateEntity_RemovesEntry` | Entity despawn invalidation. |
| 5 | `InvalidateEntity_OnMissingId_NoThrow` | Idempotent. |
| 6 | `InvalidateLandblock_RemovesAllMatchingEntries` | LB demote invalidation, single LB. |
| 7 | `InvalidateLandblock_LeavesNonMatchingEntries` | LB sweep is precise. |
| 8 | `InvalidateLandblock_OnMissingLb_NoThrow` | Idempotent. |
| 9 | `Count_TracksLiveEntries` | Diag accuracy. |
| 10 | `Populate_WithEmptyBatches_StoresEmptyEntry` | Edge case (entity with zero classifiable batches). |
### §7.2 Extended tests — `WbDrawDispatcherBucketingTests.cs`
| # | Test | What it verifies |
|---|---|---|
| 11 | `Draw_StaticEntity_RoutesThroughCache` | Spawn one static entity; first frame populates the cache; second frame's draw call doesn't invoke `ClassifyBatches` (verify via spy / counter on a mock `WbMeshAdapter`). |
| 12 | `Draw_AnimatedEntity_BypassesCache` | Spawn one entity in `animatedEntityIds`; verify cache is never populated for it; `ClassifyBatches` runs every frame. |
### §7.3 (DEBUG-only) Cross-check test
| # | Test | What it verifies |
|---|---|---|
| 13 | `DebugCrossCheck_DetectsMutatedRestPose` | Populate with synthetic data, mutate the live `MeshRef` list, invoke `DebugCrossCheck`, assert fires. Wrapped in `#if DEBUG`. |
### §7.4 Setup pre-flatten lock-in
| # | Test | What it verifies |
|---|---|---|
| 14 | `Populate_SetupMultiPart_StoresFlatBatchPerSubPart` | Synthetic Setup with N subParts × M batches each → cache stores N × M entries with the expected `RestPose` products. |
### §7.5 Lifecycle integration
| # | Test | What it verifies |
|---|---|---|
| 15 | `DespawnRespawn_UnderReusedId_RepopulatesFresh` | Populate, invalidate, populate again under same id with different batches → final state matches second populate. (Pins the audit's ObjDescEvent contract — ObjDescEvent is despawn+respawn, not in-place mutation. Audit §1 cites this.) |
Total new tests: 15. Some can collapse if overlap is identified during implementation; baseline is "≥ 10 in `EntityClassificationCacheTests` + ≥ 2 in dispatcher integration + ≥ 1 DEBUG cross-check".
### §7.6 Sentinel and baseline (existing tests, must stay green)
- N.5b conformance sentinel: filter `TerrainSlot|TerrainModernConformance|Wb|MatrixComposition|TextureCacheBindless|SplitFormulaDivergence` → 94 passing.
- Full suite: 1688 passing, 8 pre-existing failures unchanged.
---
## §8. Sequencing for implementation
(The implementation plan from `superpowers:writing-plans` will refine this into per-task increments. Sketch:)
1. **Skeleton + tests 1-10.** Add `CachedBatch`, `EntityCacheEntry`, `EntityClassificationCache`. Tests 1-10 in the new file. Cache exists but isn't wired to anything yet.
2. **Setup pre-flatten test (test 14) + populate path.** Synthetic `CachedBatch[]` populate; verify `Count` and `TryGet` round-trip on multi-part data shapes.
3. **Wire dispatcher: cache miss + populate.** Modify `WbDrawDispatcher.Draw` and `ClassifyBatches`. First-frame static entity populates; subsequent frames still go through slow path (cache hit branch not yet in). Build green.
4. **Wire dispatcher: cache hit + DEBUG cross-check.** Cache-hit fast path. Tests 11, 12, 13 added.
5. **Wire invalidation hooks.** `InvalidateEntity` from `RemoveLiveEntityByServerGuid`; `InvalidateLandblock` per chosen W1/W2/W3 from §5.3. Test 15.
6. **Visual gate.** Launch + walk Holtburg → North Yanshi at horizon-safe preset. Verify NPC animates, lifestone renders, buildings at correct positions.
7. **Perf gate.** `ACDREAM_WB_DIAG=1`; capture entity dispatcher cpu_us median + p95 over a ≥ 30s standstill at center of Holtburg. Confirm median ≤ 2.0 ms, p95 ≤ 2.5 ms.
8. **Ship.** Commit chain. Close #53 in `docs/ISSUES.md` Recently closed. Update `CLAUDE.md` "Currently in flight" (closes the post-A.5 polish phase). Update memory if any new gotchas surfaced.
---
## §9. Acceptance criteria (whole spec)
- [ ] `EntityClassificationCache.cs` exists with the public surface in §6.1.
- [ ] `WbDrawDispatcher` accepts the cache via ctor and routes static entities through the cache; animated entities bypass.
- [ ] `RemoveLiveEntityByServerGuid` invokes `InvalidateEntity`.
- [ ] LB demote / unload path invokes `InvalidateLandblock` (or per-entity invalidation, per chosen W1/W2/W3).
- [ ] All 15 new tests pass; no existing test regresses; 8 pre-existing failures unchanged.
- [ ] N.5b sentinel: 94/94 passing on every commit.
- [ ] Build green throughout.
- [ ] Visual gate: animation works on a moving NPC, the lifestone renders, buildings are at correct positions, no new artifacts.
- [ ] Perf gate at horizon-safe preset: entity dispatcher cpu_us median ≤ 2.0 ms; p95 ≤ 2.5 ms.
- [ ] ISSUE #53 moved to "Recently closed" with the closing commit SHA.
- [ ] `CLAUDE.md` "Currently in flight" updated to reflect post-A.5 polish phase complete.
- [ ] Memory updated (`project_phase_a5_state.md` or new entry) if any new gotchas surface during implementation.
---
## §10. What this design explicitly does NOT do
- Touch the animated path. Animated entities use today's `ClassifyBatches` flow unchanged.
- Touch the GPU upload pipeline (`_instanceSsbo`, `_batchSsbo`, `_indirectBuffer`). Same upload shape; just less CPU work to produce the inputs.
- Touch terrain. `TerrainModernRenderer` already runs at ~21 µs median; not in scope.
- Touch sky / particles / EnvCell rendering. All unchanged.
- Add new shader variants. The `mesh_modern.vert` / `mesh_modern.frag` pair is unchanged.
- Add new bindless texture handles. `TextureCache` is read-only from this work; it returns the same handle for the same surface id whether we ask once at populate or every frame.
---
## §11. Open implementation choices for writing-plans
These survive into the implementation plan because they're tactical (mechanical), not strategic:
- **W1 vs W2 vs W3 for the LB invalidation wiring** (§5.3). Pick one; stick with it.
- **`GroupKey` visibility.** Today `private` inside the dispatcher. Either promote to `internal` (within `AcDream.App`) or pass an opaque payload through the cache. Either works. Lean: promote to `internal`.
- **`ResolveLandblockHint` placement.** On the dispatcher (uses dispatcher state for live-spawn entities) or on the cache (passed in by caller)? Lean: dispatcher computes it, passes to `Populate`.
- **`_populateScratch` reuse.** Per-frame field on the dispatcher (matches `_walkScratch` pattern) or per-call allocation? Lean: field, matching `_walkScratch`.
- **Test fixtures.** Synthetic `WorldEntity` / `MeshRef` instances may need helper builders. Lean: add a small `EntityClassificationCacheTestFixtures.cs` if the helpers grow past ~30 lines.
---
**End of spec.** Implementation plan owned by `superpowers:writing-plans`. Audit foundation lives at [docs/research/2026-05-10-tier1-mutation-audit.md](../../research/2026-05-10-tier1-mutation-audit.md).

View file

@ -0,0 +1,786 @@
# Phase M — Network Stack Conformance — Design Spec
**Date:** 2026-05-10
**Status:** Draft (sections 13 of 8 written; sections 48 pending; opcode matrix in flight)
**Phase identifier:** M (per `docs/plans/2026-04-11-roadmap.md:414`)
**Supersedes the planned-but-never-written** `docs/superpowers/specs/2026-05-02-network-stack-conformance.md`
**Related research:**
- [`docs/research/2026-05-10-holtburger-network-stack-study.md`](../../research/2026-05-10-holtburger-network-stack-study.md) — first-pass parity study, source of recent commit references
- [`docs/research/2026-05-10-phase-m-opcode-matrix.md`](../../research/2026-05-10-phase-m-opcode-matrix.md) — opcode coverage matrix (in flight; this spec links to it as the source of "done")
**Reference repos:**
- `references/holtburger/` — fast-forwarded to `629695a` on 2026-05-10
- `references/ACE/` — server-side opcode authority
- `docs/research/named-retail/` — Sept 2013 EoR PDB-named decomp
---
## 1. Goal and non-goals
### 1.1 Goal
Build a **complete, layered, testable network protocol library** for acdream that covers every wire opcode a 2013 EoR retail client receives, sends, or both — independent of whether each opcode is yet wired into game state. The library is delivered behind three interfaces (`INetTransport`, `IReliableSession`, `IGameProtocol`); the existing `WorldSession` shrinks to a thin behavior consumer on top. Every parser, builder, and transport feature is unit-tested with golden-vector fixtures and survives a live ACE smoke loop before the phase ships.
The bar is **Bar C — "wireable on demand."** For every in-scope opcode:
- A typed message struct exists (record with named fields, no raw byte arrays)
- A parser exists if inbound (wire bytes → typed message)
- A builder exists if outbound (typed message → wire bytes)
- A round-trip test exists where applicable
- A golden-vector test exists pinning at least one canonical wire encoding
- Either the opcode is dispatched to a typed event observable by `WorldSession`, or its dispatch is documented as deferred to the gameplay phase that needs it (with the deferred-target named, e.g., "wired in Phase F")
The **behavior layer** (what to DO with each message in game state) remains the responsibility of the gameplay phase that needs it. Phase M does not wire `HouseStatusUpdate` into a house-status panel; it ensures `HouseStatusUpdate` parses correctly into a typed event so the future Phase consuming it has zero protocol work.
### 1.2 What "complete" is measured against
The opcode coverage matrix at `docs/research/2026-05-10-phase-m-opcode-matrix.md` is the **source of truth** for "done." Per opcode it cites:
- Holtburger coverage (`references/holtburger/crates/holtburger-{session,protocol,core}/`)
- ACE coverage (`references/ACE/Source/ACE.Server/Network/GameMessages/Messages/` for outbound; `Source/ACE.Server/Network/Handlers/` for inbound accept rules)
- Named retail decomp (`docs/research/named-retail/acclient_2013_pseudo_c.txt`, `symbols.json` — by `class::method` or address)
- Acdream's current state (parser? builder? wired? deferred? unknown?)
- Phase M target (parse, build, both, or "skip with documented justification")
An opcode is **in scope** if any of:
- Holtburger or ACE actively sends/receives it
- The named retail decomp shows the 2013 client invoking it
- It appears in observed live ACE traffic on `127.0.0.1:9000`
An opcode is **out of scope** if all of:
- Holtburger doesn't touch it
- ACE marks it server-internal-only or post-2013 (visible in ACE's commit history or comments)
- The named retail decomp shows no client-side reference
- It hasn't been observed on live ACE
Out-of-scope opcodes get one row in the matrix with the justification, no code work.
### 1.3 Non-goals
- **Not** reimplementing ACE server behavior. Validations, accept rules, and game-side decisions live in ACE; we mirror only what the client must produce or consume.
- **Not** replacing acdream's stricter inbound checksum verification. Our `PacketCodec` validates more aggressively than retail did (per the existing class doc); we keep that unless named retail proves it's wrong.
- **Not** rewriting renderer, animation, audio, UI, plugin, or chat layers. Those have their own phases. The new network stack must compile under, and run alongside, the current rendering and gameplay code.
- **Not** introducing async/await across the codebase. The current `Tick()`-driven recv-loop model is preserved; layer extraction is structural, not asynchrony-restructuring. (We MAY add a dedicated network thread if M.7's runtime work warrants it, but that decision is internal to M.7.)
- **Not** handling opcodes that are ACE-only invented for emulation purposes (e.g., debug echos that retail never had). The matrix calls these out per row.
- **Not** optimizing for throughput. Correctness first. Allocation profile and CPU cost tuning is a follow-up phase if the live loop measurably regresses.
- **Not** plugin-API exposure of network internals. The plugin API gets typed-event subscriptions where useful; raw packet introspection is dev-only.
### 1.4 What ships at the end of Phase M
When M.8 closes:
- `src/AcDream.Net/` (new namespace) contains `INetTransport`, `IReliableSession`, `IGameProtocol`, their concrete implementations, and the typed message library.
- `src/AcDream.Core.Net/WorldSession.cs` is a behavior consumer ~200400 LOC, not the current 1213 LOC monolith.
- The `tests/AcDream.Net.Tests/` project covers every protocol-layer surface with unit tests.
- A `tools/network-conformance-replay/` harness can replay a captured ACE session and verify byte-perfect outputs.
- `dotnet build` green, `dotnet test` green, live ACE smoke green: login → walk → chat → combat action → portal → logout, verified by user.
- The roadmap entry for Phase M moves from "PLANNED" to "shipped" with a one-line summary and commit reference.
---
## 2. Coverage definition
### 2.1 The opcode matrix
The matrix is a markdown table at `docs/research/2026-05-10-phase-m-opcode-matrix.md`, grouped by layer:
1. **Transport flags** — every value in `PacketHeaderFlags` (LoginRequest, ConnectRequest, AckSequence, EncryptedChecksum, BlobFragments, RequestRetransmit, RejectRetransmit, EchoRequest, EchoResponse, Flow, ServerSwitch, TimeSync, Disconnect, …). Each row says what the flag means, who sets it, and what acdream must do on receive.
2. **Optional-header fields** — every variable-length section (RequestRetransmit list, RejectRetransmit list, AckSequence, ConnectRequest payload, LoginRequest payload, CICMD, TimeSync, EchoRequest/EchoResponse times, Flow). Each row defines the byte layout and our parse/build status.
3. **GameMessage opcodes** — every top-level opcode the client sees (0xF658 CharacterList, 0xF745 CreateObject, 0xF74C UpdateMotion, 0xF7B0 GameEvent envelope, 0xF7DE TurbineChat, 0xEA60 AdminEnvirons, …) and every top-level opcode the client sends (0xF7C8 CharacterEnterWorldRequest, 0xF657 CharacterEnterWorld, 0xF61C MoveToState, 0xF61B JumpAction, 0xF753 AutonomousPosition, 0xF7E4 DddInterrogationResponse, …).
4. **GameEvent sub-opcodes** — every entry in `GameEventType.cs` (94 currently named; ~70+ currently unhandled). Each row identifies the parsing target plus the acdream wiring status.
5. **GameAction sub-opcodes** — every typed game-action ID (Talk, Tell, Channel, Use, UseWithTarget, MoveToObject, JumpAbsolute, CastSpell, Appraise, Identify, AttackTargetMelee/Missile, Allegiance ops, Inventory ops, Social ops, Skill/Attribute raise, Train, …).
Each row has these columns:
| Code | Direction | Name | Named-retail symbol or address | Holtburger | ACE | acdream today | Phase M target | Notes |
Cell values for "Holtburger" / "ACE" / "acdream today":
- **`P`** — parses inbound
- **`B`** — builds outbound
- **`PB`** — both
- **`W`** — wired (parser/builder + dispatched to typed event consumed somewhere)
- **``** — not implemented
- **`N/A`** — not applicable for this side (e.g., a server-only message in ACE column)
"Phase M target" cell values:
- **`PB+W`** — must parse, build (if outbound), wire to a typed event by phase end
- **`PB`** — must parse, build (if outbound), no wiring required
- **`P+W`** — inbound only, must parse and dispatch typed event
- **`defer:<phase>`** — explicitly deferred to a named gameplay phase
- **`skip:<reason>`** — out of scope, with justification
### 2.2 Inbound parser obligations
For every in-scope inbound opcode:
- A typed C# record represents the message. Fields are named, typed, and ordered to match the wire layout (so a future reader can map field-to-byte without re-reading the parser).
- The parser is a static method on the record (`public static MyMessage Parse(ref BinaryReader r)`), throws `InvalidOperationException` on malformed input with a message containing the opcode and offset.
- A round-trip test exists if the opcode is also outbound. A golden-vector test always exists with at least one specific captured wire encoding.
- The parser dispatches to a typed event on `IGameProtocol` (`event Action<MyMessage> OnMyMessage`). If wiring to game state is deferred, the matrix row says `defer:<phase>` and the typed event still exists — gameplay-phase wiring is then a one-line subscription.
### 2.3 Outbound builder obligations
For every in-scope outbound opcode:
- A typed C# record represents the message.
- A `Build(ref BinaryWriter w)` instance method writes the wire encoding.
- A golden-vector test pins at least one specific wire encoding.
- The high-level entry point lives on `IGameProtocol` (`Send(MyAction act)` or `Send(MyMessage msg)`).
- `WorldSession` exposes a behavior-friendly wrapper (`SendTalk(string text)` rather than `_protocol.Send(new TalkMessage { … })`) only for opcodes the user-facing app currently triggers. Less-used outbound builders stay on `IGameProtocol` directly until a gameplay phase needs the convenience wrapper.
### 2.4 Three test fixture sources
- **Golden vectors.** Hand-computed bytes for representative messages. Source: named retail decomp (extract via `tools/pdb-extract/`), holtburger captures, or by manual trace. Stored in `tests/AcDream.Net.Tests/Fixtures/Golden/<opcode>.bin` plus a sibling `.json` describing the fields.
- **Live capture replay.** A captured session log (raw datagrams + timestamps) replayed offline against the new stack. Captures come from running acdream itself with a `ACDREAM_PCAP=1` env-var that dumps every datagram to disk. The first capture is recorded once Phase M.7's runtime is in place; subsequent captures replace it as features land.
- **Live ACE smoke.** Per-sub-phase, a live `dotnet run` against `127.0.0.1:9000` that exercises the relevant features. Final M.8 smoke covers login → walk → chat → combat action → teleport → reconnect → logout end-to-end.
### 2.5 Acceptance for an in-scope opcode
An opcode is "done" for Phase M when:
1. Its matrix row is filled completely.
2. The typed message struct exists and matches the documented byte layout.
3. The parser and/or builder exist and pass round-trip tests where applicable.
4. At least one golden-vector test pins a canonical encoding.
5. The typed event is exposed on `IGameProtocol` (inbound) or the high-level send method exists (outbound).
6. The matrix row's `acdream today` column is updated to match `Phase M target`.
The opcode-class agents working on the matrix produce the per-row data. Phase M.6 implementation work is then "for each row in the matrix where target ≠ today, write the code and tests."
---
## 3. Three-layer architecture
### 3.1 Layer overview
```
┌─────────────────────────────────────────────────────────────┐
│ WorldSession (behavior layer — not part of Phase M's │
│ protocol library; consumes IGameProtocol) │
└────────────────────────────┬────────────────────────────────┘
│ subscribes to typed events,
│ calls Send(IGameMessage|IGameAction)
┌────────────────────────────▼────────────────────────────────┐
│ IGameProtocol — typed message routing │
│ • opcode dispatch table │
│ • GameAction sequence counter │
│ • per-message typed events │
│ • outbound: typed message → bytes via builder │
└────────────────────────────┬────────────────────────────────┘
│ delivers fully-assembled GameMessage
│ payloads; receives outbound payloads
┌────────────────────────────▼────────────────────────────────┐
│ IReliableSession — wire correctness │
│ • PacketCodec (header + optional + body framing, CRC, │
│ ISAAC c2s/s2c, fragment header layout) │
│ • inbound ordering buffer + RequestRetransmit issuing │
│ • outbound packet cache + retransmit on server request │
│ • ACK queue + piggyback │
│ • EchoRequest reply, TimeSync forwarding │
│ • port-switch state machine │
│ • fragment assembly (inbound) + splitting (outbound) │
└────────────────────────────┬────────────────────────────────┘
│ INetTransport.Send(bytes, endpoint)
│ INetTransport.TryReceive(out bytes, out endpoint)
┌────────────────────────────▼────────────────────────────────┐
│ INetTransport — UDP only │
│ • Send / TryReceive / Close │
│ • no protocol knowledge │
│ • UdpNetTransport (prod) / MockTransport (test) │
└─────────────────────────────────────────────────────────────┘
```
**Hard rules on direction:**
- Higher layers know about lower layers; lower layers do not know about higher layers.
- `IGameProtocol` does not call into `INetTransport`; it must go through `IReliableSession`.
- `WorldSession` does not directly construct UDP packets, ISAAC streams, or fragment headers.
- A unit test for any layer can mock the layer below it.
### 3.2 `INetTransport`
```csharp
public interface INetTransport : IDisposable
{
/// <summary>
/// Send a single UDP datagram to the given endpoint. Synchronous.
/// Returns the number of bytes sent (always == datagram.Length on
/// success). Throws on socket error.
/// </summary>
int Send(ReadOnlySpan<byte> datagram, IPEndPoint remote);
/// <summary>
/// Non-blocking receive. Returns false if no datagram is available.
/// On true, datagram contains the bytes (caller must not retain
/// the returned span past the next call) and remote contains the
/// source endpoint.
/// </summary>
bool TryReceive(out ReadOnlySpan<byte> datagram, out IPEndPoint remote);
/// <summary>
/// Local endpoint we are bound to (after construction).
/// </summary>
IPEndPoint LocalEndpoint { get; }
}
```
**Concrete implementations:**
- `UdpNetTransport` — wraps `UdpClient` + `Socket`. Sets a 2 MiB recv buffer (matches holtburger). Bound to `0.0.0.0:0` by default; constructor accepts an explicit local endpoint for tests that need port reproducibility.
- `MockTransport` — in-memory channel with two queues: outbound (datagrams the SUT sent) and inbound (datagrams the test wants the SUT to receive). Tests assert against outbound, inject into inbound. No threads, no async, no time.
**Forbidden in `INetTransport`:**
- Any knowledge of `PacketHeader`, `PacketHeaderFlags`, ISAAC, fragments, GameMessages.
- Dispatching to event handlers (it returns bytes; routing is the next layer up).
- Owning a recv loop. The recv loop lives in `IReliableSession.Tick()` or its async equivalent.
### 3.3 `IReliableSession`
This is the largest layer. It owns the wire.
```csharp
public interface IReliableSession : IDisposable
{
/// <summary>Drive the recv loop once. Call from the host loop or a
/// dedicated network thread. Drains all available inbound datagrams,
/// fires events for completed GameMessages, flushes pending ACKs and
/// retransmits, and emits time-sync updates.</summary>
void Tick();
/// <summary>Send a GameMessage payload. The reliable session
/// allocates a sequence number, encodes the header, computes the
/// CRC (encrypted if flags require), splits into fragments if the
/// payload exceeds the single-fragment limit, and ships via
/// INetTransport.</summary>
void SendGameMessage(ReadOnlySpan<byte> payload);
/// <summary>Send a control packet (handshake, disconnect, echo response).
/// Bypasses the GameMessage path; caller supplies the optional-header
/// content directly.</summary>
void SendControl(PacketHeaderFlags flags, ReadOnlySpan<byte> optionalContent);
/// <summary>Begin the handshake. Drives LoginRequest →
/// ConnectRequest → ConnectResponse → CharacterList ready, then
/// transitions to "ready for EnterWorld" state.</summary>
void BeginHandshake(string account, string password);
/// <summary>Advance from CharacterSelection to InWorld. Sends
/// CharacterEnterWorldRequest; waits for ServerReady; sends
/// CharacterEnterWorld.</summary>
void EnterWorld(uint characterGuid, string account);
/// <summary>Disconnect cleanly. Sends Disconnect packet with
/// client_id, then flushes and closes the transport.</summary>
void Disconnect();
// Events surfaced upward:
event Action<ReadOnlySpan<byte>> OnGameMessageReceived; // payload only
event Action<double> OnTimeSync; // server time
event Action<HandshakeState> OnHandshakeStateChanged;
event Action<DisconnectReason> OnDisconnected;
event Action<EchoStats> OnEchoStatsUpdated; // optional, dev-mode
}
```
**Concrete implementation:** `ReliableSession`. Composes seven sub-components:
1. `PacketCodec` — pure functions: encode, decode, CRC, fragment header pack/parse. Stateless except for the ISAAC streams it borrows.
2. `IsaacStreamPair` — owns `IsaacRandom c2s, s2c` plus a shared "search-and-stash" implementation for out-of-order encrypted-checksum recovery (port from holtburger `crypto.rs:73-93`).
3. `InboundOrderingBuffer``BTreeMap<uint, BufferedPacket>`-equivalent (`SortedDictionary<uint, BufferedPacket>` works in C#). Tracks `last_server_seq`, gaps, and feeds `RequestRetransmit` when gaps exceed the rate-limit threshold (1 second, max 115 seq IDs in a 256-seq window — match holtburger constants).
4. `OutboundPacketCache` — LRU dictionary (`max=512`) of recently-sent packets keyed by sequence. On server-issued `RequestRetransmit`, looks up + re-encrypts with current ISAAC + `RETRANSMISSION` flag. Uses `Iteration` field correctly.
5. `AckQueue` — pending-ack list. `IReliableSession.Tick` flushes via piggyback on the next outbound data packet; if no data goes out within the idle threshold, sends a standalone ACK packet. Piggybacks are automatic on every `SendGameMessage`.
6. `FragmentAssembler` — inbound: keyed by `(sequence, fragmentId)`, with TTL eviction (default 30s) for orphaned partials. Outbound: splits payloads >448 bytes into multiple fragments with consistent `id`/`count`/`index`/`queue` per holtburger and ACE conventions.
7. `HandshakeMachine` — state machine: `Idle``LoginSent``ConnectRequestReceived``ConnectResponseQueued` (with 200ms deferred send, non-blocking) → `PortPending``PortConfirmed``Ready``EnterWorldSent``InWorld`. Each transition is logged with timestamps for diagnostic replay.
**Forbidden in `IReliableSession`:**
- Knowing the structure of GameMessage payloads beyond "they are bytes."
- Dispatching to typed events for specific opcodes.
- Calling into `WorldSession` or game state.
### 3.4 `IGameProtocol`
```csharp
public interface IGameProtocol : IDisposable
{
/// <summary>Send a typed game action (0xF7B1 envelope, bumps the
/// per-action sequence counter). The implementation builds the
/// payload and hands it to IReliableSession.SendGameMessage.</summary>
void Send(IGameAction action);
/// <summary>Send a non-action GameMessage (e.g., 0xF657
/// CharacterEnterWorld, 0xF7C8 CharacterEnterWorldRequest, 0xF7E4
/// DddInterrogationResponse, 0xF753 AutonomousPosition,
/// 0xF61C MoveToState).</summary>
void Send(IGameMessage message);
// Inbound typed events (one per in-scope opcode):
event Action<CharacterListMessage> OnCharacterList;
event Action<CreateObjectMessage> OnCreateObject;
event Action<UpdateMotionMessage> OnUpdateMotion;
event Action<UpdatePositionMessage> OnUpdatePosition;
event Action<DddInterrogationMessage> OnDddInterrogation;
event Action<PlayerCreateMessage> OnPlayerCreate;
event Action<PlayerTeleportMessage> OnPlayerTeleport;
event Action<TurbineChatMessage> OnTurbineChat;
// ...one per opcode in the matrix...
// GameEvent sub-opcode events (one per sub-opcode):
event Action<ChannelBroadcastEvent> OnChannelBroadcast;
event Action<TellEvent> OnTell;
event Action<UpdateHealthEvent> OnUpdateHealth;
// ...one per sub-opcode in the matrix...
// Unknown / unhandled:
event Action<UnknownMessage> OnUnknownMessage; // includes opcode, raw bytes, telemetry
}
```
The dispatch table is generated from the opcode matrix at build time (or maintained by hand from the matrix; this is a M.6 sub-decision). Every in-scope opcode has its own typed event; unknown opcodes go to `OnUnknownMessage` with full byte payload so devtools can render them.
**Forbidden in `IGameProtocol`:**
- Direct UDP I/O.
- ISAAC, CRC, fragment work.
- Holding onto game state (Characters, current player guid, login state — those live in `WorldSession`).
### 3.5 `WorldSession` (the behavior consumer — not protocol library)
After Phase M, `WorldSession` is a thin layer:
```csharp
public sealed class WorldSession : IDisposable
{
private readonly IGameProtocol _protocol;
private readonly IReliableSession _reliable;
// High-level state
public CharacterListEntry[] Characters { get; private set; }
public CharacterListEntry? CurrentCharacter { get; private set; }
public uint? PlayerGuid { get; private set; }
// High-level commands (convenience wrappers around _protocol.Send)
public void Login(string account, string password) { ... }
public void EnterWorld(int characterIndex) { ... }
public void SendTalk(string text) { ... }
public void SendTell(string target, string text) { ... }
public void SendMove(MoveToState moveState) { ... }
// Subscribes to _protocol events in the constructor; routes them
// to public events GameWindow / plugins consume.
public event Action<CreateObjectMessage> OnCreateObject;
public event Action<UpdateMotionMessage> OnUpdateMotion;
// ...etc, mirroring _protocol.On... but at the WorldSession surface
// so callers don't reach into the protocol layer directly.
}
```
Target line count after migration: 200400 LOC vs the current 1213 LOC.
### 3.6 Layer dependencies and project structure
New project: **`src/AcDream.Net/`**.
- `AcDream.Net.Transport` namespace — `INetTransport`, `UdpNetTransport`, `MockTransport`.
- `AcDream.Net.Reliable` namespace — `IReliableSession`, `ReliableSession`, sub-components (`PacketCodec`, `IsaacStreamPair`, `InboundOrderingBuffer`, `OutboundPacketCache`, `AckQueue`, `FragmentAssembler`, `HandshakeMachine`), plus `PacketHeader`, `PacketHeaderFlags`, `PacketHeaderOptional`, `MessageFragment` (moved here from `AcDream.Core.Net.Packets`).
- `AcDream.Net.Protocol` namespace — `IGameProtocol`, `GameProtocol`, every typed message record, every typed event payload record. Subdivided by class: `Protocol/Messages/`, `Protocol/Events/`, `Protocol/Actions/`.
The existing `src/AcDream.Core.Net/` namespace is **deleted at end of phase**. `WorldSession` moves to `src/AcDream.Core/` (it's behavior, not network plumbing). Any helpers in the old namespace migrate into `AcDream.Net.*` if still needed; otherwise they're deleted.
Project references:
- `AcDream.Net` references `AcDream.Core` (for `IPlatformLogger`, shared types).
- `AcDream.Core` references `AcDream.Net` (for the interfaces — `WorldSession` needs `IGameProtocol`, `IReliableSession`).
This implies one logical cycle that's broken by interface-only references: `AcDream.Net` only references `AcDream.Core`'s types that don't transitively depend on network code (i.e., logging + result types). If the cycle resists clean breaking, the fallback is a third project `AcDream.Net.Abstractions` for the interfaces, with `AcDream.Net.Implementation` and `AcDream.Core` both depending on it.
### 3.7 What stays out of the architecture (and where it goes)
- **Auth / GLS ticket flow** — currently absent. If Phase M needs to support GLS-ticketed login (real retail server flow, not just account/password against ACE), it lives in `AcDream.Net.Reliable.HandshakeMachine` as an additional pre-LoginRequest stage. For now, ACE only accepts account/password, so this is documented as a non-goal until a real-server phase.
- **Plugin packet introspection** — surface lives on `WorldSession` (or a separate dev-tool API), not in the protocol library. Exposing raw fragments to plugins is risky; we expose typed events.
- **Capture/replay tooling** — lives in `tools/network-conformance-replay/`, depends on `AcDream.Net` but not vice-versa.
---
## 4. Migration strategy
### 4.1 Worktree branch model
Phase M ships entirely on a long-lived feature branch off `main`:
- Branch name: `claude/phase-m-network-stack`
- Worktree path: `.claude/worktrees/phase-m-network-stack/` (per existing repo convention)
- All sub-phase commits land on this branch.
- `main` is untouched until M.8 acceptance gates close.
- Live-ACE testing of the new stack happens by `dotnet run` from the worktree.
- Live-ACE testing of the old stack continues to happen from `main`.
### 4.2 Branch lifetime and rebase cadence
- **Estimated lifetime:** 68 weeks (per cost estimate in §8).
- **Rebase cadence:** weekly minimum, plus an immediate rebase whenever any of the following lands on main:
- Touches `src/AcDream.Core.Net/`, `src/AcDream.App/Input/PlayerMovementController.cs`, or any networking-adjacent code
- Updates `references/holtburger/` (we re-pull and re-baseline our research)
- Updates `docs/research/named-retail/` (new symbols may invalidate matrix rows)
- Modifies the roadmap in any way that changes Phase M scope
- **Conflict resolution policy:**
- Wire-format conflicts (main lands a fix to `MoveToState` while we're rewriting it): we adopt the main fix into the new stack, file an issue to verify the same behavior is reproduced post-port.
- Test conflicts (main adds a test that exercises the old `WorldSession`): the test moves to test the new stack via the same call site after migration; if the call site is gone, the test is rewritten against the new equivalent.
- Build conflicts: standard rebase resolution.
- **Frequency check:** if rebase frequency exceeds 2× per week or rebase work consistently exceeds 30 minutes, the branch is too stale. Pause feature work, catch up, then resume.
### 4.3 What ships on the branch vs in separate commits to main
- All Phase M code: branch only.
- All Phase M tests: branch only.
- Roadmap updates (ongoing status, not the final "shipped" entry): cherry-pick to main as the phase progresses, so other agents see status.
- Research notes (e.g., new opcode-matrix updates, new findings against ACE/holtburger): land directly on main since they're useful to other phases independent of M.
- The opcode matrix doc itself: lives on main from the start (it's reference data, not protected by the migration).
### 4.4 Final merge: M.8 ship gate
When M.8 closes:
1. Branch is rebased one final time against current `main`.
2. Full `dotnet build` + `dotnet test` green on the branch.
3. Live-ACE smoke run from the worktree by user: login → walk → chat → combat → portal → logout.
4. Old `src/AcDream.Core.Net/` deleted in a final branch commit (NOT before — this is the load-bearing flip).
5. Branch merged to main as a single `--no-ff` merge commit, message names every sub-phase shipped.
6. Roadmap entry for Phase M moves to "shipped" in the same merge.
7. Memory crib written summarizing the architecture for future sessions.
### 4.5 Rollback path
If post-merge live ACE breaks unexpectedly, the rollback is:
- `git revert` the merge commit on main
- File a bug with the live-ACE failure mode
- Cherry-pick the fix onto a new branch off the reverted main
- Re-merge
Since the merge is a single commit, revert is mechanical. The 68 weeks of work isn't lost — it's reachable via the original branch tip + the revert undoing the merge.
### 4.6 Work-in-flight protocol
During Phase M, other agents may want to work on other features. The protocol:
- Other agents work off main as usual.
- They are NOT permitted to touch `src/AcDream.Core.Net/` or any file the spec lists as Phase-M-owned.
- If they need to add a new outbound message (e.g., a new gameplay phase needs a new opcode), they file an issue tagged `phase-m-followup` and we incorporate post-merge.
- The Phase M branch is the only place network changes happen until M.8 closes.
This is enforced by convention, not tooling. The Phase M agent (or human equivalent) communicates in commits + roadmap updates about what's locked.
---
## 5. Sub-phase definitions of done
Each sub-phase has: **entry criteria**, **exit criteria**, **conformance test gates**, and an **hour estimate**.
### 5.1 M.1 — Audit & parity map
**Entry:** Phase M kickoff. `references/holtburger/` is at known commit (`629695a` as of 2026-05-10).
**Exit:**
- Opcode matrix at `docs/research/2026-05-10-phase-m-opcode-matrix.md` is filled to ≥95% completeness across all five sections (transport flags, optional headers, GameMessages, GameEvents, GameActions).
- For every row marked `skip:<reason>`, the reason is documented and ratified by spec review.
- For every row marked `defer:<phase>`, the deferred phase exists in the roadmap.
- A meta-section at the top of the matrix lists totals: "in-scope opcodes: N", "currently-implemented: M", "Phase M target delta: N-M".
**Conformance gates:**
- Spot-check 10 randomly-selected rows by hand against all three sources (holtburger / ACE / named retail). Discrepancies block exit.
**Hour estimate:** 16 hours.
**Notes:** the holtburger study at `docs/research/2026-05-10-holtburger-network-stack-study.md` is a partial M.1 deliverable. M.1 completion includes building the formal matrix table from that study + per-opcode source citation.
### 5.2 M.2 — Layer extraction (skeleton)
**Entry:** M.1 exit gates green.
**Exit:**
- New project `src/AcDream.Net/` exists with three namespaces (`Transport` / `Reliable` / `Protocol`).
- All three interfaces (`INetTransport`, `IReliableSession`, `IGameProtocol`) compile with their full signatures from §3.
- `MockTransport` and `UdpNetTransport` implement `INetTransport` with passing unit tests.
- Stub implementations of `IReliableSession` and `IGameProtocol` exist (throw `NotImplementedException` on member calls; pass interface compliance tests via the mock).
- The new project compiles. The old `src/AcDream.Core.Net/` is unchanged and still works.
**Conformance gates:**
- `dotnet build` green.
- `dotnet test` green for any tests in `tests/AcDream.Net.Tests/` (which at this point covers only `MockTransport` and `UdpNetTransport`).
**Hour estimate:** 40 hours.
### 5.3 M.3 — Reliability core
**Entry:** M.2 exit gates green.
**Exit:**
- `IReliableSession`'s `ReliableSession` implementation is functionally complete: codec, ISAAC pair with search-and-stash, inbound ordering buffer, outbound packet cache, retransmit (both directions), `Iteration` field handling, RequestRetransmit issuing on gaps with rate-limit, RejectRetransmit handling.
- Sub-component unit tests pass.
- An integration test connects to a `MockTransport`, simulates an entire ACE session (login → walk → disconnect) with synthetic loss/reorder, verifies state.
- Holtburger study items 1.4 (port-switch race) and 1.7 (retransmit machinery) and ISAAC search-mode (item 6) are landed in this sub-phase.
**Conformance gates:**
- 100% of unit tests pass.
- Integration test with synthetic 5% packet loss: 100% of GameMessages are eventually delivered; no false positives in retransmit requests.
- Integration test with synthetic 10% reordering: 100% of GameMessages are delivered in correct order; ISAAC search-mode keys are correctly stashed and consumed.
**Hour estimate:** 40 hours.
### 5.4 M.4 — ACK and control-packet policy
**Entry:** M.3 exit gates green.
**Exit:**
- ACK queue with piggyback works: every outbound `SendGameMessage` on `IReliableSession` carries the latest server seq automatically; standalone ACKs flush only when no data goes out within an idle threshold.
- EchoRequest handling: inbound EchoRequest triggers an outbound EchoResponse with mirrored time field.
- Disconnect packet carries `client_id` (study item 5).
- LoginComplete is sent on every PlayerTeleport and on first PlayerCreate (study item 1.2 — but the dispatch happens at the protocol layer, M.6, not here; M.4 ensures the underlying control-packet send path is correct).
- Idle ping/timeout: 1 Hz net tick, 15s timeout.
**Conformance gates:**
- ACK piggyback test: send a series of GameMessages, verify each carries the most recent server seq.
- EchoResponse test: receive synthetic EchoRequest, verify EchoResponse goes out within 1 frame with correct time.
- Idle timeout test: don't send anything for 15s, verify keepalive fires and timeout doesn't trigger.
**Hour estimate:** 16 hours.
### 5.5 M.5 — Fragment and payload completeness
**Entry:** M.4 exit gates green.
**Exit:**
- Inbound fragment assembly with TTL eviction (default 30s) for orphaned partials.
- Outbound multi-fragment splitting for payloads >448 bytes. Handles correct `id` / `count` / `index` / `queue` per fragment.
- Round-trip tests for: single-fragment, 2-fragment, 5-fragment payloads.
**Conformance gates:**
- Round-trip test with a 2KB payload: 5 fragments, all assembled correctly on receive.
- TTL test: orphan a fragment, verify it's evicted at 30s.
- Capture from holtburger or ACE of a real multi-fragment packet (e.g., long appraise text), our fragment assembler reproduces the same field values byte-perfect.
**Hour estimate:** 24 hours.
### 5.6 M.6 — Typed protocol surface
**Entry:** M.5 exit gates green. Opcode matrix complete (M.1 exit + any deltas from M.2-M.5).
**Exit:**
- For every opcode marked `PB+W`, `PB`, or `P+W` in the matrix:
- Typed message struct exists in `AcDream.Net.Protocol.Messages`, `Events`, or `Actions`.
- Parser/builder exists.
- Typed event exists on `IGameProtocol` for inbound opcodes.
- Round-trip test passes if applicable.
- Golden-vector test pins at least one canonical encoding.
- The dispatch table in `GameProtocol` routes inbound bytes to the correct typed event.
- Unknown opcodes route to `OnUnknownMessage` with full byte payload.
**Conformance gates:**
- 100% of in-scope opcodes have green tests.
- A "round-trip every opcode" meta-test exists that, given a list of golden-vector samples, encodes + decodes each and asserts bit-for-bit equivalence.
- The MoveToState wire-format audit (study items 1.1.a-e) lands as part of M.6 — i.e., the new typed `MoveToStateMessage` builder produces wire output matching holtburger's `common.rs:122-186` encoding.
**Hour estimate:** 80 hours.
**Note:** This is the largest sub-phase. M.6 is parallelizable via agent dispatch — one agent per opcode class (transport flags, GameMessages, GameEvents, GameActions). Estimated single-developer time is 80h; with effective agent dispatch on the implementation, calendar time may compress to 3-5 days.
### 5.7 M.7 — Runtime loop and diagnostics
**Entry:** M.6 exit gates green.
**Exit:**
- The new stack drives a recv loop that drains all available inbound, fires events, flushes pending ACKs/retransmits/ECHO replies, all within a single `Tick()`.
- Decode/order/reassembly is moved out of the render tick into either (a) the same render-tick `Tick()` call or (b) a dedicated network thread, depending on M.7's internal decision (logged in the sub-phase commit).
- Byte counters: per-direction, per-opcode, exposed via `IGameProtocol.GetTelemetry()`.
- Packet capture: `ACDREAM_PCAP=1` env-var dumps every datagram to disk in a parseable format.
- Replay tool: `tools/network-conformance-replay/` reads a capture, replays it against the new stack, asserts no decode errors and matching event sequence.
- Dev-panel diagnostics: a debug overlay shows current handshake state, ACK depth, retransmit queue depth, byte counters.
**Conformance gates:**
- A 5-minute live ACE session captures a clean replay; replay against the new stack: zero decode errors.
- The render thread's per-frame budget for network work is < 0.5ms median (measured via existing perf instrumentation).
**Hour estimate:** 16 hours.
### 5.8 M.8 — Conformance tests and live validation
**Entry:** M.7 exit gates green.
**Exit:**
- All `tests/AcDream.Net.Tests/` tests green: unit, round-trip, golden-vector, integration with synthetic loss/reorder, replay-against-capture.
- Live ACE smoke: login → walk to lifestone → chat in /general → engage NPC for combat (one attack) → portal recall → logout. User-confirmed visually + via decode-error counter (must be 0).
- The `WorldSession` shrinkage is complete: pre-migration ~1213 LOC, post-migration ≤400 LOC.
- The `src/AcDream.Core.Net/` namespace is deleted.
- Memory crib written: `memory/project_phase_m_network.md` summarizing layer architecture, key gotchas discovered during implementation, location of opcode matrix.
- Roadmap updated: Phase M moves from "PLANNED" to "shipped" with merge commit reference.
**Conformance gates:**
- All M.1M.7 exit gates remain green.
- Final live ACE smoke green.
**Hour estimate:** 24 hours.
### 5.9 Total
| Sub-phase | Hours | Cumulative |
|-----------|-------|------------|
| M.1 — Audit & matrix | 16 | 16 |
| M.2 — Layer extraction | 40 | 56 |
| M.3 — Reliability core | 40 | 96 |
| M.4 — ACK + control | 16 | 112 |
| M.5 — Fragments | 24 | 136 |
| M.6 — Typed protocol | 80 | 216 |
| M.7 — Runtime + diagnostics | 16 | 232 |
| M.8 — Tests + live val | 24 | 256 |
**Total: 256 hours ≈ 32 working days ≈ 6.4 weeks single-developer.**
Realistic with subagent parallelization on M.6 (typed-message implementation) and M.1 (matrix population): 4-6 weeks calendar time.
---
## 6. Conformance test plan
### 6.1 Test surfaces per layer
| Layer | Test surface | Backing project |
|-------|--------------|-----------------|
| Transport | Mock + Udp behavior, recv-buffer sizing, error paths | `tests/AcDream.Net.Tests/Transport/` |
| Reliable | Codec round-trip, CRC encrypted+unencrypted, ISAAC search edge cases, ordering buffer scenarios, retransmit cycles, ACK piggyback, Echo, port-switch state machine, fragment assembly + splitting | `tests/AcDream.Net.Tests/Reliable/` |
| Protocol | Per-opcode round-trip + golden-vector + unknown-opcode telemetry | `tests/AcDream.Net.Tests/Protocol/` |
| End-to-end | Replay-against-capture, live-ACE smoke | `tests/AcDream.Net.Tests/Replay/` + `tools/network-conformance-replay/` |
### 6.2 Golden-vector library structure
```
tests/AcDream.Net.Tests/Fixtures/Golden/
├── Transport/
│ ├── login_request.bin
│ ├── connect_request.bin
│ ├── ack_only.bin
│ ├── echo_request.bin
│ └── ...
├── Messages/
│ ├── 0xF658_character_list.bin
│ ├── 0xF61C_movetostate_run_forward.bin
│ ├── 0xF753_autonomous_position.bin
│ └── ...
├── Events/
│ ├── 0x0147_channel_broadcast.bin
│ ├── 0x02BD_tell.bin
│ └── ...
└── manifests/
└── all-golden.json # (filename, opcode, decoded fields, source citation)
```
Each `.bin` has a sibling `.json` with the decoded fields and source attribution (holtburger capture / named retail trace / ACE-generated).
### 6.3 Live capture replay
`tools/network-conformance-replay/` is a small console app:
- Reads a `.pcap`-like capture from disk (binary format defined as part of M.7).
- For each datagram, hands bytes to a fresh `ReliableSession` + `GameProtocol`.
- Asserts: no decode errors, every typed event fires in the expected order (event order is part of the capture metadata), final session state matches the capture's recorded final state.
- Output: PASS/FAIL with detailed first-failure diff.
### 6.4 Live ACE smoke flows
Two tiers:
- **Per-sub-phase smoke** (lightweight, automated where possible):
- M.3: handshake completes; CharacterList received; clean disconnect.
- M.4: 60-second idle session with ECHO traffic flowing both ways; 0 disconnects.
- M.5: a multi-fragment payload from ACE (e.g., long appraise text) parses correctly.
- M.6: every opcode the live session naturally produces (login → walk → chat → portal) parses to its typed event.
- **M.8 final smoke** (manual, user-driven):
- Account login: user enters credentials, picks +Acdream, enters world.
- Walk: WASD around Holtburg for 30s; observe local + retail-observer view (via parallel retail client) for blippy movement.
- Chat: /general "hello", /tell to a name, /a (allegiance), /f (fellowship).
- Combat: target a guard, swing once, observe damage notification + animation.
- Portal recall: cast Portal Recall, watch teleport.
- Logout: clean disconnect, verify ACE shows session ended.
- Decode-error counter must be 0 throughout.
### 6.5 What's not tested at this layer
- Game-state correctness: that's per-feature in gameplay phases.
- Rendering correctness: that's the existing renderer test surface.
- Plugin behavior: separate test surface.
---
## 7. Risk register
| # | Risk | Probability | Impact | Mitigation |
|---|------|-------------|--------|------------|
| 1 | **Branch drift** — main moves faster than expected, rebase work overwhelms. | Medium | High (could double phase calendar time) | Weekly rebase minimum + watchpoints on key files. Pause and catch up if conflict effort exceeds 30min/week. |
| 2 | **Opcode ambiguity** — three sources (holtburger / ACE / named retail) disagree on a field layout. | Medium | Medium (delays the affected M.6 row) | Per-row triage: cross-check against live ACE traffic if available; file a research note documenting disagreement; pick the source with strongest evidence; revisit if a real-server-deploy phase invalidates the choice. |
| 3 | **ISAAC stream desync** — search-mode port has a subtle bug that corrupts the keystream. | Low | Critical (silent corruption looks like ACE incompat) | Parallel-run old + new ISAAC for 1 week in dev mode; log every divergence; smoke-test with synthetic out-of-order injection. |
| 4 | **Live ACE incompat** — new stack works in unit tests but real ACE rejects something subtle. | Medium | High (blocks M.8) | Per-sub-phase live smoke (not just final). Catches incompats early. |
| 5 | **Dead-builder integration drift** — Phase B.4 surface (Use/UseWithTarget/PickUp) was built without wiring; we may rebuild without verifying the wiring works. | Medium | Medium (fixes one bug, introduces another) | Every typed builder must have a golden-vector test. The matrix row's "Phase M target" includes "verified against live ACE" for any opcode previously dead-built. |
| 6 | **`Iteration` field** — current code always writes 0; if retail uses non-zero iteration on retransmits in a way ACE validates, we get rejected. | Low | Medium (breaks retransmit specifically) | M.3's retransmit test exercises iteration values 0, 1, 2; live-ACE smoke with synthetic loss to trigger real retransmits. |
| 7 | **Project structure refactor breaks downstream code** — moving `WorldSession` or deleting `AcDream.Core.Net` shifts a namespace many files reference. | High | Low (compile errors are immediate) | M.8 deletion is the last commit; entire branch compiles up to that point; deletion + namespace fix lands in one commit, single rebuild. |
| 8 | **Threading model regression** — if M.7 introduces a network thread, render-thread races appear. | Medium | High (intermittent crashes) | Default to keeping single-threaded model; threading is opt-in via a flag for one test session before becoming default. |
| 9 | **Test fixture rot** — golden vectors capture a 2026-05 ACE version; future ACE versions diverge. | Low | Low (fixtures still valid for retail-conformance baseline) | Golden vectors are pinned to retail behavior, not ACE-specific. Live capture replay is from acdream itself (most reproducible). |
| 10 | **Calendar overrun** — 6.4 weeks expands to 12+ weeks. | Medium | Medium (delays Phase F+ gameplay phases) | Mid-phase checkpoint at M.4 close (week 3 in plan). If hours-spent ≥ 1.5× estimate, scope-cut M.6 to "matrix-deferred opcodes only, batch the long tail to M.6.b post-merge." |
---
## 8. Cost estimate
### 8.1 Summary
**Total estimate: 256 hours ≈ 6.4 working weeks single-developer.**
With effective subagent dispatch (especially on M.1 matrix population and M.6 typed-message implementation), realistic calendar compression to **46 weeks**.
### 8.2 Cost breakdown by sub-phase (repeating for visibility)
| Sub-phase | Hours | Calendar weeks | Subagent-friendly? |
|-----------|-------|----------------|--------------------|
| M.1 — Audit & matrix | 16 | 0.4 | Yes (per-class agents) |
| M.2 — Layer extraction | 40 | 1.0 | Limited (architecture-driven, single voice) |
| M.3 — Reliability core | 40 | 1.0 | Limited (ISAAC + ordering buffer interact) |
| M.4 — ACK + control | 16 | 0.4 | Limited |
| M.5 — Fragments | 24 | 0.6 | Limited |
| M.6 — Typed protocol | 80 | 2.0 | **Yes (per-opcode-class agents)** |
| M.7 — Runtime + diagnostics | 16 | 0.4 | Limited |
| M.8 — Tests + live val | 24 | 0.6 | Limited (live val needs human) |
| **Total** | **256** | **6.4** | |
### 8.3 Critical path
```
M.1 → M.2 → M.3 → M.4 → M.5 → M.6 → M.7 → M.8
(mostly sequential within a single-developer flow)
```
M.1 can partially overlap M.2 (matrix work continues while skeleton lands).
M.3 / M.4 / M.5 are conceptually parallel within the reliable layer, but practically sequenced because they share state.
M.6 is the parallelization cliff — agents work on different opcode classes simultaneously.
M.7 / M.8 are sequential.
### 8.4 Resource assumptions
- One primary developer driving the architecture and integration.
- Subagent dispatch budget: liberal (acdream's sustained pattern is to use Sonnet agents heavily for bounded chunks; per CLAUDE.md "Subagent policy").
- Live ACE on `127.0.0.1:9000` available throughout for smoke tests.
- User available for M.8 final visual gate (the only step that genuinely needs human eyes).
### 8.5 What buys schedule slack
If budget compresses (e.g., 4 weeks max), the following are scope-cuts in order:
1. **Long-tail GameEvent sub-opcodes** (House*, Trade*, Book*, Vendor*, Barber*, Allegiance updates, ContractTracker*) — 30+ rows that gameplay phases will need eventually but not for M.8 acceptance. Move to a `M.6.b` follow-up.
2. **Outbound multi-fragment splitting** (M.5 second half) — defer until a gameplay phase needs >448-byte outbound payload.
3. **M.7 dev-panel diagnostics** — keep the byte counters and capture, drop the visual overlay.
4. **M.8 replay harness** — keep the smoke gate, drop the automated replay testing (move to follow-up).
These cuts get total down to ~150180 hours / 4 weeks if necessary. The architecture is preserved; the long-tail completeness regresses to "covers everything observed in live ACE during normal play, not the long tail."
---
## Status & next steps
**Spec status as of 2026-05-10:** Sections 18 written. Awaiting:
1. **Opcode matrix construction** (M.1's main deliverable). Dispatch agents: one per opcode class. Output: `docs/research/2026-05-10-phase-m-opcode-matrix.md`.
2. **Roadmap update.** Phase M entry shrinks to a one-paragraph summary + status table + pointer to this spec. M.0 sub-lane folds into M.3 / M.4 / M.6 (no longer ships separately).
**When implementation starts:** create the worktree, branch off main, begin M.1 matrix completion → M.2 skeleton.

View file

@ -18,8 +18,10 @@ public sealed class GameWindow : IDisposable
private IWindow? _window;
private GL? _gl;
private IInputContext? _input;
private TerrainChunkRenderer? _terrain;
private Shader? _shader;
private TerrainModernRenderer? _terrain;
/// <summary>Phase N.5b: terrain_modern.vert/.frag program. Owned by
/// <see cref="_terrain"/> at draw time but allocated + disposed here.</summary>
private Shader? _terrainModernShader;
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private DatCollection? _dats;
@ -68,11 +70,29 @@ public sealed class GameWindow : IDisposable
private string _lastNearestObjLabel = "-";
private bool _lastColliding;
// Phase N.5b: CPU timing for [TERRAIN-DIAG] under ACDREAM_WB_DIAG=1
// (parallel diagnostic to [WB-DIAG] in WbDrawDispatcher — same env var
// gate so flipping one switch turns on both dispatcher rollups). Mirrors
// the rolling-256-sample buffer pattern from WbDrawDispatcher.
private readonly System.Diagnostics.Stopwatch _terrainCpuStopwatch = new();
private readonly long[] _terrainCpuSamples = new long[256]; // microseconds
private int _terrainCpuSampleCursor;
private long _terrainLastDiagTick;
// Phase A.1: streaming fields replacing the one-shot _entities list.
private AcDream.App.Streaming.LandblockStreamer? _streamer;
private AcDream.App.Streaming.GpuWorldState _worldState = new();
private AcDream.App.Streaming.StreamingController? _streamingController;
private int _streamingRadius = 2; // default 5×5
private int _streamingRadius = 2; // default 5×5 (kept for debug overlay getStreamingRadius callback)
private int _nearRadius = 4; // Phase A.5 T16: two-tier near ring (default 4 → 9×9)
private int _farRadius = 12; // Phase A.5 T16: two-tier far ring (default 12 → 25×25)
// A.5 T22.5: resolved quality settings (preset + env-var overrides).
// Set once in OnLoad after LoadAndApplyPersistedSettings(); re-set on
// ReapplyQualityPreset(). Default matches QualityPreset.High so the field
// is valid before OnLoad fires (no GL calls are made before OnLoad anyway).
private AcDream.UI.Abstractions.Settings.QualitySettings _resolvedQuality =
AcDream.UI.Abstractions.Settings.QualitySettings.From(
AcDream.UI.Abstractions.Settings.QualityPreset.High);
private uint? _lastLivePlayerLandblockId;
// Phase B.3: physics engine — populated from the streaming pipeline.
@ -86,13 +106,24 @@ public sealed class GameWindow : IDisposable
// Step 4: portal-based interior cell visibility.
private readonly CellVisibility _cellVisibility = new();
// Phase A.1 hotfix: DatCollection is NOT thread-safe. The streaming worker
// thread and the render thread both read dats (BuildLandblockForStreaming
// on the worker; ApplyLoadedTerrain + live-spawn handlers on the render
// thread). Concurrent reads corrupt internal caches and produce
// half-populated LandBlock.Height[] arrays, which caused terrain to render
// as "a giant ball with spikes" before this lock was added. All _dats.Get
// calls that can race with the worker thread MUST acquire this lock.
// Phase A.1 hotfix / Phase A.5 T10: DatCollection is NOT thread-safe.
// DatReaderWriter's DatBinReader uses a shared buffer position internally —
// concurrent _dats.Get<T> calls from the streaming worker thread (T11+) and
// the render thread (BuildLandblockForStreaming on the worker;
// ApplyLoadedTerrain + live-spawn handlers + animation ticks on the render
// thread) corrupt that state and produce half-populated LandBlock.Height[]
// arrays, rendering as "a giant ball with spikes". All _dats.Get<T> call
// sites that can race with the streaming worker MUST hold this lock.
//
// Worker-thread dat reads enter via the factory closures passed to
// LandblockStreamer at construction (loadLandblock + buildMeshOrNull).
// Those closures already acquire _datLock, so no additional wrapping is
// needed for reads inside BuildLandblockForStreamingLocked /
// BuildSceneryEntitiesForStreaming / BuildInteriorEntitiesForStreaming.
// Render-thread paths (ApplyLoadedTerrain, OnLiveEntitySpawned) already
// hold this lock via their outer wrappers; all remaining render-thread
// _dats.Get calls run only when no worker dat read can be in flight (during
// initialization or within the same lock scope).
private readonly object _datLock = new();
// Terrain build context shared across all streamed landblocks. Stored as
@ -100,7 +131,9 @@ public sealed class GameWindow : IDisposable
// LandblockMesh.Build without re-deriving these each time.
private float[]? _heightTable;
private AcDream.Core.Terrain.TerrainBlendingContext? _blendCtx;
private Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Was: Dictionary<uint, SurfaceInfo>. ConcurrentDictionary so the off-thread
// mesh builder (A.5 T11+) can call LandblockMesh.Build without a lock.
private System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>? _surfaceCache;
// Phase A.1 Task 8: worker thread pre-builds EnvCell room-mesh sub-meshes
// (CPU only) and stores them here. ApplyLoadedTerrain (render thread) drains
@ -126,6 +159,17 @@ public sealed class GameWindow : IDisposable
/// </summary>
private readonly Dictionary<uint, AnimatedEntity> _animatedEntities = new();
/// <summary>
/// Tier 1 cache (#53): per-entity classification results for static
/// entities (those NOT in <see cref="_animatedEntities"/>). Conceptually
/// paired with <see cref="_animatedEntities"/> — that dictionary is the
/// gating predicate, this cache is the lookup that depends on it.
/// Passed to <see cref="AcDream.App.Rendering.Wb.WbDrawDispatcher"/> at
/// construction time. Tasks 9-10 of the cache plan wire the per-entity
/// miss-populate / hit-fast-path through the dispatcher's loop.
/// </summary>
private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new();
private sealed class AnimatedEntity
{
public required AcDream.Core.World.WorldEntity Entity;
@ -794,6 +838,16 @@ public sealed class GameWindow : IDisposable
public void Run()
{
// A.5 T22.5: resolve quality preset BEFORE creating the window so
// Samples (MSAA) is baked into WindowOptions correctly. GL context
// sample count cannot change at runtime; all other quality fields are
// applied again in OnLoad after the full settings load.
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
@ -804,6 +858,11 @@ public sealed class GameWindow : IDisposable
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = false, // off during development so the perf overlay shows true framerate
// A.5 T22.5: MSAA from quality preset (0 = disabled, 2/4/8 = multisample).
// Silk.NET passes this to SDL_GL_SetAttribute(SDL_GL_MULTISAMPLESAMPLES).
// Cannot be changed at runtime; Quality changes mid-session that would
// alter MsaaSamples are logged as a restart-required warning.
Samples = startupQuality.MsaaSamples,
};
_window = Window.Create(options);
@ -965,9 +1024,13 @@ public sealed class GameWindow : IDisposable
_gl.Enable(EnableCap.DepthTest);
string shadersDir = Path.Combine(AppContext.BaseDirectory, "Rendering", "Shaders");
_shader = new Shader(_gl,
Path.Combine(shadersDir, "terrain.vert"),
Path.Combine(shadersDir, "terrain.frag"));
// Phase N.5b: terrain_modern shader pair — bindless texture handles +
// glMultiDrawElementsIndirect dispatch path. The only terrain shader
// since Task 9 retired the legacy terrain.vert/.frag program.
_terrainModernShader = new Shader(_gl,
Path.Combine(shadersDir, "terrain_modern.vert"),
Path.Combine(shadersDir, "terrain_modern.frag"));
// Phase G.1/G.2: shared scene-lighting UBO. Stays bound at
// binding=1 for the lifetime of the process — every shader that
@ -1063,6 +1126,18 @@ public sealed class GameWindow : IDisposable
// without re-loading.
LoadAndApplyPersistedSettings();
// A.5 T22.5: resolve quality preset immediately after settings load so
// _resolvedQuality is available for TerrainAtlas.SetAnisotropic,
// WbDrawDispatcher.AlphaToCoverage, and StreamingController wiring below.
{
var qBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(_persistedDisplay.Quality);
_resolvedQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(qBase);
if (!_resolvedQuality.Equals(qBase))
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} overridden by env vars: {_resolvedQuality}");
else
Console.WriteLine($"[QUALITY] Preset {_persistedDisplay.Quality} → {_resolvedQuality}");
}
// Phase D.2a — ImGui devtools overlay. Zero cost when the env var
// isn't set: no context creation, no per-frame branches hit.
// See docs/plans/2026-04-24-ui-framework.md + memory/project_ui_architecture.md.
@ -1121,7 +1196,8 @@ public sealed class GameWindow : IDisposable
getNearestObjLabel: () => _lastNearestObjLabel,
getColliding: () => _lastColliding,
getDebugWireframes: () => _debugCollisionVisible,
getStreamingRadius: () => _streamingRadius,
getStreamingRadius: () => _nearRadius, // A.5 T16 follow-up: was _streamingRadius (legacy single-tier); show near tier
getMouseSensitivity: () => GetActiveSensitivity(),
getChaseDistance: () => _chaseCamera?.Distance ?? 0f,
getRmbOrbit: () => _rmbHeld,
@ -1195,6 +1271,12 @@ public sealed class GameWindow : IDisposable
// already track DisplayDraft via the
// per-frame push.
ApplyDisplayWindowState(display);
// A.5 T22.5: apply quality preset if it changed.
// MSAA changes log a restart-required warning
// inside ReapplyQualityPreset; all other fields
// apply immediately.
_persistedDisplay = display;
ReapplyQualityPreset(display.Quality);
}
catch (Exception ex)
{
@ -1385,40 +1467,9 @@ public sealed class GameWindow : IDisposable
// TimeSync arrives.
WorldTime.SyncFromServer(AcDream.Core.World.DerethDateTime.DayTicks / 16.0); // = 476.25 = Midsong (noon)
// Build the terrain atlas once from the Region dat.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats);
_terrain = new TerrainChunkRenderer(_gl, _shader, terrainAtlas);
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
// Build blending context from the terrain atlas. Stored as fields so
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
// can call LandblockMesh.Build without re-deriving these every time.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new Dictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters.
// N.5: detect ARB_bindless_texture + ARB_shader_draw_parameters BEFORE
// building the terrain atlas / renderer — both consume BindlessSupport
// (atlas via Texture2DArray bindless handles, renderer for SSBO uploads).
// The modern path (SSBO + glMultiDrawElementsIndirect + bindless textures)
// is mandatory as of Phase N.5 — missing extensions throw at startup with
// a clear error so users can file a real bug report rather than silently
@ -1448,6 +1499,48 @@ public sealed class GameWindow : IDisposable
"If this is unexpected, please file a bug report with your GPU vendor + driver version.");
}
// Build the terrain atlas once from the Region dat. Phase N.5b: the
// atlas exposes bindless handles for the modern terrain path, so
// BindlessSupport is threaded through.
var terrainAtlas = AcDream.App.Rendering.TerrainAtlas.Build(_gl, _dats, _bindlessSupport);
// A.5 T22.5: apply anisotropic level from quality preset. Build()
// hard-codes 16x; override here to match the resolved quality so Low
// (4x) and Medium (8x) actually take effect.
terrainAtlas.SetAnisotropic(_resolvedQuality.AnisotropicLevel);
_terrain = new TerrainModernRenderer(_gl, _bindlessSupport, _terrainModernShader!, terrainAtlas);
int centerX = (int)((centerLandblockId >> 24) & 0xFFu);
int centerY = (int)((centerLandblockId >> 16) & 0xFFu);
// Build blending context from the terrain atlas. Stored as fields so
// ApplyLoadedTerrain (render-thread callback invoked per streamed lb)
// can call LandblockMesh.Build without re-deriving these every time.
var terrainTypeToLayerBytes = new Dictionary<uint, byte>(terrainAtlas.TerrainTypeToLayer.Count);
foreach (var kvp in terrainAtlas.TerrainTypeToLayer)
terrainTypeToLayerBytes[kvp.Key] = (byte)kvp.Value;
const uint RoadTypeEnumValue = 0x20; // TerrainTextureType.RoadType
byte roadLayer = terrainTypeToLayerBytes.TryGetValue(RoadTypeEnumValue, out var rl)
? rl
: AcDream.Core.Terrain.SurfaceInfo.None;
_blendCtx = new AcDream.Core.Terrain.TerrainBlendingContext(
TerrainTypeToLayer: terrainTypeToLayerBytes,
RoadLayer: roadLayer,
CornerAlphaLayers: terrainAtlas.CornerAlphaLayers,
SideAlphaLayers: terrainAtlas.SideAlphaLayers,
RoadAlphaLayers: terrainAtlas.RoadAlphaLayers,
CornerAlphaTCodes: terrainAtlas.CornerAlphaTCodes,
SideAlphaTCodes: terrainAtlas.SideAlphaTCodes,
RoadAlphaRCodes: terrainAtlas.RoadAlphaRCodes);
_heightTable = heightTable;
_surfaceCache = new System.Collections.Concurrent.ConcurrentDictionary<uint, AcDream.Core.Terrain.SurfaceInfo>();
// (Bindless detection moved above — must precede TerrainAtlas.Build /
// TerrainModernRenderer ctor so they can consume BindlessSupport.)
// Mesh shader always loads (modern path is the only path).
_meshShader = new Shader(_gl,
Path.Combine(shadersDir, "mesh_modern.vert"),
@ -1519,10 +1612,20 @@ public sealed class GameWindow : IDisposable
var wbEntitySpawnAdapter = new AcDream.App.Rendering.Wb.EntitySpawnAdapter(
_textureCache!, SequencerFactory, _wbMeshAdapter!);
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_worldState = new AcDream.App.Streaming.GpuWorldState(wbSpawnAdapter, wbEntitySpawnAdapter);
// Phase Post-A.5 #53 (Task 12): wire EntityClassificationCache.InvalidateLandblock
// so Tier 1 cache entries get swept on LB demote (Near to Far) and unload.
// Per spec §5.3 W3b. The callback receives the canonical landblock id
// matching the LandblockHint stored at Populate time.
_worldState = new AcDream.App.Streaming.GpuWorldState(
wbSpawnAdapter,
wbEntitySpawnAdapter,
onLandblockUnloaded: _classificationCache.InvalidateLandblock);
_wbDrawDispatcher = new AcDream.App.Rendering.Wb.WbDrawDispatcher(
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!);
_gl, _meshShader!, _textureCache!, _wbMeshAdapter!, _wbEntitySpawnAdapter, _bindlessSupport!,
_classificationCache);
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -1540,27 +1643,57 @@ public sealed class GameWindow : IDisposable
// the player.
_particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats);
// Phase A.1: replace the one-shot 3×3 preload with a streaming controller.
// Parse runtime radius from environment (default 2 → 5×5 window).
// Values outside [0, 8] fall back to the field default of 2.
var radiusEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(radiusEnv, out var r) && r >= 0 && r <= 8)
_streamingRadius = r;
Console.WriteLine($"streaming: radius={_streamingRadius} (window={2*_streamingRadius+1}×{2*_streamingRadius+1})");
// A.5 T22.5: apply radii from the already-resolved _resolvedQuality.
// _resolvedQuality was set by the quality block immediately after
// LoadAndApplyPersistedSettings() above, absorbing all env-var overrides.
// Legacy ACDREAM_STREAM_RADIUS is still honoured for backward-compat.
_nearRadius = _resolvedQuality.NearRadius;
_farRadius = _resolvedQuality.FarRadius;
// The streamer's load delegate wraps LandblockLoader.Load + stab
// hydration. Scenery + interior will land in Task 8.
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
// ensures farRadius >= streamRadius.
{
var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
if (int.TryParse(legacyEnv, out var sr) && sr >= 0)
{
_nearRadius = sr;
_streamingRadius = sr; // keep debug overlay in sync
_farRadius = System.Math.Max(sr, _farRadius);
}
}
Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" +
$" farRadius={_farRadius} (window={2*_farRadius+1}x{2*_farRadius+1})");
// Phase A.5 T11+: the streamer now runs on a dedicated worker thread.
// loadLandblock acquires _datLock (T10) before touching DatCollection.
// buildMeshOrNull (T12) receives the already-loaded LoadedLandblock so
// it can call LandblockMesh.Build without a dat read — _heightTable and
// _blendCtx are read-only after init, _surfaceCache is ConcurrentDictionary (T9).
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: id => BuildLandblockForStreaming(id));
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
buildMeshOrNull: (id, lb) =>
{
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
return null;
uint lbX = (id >> 24) & 0xFFu;
uint lbY = (id >> 16) & 0xFFu;
// _surfaceCache is ConcurrentDictionary (T9) — safe from worker thread.
// _heightTable and _blendCtx are read-only after initialization.
// lb.Heightmap is the pre-loaded LandBlock; no dat read needed here.
return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
});
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: _streamer.EnqueueLoad,
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
radius: _streamingRadius,
nearRadius: _nearRadius,
farRadius: _farRadius,
removeTerrain: id =>
{
// Phase G.2: release any LightSources attached to entities
@ -1577,6 +1710,8 @@ public sealed class GameWindow : IDisposable
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
});
// A.5 T22.5: apply max-completions from resolved quality.
_streamingController.MaxCompletionsPerFrame = _resolvedQuality.MaxCompletionsPerFrame;
// Phase 4.7: optional live-mode startup. Connect to the ACE server,
// enter the world as the first character on the account, and stream
@ -2817,6 +2952,7 @@ public sealed class GameWindow : IDisposable
_worldState.RemoveEntityByServerGuid(serverGuid);
_worldGameState.RemoveById(existingEntity.Id);
_animatedEntities.Remove(existingEntity.Id);
_classificationCache.InvalidateEntity(existingEntity.Id);
_physicsEngine.ShadowObjects.Deregister(existingEntity.Id);
// Dead-reckon state is keyed by SERVER guid (not local id) so we
@ -3873,7 +4009,7 @@ public sealed class GameWindow : IDisposable
// position by adding the residual back (so the visual doesn't jerk
// for one frame before the residual decay kicks in on the next tick).
System.Numerics.Vector3 preSnapPos = entity.Position;
entity.Position = worldPos;
entity.SetPosition(worldPos);
entity.Rotation = rot;
// Commit B 2026-04-29 — keep the shadow registry in sync with
@ -3993,11 +4129,11 @@ public sealed class GameWindow : IDisposable
if (!update.IsGrounded)
{
// Undo the unconditional entity hard-snap at the top of the
// function (entity.Position = worldPos): the body is mid-arc
// function (entity.SetPosition(worldPos)): the body is mid-arc
// and TickAnimations will write entity = body next frame
// anyway. Setting entity = body now prevents a 1-frame
// teleport-to-server-then-yank-back rubber-band.
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
return;
}
@ -4106,12 +4242,12 @@ public sealed class GameWindow : IDisposable
}
// Sync the visible entity to the body — overrides the unconditional
// entity.Position = worldPos snap at the top of this function.
// entity.SetPosition(worldPos) snap at the top of this function.
// For the far-snap branch this is a no-op (body == worldPos); for
// the near-enqueue branch this prevents a 1-frame teleport-then-
// yank-back rubber-band as TickAnimations chases worldPos via the
// queue.
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
return;
}
@ -4243,7 +4379,7 @@ public sealed class GameWindow : IDisposable
rmState.ServerVelocity);
}
entity.Position = rmState.Body.Position;
entity.SetPosition(rmState.Body.Position);
entity.Rotation = rmState.Body.Orientation;
}
@ -4288,7 +4424,7 @@ public sealed class GameWindow : IDisposable
resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
// 3. Snap player entity + controller.
entity.Position = snappedPos;
entity.SetPosition(snappedPos);
entity.Rotation = rot;
_playerController.SetPosition(snappedPos, resolved.CellId);
@ -4523,8 +4659,18 @@ public sealed class GameWindow : IDisposable
/// DatReaderWriter) and pure CPU work. No GL calls here.
///
/// MVP scope: stabs only. Scenery + interior added in Task 8.
///
/// ISSUE #54 (post-A.5): far-tier loads (<c>kind == LoadFar</c>) skip
/// LandBlockInfo + scenery + interior hydration. They return only the
/// LandBlock heightmap dat record + an empty entity list — enough for
/// terrain-mesh build on the next phase. Near-tier loads run the full
/// path. This replaces Bug A's post-load entity strip in
/// <see cref="AcDream.App.Streaming.LandblockStreamer"/> with an
/// early-out at the source.
/// </summary>
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(uint landblockId)
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreaming(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
if (_dats is null) return null;
@ -4537,14 +4683,31 @@ public sealed class GameWindow : IDisposable
// contention by pre-building render-thread work on the worker.
lock (_datLock)
{
return BuildLandblockForStreamingLocked(landblockId);
return BuildLandblockForStreamingLocked(landblockId, kind);
}
}
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(uint landblockId)
private AcDream.Core.World.LoadedLandblock? BuildLandblockForStreamingLocked(
uint landblockId,
AcDream.App.Streaming.LandblockStreamJobKind kind)
{
if (_dats is null) return null;
// ISSUE #54: far-tier early-out — heightmap only, empty entities.
// Skips the LandBlockInfo dat read AND all entity hydration (stabs
// + buildings) AND the SceneryGenerator AND interior cells. Cuts
// worker-thread cost per far-tier LB from ~tens of ms to a single
// dat read.
if (kind == AcDream.App.Streaming.LandblockStreamJobKind.LoadFar)
{
var heightmapOnly = _dats.Get<DatReaderWriter.DBObjs.LandBlock>(landblockId);
if (heightmapOnly is null) return null;
return new AcDream.Core.World.LoadedLandblock(
landblockId,
heightmapOnly,
System.Array.Empty<AcDream.Core.World.WorldEntity>());
}
var baseLoaded = AcDream.Core.World.LandblockLoader.Load(_dats, landblockId);
if (baseLoaded is null) return null;
@ -4723,7 +4886,7 @@ public sealed class GameWindow : IDisposable
float localY = spawn.LocalPosition.Y;
// Prefer the physics engine's terrain sampler (TerrainSurface.SampleZ)
// — it uses the same AC2D render split-direction formula the
// TerrainChunkRenderer uses for the visible terrain mesh. This
// TerrainModernRenderer uses for the visible terrain mesh. This
// guarantees trees are placed on the SAME Z height the player
// walks on. If physics hasn't registered this landblock yet,
// fall back to the local bilinear sample.
@ -4948,24 +5111,26 @@ public sealed class GameWindow : IDisposable
}
/// <summary>
/// Phase A.1: render-thread callback from StreamingController.Tick
/// Phase A.1 / A.5 T12: render-thread callback from StreamingController.Tick
/// whenever a new landblock's terrain + entities are ready for GPU upload.
/// Mirrors the terrain-build + entity-upload part of the old preload.
/// Phase A.5 T12: the worker pre-builds <paramref name="meshData"/> off the
/// render thread via <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/>;
/// this callback no longer pays that CPU cost.
/// Must only be called from the render thread.
/// </summary>
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb)
private void ApplyLoadedTerrain(AcDream.Core.World.LoadedLandblock lb,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
if (_terrain is null || _dats is null || _blendCtx is null
|| _heightTable is null || _surfaceCache is null) return;
if (_terrain is null || _dats is null) return;
// Phase A.1 hotfix: render-thread path also takes the dat lock so it
// doesn't race with BuildLandblockForStreaming on the worker thread.
// Hold the lock across the entire apply because we read dats below
// (GfxObj sub-mesh builds) and mutate the shared _surfaceCache from
// LandblockMesh.Build.
// Hold the lock across the entity hydration below (GfxObj sub-mesh
// builds). The terrain mesh is pre-built by the worker (T12) and passed
// in via meshData, so LandblockMesh.Build no longer runs under this lock.
lock (_datLock)
{
ApplyLoadedTerrainLocked(lb);
ApplyLoadedTerrainLocked(lb, meshData);
}
}
@ -5075,10 +5240,12 @@ public sealed class GameWindow : IDisposable
_pendingCells.Add(loaded);
}
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb)
private void ApplyLoadedTerrainLocked(AcDream.Core.World.LoadedLandblock lb,
AcDream.Core.Terrain.LandblockMeshData meshData)
{
if (_terrain is null || _dats is null || _blendCtx is null
|| _heightTable is null || _surfaceCache is null) return;
// _blendCtx / _surfaceCache no longer needed here (mesh pre-built by worker).
// _heightTable still needed for physics TerrainSurface below.
if (_terrain is null || _dats is null || _heightTable is null) return;
uint lbXu = (lb.LandblockId >> 24) & 0xFFu;
uint lbYu = (lb.LandblockId >> 16) & 0xFFu;
@ -5089,11 +5256,10 @@ public sealed class GameWindow : IDisposable
(lbY - _liveCenterY) * 192f,
0f);
// Build terrain mesh data on the render thread (pure CPU; acceptable
// for the MVP; a future pass can move it to the worker thread).
var meshData = AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbXu, lbYu, _heightTable, _blendCtx, _surfaceCache);
_terrain.AddLandblock(lb.LandblockId, meshData, origin);
// Phase A.5 T15/T16: route through AddLandblockWithMesh — the named
// two-tier entry point. Delegates to AddLandblock internally; both
// paths share one GPU upload path.
_terrain.AddLandblockWithMesh(lb.LandblockId, meshData, origin);
// Step 4: drain pending LoadedCells from the worker thread.
while (_pendingCells.TryTake(out var cell))
@ -5865,7 +6031,7 @@ public sealed class GameWindow : IDisposable
// the physics-resolved location each frame.
if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe))
{
pe.Position = result.RenderPosition;
pe.SetPosition(result.RenderPosition); // A.5 T18: SetPosition propagates AabbDirty
pe.Rotation = System.Numerics.Quaternion.CreateFromAxisAngle(
System.Numerics.Vector3.UnitZ, _playerController.Yaw - MathF.PI / 2f);
@ -6269,6 +6435,28 @@ public sealed class GameWindow : IDisposable
Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
// A.5 T22: override fog ramp with N₁/N₂-derived distances so the
// horizon fog masks the N₁ scenery boundary. Sky keyframe fog is
// retail-accurate at normal view distances but far too short for
// the extended N₂=12 (25×25 LB) streaming window.
// FogStart = N₁ × 192m × 0.7 ≈ 538m at defaults (4/12).
// FogEnd = N₂ × 192m × 0.95 ≈ 2188m at defaults.
// Multipliers exposed as env vars for fast iteration at visual gate.
{
const float LandblockSize = 192.0f;
float startMult = ParseEnvFloat("ACDREAM_FOG_START_MULT", 0.7f);
float endMult = ParseEnvFloat("ACDREAM_FOG_END_MULT", 0.95f);
float fogStart = _nearRadius * LandblockSize * startMult;
float fogEnd = _farRadius * LandblockSize * endMult;
// Preserve fog color (xyz), lightning flash (z), and mode (w).
ubo.FogParams = new System.Numerics.Vector4(
fogStart,
fogEnd,
ubo.FogParams.Z, // lightning flash — unchanged
ubo.FogParams.W); // fog mode — unchanged
}
_sceneLightingUbo?.Upload(ubo);
// Never cull the landblock the player is currently on.
@ -6314,7 +6502,19 @@ public sealed class GameWindow : IDisposable
goto SkipWorldGeometry;
}
// Phase N.5b: wrap Draw in CPU stopwatch for [TERRAIN-DIAG] rollup
// (gated on ACDREAM_WB_DIAG=1, same env var as [WB-DIAG]). Stopwatch
// is cheap; only the periodic Console.WriteLine is gated.
_terrainCpuStopwatch.Restart();
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb);
_terrainCpuStopwatch.Stop();
// Multiply by 100 then divide by 100 in the diag print to keep
// 0.01 µs precision in the long-typed sample buffer. Terrain Draw
// is sub-microsecond on simple scenes; truncating to integer µs
// would round nearly every sample to 0.
_terrainCpuSamples[_terrainCpuSampleCursor] = (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);
_terrainCpuSampleCursor = (_terrainCpuSampleCursor + 1) % _terrainCpuSamples.Length;
MaybeFlushTerrainDiag();
// Conditional depth clear: when camera is inside a building, clear
// depth (not color) so interior geometry writes fresh Z values on top
@ -6862,7 +7062,7 @@ public sealed class GameWindow : IDisposable
rm.MaxSeqSpeedSinceLastUP = seqSpeedNow;
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation;
}
else
@ -7190,7 +7390,7 @@ public sealed class GameWindow : IDisposable
}
}
ae.Entity.Position = rm.Body.Position;
ae.Entity.SetPosition(rm.Body.Position); // A.5 T18: SetPosition propagates AabbDirty
ae.Entity.Rotation = rm.Body.Orientation;
}
}
@ -7477,7 +7677,11 @@ public sealed class GameWindow : IDisposable
// we always want it animated in player mode.
if (!_animatedEntities.TryGetValue(pe.Id, out var ae))
{
var setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId);
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently. DatBinReader's
// shared buffer position would corrupt without serialization.
DatReaderWriter.DBObjs.Setup? setup;
lock (_datLock) { setup = _dats.Get<DatReaderWriter.DBObjs.Setup>(pe.SourceGfxObjOrSetupId); }
if (setup is null) return;
_physicsDataCache.CacheSetup(pe.SourceGfxObjOrSetupId, setup);
@ -7935,6 +8139,111 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// A.5 T22.5: apply a new quality preset mid-session (called from the
/// Settings panel Save path when <see cref="DisplaySettings.Quality"/>
/// changes).
///
/// <para>
/// What changes immediately:
/// <list type="bullet">
/// <item>Streaming radii: disposes the old
/// <see cref="_streamingController"/> + <see cref="_streamer"/>
/// and constructs new ones with the new radii.</item>
/// <item>Anisotropic filtering: calls
/// <c>TerrainAtlas.SetAnisotropic</c>.</item>
/// <item>Alpha-to-coverage gate: sets
/// <c>WbDrawDispatcher.AlphaToCoverage</c>.</item>
/// <item>Max completions per frame: updates
/// <c>StreamingController.MaxCompletionsPerFrame</c>.</item>
/// </list>
/// </para>
///
/// <para>
/// What requires a restart:
/// MSAA samples are baked into the GL context via <c>WindowOptions.Samples</c>
/// at window creation time and cannot change at runtime. If the new preset
/// would change <c>MsaaSamples</c>, a warning is logged and MSAA is left
/// at its current level until the next launch.
/// </para>
/// </summary>
public void ReapplyQualityPreset(AcDream.UI.Abstractions.Settings.QualityPreset newPreset)
{
var newBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(newPreset);
var newResolved = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(newBase);
Console.WriteLine($"[QUALITY] ReapplyQualityPreset: {newPreset} → {newResolved}");
// MSAA samples cannot change at runtime — warn if preset would differ.
if (newResolved.MsaaSamples != _resolvedQuality.MsaaSamples)
{
Console.WriteLine(
$"[QUALITY] MSAA samples change ({_resolvedQuality.MsaaSamples} → " +
$"{newResolved.MsaaSamples}) requires a restart — skipped for this session.");
}
_resolvedQuality = newResolved;
// A2C gate — immediate toggle, no GL context restart needed.
if (_wbDrawDispatcher is not null)
_wbDrawDispatcher.AlphaToCoverage = newResolved.AlphaToCoverage;
// Anisotropic — immediate GL TexParameter call on the terrain atlas.
_terrain?.Atlas?.SetAnisotropic(newResolved.AnisotropicLevel);
// Streaming radii — requires tearing down + rebuilding the controller
// (radii are constructor-time on StreamingController, not live-mutable).
// The ~1-2s hitch while the worker drains is acceptable for a settings change.
if (_streamer is not null && _streamingController is not null)
{
_nearRadius = newResolved.NearRadius;
_farRadius = newResolved.FarRadius;
// StreamingController is stateless (no Dispose needed); dispose
// only the LandblockStreamer worker thread.
_streamer.Dispose();
_streamer = new AcDream.App.Streaming.LandblockStreamer(
loadLandblock: (id, kind) => BuildLandblockForStreaming(id, kind),
buildMeshOrNull: (id, lb) =>
{
if (lb is null || _heightTable is null || _blendCtx is null || _surfaceCache is null)
return null;
uint lbX = (id >> 24) & 0xFFu;
uint lbY = (id >> 16) & 0xFFu;
return AcDream.Core.Terrain.LandblockMesh.Build(
lb.Heightmap, lbX, lbY, _heightTable, _blendCtx, _surfaceCache);
});
_streamer.Start();
_streamingController = new AcDream.App.Streaming.StreamingController(
enqueueLoad: (id, kind) => _streamer.EnqueueLoad(id, kind),
enqueueUnload: _streamer.EnqueueUnload,
drainCompletions: _streamer.DrainCompletions,
applyTerrain: ApplyLoadedTerrain,
state: _worldState,
nearRadius: _nearRadius,
farRadius: _farRadius,
removeTerrain: id =>
{
if (_lightingSink is not null &&
_worldState.TryGetLandblock(id, out var lb))
{
foreach (var ent in lb!.Entities)
_lightingSink.UnregisterOwner(ent.Id);
}
_terrain?.RemoveLandblock(id);
_physicsEngine.RemoveLandblock(id);
_cellVisibility.RemoveLandblock((id >> 16) & 0xFFFFu);
});
_streamingController.MaxCompletionsPerFrame = newResolved.MaxCompletionsPerFrame;
Console.WriteLine(
$"[QUALITY] Streaming restarted: nearRadius={_nearRadius}, " +
$"farRadius={_farRadius}, maxCompletions={newResolved.MaxCompletionsPerFrame}");
}
}
/// <summary>
/// L.0 Display tab: framebuffer-resize handler — update GL viewport
/// + camera aspect when the window is resized (by the user dragging
@ -8498,7 +8807,10 @@ public sealed class GameWindow : IDisposable
// 0.4 m fallbacks.
if (_dats is not null && (playerEntity.SourceGfxObjOrSetupId & 0xFF000000u) == 0x02000000u)
{
var playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId);
// A.5 T10: lock around _dats.Get — worker thread may be
// building a landblock mesh concurrently.
DatReaderWriter.DBObjs.Setup? playerSetup;
lock (_datLock) { playerSetup = _dats.Get<DatReaderWriter.DBObjs.Setup>(playerEntity.SourceGfxObjOrSetupId); }
if (playerSetup is not null)
_physicsDataCache.CacheSetup(playerEntity.SourceGfxObjOrSetupId, playerSetup);
_playerController.StepUpHeight = (playerSetup is not null && playerSetup.StepUpHeight > 0f)
@ -8713,6 +9025,76 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>Phase N.5b: emits [TERRAIN-DIAG] once per ~5s under
/// ACDREAM_WB_DIAG=1. Mirrors <c>WbDrawDispatcher.MaybeFlushDiag</c>:
/// rolling 256-sample buffer of microseconds, median + p95 reported.
/// Sample buffer is NOT cleared on flush — it's a moving window so the
/// next 5s window already has 256 frames of recent history.</summary>
private void MaybeFlushTerrainDiag()
{
if (!string.Equals(Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG"), "1", StringComparison.Ordinal))
return;
long now = Environment.TickCount64;
if (now - _terrainLastDiagTick <= 5000) return;
// Samples are stored as microseconds × 100 (so 1.23 µs becomes 123 long).
long cpuMedHundredthsUs = TerrainDiagMedianMicros(_terrainCpuSamples);
long cpuP95HundredthsUs = TerrainDiagPercentile95Micros(_terrainCpuSamples);
double cpuMedUs = cpuMedHundredthsUs / 100.0;
double cpuP95Us = cpuP95HundredthsUs / 100.0;
// A.5 T23: flag when terrain dispatcher median exceeds 1.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const double TerrainBudgetUs = 1000.0;
string terrainBudgetFlag = cpuMedUs > TerrainBudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[TERRAIN-DIAG]{terrainBudgetFlag} cpu_us={cpuMedUs:F2}m/{cpuP95Us:F2}p95 " +
$"draws={_terrain?.VisibleSlots ?? 0}/frame " +
$"visible={_terrain?.VisibleSlots ?? 0} " +
$"loaded={_terrain?.LoadedSlots ?? 0} " +
$"capacity={_terrain?.CapacitySlots ?? 0}");
_terrainLastDiagTick = now;
}
private static long TerrainDiagMedianMicros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// Sorted ascending: zero-padding at the front, samples at the back.
// Median of nz samples is the middle of the last nz entries; using
// (nz - 1) / 2 from the end keeps the offset >= 0 for all nz >= 1
// (the original nz / 2 form underflowed to copy.Length on first
// diag-flush when only 1 sample had been recorded).
return copy[copy.Length - 1 - (nz - 1) / 2];
}
private static long TerrainDiagPercentile95Micros(long[] samples)
{
var copy = (long[])samples.Clone();
Array.Sort(copy);
int nz = 0;
foreach (var v in copy) if (v > 0) nz++;
if (nz == 0) return 0;
// 95th percentile = upper end of the sorted samples; clamp the
// offset to stay inside the populated tail when nz < 20.
int offset = (int)((nz - 1) * 0.05);
return copy[copy.Length - 1 - offset];
}
/// <summary>A.5 T22: parse a float environment variable, returning
/// <paramref name="defaultValue"/> when the variable is absent or unparseable.</summary>
private static float ParseEnvFloat(string name, float defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
if (s is not null && float.TryParse(s, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v))
return v;
return defaultValue;
}
private void OnClosing()
{
// Phase A.1: join the streamer worker thread before tearing down GL
@ -8732,7 +9114,7 @@ public sealed class GameWindow : IDisposable
_meshShader?.Dispose();
_terrain?.Dispose();
_shader?.Dispose();
_terrainModernShader?.Dispose();
_sceneLightingUbo?.Dispose();
_particleRenderer?.Dispose();
_debugLines?.Dispose();

View file

@ -80,11 +80,30 @@ void main() {
vec4 color = texture(tex, vec3(vTexCoord, float(vTextureLayer)));
// Two-pass alpha-test (N.5 Decision 2).
// A.5 T20: opaque pass writes alpha as-sampled so GL_SAMPLE_ALPHA_TO_COVERAGE
// derives the MSAA sample mask from it — ClipMap foliage edges become smooth.
// Discard only fully-transparent (α < 0.05); the GPU handles coverage masking.
if (uRenderPass == 0) {
if (color.a < 0.95) discard; // opaque pass
if (color.a < 0.05) discard; // opaque pass — kill truly empty only (A2C)
} else {
if (color.a >= 0.95) discard; // transparent pass
if (color.a < 0.05) discard; // skip totally-empty
// Transparent pass.
//
// Phase Post-A.5 (ISSUE #52, 2026-05-10): do NOT discard α≥0.95 here.
// Native AC transparent-flagged surfaces routinely include
// effectively-opaque pixels — e.g. the Holtburg lifestone crystal core
// (surface 0x080011DE) which the spawn manifest classifies as
// transparent (batch.IsTransparent=True) but whose decoded texture
// alpha lands ≥0.95 across the visible surface. Those pixels still
// compose correctly under (SrcAlpha, 1-SrcAlpha) alpha-blending, so
// discarding them here threw away the whole crystal. The original
// N.5 §2 rationale (high-α fragments belong in the opaque pass) does
// not apply when the SURFACE is dat-flagged transparent — those
// pixels can't reach the opaque pass at all.
//
// Keep the α<0.05 short-circuit as a fragment-cost optimization
// (skip fully-empty pixels — saves blend bandwidth on alpha-keyed
// sprites with large transparent margins).
if (color.a < 0.05) discard;
}
vec3 N = normalize(vNormal);

View file

@ -39,6 +39,21 @@ layout(std430, binding = 1) readonly buffer BatchBuffer {
uniform mat4 uViewProjection;
// Phase Post-A.5 (ISSUE #52, 2026-05-10): per-pass offset into Batches[].
// 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 (a transparent draw)
// ended up reading the FIRST OPAQUE batch's TextureHandle every frame. As
// the camera moved and the opaque front-to-back sort reordered which group
// landed at BatchData[0], the lifestone's apparent texture flickered to
// whatever was first — frequently the player character's body parts.
//
// WbDrawDispatcher.Draw sets this to 0 before the opaque MDI call and to
// _opaqueDrawCount before the transparent MDI call, matching WorldBuilder's
// uDrawIDOffset pattern in BaseObjectRenderManager.cs line 845.
uniform int uDrawIDOffset;
out vec3 vNormal;
out vec2 vTexCoord;
out vec3 vWorldPos;
@ -56,7 +71,7 @@ void main() {
vNormal = normalize(mat3(model) * aNormal);
vTexCoord = aTexCoord;
BatchData b = Batches[gl_DrawIDARB];
BatchData b = Batches[uDrawIDOffset + gl_DrawIDARB];
vTextureHandle = b.textureHandle;
vTextureLayer = b.textureLayer;
}

View file

@ -1,9 +1,18 @@
#version 430 core
// Per-cell terrain blending (Phase 3c.4) — ported from WorldBuilder's
// Landscape.frag, trimmed of editor-specific features (grid, brush,
// walkable-slope highlighting). Phase G extends this with the shared
// SceneLighting UBO driving per-vertex sun bake + fragment-stage fog
// + lightning flash.
#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain fragment shader on the modern bindless dispatcher.
// Math identical to terrain.frag (Phase 3c per-cell maskBlend3 +
// Phase G fog + lightning flash).
//
// Bindless texture handles are passed as uvec2 (low/high 32 bits) and
// reconstructed into sampler2DArray at use sites via the GLSL
// sampler-from-handle constructor. The alternative pattern —
// `uniform sampler2DArray` set via glProgramUniformHandleARB — produces
// GL_INVALID_OPERATION on at least one driver in practice (NVIDIA on
// Windows). The uvec2 + constructor pattern is what N.5's mesh_modern
// shader uses and is the documented "always works" form per the
// ARB_bindless_texture spec.
in vec2 vBaseUV;
in vec3 vWorldNormal;
@ -18,11 +27,11 @@ flat in float vBaseTexIdx;
out vec4 fragColor;
uniform sampler2DArray uTerrain; // 33+ layers — TerrainAtlas.GlTexture
uniform sampler2DArray uAlpha; // 8+ layers — TerrainAtlas.GlAlphaTexture
uniform uvec2 uTerrainHandle;
uniform uvec2 uAlphaHandle;
#define uTerrain sampler2DArray(uTerrainHandle)
#define uAlpha sampler2DArray(uAlphaHandle)
// Shared scene-lighting UBO — fog + flash are consumed here; the per-vertex
// AdjustPlanes bake already incorporated sun + ambient.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -37,12 +46,8 @@ layout(std140, binding = 1) uniform SceneLighting {
vec4 uCameraAndTime;
};
// Per-texture tiling repeat count across a cell. WorldBuilder uses
// uTexTiling[36] uploaded from the dats; we default to 1.0 (one tile per
// cell, 8 tiles across a landblock).
const float TILE = 1.0;
// Three-layer alpha-weighted composite.
vec4 maskBlend3(vec4 t0, vec4 t1, vec4 t2, float h0, float h1, float h2) {
float a0 = h0 == 0.0 ? 1.0 : t0.a;
float a1 = h1 == 0.0 ? 1.0 : t1.a;
@ -129,20 +134,16 @@ void main() {
if (vRoad0.z >= 0.0)
roads = combineRoad(vBaseUV, vRoad0, vRoad1);
// Composite: base × (1 - ovlA) × (1 - rdA) + ovl × ovlA × (1 - rdA) + road × rdA
vec3 baseMasked = baseColor.rgb * ((1.0 - overlays.a) * (1.0 - roads.a));
vec3 ovlMasked = overlays.rgb * (overlays.a * (1.0 - roads.a));
vec3 roadMasked = roads.rgb * roads.a;
vec3 rgb = clamp(baseMasked + ovlMasked + roadMasked, 0.0, 1.0);
// Apply the per-vertex baked sun+ambient.
vec3 lit = rgb * min(vLightingRGB, vec3(1.0));
// Lightning flash — additive.
float flash = uFogParams.z;
lit += flash * vec3(0.6, 0.6, 0.75);
// Atmospheric fog.
lit = applyFog(lit, vWorldPos);
fragColor = vec4(lit, 1.0);

View file

@ -1,17 +1,21 @@
#version 430 core
#version 460 core
#extension GL_ARB_bindless_texture : require
// Phase N.5b: terrain shader on the modern bindless dispatcher.
// Math identical to terrain.vert (Phase 3c per-cell mesh + Phase G AdjustPlanes
// lighting). The only structural change is the version + bindless extension
// — sampler access in the fragment stage is unchanged at the GLSL level.
layout(location = 0) in vec3 aPos;
layout(location = 1) in vec3 aNormal;
layout(location = 2) in uvec4 aPacked0; // bytes: baseTex, baseAlpha(255), ovl0Tex, ovl0Alpha
layout(location = 3) in uvec4 aPacked1; // bytes: ovl1Tex, ovl1Alpha, ovl2Tex, ovl2Alpha
layout(location = 4) in uvec4 aPacked2; // bytes: road0Tex, road0Alpha, road1Tex, road1Alpha
layout(location = 5) in uvec4 aPacked3; // bits: rot fields + splitDir (see below)
layout(location = 2) in uvec4 aPacked0;
layout(location = 3) in uvec4 aPacked1;
layout(location = 4) in uvec4 aPacked2;
layout(location = 5) in uvec4 aPacked3;
uniform mat4 uView;
uniform mat4 uProjection;
// Phase G.1+G.2: sky/scene UBO. Terrain reads uLights[0] for the sun
// (slot 0 is reserved) plus uCellAmbient for outdoor ambient; the fog
// fields are consumed by the fragment stage.
struct Light {
vec4 posAndKind;
vec4 dirAndRange;
@ -29,9 +33,7 @@ layout(std140, binding = 1) uniform SceneLighting {
out vec2 vBaseUV;
out vec3 vWorldNormal;
out vec3 vWorldPos;
out vec3 vLightingRGB; // pre-computed sun+ambient contribution for retail-style AdjustPlanes bake
// Per-layer "UV.xy in cell-local 0..1 space, tex index .z, alpha index .w".
// Negative .z means "layer not present, skip it in the fragment shader."
out vec3 vLightingRGB;
out vec4 vOverlay0;
out vec4 vOverlay1;
out vec4 vOverlay2;
@ -53,9 +55,6 @@ flat out float vBaseTexIdx;
// Cross-ref: docs/research/2026-04-24-lambert-brightness-split.md.
const float MIN_FACTOR = 0.0;
// Port of WorldBuilder's Landscape.vert unpackOverlayLayer: sentinel-check
// 255 → -1 (shader skips), then rotate the cell-local UV by the overlay's
// 90° rotation count.
vec4 unpackOverlayLayer(uint texIdxU, uint alphaIdxU, uint rotIdx, vec2 baseUV) {
float texIdx = float(texIdxU);
float alphaIdx = float(alphaIdxU);
@ -121,15 +120,9 @@ void main() {
vWorldPos = aPos;
vWorldNormal = normalize(aNormal);
// Retail AdjustPlanes bake (r13 §7):
// L = max(N · -sunDir, MIN_FACTOR)
// vertex.color = sun_color * L + ambient_color
//
// Slot 0 of the UBO is the sun (directional). We read its forward
// vector and pre-multiplied color, apply the ambient floor, layer
// in the scene ambient separately.
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
// Retail AdjustPlanes bake (terrain.vert:124-134 — identical math).
vec3 sunDir = uLights[0].dirAndRange.xyz;
vec3 sunCol = uLights[0].colorAndIntensity.xyz * uLights[0].colorAndIntensity.w;
float L = max(dot(vWorldNormal, -sunDir), MIN_FACTOR);
vLightingRGB = sunCol * L + uCellAmbient.xyz;

View file

@ -53,14 +53,45 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// <summary>RCode for each RoadMap, parallel to <see cref="RoadAlphaLayers"/>.</summary>
public IReadOnlyList<uint> RoadAlphaRCodes { get; }
private readonly Wb.BindlessSupport? _bindless;
// Cached bindless handles. Generated lazily on first GetBindlessHandles() call;
// reused for the lifetime of the atlas.
private ulong _terrainHandle;
private ulong _alphaHandle;
private bool _handlesGenerated;
/// <summary>
/// Get 64-bit bindless handles for the terrain + alpha texture arrays.
/// Throws <see cref="InvalidOperationException"/> if the atlas was constructed
/// without a <see cref="Wb.BindlessSupport"/> instance. Handles are generated
/// lazily on first call and cached for the atlas's lifetime; both textures
/// are made resident.
/// </summary>
public (ulong terrain, ulong alpha) GetBindlessHandles()
{
if (_bindless is null)
throw new InvalidOperationException(
"TerrainAtlas was constructed without BindlessSupport; cannot return bindless handles.");
if (!_handlesGenerated)
{
_terrainHandle = _bindless.GetResidentHandle(GlTexture);
_alphaHandle = _bindless.GetResidentHandle(GlAlphaTexture);
_handlesGenerated = true;
}
return (_terrainHandle, _alphaHandle);
}
private TerrainAtlas(
GL gl,
Wb.BindlessSupport? bindless,
uint glTexture, IReadOnlyDictionary<uint, uint> map, int layerCount,
uint glAlphaTexture, int alphaLayerCount,
IReadOnlyList<byte> cornerLayers, IReadOnlyList<byte> sideLayers, IReadOnlyList<byte> roadLayers,
IReadOnlyList<uint> cornerTCodes, IReadOnlyList<uint> sideTCodes, IReadOnlyList<uint> roadRCodes)
{
_gl = gl;
_bindless = bindless;
GlTexture = glTexture;
TerrainTypeToLayer = map;
LayerCount = layerCount;
@ -79,7 +110,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
/// for the mapping from TerrainTextureType to SurfaceTexture id, decoding each
/// to RGBA8, and uploading as layers in a single GL_TEXTURE_2D_ARRAY.
/// </summary>
public static TerrainAtlas Build(GL gl, DatCollection dats)
public static TerrainAtlas Build(GL gl, DatCollection dats, Wb.BindlessSupport? bindless = null)
{
var region = dats.Get<Region>(0x13000000u)
?? throw new InvalidOperationException("Region dat id 0x13000000 missing");
@ -89,7 +120,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
if (terrainDesc is null || terrainDesc.Count == 0)
{
Console.WriteLine("WARN: TerrainDesc missing, using single white fallback layer");
return BuildFallback(gl);
return BuildFallback(gl, bindless);
}
// ---- Terrain atlas (unchanged Phase 2b logic) ----
@ -152,13 +183,17 @@ public sealed unsafe class TerrainAtlas : IDisposable
layerIdx++;
}
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.Linear);
// A.5 T19: generate mipmaps + trilinear + 16x anisotropic for distant-LB quality.
gl.GenerateMipmap(TextureTarget.Texture2DArray);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMinFilter, (int)TextureMinFilter.LinearMipmapLinear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureMagFilter, (int)TextureMagFilter.Linear);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapS, (int)TextureWrapMode.Repeat);
gl.TexParameter(TextureTarget.Texture2DArray, TextureParameterName.TextureWrapT, (int)TextureWrapMode.Repeat);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE (GL_EXT_texture_filter_anisotropic / ARB_texture_filter_anisotropic).
gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, 16.0f);
gl.BindTexture(TextureTarget.Texture2DArray, 0);
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH}");
Console.WriteLine($"TerrainAtlas: {layerCount} terrain layers at {maxW}x{maxH} (mipmaps+aniso16x)");
// ---- Alpha atlas (new in Phase 3c.2) ----
// texMerge is guaranteed non-null here: the early return above exited
@ -167,6 +202,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas(
gl,
bindless,
tex, map, layerCount,
alphaBuild.gl, alphaBuild.layerCount,
alphaBuild.corner, alphaBuild.side, alphaBuild.road,
@ -350,7 +386,7 @@ public sealed unsafe class TerrainAtlas : IDisposable
return dst;
}
private static TerrainAtlas BuildFallback(GL gl)
private static TerrainAtlas BuildFallback(GL gl, Wb.BindlessSupport? bindless = null)
{
uint tex = gl.GenTexture();
gl.BindTexture(TextureTarget.Texture2DArray, tex);
@ -372,14 +408,62 @@ public sealed unsafe class TerrainAtlas : IDisposable
return new TerrainAtlas(
gl,
bindless,
tex, new Dictionary<uint, uint> { [0] = 0u }, 1,
alphaTex, 1,
Array.Empty<byte>(), Array.Empty<byte>(), Array.Empty<byte>(),
Array.Empty<uint>(), Array.Empty<uint>(), Array.Empty<uint>());
}
/// <summary>
/// A.5 T22.5: update GL_TEXTURE_MAX_ANISOTROPY on the terrain atlas at
/// runtime (called by <see cref="GameWindow.ReapplyQualityPreset"/> when
/// the user changes Quality preset mid-session). Idempotent — calling with
/// the same level as the current setting is safe and produces no visual
/// change. The texture must not be resident-bindless when its parameters
/// are mutated; we temporarily make it non-resident if needed.
/// </summary>
public void SetAnisotropic(int level)
{
// If bindless handles are live we must make them non-resident before
// mutating texture state, then re-resident after.
bool wasResident = _handlesGenerated && _bindless is not null;
if (wasResident)
{
_bindless!.MakeNonResident(_terrainHandle);
// Alpha texture is not affected by anisotropic but we must keep
// residency symmetric — re-generate both handles after.
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
_gl.BindTexture(TextureTarget.Texture2DArray, GlTexture);
// GL_TEXTURE_MAX_ANISOTROPY = 0x84FE
_gl.TexParameter(TextureTarget.Texture2DArray, (TextureParameterName)0x84FE, (float)level);
_gl.BindTexture(TextureTarget.Texture2DArray, 0);
// Re-generate bindless handles if they were live before.
if (wasResident)
{
// GetBindlessHandles regenerates and makes resident.
_ = GetBindlessHandles();
}
Console.WriteLine($"TerrainAtlas: anisotropic updated to {level}x");
}
public void Dispose()
{
// Phase 1: release bindless residency BEFORE deleting textures.
// ARB_bindless_texture requires this ordering; interleaving is UB.
if (_handlesGenerated && _bindless is not null)
{
_bindless.MakeNonResident(_terrainHandle);
_bindless.MakeNonResident(_alphaHandle);
_handlesGenerated = false;
}
// Phase 2: delete the underlying GL textures.
_gl.DeleteTexture(GlTexture);
_gl.DeleteTexture(GlAlphaTexture);
}

View file

@ -1,454 +0,0 @@
using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Chunk-based terrain renderer matching ACME's architecture. Each 16x16
/// landblock region gets its own VAO/VBO/EBO with pre-allocated max-size
/// buffers. Landblocks are added/removed incrementally via glBufferSubData
/// instead of rebuilding the entire buffer.
///
/// Attribute layout (same as TerrainRenderer, see TerrainVertex):
/// location 0: vec3 aPos (3 floats, world space)
/// location 1: vec3 aNormal (3 floats)
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
/// location 4: uvec4 aPacked2 (4 bytes, Data2)
/// location 5: uvec4 aPacked3 (4 bytes, Data3)
/// </summary>
public sealed unsafe class TerrainChunkRenderer : IDisposable
{
// -------------------------------------------------------------------------
// Constants
// -------------------------------------------------------------------------
/// <summary>Number of landblocks per chunk dimension (matching ACME).</summary>
public const int ChunkSizeInLandblocks = 16;
/// <summary>Max landblock slots per chunk (16x16 = 256).</summary>
public const int SlotsPerChunk = ChunkSizeInLandblocks * ChunkSizeInLandblocks;
/// <summary>Vertices per landblock: 64 cells x 6 verts = 384.</summary>
public const int VerticesPerLandblock = LandblockMesh.VerticesPerLandblock;
/// <summary>Indices per landblock (trivial 0..383, same count as vertices).</summary>
public const int IndicesPerLandblock = VerticesPerLandblock;
/// <summary>Byte size of one TerrainVertex (40 bytes).</summary>
private static readonly int VertexSize = sizeof(TerrainVertex);
/// <summary>Max VBO size per chunk: 256 slots x 384 verts x 40 bytes = ~3.75 MB.</summary>
private static readonly nuint MaxVboBytes =
(nuint)(SlotsPerChunk * VerticesPerLandblock * VertexSize);
/// <summary>Max EBO size per chunk: 256 slots x 384 indices x 4 bytes = ~393 KB.</summary>
private static readonly nuint MaxEboBytes =
(nuint)(SlotsPerChunk * IndicesPerLandblock * sizeof(uint));
// -------------------------------------------------------------------------
// Fields
// -------------------------------------------------------------------------
private readonly GL _gl;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// <summary>Active chunks keyed by (chunkX, chunkY) packed into a ulong.</summary>
private readonly Dictionary<ulong, ChunkData> _chunks = new();
/// <summary>Reverse map: landblockId -> chunkId, for fast RemoveLandblock.</summary>
private readonly Dictionary<uint, ulong> _landblockToChunk = new();
// -------------------------------------------------------------------------
// Construction
// -------------------------------------------------------------------------
public TerrainChunkRenderer(GL gl, Shader shader, TerrainAtlas atlas)
{
_gl = gl;
_shader = shader;
_atlas = atlas;
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/// <summary>
/// Add (or replace) a landblock's terrain mesh. Vertices are baked to world
/// space using <paramref name="worldOrigin"/>, then uploaded to the correct
/// chunk buffer slot via glBufferSubData.
/// </summary>
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
// If this landblock already exists, remove it first.
if (_landblockToChunk.ContainsKey(landblockId))
RemoveLandblock(landblockId);
// Determine chunk coordinates and slot index.
// Landblock ID format: 0xXXYYnnnn (X at bits 24-31, Y at bits 16-23).
int lbX = (int)(landblockId >> 24) & 0xFF;
int lbY = (int)(landblockId >> 16) & 0xFF;
int chunkX = lbX / ChunkSizeInLandblocks;
int chunkY = lbY / ChunkSizeInLandblocks;
ulong chunkId = PackChunkId(chunkX, chunkY);
int localX = lbX % ChunkSizeInLandblocks;
int localY = lbY % ChunkSizeInLandblocks;
int slotIndex = localX * ChunkSizeInLandblocks + localY;
// Create chunk on demand.
if (!_chunks.TryGetValue(chunkId, out var chunk))
{
chunk = CreateChunk(chunkX, chunkY);
_chunks[chunkId] = chunk;
}
// Bake world-space vertices.
var worldVerts = new TerrainVertex[meshData.Vertices.Length];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < meshData.Vertices.Length; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
// Upload vertices into the slot's region of the VBO.
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
fixed (void* p = worldVerts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset,
(nuint)(worldVerts.Length * VertexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
// Track the slot.
chunk.Slots[slotIndex] = new LandblockSlot
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
MinZ = zMin,
MaxZ = zMax,
};
chunk.Occupied.Add(slotIndex);
_landblockToChunk[landblockId] = chunkId;
// Rebuild the EBO for this chunk (only includes occupied slots).
RebuildChunkEbo(chunk);
// Update chunk AABB.
UpdateChunkBounds(chunk);
}
/// <summary>
/// Remove a landblock from its chunk. If the chunk becomes empty, dispose it.
/// </summary>
public void RemoveLandblock(uint landblockId)
{
if (!_landblockToChunk.TryGetValue(landblockId, out var chunkId))
return;
_landblockToChunk.Remove(landblockId);
if (!_chunks.TryGetValue(chunkId, out var chunk))
return;
// Find which slot this landblock occupies.
int slotIndex = -1;
foreach (var s in chunk.Occupied)
{
if (chunk.Slots[s].LandblockId == landblockId)
{
slotIndex = s;
break;
}
}
if (slotIndex < 0)
return;
// Zero out the VBO region for this slot (optional but clean).
nint vboOffset = (nint)(slotIndex * VerticesPerLandblock * VertexSize);
nuint vboSize = (nuint)(VerticesPerLandblock * VertexSize);
var zeros = new byte[VerticesPerLandblock * VertexSize];
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
fixed (void* p = zeros)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboOffset, vboSize, p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
chunk.Slots[slotIndex] = default;
chunk.Occupied.Remove(slotIndex);
if (chunk.Occupied.Count == 0)
{
// Chunk is empty -- dispose GPU resources.
chunk.Dispose(_gl);
_chunks.Remove(chunkId);
}
else
{
RebuildChunkEbo(chunk);
UpdateChunkBounds(chunk);
}
}
/// <summary>
/// Draw all visible terrain chunks. One glDrawElements per non-empty chunk.
/// Frustum culling is performed at the chunk AABB level.
/// </summary>
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_chunks.Count == 0)
return;
// Determine which chunk the never-cull landblock lives in.
ulong? neverCullChunkId = null;
if (neverCullLandblockId is not null && _landblockToChunk.TryGetValue(neverCullLandblockId.Value, out var ncId))
neverCullChunkId = ncId;
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// Phase G: light direction + ambient + fog come from the shared
// SceneLighting UBO (binding=1) uploaded by GameWindow once per
// frame. Terrain bakes per-vertex AdjustPlanes lighting (r13 §7)
// from the UBO's slot-0 sun + uCellAmbient, then the fragment
// stage adds fog + lightning flash. No per-program uniforms here.
// Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
_gl.ActiveTexture(TextureUnit.Texture1);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
foreach (var (chunkId, chunk) in _chunks)
{
if (chunk.IndexCount == 0)
continue;
// Chunk-level frustum cull.
if (frustum is not null && chunkId != neverCullChunkId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, chunk.AabbMin, chunk.AabbMax))
continue;
}
_gl.BindVertexArray(chunk.Vao);
_gl.DrawElements(
PrimitiveType.Triangles,
(uint)chunk.IndexCount,
DrawElementsType.UnsignedInt,
(void*)0);
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
foreach (var chunk in _chunks.Values)
chunk.Dispose(_gl);
_chunks.Clear();
_landblockToChunk.Clear();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private static ulong PackChunkId(int chunkX, int chunkY)
=> ((ulong)(uint)chunkX << 32) | (uint)chunkY;
/// <summary>
/// Allocate a new chunk with max-size VBO and empty EBO, plus a configured VAO.
/// </summary>
private ChunkData CreateChunk(int chunkX, int chunkY)
{
var chunk = new ChunkData
{
ChunkX = chunkX,
ChunkY = chunkY,
Vao = _gl.GenVertexArray(),
Vbo = _gl.GenBuffer(),
Ebo = _gl.GenBuffer(),
};
// Pre-allocate VBO to max size with DynamicDraw.
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, MaxVboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
// Pre-allocate EBO (empty initially, will be rebuilt on first AddLandblock).
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, MaxEboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
// Configure VAO with the same attribute layout as the old TerrainRenderer.
ConfigureVao(chunk);
return chunk;
}
/// <summary>
/// Set up vertex attribute pointers on the chunk's VAO. Identical layout
/// to the old TerrainRenderer.
/// </summary>
private void ConfigureVao(ChunkData chunk)
{
_gl.BindVertexArray(chunk.Vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, chunk.Vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
uint stride = (uint)VertexSize;
// location 0: Position (12 bytes)
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal (12 bytes, offset 12)
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each, offsets 24, 28, 32, 36).
nint dataOffset = 6 * sizeof(float); // 24 bytes
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
/// <summary>
/// Rebuild the EBO for a chunk, emitting rebased indices only for occupied
/// slots. Each slot's indices are offset by (slotIndex * VerticesPerLandblock)
/// so they point to the correct region of the VBO.
/// </summary>
private void RebuildChunkEbo(ChunkData chunk)
{
int totalIndices = chunk.Occupied.Count * IndicesPerLandblock;
var indices = new uint[totalIndices];
int writePos = 0;
foreach (var slotIndex in chunk.Occupied)
{
uint vertexBase = (uint)(slotIndex * VerticesPerLandblock);
for (uint i = 0; i < IndicesPerLandblock; i++)
indices[writePos++] = vertexBase + i;
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, chunk.Ebo);
fixed (void* p = indices)
{
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, 0,
(nuint)(totalIndices * sizeof(uint)), p);
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
chunk.IndexCount = totalIndices;
}
/// <summary>
/// Recompute the chunk's world-space AABB from all occupied landblock slots.
/// </summary>
private static void UpdateChunkBounds(ChunkData chunk)
{
float minX = float.MaxValue, minY = float.MaxValue, minZ = float.MaxValue;
float maxX = float.MinValue, maxY = float.MinValue, maxZ = float.MinValue;
foreach (var slotIndex in chunk.Occupied)
{
var slot = chunk.Slots[slotIndex];
float ox = slot.WorldOrigin.X;
float oy = slot.WorldOrigin.Y;
if (ox < minX) minX = ox;
if (oy < minY) minY = oy;
if (slot.MinZ < minZ) minZ = slot.MinZ;
float ex = ox + LandblockMesh.LandblockSize;
float ey = oy + LandblockMesh.LandblockSize;
if (ex > maxX) maxX = ex;
if (ey > maxY) maxY = ey;
if (slot.MaxZ > maxZ) maxZ = slot.MaxZ;
}
if (minX == float.MaxValue)
{
chunk.AabbMin = Vector3.Zero;
chunk.AabbMax = Vector3.Zero;
}
else
{
chunk.AabbMin = new Vector3(minX, minY, minZ);
chunk.AabbMax = new Vector3(maxX, maxY, maxZ);
}
}
// -------------------------------------------------------------------------
// Inner types
// -------------------------------------------------------------------------
/// <summary>
/// Per-landblock slot tracking within a chunk's VBO.
/// </summary>
private struct LandblockSlot
{
public uint LandblockId;
public Vector3 WorldOrigin;
public float MinZ;
public float MaxZ;
}
/// <summary>
/// GPU resources and metadata for a single 16x16 terrain chunk.
/// </summary>
private sealed class ChunkData
{
public int ChunkX;
public int ChunkY;
// GPU handles.
public uint Vao;
public uint Vbo;
public uint Ebo;
/// <summary>Per-slot landblock data. Indexed by (localX * 16 + localY).</summary>
public readonly LandblockSlot[] Slots = new LandblockSlot[SlotsPerChunk];
/// <summary>Set of occupied slot indices within this chunk.</summary>
public readonly HashSet<int> Occupied = new();
/// <summary>Current number of valid indices in the EBO (set by RebuildChunkEbo).</summary>
public int IndexCount;
/// <summary>World-space AABB for chunk-level frustum culling.</summary>
public Vector3 AabbMin;
public Vector3 AabbMax;
public void Dispose(GL gl)
{
gl.DeleteVertexArray(Vao);
gl.DeleteBuffer(Vbo);
gl.DeleteBuffer(Ebo);
}
}
}

View file

@ -0,0 +1,376 @@
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Phase N.5b modern terrain dispatcher. Single global VBO/EBO with a slot
/// allocator (one slot per landblock, 384 verts × 40 bytes = 15,360 bytes
/// per slot). Per-frame: build a DrawElementsIndirectCommand array from
/// visible slots, upload, dispatch via glMultiDrawElementsIndirect. Atlas
/// textures bound via bindless handles set per-frame as sampler uniforms.
///
/// Total ~6-8 GL calls per frame for terrain regardless of visible
/// landblock count.
/// </summary>
public sealed unsafe class TerrainModernRenderer : IDisposable
{
// VertsPerLandblock MUST stay divisible by 6 — terrain_modern.vert uses
// `gl_VertexID % 6` to pick the cell-corner index (BL/BR/TR/TL), and
// because we bake `slot * VertsPerLandblock` into indices CPU-side and
// pass BaseVertex=0 to MultiDrawElementsIndirect, gl_VertexID becomes
// `slot * VertsPerLandblock + local_index`. The shader's modulo-6 only
// reduces to `local_index % 6` because 384 is a multiple of 6. Changing
// either constant without auditing the shader will silently mis-render.
private const int VertsPerLandblock = LandblockMesh.VerticesPerLandblock; // 384 (= 64 cells * 6 verts)
private const int IndicesPerLandblock = VertsPerLandblock;
private const int VertexSize = 40; // sizeof(TerrainVertex)
private const int IndexSize = sizeof(uint);
private const float LandblockSize = LandblockMesh.LandblockSize; // 192
private readonly GL _gl;
private readonly BindlessSupport _bindless;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
/// <summary>A.5 T22.5: exposes the terrain atlas so callers can update
/// anisotropic level mid-session via <see cref="TerrainAtlas.SetAnisotropic"/>.</summary>
public TerrainAtlas Atlas => _atlas;
private readonly TerrainSlotAllocator _alloc;
// Per-slot live data (index by slot integer; null entries are unused slots).
private SlotData?[] _slots;
// Reverse map: landblockId -> slot, for RemoveLandblock and replacement.
private readonly Dictionary<uint, int> _idToSlot = new();
// GPU buffers.
private uint _globalVao;
private uint _globalVbo;
private uint _globalEbo;
private uint _indirectBuffer;
private int _indirectCapacity;
// Cached uvec2-handle uniform locations (matrix uniforms are set by name via Shader.SetMatrix4).
private int _uTerrainHandleLoc;
private int _uAlphaHandleLoc;
// Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
// Diag.
public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity;
public TerrainModernRenderer(
GL gl,
BindlessSupport bindless,
Shader shader,
TerrainAtlas atlas,
int initialSlotCapacity = 64)
{
_gl = gl;
_bindless = bindless;
_shader = shader;
_atlas = atlas;
_alloc = new TerrainSlotAllocator(initialSlotCapacity);
_slots = new SlotData?[initialSlotCapacity];
_uTerrainHandleLoc = _gl.GetUniformLocation(_shader.Program, "uTerrainHandle");
_uAlphaHandleLoc = _gl.GetUniformLocation(_shader.Program, "uAlphaHandle");
_globalVao = _gl.GenVertexArray();
_globalVbo = _gl.GenBuffer();
_globalEbo = _gl.GenBuffer();
AllocateGpuBuffers(initialSlotCapacity);
ConfigureVao();
_indirectBuffer = _gl.GenBuffer();
}
/// <summary>
/// Two-tier streaming entry point. Accepts a prebuilt mesh from
/// <see cref="LandblockStreamResult.Loaded.MeshData"/> built on the worker
/// thread, together with the world-space origin computed by the caller
/// (render-thread GameWindow derives it from landblockId + liveCenterX/Y).
///
/// Delegates to <see cref="AddLandblock(uint,LandblockMeshData,Vector3)"/>
/// so both paths share one upload path. Per Phase A.5 spec T15.
/// </summary>
public void AddLandblockWithMesh(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
=> AddLandblock(landblockId, meshData, worldOrigin);
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
ArgumentNullException.ThrowIfNull(meshData);
if (meshData.Vertices.Length != VertsPerLandblock)
throw new ArgumentException(
$"Expected {VertsPerLandblock} vertices, got {meshData.Vertices.Length}",
nameof(meshData));
if (meshData.Indices.Length != IndicesPerLandblock)
throw new ArgumentException(
$"Expected {IndicesPerLandblock} indices, got {meshData.Indices.Length}",
nameof(meshData));
if (_idToSlot.ContainsKey(landblockId))
RemoveLandblock(landblockId);
int slot = _alloc.Allocate(out var needsGrow);
if (needsGrow)
{
int newCap = Math.Max(_alloc.Capacity * 2, slot + 1);
EnsureCapacity(newCap);
}
// Bake worldOrigin into vertex positions; capture min/max Z for AABB.
var bakedVerts = new TerrainVertex[VertsPerLandblock];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < VertsPerLandblock; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
bakedVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
// Bake baseVertex into indices on the CPU side (driver-portable pattern).
uint baseVertex = (uint)(slot * VertsPerLandblock);
var bakedIndices = new uint[IndicesPerLandblock];
for (int i = 0; i < IndicesPerLandblock; i++)
bakedIndices[i] = meshData.Indices[i] + baseVertex;
// glBufferSubData into the slot's VBO + EBO regions.
nint vboByteOffset = (nint)(slot * VertsPerLandblock * VertexSize);
nint eboByteOffset = (nint)(slot * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
fixed (TerrainVertex* p = bakedVerts)
{
_gl.BufferSubData(BufferTargetARB.ArrayBuffer, vboByteOffset,
(nuint)(VertsPerLandblock * VertexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
fixed (uint* p = bakedIndices)
{
_gl.BufferSubData(BufferTargetARB.ElementArrayBuffer, eboByteOffset,
(nuint)(IndicesPerLandblock * IndexSize), p);
}
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
_slots[slot] = new SlotData
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
FirstIndex = (uint)(slot * IndicesPerLandblock),
IndexCount = IndicesPerLandblock,
AabbMin = new Vector3(worldOrigin.X, worldOrigin.Y, zMin),
AabbMax = new Vector3(worldOrigin.X + LandblockSize, worldOrigin.Y + LandblockSize, zMax),
};
_idToSlot[landblockId] = slot;
}
public void RemoveLandblock(uint landblockId)
{
if (!_idToSlot.TryGetValue(landblockId, out var slot))
return;
_idToSlot.Remove(landblockId);
_slots[slot] = null;
_alloc.Free(slot);
// No GPU clear: the per-frame DEIC array won't reference this slot.
}
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_alloc.LoadedCount == 0) return;
// Build visible slot list with per-slot frustum cull.
_visibleSlots.Clear();
for (int slot = 0; slot < _slots.Length; slot++)
{
var data = _slots[slot];
if (data is null) continue;
if (frustum is not null && data.LandblockId != neverCullLandblockId)
{
if (!FrustumCuller.IsAabbVisible(frustum.Value, data.AabbMin, data.AabbMax))
continue;
}
_visibleSlots.Add(slot);
}
if (_visibleSlots.Count == 0) return;
// Build DEIC array.
if (_deicScratch.Length < _visibleSlots.Count)
_deicScratch = new DrawElementsIndirectCommand[Math.Max(_visibleSlots.Count, 64)];
for (int i = 0; i < _visibleSlots.Count; i++)
{
var data = _slots[_visibleSlots[i]]!;
_deicScratch[i] = new DrawElementsIndirectCommand
{
Count = (uint)data.IndexCount,
InstanceCount = 1u,
FirstIndex = data.FirstIndex,
BaseVertex = 0, // baked into indices on upload
BaseInstance = 0,
};
}
// Grow indirect buffer if needed.
if (_visibleSlots.Count > _indirectCapacity)
{
_indirectCapacity = Math.Max(64, _visibleSlots.Count * 2);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
_gl.BufferData(GLEnum.DrawIndirectBuffer,
(nuint)(_indirectCapacity * sizeof(DrawElementsIndirectCommand)),
null, GLEnum.DynamicDraw);
}
else
{
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, _indirectBuffer);
}
// Upload DEIC array.
fixed (DrawElementsIndirectCommand* p = _deicScratch)
{
_gl.BufferSubData(GLEnum.DrawIndirectBuffer, 0,
(nuint)(_visibleSlots.Count * sizeof(DrawElementsIndirectCommand)), p);
}
// Bind shader + uniforms + atlas handles.
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
var (terrainHandle, alphaHandle) = _atlas.GetBindlessHandles();
// Pass each 64-bit handle as a uvec2 (low 32 bits, high 32 bits).
// GLSL constructs sampler2DArray(uTerrainHandle) at the use site —
// see terrain_modern.frag for why this is the safe pattern.
_gl.ProgramUniform2(_shader.Program, _uTerrainHandleLoc,
(uint)(terrainHandle & 0xFFFFFFFFu), (uint)(terrainHandle >> 32));
_gl.ProgramUniform2(_shader.Program, _uAlphaHandleLoc,
(uint)(alphaHandle & 0xFFFFFFFFu), (uint)(alphaHandle >> 32));
_gl.BindVertexArray(_globalVao);
_gl.MemoryBarrier(MemoryBarrierMask.CommandBarrierBit);
_gl.MultiDrawElementsIndirect(
PrimitiveType.Triangles, DrawElementsType.UnsignedInt,
(void*)0,
(uint)_visibleSlots.Count,
(uint)sizeof(DrawElementsIndirectCommand));
_gl.BindVertexArray(0);
_gl.BindBuffer(GLEnum.DrawIndirectBuffer, 0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_globalVao);
_gl.DeleteBuffer(_globalVbo);
_gl.DeleteBuffer(_globalEbo);
_gl.DeleteBuffer(_indirectBuffer);
}
// ----------------------------------------------------------------
// Private helpers
// ----------------------------------------------------------------
private void AllocateGpuBuffers(int capacitySlots)
{
nuint vboBytes = (nuint)(capacitySlots * VertsPerLandblock * VertexSize);
nuint eboBytes = (nuint)(capacitySlots * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, vboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, eboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, 0);
}
private void ConfigureVao()
{
_gl.BindVertexArray(_globalVao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _globalEbo);
uint stride = (uint)VertexSize;
// location 0: Position
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// locations 2-5: Data0..Data3 (uvec4 byte attributes)
nint dataOffset = 6 * sizeof(float);
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
private void EnsureCapacity(int newCapacity)
{
if (newCapacity <= _alloc.Capacity) return;
// Allocate new VBO + EBO at new size; copy old contents; swap; recreate VAO.
uint newVbo = _gl.GenBuffer();
uint newEbo = _gl.GenBuffer();
nuint newVboBytes = (nuint)(newCapacity * VertsPerLandblock * VertexSize);
nuint newEboBytes = (nuint)(newCapacity * IndicesPerLandblock * IndexSize);
nuint oldVboBytes = (nuint)(_alloc.Capacity * VertsPerLandblock * VertexSize);
nuint oldEboBytes = (nuint)(_alloc.Capacity * IndicesPerLandblock * IndexSize);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, newVbo);
_gl.BufferData(BufferTargetARB.ArrayBuffer, newVboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalVbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newVbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldVboBytes);
_gl.DeleteBuffer(_globalVbo);
_globalVbo = newVbo;
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, newEbo);
_gl.BufferData(BufferTargetARB.ElementArrayBuffer, newEboBytes, null, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.CopyReadBuffer, _globalEbo);
_gl.BindBuffer(BufferTargetARB.CopyWriteBuffer, newEbo);
_gl.CopyBufferSubData(CopyBufferSubDataTarget.CopyReadBuffer, CopyBufferSubDataTarget.CopyWriteBuffer,
0, 0, oldEboBytes);
_gl.DeleteBuffer(_globalEbo);
_globalEbo = newEbo;
// Recreate VAO with new buffer bindings.
_gl.DeleteVertexArray(_globalVao);
_globalVao = _gl.GenVertexArray();
ConfigureVao();
// Grow slot tracking array.
Array.Resize(ref _slots, newCapacity);
_alloc.GrowTo(newCapacity);
}
private sealed class SlotData
{
public uint LandblockId;
public Vector3 WorldOrigin;
public uint FirstIndex;
public int IndexCount;
public Vector3 AabbMin;
public Vector3 AabbMax;
}
}

View file

@ -1,247 +0,0 @@
using System.Numerics;
using AcDream.Core.Terrain;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Draws the Phase 3c per-cell terrain mesh. All loaded landblocks share a
/// single VBO + EBO + VAO. Vertex positions are baked in world space so no
/// uModel uniform is needed. The VAO is bound once per frame; each visible
/// landblock gets one glDrawElements call into its sub-range of the shared EBO.
///
/// Attribute layout (see TerrainVertex for the byte layout):
/// location 0: vec3 aPos (3 floats, world space)
/// location 1: vec3 aNormal (3 floats)
/// location 2: uvec4 aPacked0 (4 bytes, Data0)
/// location 3: uvec4 aPacked1 (4 bytes, Data1)
/// location 4: uvec4 aPacked2 (4 bytes, Data2)
/// location 5: uvec4 aPacked3 (4 bytes, Data3)
/// </summary>
public sealed unsafe class TerrainRenderer : IDisposable
{
private readonly GL _gl;
private readonly Shader _shader;
private readonly TerrainAtlas _atlas;
// Logical per-landblock data (CPU side).
private readonly Dictionary<uint, LandblockEntry> _entries = new();
// Shared GPU buffers — rebuilt whenever a landblock is added or removed.
private uint _vao;
private uint _vbo;
private uint _ebo;
private bool _gpuDirty = true; // true = buffers need rebuilding before next Draw
public TerrainRenderer(GL gl, Shader shader, TerrainAtlas atlas)
{
_gl = gl;
_shader = shader;
_atlas = atlas;
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_ebo = _gl.GenBuffer();
ConfigureVao();
}
public void AddLandblock(uint landblockId, LandblockMeshData meshData, Vector3 worldOrigin)
{
if (_entries.ContainsKey(landblockId))
_entries.Remove(landblockId);
// Bake world-space positions: offset every vertex by worldOrigin.
var worldVerts = new TerrainVertex[meshData.Vertices.Length];
float zMin = float.MaxValue, zMax = float.MinValue;
for (int i = 0; i < meshData.Vertices.Length; i++)
{
var v = meshData.Vertices[i];
var worldPos = v.Position + worldOrigin;
worldVerts[i] = new TerrainVertex(worldPos, v.Normal, v.Data0, v.Data1, v.Data2, v.Data3);
if (worldPos.Z < zMin) zMin = worldPos.Z;
if (worldPos.Z > zMax) zMax = worldPos.Z;
}
if (zMin == float.MaxValue) { zMin = 0f; zMax = 0f; }
_entries[landblockId] = new LandblockEntry
{
LandblockId = landblockId,
WorldOrigin = worldOrigin,
Vertices = worldVerts,
Indices = meshData.Indices, // local 0..N-1; will be rebased on rebuild
MinZ = zMin,
MaxZ = zMax,
};
_gpuDirty = true;
}
public void RemoveLandblock(uint landblockId)
{
if (_entries.Remove(landblockId))
_gpuDirty = true;
}
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null)
{
if (_entries.Count == 0)
return;
if (_gpuDirty)
RebuildGpuBuffers();
_shader.Use();
_shader.SetMatrix4("uView", camera.View);
_shader.SetMatrix4("uProjection", camera.Projection);
// Terrain atlas on unit 0, alpha atlas on unit 1.
_gl.ActiveTexture(TextureUnit.Texture0);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlTexture);
_gl.ActiveTexture(TextureUnit.Texture1);
_gl.BindTexture(TextureTarget.Texture2DArray, _atlas.GlAlphaTexture);
int terrainLoc = _gl.GetUniformLocation(_shader.Program, "uTerrain");
if (terrainLoc >= 0) _gl.Uniform1(terrainLoc, 0);
int alphaLoc = _gl.GetUniformLocation(_shader.Program, "uAlpha");
if (alphaLoc >= 0) _gl.Uniform1(alphaLoc, 1);
// Bind the shared VAO once for the entire frame.
_gl.BindVertexArray(_vao);
foreach (var entry in _entries.Values)
{
// Per-landblock frustum cull using world-space AABB.
if (frustum is not null && entry.LandblockId != neverCullLandblockId)
{
var aabbMin = new Vector3(entry.WorldOrigin.X, entry.WorldOrigin.Y, entry.MinZ);
var aabbMax = new Vector3(entry.WorldOrigin.X + 192f, entry.WorldOrigin.Y + 192f, entry.MaxZ);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aabbMin, aabbMax))
continue;
}
// Draw only this landblock's sub-range in the shared EBO.
// EboOffset is in bytes (uint = 4 bytes).
_gl.DrawElements(
PrimitiveType.Triangles,
(uint)entry.IndexCount,
DrawElementsType.UnsignedInt,
(void*)(entry.EboByteOffset));
}
_gl.BindVertexArray(0);
}
public void Dispose()
{
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
_gl.DeleteBuffer(_ebo);
_entries.Clear();
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private void ConfigureVao()
{
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
uint stride = (uint)sizeof(TerrainVertex);
// location 0: Position (12 bytes)
_gl.EnableVertexAttribArray(0);
_gl.VertexAttribPointer(0, 3, VertexAttribPointerType.Float, false, stride, (void*)0);
// location 1: Normal (12 bytes, offset 12)
_gl.EnableVertexAttribArray(1);
_gl.VertexAttribPointer(1, 3, VertexAttribPointerType.Float, false, stride, (void*)(3 * sizeof(float)));
// location 2..5: Data0..Data3 as uvec4 byte attributes (4 bytes each,
// offsets 24, 28, 32, 36).
nint dataOffset = 6 * sizeof(float); // 24 bytes
_gl.EnableVertexAttribArray(2);
_gl.VertexAttribIPointer(2, 4, VertexAttribIType.UnsignedByte, stride, (void*)dataOffset);
_gl.EnableVertexAttribArray(3);
_gl.VertexAttribIPointer(3, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 4));
_gl.EnableVertexAttribArray(4);
_gl.VertexAttribIPointer(4, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 8));
_gl.EnableVertexAttribArray(5);
_gl.VertexAttribIPointer(5, 4, VertexAttribIType.UnsignedByte, stride, (void*)(dataOffset + 12));
_gl.BindVertexArray(0);
}
/// <summary>
/// Concatenate all loaded landblocks into a single VBO + EBO and upload.
/// Called on the cold path (landblock load / unload), not per frame.
/// </summary>
private void RebuildGpuBuffers()
{
// Measure totals.
int totalVerts = 0;
int totalIndices = 0;
foreach (var e in _entries.Values)
{
totalVerts += e.Vertices.Length;
totalIndices += e.Indices.Length;
}
var allVerts = new TerrainVertex[totalVerts];
var allIndices = new uint[totalIndices];
int vertBase = 0;
int indexBase = 0;
foreach (var entry in _entries.Values)
{
// Copy world-space vertices.
entry.Vertices.CopyTo(allVerts, vertBase);
// Rebase local indices (0..N-1) → absolute (vertBase..vertBase+N-1).
for (int i = 0; i < entry.Indices.Length; i++)
allIndices[indexBase + i] = (uint)(vertBase + entry.Indices[i]);
// Record where this landblock's indices live in the EBO (byte offset).
entry.EboByteOffset = (nint)(indexBase * sizeof(uint));
entry.IndexCount = entry.Indices.Length;
vertBase += entry.Vertices.Length;
indexBase += entry.Indices.Length;
}
// Upload to GPU.
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
fixed (void* p = allVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(totalVerts * sizeof(TerrainVertex)), p, BufferUsageARB.DynamicDraw);
_gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _ebo);
fixed (void* p = allIndices)
_gl.BufferData(BufferTargetARB.ElementArrayBuffer,
(nuint)(totalIndices * sizeof(uint)), p, BufferUsageARB.DynamicDraw);
_gl.BindVertexArray(0);
_gpuDirty = false;
}
// -------------------------------------------------------------------------
// Data types
// -------------------------------------------------------------------------
private sealed class LandblockEntry
{
public uint LandblockId;
public Vector3 WorldOrigin;
public TerrainVertex[] Vertices = Array.Empty<TerrainVertex>();
public uint[] Indices = Array.Empty<uint>();
public float MinZ;
public float MaxZ;
// Set by RebuildGpuBuffers:
public nint EboByteOffset;
public int IndexCount;
}
}

View file

@ -45,6 +45,15 @@ public sealed class BindlessSupport
_ext.MakeTextureHandleNonResident(handle);
}
// Phase N.5b note: a `SetSamplerHandleUniform` wrapper was added in T6
// and removed when terrain rendering surfaced GL_INVALID_OPERATION on
// NVIDIA Windows for the `uniform sampler2DArray` + glProgramUniformHandleARB
// combination. The replacement pattern (uvec2 handle uniform + GLSL
// sampler-from-handle constructor — see terrain_modern.frag) lives at the
// call site via plain `_gl.ProgramUniform2(program, loc, low, high)`. If
// you re-introduce a sampler-handle helper, restrict it to drivers known
// to accept the direct sampler-uniform path.
/// <summary>Detect <c>GL_ARB_shader_draw_parameters</c> in addition to bindless.
/// N.5's vertex shader uses <c>gl_BaseInstanceARB</c> and <c>gl_DrawIDARB</c>
/// from this extension.</summary>

View file

@ -0,0 +1,39 @@
using System.Numerics;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Per-(entity, partIdx, batchIdx) classification result, stored flat inside
/// <see cref="EntityCacheEntry.Batches"/>. For Setup multi-part MeshRefs each
/// subPart contributes its own <see cref="CachedBatch"/> entries, with
/// <see cref="RestPose"/> already containing the
/// <c>subPart.PartTransform * meshRef.PartTransform</c> product.
///
/// Accessibility: <c>internal</c> because <see cref="GroupKey"/> is
/// <c>internal</c> and shows up in this struct's constructor / <c>Deconstruct</c>
/// signature. The cache itself is dispatcher-internal coordination state;
/// <see cref="InternalsVisibleTo"/> on <c>AcDream.App</c> exposes the type to
/// <c>AcDream.Core.Tests</c>.
/// </summary>
internal readonly record struct CachedBatch(
GroupKey Key,
ulong BindlessTextureHandle,
Matrix4x4 RestPose);
/// <summary>
/// One entity's cached classification. <see cref="Batches"/> is flat across
/// (partIdx, batchIdx) and ordered as <c>WbDrawDispatcher.ClassifyBatches</c>
/// produced them. <see cref="LandblockHint"/> lets
/// <see cref="EntityClassificationCache.InvalidateLandblock"/> sweep entries
/// efficiently when a landblock demotes or unloads.
///
/// Accessibility: <c>internal</c> for the same reason as <see cref="CachedBatch"/>
/// — its <see cref="Batches"/> property is <c>CachedBatch[]</c>, which
/// transitively involves <see cref="GroupKey"/>.
/// </summary>
internal sealed class EntityCacheEntry
{
public required uint EntityId { get; init; }
public required uint LandblockHint { get; init; }
public required CachedBatch[] Batches { get; init; }
}

View file

@ -0,0 +1,193 @@
using System.Collections.Generic;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Cache of per-entity classification results for static entities (those NOT
/// in <c>GameWindow._animatedEntities</c>). Holds one
/// <see cref="EntityCacheEntry"/> per cached entity. The cache is opaque
/// w.r.t. classification logic — it simply stores what callers populate.
///
/// <para>
/// <b>Key composition:</b> entries are keyed by the tuple
/// <c>(EntityId, LandblockHint)</c>, NOT by <c>EntityId</c> alone. Issue #53
/// uncovered that <c>entity.Id</c> is NOT globally unique across all
/// static-entity hydration paths: scenery (<c>0x80LLBB00 + localIndex</c>)
/// and interior cells (<c>0x40LLBB00 + localCounter</c>) overflow at >256
/// items per landblock, wrapping into the <c>lbY</c> byte and producing
/// cross-LB collisions in dense forest/urban LBs outside Holtburg. Keying
/// by the tuple is correct-by-construction regardless of any hydration
/// path's id strategy.
/// </para>
///
/// <para>
/// <b>Invariants:</b>
/// <list type="bullet">
/// <item><see cref="Populate"/> overwrites any existing entry for the same (id, lb) tuple (defensive).</item>
/// <item><see cref="InvalidateEntity"/> sweeps all entries with the given <c>EntityId</c>
/// regardless of <c>LandblockHint</c>; idempotent (no-throw on missing id).</item>
/// <item><see cref="InvalidateLandblock"/> walks all entries; entries whose
/// <see cref="EntityCacheEntry.LandblockHint"/> equals the argument are removed.</item>
/// <item>All operations are render-thread only. No internal locking.</item>
/// </list>
/// </para>
///
/// <para>
/// <b>Audit foundation:</b> see
/// <c>docs/research/2026-05-10-tier1-mutation-audit.md</c> for why static
/// entities can be cached and what invalidation is needed.
/// </para>
///
/// <para>
/// <b>Accessibility:</b> <c>internal</c>. <see cref="EntityCacheEntry"/> and
/// <see cref="CachedBatch"/> both transitively reference the <c>internal</c>
/// <see cref="GroupKey"/>; surfacing the cache as <c>public</c> would create
/// inconsistent-accessibility errors. Cross-assembly access for the test
/// project comes via <c>InternalsVisibleTo("AcDream.Core.Tests")</c> on
/// <c>AcDream.App.csproj</c>.
/// </para>
/// </summary>
internal sealed class EntityClassificationCache
{
private readonly Dictionary<(uint EntityId, uint LandblockHint), EntityCacheEntry> _entries = new();
/// <summary>Number of cached entities — for diagnostics.</summary>
public int Count => _entries.Count;
/// <summary>
/// Look up an entity's cached classification. Keyed by both
/// <paramref name="entityId"/> AND <paramref name="landblockHint"/> to
/// disambiguate entities whose Ids collide across landblocks (e.g.,
/// scenery's <c>0x80LLBB00 + localIndex</c> overflow at >256 items/LB).
/// Returns <c>true</c> with the entry on hit; <c>false</c> with
/// <paramref name="entry"/> set to <c>null</c> on miss.
/// </summary>
public bool TryGet(uint entityId, uint landblockHint, out EntityCacheEntry? entry)
=> _entries.TryGetValue((entityId, landblockHint), out entry);
/// <summary>
/// Insert or overwrite a cache entry for the
/// <c>(<paramref name="entityId"/>, <paramref name="landblockHint"/>)</c>
/// tuple. Defensive: if an entry already exists, replaces it.
/// </summary>
public void Populate(uint entityId, uint landblockHint, CachedBatch[] batches)
{
_entries[(entityId, landblockHint)] = new EntityCacheEntry
{
EntityId = entityId,
LandblockHint = landblockHint,
Batches = batches,
};
}
/// <summary>
/// Remove all cache entries for the given <paramref name="entityId"/>,
/// regardless of which landblock they were populated under. Sweep is
/// needed because we may have entries for the same Id under different
/// LandblockHints if any hydration path produced colliding Ids
/// historically (defensive even though current paths shouldn't produce
/// duplicates per-LB). Was <c>O(1)</c> before the #53 tuple-key change;
/// now <c>O(n)</c>, but called rarely (only on entity despawn).
/// </summary>
public void InvalidateEntity(uint entityId)
{
if (_entries.Count == 0) return;
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.EntityId == entityId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
/// <summary>
/// Remove every cache entry whose <see cref="EntityCacheEntry.LandblockHint"/>
/// equals <paramref name="landblockId"/>. Used by the streaming pipeline
/// when a landblock demotes from near to far or unloads. No-op if no
/// entries match.
/// </summary>
public void InvalidateLandblock(uint landblockId)
{
if (_entries.Count == 0) return;
// Collect the keys to remove first to avoid mutating the dict during iteration.
// Buffered locally because the typical case removes ~all entries in the LB
// (which is still small relative to the total cache).
List<(uint, uint)>? toRemove = null;
foreach (var key in _entries.Keys)
{
if (key.LandblockHint == landblockId)
{
toRemove ??= new List<(uint, uint)>();
toRemove.Add(key);
}
}
if (toRemove is null) return;
foreach (var k in toRemove) _entries.Remove(k);
}
#if DEBUG
/// <summary>
/// Asserts that the cached entry for <paramref name="entityId"/> still
/// matches what fresh classification would produce. Catches the prior
/// Tier 1 bug class — silent caching of mutable per-frame state — by
/// firing <see cref="System.Diagnostics.Debug.Assert"/> when any cached
/// field has drifted from live state.
///
/// <para>
/// Caller passes per-batch live state (Key, BindlessTextureHandle, RestPose)
/// reconstructed from the same path the populate ran. The cache iterates
/// its stored entries in parallel and asserts equality.
/// </para>
///
/// <para>
/// As of Phase 4 (commit f16604b) this method is exercised by unit tests
/// only; the dispatcher's cache-hit branch fires a simpler predicate assert
/// (<c>!isAnimated</c>) at production hit time. Wiring the full live-state
/// cross-check into the per-entity branch is the spec section 6.5 stretch
/// goal and remains open as a follow-up. Zero cost in Release; the method
/// stays here so the regression-class guard is locked behind tests.
/// </para>
/// </summary>
public void DebugCrossCheck(uint entityId, uint landblockHint, IReadOnlyList<CachedBatch> liveBatches)
{
if (!_entries.TryGetValue((entityId, landblockHint), out var entry)) return;
System.Diagnostics.Debug.Assert(
entry.Batches.Length == liveBatches.Count,
$"EntityClassificationCache: batch count mismatch for entity {entityId}: cached={entry.Batches.Length} live={liveBatches.Count}");
for (int i = 0; i < entry.Batches.Length && i < liveBatches.Count; i++)
{
var cached = entry.Batches[i];
var live = liveBatches[i];
System.Diagnostics.Debug.Assert(
cached.Key.Equals(live.Key),
$"EntityClassificationCache: GroupKey drift for entity {entityId} batch {i}");
System.Diagnostics.Debug.Assert(
cached.BindlessTextureHandle == live.BindlessTextureHandle,
$"EntityClassificationCache: texture handle drift for entity {entityId} batch {i}");
System.Diagnostics.Debug.Assert(
MatrixApproxEqual(cached.RestPose, live.RestPose, epsilon: 1e-5f),
$"EntityClassificationCache: RestPose drift for entity {entityId} batch {i}");
}
}
private static bool MatrixApproxEqual(System.Numerics.Matrix4x4 a, System.Numerics.Matrix4x4 b, float epsilon)
{
return System.MathF.Abs(a.M11 - b.M11) <= epsilon && System.MathF.Abs(a.M12 - b.M12) <= epsilon &&
System.MathF.Abs(a.M13 - b.M13) <= epsilon && System.MathF.Abs(a.M14 - b.M14) <= epsilon &&
System.MathF.Abs(a.M21 - b.M21) <= epsilon && System.MathF.Abs(a.M22 - b.M22) <= epsilon &&
System.MathF.Abs(a.M23 - b.M23) <= epsilon && System.MathF.Abs(a.M24 - b.M24) <= epsilon &&
System.MathF.Abs(a.M31 - b.M31) <= epsilon && System.MathF.Abs(a.M32 - b.M32) <= epsilon &&
System.MathF.Abs(a.M33 - b.M33) <= epsilon && System.MathF.Abs(a.M34 - b.M34) <= epsilon &&
System.MathF.Abs(a.M41 - b.M41) <= epsilon && System.MathF.Abs(a.M42 - b.M42) <= epsilon &&
System.MathF.Abs(a.M43 - b.M43) <= epsilon && System.MathF.Abs(a.M44 - b.M44) <= epsilon;
}
#endif
}

View file

@ -128,6 +128,12 @@ public sealed class EntitySpawnAdapter
}
}
// A.5 T18: populate cached AABB so WalkEntities reads from the cache
// rather than recomputing Position±5 per frame. Called here because
// all entity-state initialization (position, rotation) is complete
// by this point via the WorldEntity passed in.
entity.RefreshAabb();
// Build the per-entity AnimatedEntityState. The sequencer factory
// may return a stub (in tests) or a fully-constructed sequencer from
// the MotionTable (in production). Factory must not return null —

View file

@ -0,0 +1,20 @@
using AcDream.Core.Meshing;
namespace AcDream.App.Rendering.Wb;
/// <summary>
/// Bucket identity for <see cref="WbDrawDispatcher"/>'s per-frame group dictionary.
/// Two (entity, batch) pairs that share the same <see cref="GroupKey"/> render
/// in a single <c>glMultiDrawElementsIndirect</c> draw command. Promoted to
/// <c>internal</c> at file scope (was a private nested type) so
/// <see cref="EntityClassificationCache"/> can store it inside <see cref="CachedBatch"/>
/// without depending on dispatcher internals.
/// </summary>
internal readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,
ulong BindlessTextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);

View file

@ -68,6 +68,26 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly BindlessSupport _bindless;
// Tier 1 cache (#53): per-entity classification results for static
// entities (those NOT in GameWindow._animatedEntities). Wired here in
// Task 7 for plumbing only — Tasks 9-10 wire the per-entity
// miss-populate / hit-fast-path through the loop.
private readonly EntityClassificationCache _cache;
// ACDREAM_DISABLE_TIER1_CACHE=1 A/B diagnostic — forces every static
// entity through the slow path. Read once in ctor.
private readonly bool _tier1CacheDisabled =
string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DISABLE_TIER1_CACHE"), "1", StringComparison.Ordinal);
/// <summary>
/// A.5 T22.5: gate for GL_SAMPLE_ALPHA_TO_COVERAGE around the opaque pass.
/// Default true matches T20 behavior. Set false for Low/Medium presets that
/// have MsaaSamples=0 (A2C is a no-op without MSAA, but turning it off
/// avoids the unnecessary GL state thrash and is cleaner diagnostics).
/// Can be toggled mid-session via <see cref="GameWindow.ReapplyQualityPreset"/>.
/// </summary>
public bool AlphaToCoverage { get; set; } = true;
// SSBO buffer ids
private uint _instanceSsbo;
private uint _batchSsbo;
@ -100,6 +120,19 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private readonly Dictionary<GroupKey, InstanceGroup> _groups = new();
private readonly List<InstanceGroup> _opaqueDraws = new();
private readonly List<InstanceGroup> _translucentDraws = new();
// A.5 T26 follow-up (Bug B): WalkEntities populates this scratch list
// instead of allocating a fresh List<(WorldEntity, int)> per frame. At
// ~10K entities × ~3 mesh refs = ~30K tuples × 16 bytes = ~480 KB / frame
// of GC pressure on the render thread under the original T17 shape.
private readonly List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> _walkScratch = new();
// Tier 1 cache (#53) — per-entity classification collector. Reused across
// frames; cleared at flush time when the per-entity loop crosses an entity
// boundary in _walkScratch (and once more at end-of-loop for the last
// entity). _walkScratch is in entity-order, so all MeshRefs of one entity
// are contiguous — accumulate them all before flushing one Populate call.
// Animated entities skip this scratch entirely (collector = null).
private readonly List<CachedBatch> _populateScratch = new();
// Per-entity-cull AABB radius. Conservative — covers most entities; large
// outliers (long banners, tall columns) are still landblock-culled.
@ -125,25 +158,32 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private int _gpuSampleCursor;
private bool _gpuQueriesInitialized;
public WbDrawDispatcher(
// Constructor accessibility is internal because EntityClassificationCache
// is internal — a public ctor with an internal-typed parameter would be
// an inconsistent-accessibility error. The dispatcher is constructed
// exclusively from GameWindow (same assembly), so internal is fine.
internal WbDrawDispatcher(
GL gl,
Shader shader,
TextureCache textures,
WbMeshAdapter meshAdapter,
EntitySpawnAdapter entitySpawnAdapter,
BindlessSupport bindless)
BindlessSupport bindless,
EntityClassificationCache classificationCache)
{
ArgumentNullException.ThrowIfNull(gl);
ArgumentNullException.ThrowIfNull(shader);
ArgumentNullException.ThrowIfNull(textures);
ArgumentNullException.ThrowIfNull(meshAdapter);
ArgumentNullException.ThrowIfNull(entitySpawnAdapter);
ArgumentNullException.ThrowIfNull(classificationCache);
_gl = gl;
_shader = shader;
_textures = textures;
_meshAdapter = meshAdapter;
_entitySpawnAdapter = entitySpawnAdapter;
_cache = classificationCache;
_bindless = bindless ?? throw new ArgumentNullException(nameof(bindless));
_instanceSsbo = _gl.GenBuffer();
@ -157,9 +197,142 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
Matrix4x4 restPose)
=> restPose * animOverride * entityWorld;
/// <summary>
/// Entry for <see cref="WalkEntities"/> per-landblock iteration.
/// Mirrors the shape yielded by <c>GpuWorldState.LandblockEntries</c>.
/// </summary>
public readonly record struct LandblockEntry(
uint LandblockId,
Vector3 AabbMin,
Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById);
/// <summary>
/// Result of <see cref="WalkEntities"/> — the list of (entity, meshRef index)
/// pairs that passed all visibility filters, plus a diagnostic walk count.
/// </summary>
public struct WalkResult
{
public int EntitiesWalked;
public List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> ToDraw;
}
/// <summary>
/// Pure-CPU visibility filter over <paramref name="landblockEntries"/>.
/// Separated from <see cref="Draw"/> so tests can exercise it without GL state.
///
/// <para>
/// A.5 T17 Change #1: when an LB is frustum-culled AND
/// <paramref name="animatedEntityIds"/> is non-empty, the OLD path walked
/// every entity in the LB just to find the few animated ones. This helper
/// fixes that: if the LB is invisible, we iterate
/// <paramref name="animatedEntityIds"/> directly and look each up in
/// <c>entry.AnimatedById</c> (typically &lt;50 animated, up to ~10K total).
/// </para>
///
/// <para>
/// A.5 T18 Change #2: per-entity AABB cull reads from the cached
/// <see cref="WorldEntity.AabbMin"/>/<see cref="WorldEntity.AabbMax"/>
/// (refreshed lazily if <see cref="WorldEntity.AabbDirty"/>), instead of
/// recomputing Position±5 each frame.
/// </para>
/// </summary>
/// <summary>
/// Test-friendly overload that allocates a fresh ToDraw list per call.
/// Production code (<see cref="Draw"/>) uses the no-alloc overload below
/// with a caller-provided scratch list.
/// </summary>
internal static WalkResult WalkEntities(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds)
{
var scratch = new List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)>();
var result = new WalkResult { ToDraw = scratch };
WalkEntitiesInto(
landblockEntries, frustum, neverCullLandblockId,
visibleCellIds, animatedEntityIds, scratch, ref result);
return result;
}
/// <summary>
/// No-alloc overload: clears + populates the caller-provided <paramref name="scratch"/>
/// list. <see cref="Draw"/> reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via <paramref name="result"/>'s <c>EntitiesWalked</c> field.
/// </summary>
internal static void WalkEntitiesInto(
IEnumerable<LandblockEntry> landblockEntries,
FrustumPlanes? frustum,
uint? neverCullLandblockId,
HashSet<uint>? visibleCellIds,
HashSet<uint>? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
ref WalkResult result)
{
scratch.Clear();
result.EntitiesWalked = 0;
result.ToDraw = scratch;
foreach (var entry in landblockEntries)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible)
{
// A.5 T17 Change #1: walk only animated entities, not all entities.
// Avoids O(N_entities) scan when only O(N_animated) work is needed.
if (animatedEntityIds is null || animatedEntityIds.Count == 0) continue;
if (entry.AnimatedById is null) continue;
foreach (var animatedId in animatedEntityIds)
{
if (!entry.AnimatedById.TryGetValue(animatedId, out var entity)) continue;
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value)) continue;
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
continue;
}
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
continue;
}
result.EntitiesWalked++;
for (int i = 0; i < entity.MeshRefs.Count; i++)
scratch.Add((entity, i, entry.LandblockId));
}
}
}
public void Draw(
ICamera camera,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> landblockEntries,
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> landblockEntries,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
HashSet<uint>? visibleCellIds = null,
@ -194,99 +367,283 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var metaTable = _meshAdapter.MetadataTable;
uint anyVao = 0;
foreach (var entry in landblockEntries)
// Project the 5-tuple enumerable into LandblockEntry records for WalkEntities.
static IEnumerable<LandblockEntry> ToEntries(
IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> src)
{
bool landblockVisible = frustum is null
|| entry.LandblockId == neverCullLandblockId
|| FrustumCuller.IsAabbVisible(frustum.Value, entry.AabbMin, entry.AabbMax);
if (!landblockVisible && (animatedEntityIds is null || animatedEntityIds.Count == 0))
continue;
foreach (var entity in entry.Entities)
{
if (entity.MeshRefs.Count == 0) continue;
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
if (!landblockVisible && !isAnimated) continue;
if (entity.ParentCellId.HasValue && visibleCellIds is not null
&& !visibleCellIds.Contains(entity.ParentCellId.Value))
continue;
// Per-entity AABB frustum cull (perf #3). Skips work for distant
// entities even when their landblock is visible. Animated
// entities bypass — they're tracked at landblock level + need
// per-frame work for animation regardless. Conservative 5m
// radius covers typical entity bounds.
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
var p = entity.Position;
var aMin = new Vector3(p.X - PerEntityCullRadius, p.Y - PerEntityCullRadius, p.Z - PerEntityCullRadius);
var aMax = new Vector3(p.X + PerEntityCullRadius, p.Y + PerEntityCullRadius, p.Z + PerEntityCullRadius);
if (!FrustumCuller.IsAabbVisible(frustum.Value, aMin, aMax))
continue;
}
if (diag) _entitiesSeen++;
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
// Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the
// entity has no palette override (trees, scenery).
ulong palHash = 0;
if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
bool drewAny = false;
for (int partIdx = 0; partIdx < entity.MeshRefs.Count; partIdx++)
{
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
var meshRef = entity.MeshRefs[partIdx];
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
if (diag) _meshesMissing++;
continue;
}
drewAny = true;
if (anyVao == 0) anyVao = renderData.VAO;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable);
}
}
if (diag && drewAny) _entitiesDrawn++;
}
foreach (var e in src)
yield return new LandblockEntry(e.LandblockId, e.AabbMin, e.AabbMax, e.Entities, e.AnimatedById);
}
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
var walkResult = default(WalkResult);
WalkEntitiesInto(
ToEntries(landblockEntries),
frustum,
neverCullLandblockId,
visibleCellIds,
animatedEntityIds,
_walkScratch,
ref walkResult);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
// a given entity are contiguous. We accumulate ALL of an entity's
// batches into _populateScratch, then flush exactly once per entity:
// either when the iteration crosses to a different entity, or at the
// end of the loop for the last entity. Flushing per-tuple would
// overwrite earlier MeshRefs (the cache is keyed by entity.Id), so
// multi-part Setup-backed entities would only retain their LAST
// MeshRef's batches — bug fixed in commit after 2f489a8.
uint? populateEntityId = null;
uint populateLandblockId = 0;
// Tier 1 cache (#53) — fast-path one-shot tracker. The cache stores a
// FLAT list of batches across all MeshRefs of an entity, so a single
// ApplyCacheHit call already drew every batch. _walkScratch yields
// one tuple per (entity, MeshRefIndex), so without this guard a
// 3-MeshRef static entity on a frame-2 cache hit would call
// ApplyCacheHit 3 times — appending all 6 batches × 3 = 18 instances
// to _groups instead of 6. Result: severe Z-fighting + 3× perf hit
// on every multi-part static entity (buildings, statues, multi-MeshRef
// NPCs). The fast path must fire only on the FIRST tuple of each
// entity; subsequent tuples skip via this tracker.
uint? lastHitEntityId = null;
// Tier 1 cache (#53) — incomplete-entity guard. When any MeshRef of
// the current entity has _meshAdapter.TryGetRenderData return null
// (mesh still async-decoding via ObjectMeshManager.PrepareMeshDataAsync),
// we mark the entity incomplete and DROP the accumulated populate
// scratch at entity boundary instead of writing it to the cache.
// Otherwise the cache would hold a partial classification (some parts
// missing), and frame-2 cache hits would persist that partial render
// even after the missing mesh loads — every subsequent frame sees the
// cache hit and skips re-classification, so the missing parts never
// recover. User-visible symptom: the drudge statue on top of the
// Foundry (multi-part Setup entity with AnimPartChange) renders with
// some parts missing permanently. Reset on entity change.
bool currentEntityIncomplete = false;
// Per-tuple entity tracker used purely for entity-change detection.
// Updated UNCONDITIONALLY at end of every tuple (including tuples that
// skip via null renderData), so the flag-reset block below correctly
// distinguishes "new entity" from "same entity, different tuple."
// populateEntityId can't be used for this because it's only set after
// a successful slow-path classification.
uint? prevTupleEntityId = null;
foreach (var (entity, partIdx, landblockId) in _walkScratch)
{
if (diag) _entitiesSeen++;
// Skip subsequent tuples of an entity that already cache-hit on
// its first tuple. ApplyCacheHit drew the full flat batch list;
// re-firing here would N-multiply the instance count. Diag
// _entitiesDrawn is bumped here to preserve per-tuple parity with
// the previous counting semantics.
if (lastHitEntityId == entity.Id)
{
if (diag) _entitiesDrawn++;
continue;
}
// Reset the hit tracker on entity change so the next entity's
// first tuple re-checks the cache. (When this iteration is the
// FIRST tuple of a new entity after a cache-hit entity, we must
// not retain the previous entity's id.)
if (lastHitEntityId.HasValue && lastHitEntityId.Value != entity.Id)
{
lastHitEntityId = null;
}
// Tier 1 cache (#53) — drop the previous entity's accumulated
// populate scratch BEFORE MaybeFlushOnEntityChange runs. If the
// previous entity ended incomplete (≥1 null renderData), we MUST
// NOT cache its partial classification: clear scratch and null
// the tracker so MaybeFlushOnEntityChange sees the cleaned state
// and no-ops for this entity. Reset the incomplete flag for the
// new entity so each one gets a fresh measurement.
//
// CRITICAL: the flag reset must fire ONLY on entity change, not
// every tuple. Resetting per-tuple within the same entity would
// undo a null-renderData flag set by a previous tuple of the same
// entity → if the missing MeshRef sits in the MIDDLE of the
// entity's MeshRefs list, a later valid tuple's reset would
// re-mark the entity "complete" and let partial data populate
// the cache. Trees with [trunk valid, branches null, leaves
// valid] hit this exactly — branches never recover.
bool isNewEntity = !prevTupleEntityId.HasValue || prevTupleEntityId.Value != entity.Id;
if (isNewEntity)
{
if (populateEntityId.HasValue && currentEntityIncomplete)
{
_populateScratch.Clear();
populateEntityId = null;
}
currentEntityIncomplete = false;
}
prevTupleEntityId = entity.Id;
// Flush-on-entity-change: if the previous entity accumulated any
// batches AND this iteration is for a different entity, populate
// its cache entry now and reset the scratch buffer.
(populateEntityId, populateLandblockId) = MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, entity.Id, _cache, _populateScratch);
var entityWorld =
Matrix4x4.CreateFromQuaternion(entity.Rotation) *
Matrix4x4.CreateTranslation(entity.Position);
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
// Cache-hit fast path (Task 10): static entity with a populated
// cache entry skips classification entirely. Walk the cached
// (GroupKey, RestPose) flat list and append cached.RestPose *
// entityWorld to each matching group's matrices. Animated entities
// bypass the cache (collector is set null below; their entries are
// never populated in the first place).
//
// Placed AFTER the entity-change flush above so that, on a
// hit, this iteration also finishes flushing any pending
// populate state from a previous entity. Animated entities never
// enter this branch — the !isAnimated guard makes that explicit.
//
// Fires ONCE per entity: the first tuple reaches here, runs
// ApplyCacheHit, sets lastHitEntityId, and continues. Subsequent
// tuples of the same entity short-circuit at the top of the loop
// body via the lastHitEntityId == entity.Id check above.
if (!isAnimated && !_tier1CacheDisabled && _cache.TryGet(entity.Id, landblockId, out var cachedEntry))
{
ApplyCacheHit(cachedEntry!, entityWorld, AppendInstanceToGroup);
// anyVao recovery: when the first visible entity in the frame
// takes the fast path, no slow-path lookup has populated
// anyVao yet. Look up THIS entity's first MeshRef once via
// the mesh adapter — cheap dict lookup, not a re-classify.
if (anyVao == 0)
{
var firstMeshRef = entity.MeshRefs[partIdx];
var firstRenderData = _meshAdapter.TryGetRenderData(firstMeshRef.GfxObjId);
if (firstRenderData is not null) anyVao = firstRenderData.VAO;
}
if (diag) _entitiesDrawn++;
lastHitEntityId = entity.Id;
#if DEBUG
// Cross-check guard: assert the membership predicate held at hit time.
// The full re-classification cross-check (spec section 6.5) is a stretch
// goal; this simpler assert catches the prior Tier 1 bug class — a
// static entity that turns out to actually be animated would fire here.
//
// Structurally redundant with the `if (!isAnimated && ...)` branch
// condition, but serves as a TRIPWIRE: a future refactor that
// incorrectly relaxes the branch condition (e.g., removes
// `!isAnimated` from the guard) would silently allow animated
// entities into the fast path; the assert catches that immediately.
System.Diagnostics.Debug.Assert(
!isAnimated,
$"EntityClassificationCache hit on animated entity {entity.Id} — invariant violated");
#endif
continue;
}
// Compute palette-override hash ONCE per entity (perf #4).
// Reused across every (part, batch) lookup so the FNV-1a fold
// over SubPalettes runs once instead of N times. Zero when the
// entity has no palette override (trees, scenery).
ulong palHash = 0;
if (entity.PaletteOverride is not null)
palHash = TextureCache.HashPaletteOverride(entity.PaletteOverride);
// Note: GameWindow's spawn path already applies
// AnimPartChanges + GfxObjDegradeResolver (Issue #47 fix —
// close-detail mesh swap for humanoids) to MeshRefs. We
// trust MeshRefs as the source of truth here. AnimatedEntityState's
// overrides become relevant only for hot-swap (0xF625
// ObjDescEvent) which today rebuilds MeshRefs anyway.
var meshRef = entity.MeshRefs[partIdx];
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
if (renderData is null)
{
// Tier 1 cache (#53): mesh data is still async-decoding via
// WB's ObjectMeshManager.PrepareMeshDataAsync. Flag the entity
// as incomplete so the entity-boundary check (or end-of-loop
// check) drops the accumulated populate scratch instead of
// caching a partial classification. The slow path retries on
// the next frame; once all this entity's meshes have loaded,
// the populate fires with the complete batch set.
currentEntityIncomplete = true;
if (diag) _meshesMissing++;
continue;
}
if (anyVao == 0) anyVao = renderData.VAO;
// Cache-miss path (animated entities skip cache entirely).
// Static entities accumulate into _populateScratch across ALL
// their MeshRefs; the flush at next-entity-boundary (or
// end-of-loop) commits them as a single Populate call.
var collector = isAnimated ? null : _populateScratch;
bool drewAny = false;
if (renderData.IsSetup && renderData.SetupParts.Count > 0)
{
foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)
{
var partData = _meshAdapter.TryGetRenderData(partGfxObjId);
if (partData is null) continue;
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
drewAny = true;
}
}
else
{
var model = meshRef.PartTransform * entityWorld;
ClassifyBatches(renderData, gfxObjId, model, entity, meshRef, palHash, metaTable, restPose: meshRef.PartTransform, collector: collector);
drewAny = true;
}
// Track THIS entity for the next iteration's flush check. Only
// when collector is non-null (entity is static); animated entities
// leave the tracker null so we don't try to flush them.
if (collector is not null)
{
populateEntityId = entity.Id;
populateLandblockId = landblockId;
}
if (diag && drewAny) _entitiesDrawn++;
}
// Tier 1 cache (#53) — drop the accumulated populate scratch if the
// LAST entity in the loop ended incomplete (had ≥1 null renderData).
// Same reason as the entity-boundary handling above: avoid caching a
// partial classification. The slow path will retry on the next frame
// and populate correctly once all meshes have loaded.
if (currentEntityIncomplete)
{
_populateScratch.Clear();
populateEntityId = null;
}
// Final flush: the last entity in _walkScratch has no "next iteration"
// to trigger the entity-change flush, so commit its accumulated batches
// here. No-op when the last entity was animated (populateEntityId stays
// null) or when no entities walked at all.
FinalFlushPopulate(populateEntityId, populateLandblockId, _cache, _populateScratch);
// Nothing visible — skip the GL pass entirely.
if (anyVao == 0)
{
@ -402,7 +759,17 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
_gl.Disable(EnableCap.Blend);
_gl.DepthMask(true);
// A.5 T20: enable A2C for ClipMap foliage — GPU derives sample mask
// from the alpha written by mesh_modern.frag so foliage edges are
// smooth under MSAA 4x. A no-op for fully-opaque (α=1) batches.
// A.5 T22.5: gated by AlphaToCoverage property so Low/Medium presets
// (no MSAA) skip the unnecessary GL state change.
if (AlphaToCoverage) _gl.Enable(EnableCap.SampleAlphaToCoverage);
_shader.SetInt("uRenderPass", 0);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): opaque section of
// Batches[] starts at index 0. See uDrawIDOffset comment in
// mesh_modern.vert for why this is needed.
_shader.SetInt("uDrawIDOffset", 0);
_gl.BindBuffer(BufferTargetARB.DrawIndirectBuffer, _indirectBuffer);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryOpaque);
_gl.MultiDrawElementsIndirect(
@ -412,6 +779,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
(uint)_opaqueDrawCount,
(uint)DrawCommandStride);
if (diag && _gpuQueriesInitialized) _gl.EndQuery(QueryTarget.TimeElapsed);
if (AlphaToCoverage) _gl.Disable(EnableCap.SampleAlphaToCoverage);
}
// ── Phase 8: transparent pass ────────────────────────────────────────
@ -420,6 +788,37 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
_gl.DepthMask(false);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): transparent section of
// Batches[] starts at index _opaqueDrawCount. Without this offset,
// each transparent draw reads BatchData[0..transparentCount) — the
// OPAQUE section — and the lifestone crystal's apparent texture
// flickers to whatever opaque batch sorted first that frame. See
// uDrawIDOffset comment in mesh_modern.vert.
_shader.SetInt("uDrawIDOffset", _opaqueDrawCount);
// Phase Post-A.5 (ISSUE #52, 2026-05-10): re-establish Phase 9.2's
// back-face cull setup. The legacy StaticMeshRenderer had this
// (commit 6f1971a, 2026-04-11) until the N.5 retirement amendment
// (commit dcae2b6, 2026-05-08) deleted that renderer; the new
// WbDrawDispatcher never inherited the cull-face state.
//
// Closed-shell translucent meshes — lifestone crystal, glow gems,
// any convex blended mesh — NEED back-face culling in the
// translucent pass. Without it, back faces composite OVER front
// faces in arbitrary iteration order, because DepthMask(false)
// means nothing records depth within the translucent set. The
// result is the user-visible "one face missing, see into the
// hollow interior" + frame-to-frame color flicker as rotation
// shifts the triangle order.
//
// Our fan triangulation emits pos-side polygons as (0, i, i+1) —
// CCW in standard OpenGL conventions — so GL_BACK + CCW-front is
// the correct state. Matches WorldBuilder's per-batch CullMode
// handling. Neg-side polygons (rare on translucent AC content)
// use reversed winding and get culled here, matching the opaque
// pass and the original Phase 9.2 fix's known limitation.
_gl.Enable(EnableCap.CullFace);
_gl.CullFace(TriangleFace.Back);
_gl.FrontFace(FrontFaceDirection.Ccw);
_shader.SetInt("uRenderPass", 1);
if (diag && _gpuQueriesInitialized) _gl.BeginQuery(QueryTarget.TimeElapsed, _gpuQueryTransparent);
_gl.MultiDrawElementsIndirect(
@ -492,8 +891,12 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
long cpuP95 = Percentile95Micros(_cpuSamples);
long gpuMed = MedianMicros(_gpuSamples);
long gpuP95 = Percentile95Micros(_gpuSamples);
// A.5 T23: flag when entity dispatcher median exceeds 2.0ms budget
// (Phase A.5 spec §2 acceptance criterion 6). Grep-friendly prefix.
const long BudgetUs = 2000;
string budgetFlag = cpuMed > BudgetUs ? " BUDGET_OVER" : "";
Console.WriteLine(
$"[WB-DIAG] entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"[WB-DIAG]{budgetFlag} entSeen={_entitiesSeen} entDrawn={_entitiesDrawn} meshMissing={_meshesMissing} drawsIssued={_drawsIssued} instances={_instancesIssued} groups={_groups.Count} " +
$"cpu_us={cpuMed}m/{cpuP95}p95 gpu_us={gpuMed}m/{gpuP95}p95");
_entitiesSeen = _entitiesDrawn = _meshesMissing = _drawsIssued = _instancesIssued = 0;
_lastLogTick = now;
@ -523,6 +926,120 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
return copy[idx];
}
// ── Tier 1 cache (#53) helpers extracted for testability ─────────────────
//
// Three pure-CPU static helpers carved out of Draw's per-entity loop so
// unit tests can exercise the populate/flush algorithm + cache-hit fast
// path without needing a real GL context. Production code (Draw) calls
// these helpers; the dispatcher integration tests in
// WbDrawDispatcherBucketingTests use them to drive the same algorithm
// through deterministic inputs.
/// <summary>
/// Apply a cache hit's batches into the per-frame group dictionary by
/// composing <c>cached.RestPose * entityWorld</c> per batch and routing
/// the result through <paramref name="appendInstance"/>. The delegate
/// abstracts over <see cref="InstanceGroup"/> so this helper stays
/// GL-free and unit-testable.
/// </summary>
/// <remarks>
/// Matrix multiplication is non-commutative: it MUST be
/// <c>RestPose * entityWorld</c>, not the reverse. See
/// <see cref="ComposePartWorldMatrix"/> for the full part-world product.
/// </remarks>
internal static void ApplyCacheHit(
EntityCacheEntry entry,
Matrix4x4 entityWorld,
Action<GroupKey, Matrix4x4> appendInstance)
{
foreach (var cached in entry.Batches)
{
appendInstance(cached.Key, cached.RestPose * entityWorld);
}
}
/// <summary>
/// Per-tuple flush check. If <paramref name="populateEntityId"/> is set
/// AND differs from <paramref name="currentEntityId"/>, the previous
/// entity's accumulated batches are committed to <paramref name="cache"/>
/// and <paramref name="populateScratch"/> is cleared. Returns the
/// updated tracker tuple — pass these back into the field locals in the
/// caller's loop.
/// </summary>
/// <remarks>
/// This is the bug-fix structure from commit 00fa8ae (per-MeshRef
/// Populate would overwrite earlier MeshRefs because the cache is
/// keyed by entity.Id; flushing only on entity boundary preserves all
/// MeshRefs' batches). _walkScratch is in entity-order so all MeshRefs
/// of one entity arrive contiguously.
/// </remarks>
internal static (uint? PopulateEntityId, uint PopulateLandblockId)
MaybeFlushOnEntityChange(
uint? populateEntityId,
uint populateLandblockId,
uint currentEntityId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch)
{
if (populateEntityId.HasValue && populateEntityId.Value != currentEntityId)
{
if (populateScratch.Count > 0)
{
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
}
populateScratch.Clear();
return (null, 0u);
}
return (populateEntityId, populateLandblockId);
}
/// <summary>
/// End-of-loop final flush. The last entity in <c>_walkScratch</c> has
/// no next-iteration to trigger <see cref="MaybeFlushOnEntityChange"/>,
/// so commit its accumulated batches here. No-op when no populate is
/// pending (the last entity was animated, or the scratch is empty).
/// <para>
/// End-of-loop only — does NOT reset the caller's tracker locals
/// (intentional, since they go out of scope immediately after).
/// </para>
/// </summary>
internal static void FinalFlushPopulate(
uint? populateEntityId,
uint populateLandblockId,
EntityClassificationCache cache,
List<CachedBatch> populateScratch)
{
if (populateEntityId.HasValue && populateScratch.Count > 0)
{
cache.Populate(populateEntityId.Value, populateLandblockId, populateScratch.ToArray());
populateScratch.Clear();
}
}
/// <summary>
/// Instance-side helper used by <see cref="ApplyCacheHit"/>. Looks up or
/// creates an <see cref="InstanceGroup"/> for the given key in
/// <c>_groups</c> and appends the per-instance world matrix.
/// </summary>
private void AppendInstanceToGroup(GroupKey key, Matrix4x4 model)
{
if (!_groups.TryGetValue(key, out var grp))
{
grp = new InstanceGroup
{
Ibo = key.Ibo,
FirstIndex = key.FirstIndex,
BaseVertex = key.BaseVertex,
IndexCount = key.IndexCount,
BindlessTextureHandle = key.BindlessTextureHandle,
TextureLayer = key.TextureLayer,
Translucency = key.Translucency,
};
_groups[key] = grp;
}
grp.Matrices.Add(model);
}
private void ClassifyBatches(
ObjectRenderData renderData,
ulong gfxObjId,
@ -530,7 +1047,9 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
WorldEntity entity,
MeshRef meshRef,
ulong palHash,
AcSurfaceMetadataTable metaTable)
AcSurfaceMetadataTable metaTable,
Matrix4x4 restPose,
List<CachedBatch>? collector = null)
{
for (int batchIdx = 0; batchIdx < renderData.Batches.Count; batchIdx++)
{
@ -574,6 +1093,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
_groups[key] = grp;
}
grp.Matrices.Add(model);
collector?.Add(new CachedBatch(key, texHandle, restPose));
}
}
@ -739,15 +1259,6 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ────────────────────────────────────────────────────────────────────────
private readonly record struct GroupKey(
uint Ibo,
uint FirstIndex,
int BaseVertex,
int IndexCount,
ulong BindlessTextureHandle,
uint TextureLayer,
TranslucencyKind Translucency);
private sealed class InstanceGroup
{
public uint Ibo;

View file

@ -42,12 +42,26 @@ public sealed class GpuWorldState
private readonly LandblockSpawnAdapter? _wbSpawnAdapter;
private readonly EntitySpawnAdapter? _wbEntitySpawnAdapter;
/// <summary>
/// Phase Post-A.5 #53 (Task 12): optional callback fired before
/// <see cref="RemoveEntitiesFromLandblock"/> zeroes a landblock's entity
/// list. Wired by <c>GameWindow</c> to
/// <c>EntityClassificationCache.InvalidateLandblock</c> so Tier 1 cache
/// entries get swept on LB demote (Near to Far) and unload. Receives
/// the canonicalized landblock id (low 16 bits forced to <c>0xFFFF</c>),
/// matching the <c>LandblockHint</c> stored at <c>Populate</c> time.
/// Null when the cache isn't relevant (tests).
/// </summary>
private readonly System.Action<uint>? _onLandblockUnloaded;
public GpuWorldState(
LandblockSpawnAdapter? wbSpawnAdapter = null,
EntitySpawnAdapter? wbEntitySpawnAdapter = null)
EntitySpawnAdapter? wbEntitySpawnAdapter = null,
System.Action<uint>? onLandblockUnloaded = null)
{
_wbSpawnAdapter = wbSpawnAdapter;
_wbEntitySpawnAdapter = wbEntitySpawnAdapter;
_onLandblockUnloaded = onLandblockUnloaded;
}
private readonly Dictionary<uint, LoadedLandblock> _loaded = new();
@ -106,17 +120,33 @@ public sealed class GpuWorldState
/// Per-landblock iteration with AABB data for use by the frustum-culling
/// draw path. Landblocks without a stored AABB yield <see cref="Vector3.Zero"/>
/// for both corners, which the culler will conservatively treat as visible.
///
/// <para>
/// A.5 T17: also yields an <c>AnimatedById</c> dictionary built on the fly
/// from the landblock's entity list. This lets <see cref="WbDrawDispatcher"/>
/// skip the full entity walk when the landblock is frustum-culled but animated
/// entities inside it must still be processed (Change #1).
/// Building the dict per-yield is cheap (~132 entities/LB max). A caching
/// layer is out of A.5 scope.
/// </para>
/// </summary>
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax, IReadOnlyList<WorldEntity> Entities)> LandblockEntries
public IEnumerable<(uint LandblockId, Vector3 AabbMin, Vector3 AabbMax,
IReadOnlyList<WorldEntity> Entities,
IReadOnlyDictionary<uint, WorldEntity>? AnimatedById)> LandblockEntries
{
get
{
foreach (var kvp in _loaded)
{
// Build AnimatedById on the fly — cheap (~132 entities/LB max).
var byId = new Dictionary<uint, WorldEntity>(kvp.Value.Entities.Count);
foreach (var e in kvp.Value.Entities)
byId[e.Id] = e;
if (_aabbs.TryGetValue(kvp.Key, out var aabb))
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities);
yield return (kvp.Key, aabb.Min, aabb.Max, kvp.Value.Entities, byId);
else
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities);
yield return (kvp.Key, Vector3.Zero, Vector3.Zero, kvp.Value.Entities, byId);
}
}
}
@ -339,6 +369,81 @@ public sealed class GpuWorldState
bucket.Add(entity);
}
/// <summary>
/// Drop all entities from a landblock without removing the terrain. Used
/// by two-tier streaming when a landblock crosses Near→Far hysteresis.
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Persistent-entity rescue is intentionally omitted</b> (unlike
/// <see cref="RemoveLandblock"/>): demote-tier entities are atlas-tier
/// only (procedural scenery, dat-static stabs/buildings) — they never
/// have <c>ServerGuid != 0</c> and so can never be in <see cref="_persistentGuids"/>.
/// The local player and other live server-spawned entities live in their
/// landblock via <c>RelocateEntity</c> per frame and are not affected
/// by Near→Far demotion of dat-static landblock layers.
/// </para>
/// </summary>
public void RemoveEntitiesFromLandblock(uint landblockId)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
// Streaming callers always pass canonical (0xAAAA0xFFFF) ids; this
// protects against future callers that mirror AppendLiveEntity's
// cell-resolved-id pattern.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb)) return;
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockUnloaded(canonical);
// Phase Post-A.5 #53 (Task 12): invalidate the EntityClassificationCache
// for this landblock BEFORE we drop the entity list. The cache stores
// canonical landblock ids (the dispatcher's _walkScratch carries
// entry.LandblockId from GpuWorldState.LandblockEntries, whose keys are
// canonicalized). Null when the cache isn't wired (tests). Per spec §5.3 W3b.
_onLandblockUnloaded?.Invoke(canonical);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, System.Array.Empty<WorldEntity>());
_pendingByLandblock.Remove(canonical);
RebuildFlatView();
}
/// <summary>
/// Merge entities into an existing-loaded landblock. Used by two-tier
/// streaming for the Far→Near promotion case (terrain already loaded;
/// entity layer streaming in). Falls back to the pending bucket if the
/// landblock isn't loaded yet (handles the rare "promote arrives before
/// far load completes" race).
/// Per Phase A.5 spec §4.4.
///
/// <para>
/// <b>Landblock id is canonicalized</b> (low 16 bits forced to 0xFFFF) —
/// callers may pass cell-resolved ids and they will key correctly.
/// </para>
/// </summary>
public void AddEntitiesToExistingLandblock(uint landblockId, IReadOnlyList<WorldEntity> entities)
{
// A.5 T14 follow-up: canonicalize for symmetry with AppendLiveEntity.
uint canonical = (landblockId & 0xFFFF0000u) | 0xFFFFu;
if (!_loaded.TryGetValue(canonical, out var lb))
{
// Park as pending — same pattern as AppendLiveEntity for not-yet-loaded LBs.
if (!_pendingByLandblock.TryGetValue(canonical, out var bucket))
{
bucket = new List<WorldEntity>();
_pendingByLandblock[canonical] = bucket;
}
bucket.AddRange(entities);
return;
}
var merged = new List<WorldEntity>(lb.Entities.Count + entities.Count);
merged.AddRange(lb.Entities);
merged.AddRange(entities);
_loaded[canonical] = new LoadedLandblock(lb.LandblockId, lb.Heightmap, merged);
if (_wbSpawnAdapter is not null)
_wbSpawnAdapter.OnLandblockLoaded(_loaded[canonical]);
RebuildFlatView();
}
private void RebuildFlatView()
{
_flatEntities = _loaded.Values.SelectMany(lb => lb.Entities).ToArray();

View file

@ -1,3 +1,5 @@
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -10,7 +12,7 @@ namespace AcDream.App.Streaming;
/// </summary>
public abstract record LandblockStreamJob(uint LandblockId)
{
public sealed record Load(uint LandblockId) : LandblockStreamJob(LandblockId);
public sealed record Load(uint LandblockId, LandblockStreamJobKind Kind) : LandblockStreamJob(LandblockId);
public sealed record Unload(uint LandblockId) : LandblockStreamJob(LandblockId);
}
@ -22,7 +24,29 @@ public abstract record LandblockStreamJob(uint LandblockId)
/// </summary>
public abstract record LandblockStreamResult(uint LandblockId)
{
public sealed record Loaded(uint LandblockId, LoadedLandblock Landblock) : LandblockStreamResult(LandblockId);
/// <summary>
/// A landblock load completed. <see cref="Tier"/> distinguishes Far
/// (terrain only) from Near (terrain + entities). <see cref="MeshData"/>
/// is built off the render thread on the streaming worker.
/// </summary>
public sealed record Loaded(
uint LandblockId,
LandblockStreamTier Tier,
LoadedLandblock Landblock,
LandblockMeshData MeshData
) : LandblockStreamResult(LandblockId);
/// <summary>
/// A previously-Far-resident landblock was promoted to Near. Terrain
/// mesh is already on the GPU; the result carries the entity layer
/// (stabs, buildings, scenery) to merge into the existing GpuWorldState
/// entry.
/// </summary>
public sealed record Promoted(
uint LandblockId,
IReadOnlyList<WorldEntity> Entities
) : LandblockStreamResult(LandblockId);
public sealed record Failed(uint LandblockId, string Error) : LandblockStreamResult(LandblockId);
public sealed record Unloaded(uint LandblockId) : LandblockStreamResult(LandblockId);

View file

@ -0,0 +1,28 @@
namespace AcDream.App.Streaming;
/// <summary>
/// Streaming-tier classification for a landblock. <see cref="Far"/> means
/// terrain mesh only; <see cref="Near"/> means terrain + scenery + EnvCells +
/// entity registration with the WB dispatcher. Per Phase A.5 spec §3.
/// </summary>
public enum LandblockStreamTier
{
Far,
Near,
}
/// <summary>
/// What work the streaming worker should perform for a given job. Distinct
/// from <see cref="LandblockStreamTier"/> because <see cref="PromoteToNear"/>
/// reads only the entity layer (terrain mesh already loaded), while
/// <see cref="LoadNear"/> reads everything from scratch. Per Phase A.5 spec §4.3.
/// </summary>
public enum LandblockStreamJobKind
{
/// <summary>Read LandBlock heightmap, build mesh, no entity layer.</summary>
LoadFar,
/// <summary>Read LandBlock + LandBlockInfo, generate scenery, build mesh, full entity layer.</summary>
LoadNear,
/// <summary>Read LandBlockInfo + scenery only — terrain already loaded for this LB.</summary>
PromoteToNear,
}

View file

@ -8,28 +8,27 @@ using AcDream.Core.World;
namespace AcDream.App.Streaming;
/// <summary>
/// Services landblock load/unload requests by invoking a caller-supplied
/// load delegate (the production instance wraps
/// <see cref="LandblockLoader.Load"/>) and posting results to an outbox
/// the render thread drains once per OnUpdate.
/// Services landblock load/unload requests by invoking caller-supplied
/// factory delegates (the production instance wraps
/// <see cref="LandblockLoader.Load"/> for loading and
/// <see cref="AcDream.Core.Terrain.LandblockMesh.Build"/> for the terrain
/// mesh) and posting results to an outbox the render thread drains once
/// per OnUpdate.
///
/// <para>
/// <b>Currently runs synchronously on the calling thread.</b> The original
/// Phase A.1 design ran loads on a dedicated worker thread, but DatReaderWriter's
/// <c>DatCollection</c> is not thread-safe — concurrent reads from a worker
/// and the render thread (animation tick, live spawn handlers) corrupt
/// internal buffer state and produce half-populated <c>LandBlock.Height[]</c>
/// arrays which render as wildly distorted terrain. Until Phase A.3 introduces
/// a thread-safe dat wrapper, loads are synchronous: <see cref="EnqueueLoad"/>
/// invokes the load delegate inline and writes the result to the outbox in
/// a single call. This causes a frame hitch when crossing landblock
/// boundaries, but the rendering is correct.
/// <b>Thread model (Phase A.5 T11+):</b> <see cref="Start"/> spawns a
/// dedicated background worker thread. <see cref="EnqueueLoad"/> and
/// <see cref="EnqueueUnload"/> write non-blocking to the inbox
/// <see cref="Channel{T}"/>; the worker drains it and posts
/// <see cref="LandblockStreamResult"/> records to the outbox.
/// </para>
///
/// <para>
/// The Channel-based outbox + <see cref="DrainCompletions"/> API is
/// preserved so the move back to async loading is a single-class change
/// when DatCollection thread safety lands.
/// <b>DatCollection thread safety</b> is provided by the caller:
/// GameWindow's <c>_datLock</c> (Phase A.5 T10) serialises all
/// <c>DatCollection.Get&lt;T&gt;</c> calls. Both factory closures passed at
/// construction acquire that lock before reading dats. The worker never
/// touches <c>DatCollection</c> directly — it only calls the factories.
/// </para>
///
/// <para>
@ -39,8 +38,9 @@ namespace AcDream.App.Streaming;
/// </para>
///
/// <remarks>
/// Threading: synchronous mode means all methods must be called from the
/// same thread (the render thread in production).
/// Threading: <see cref="DrainCompletions"/> must be called from a single
/// consumer thread (the render thread in production). All other public
/// methods are thread-safe.
/// </remarks>
/// </summary>
public sealed class LandblockStreamer : IDisposable
@ -52,50 +52,93 @@ public sealed class LandblockStreamer : IDisposable
/// </summary>
public const int DefaultDrainBatchSize = 4;
private readonly Func<uint, LoadedLandblock?> _loadLandblock;
private readonly Func<uint, LandblockStreamJobKind, LoadedLandblock?> _loadLandblock;
private readonly Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?> _buildMeshOrNull;
private readonly Channel<LandblockStreamJob> _inbox;
private readonly Channel<LandblockStreamResult> _outbox;
private readonly CancellationTokenSource _cancel = new();
#pragma warning disable CS0649 // _worker stays declared for the future async path; unused in synchronous mode.
private Thread? _worker;
#pragma warning restore CS0649
private int _disposed;
public LandblockStreamer(Func<uint, LoadedLandblock?> loadLandblock)
/// <summary>
/// Primary ctor — the factory takes the job's <see cref="LandblockStreamJobKind"/>
/// so it can branch on far-tier vs near-tier and skip entity hydration on far-tier
/// loads (heightmap-only). See ISSUE #54: prior to this signature the worker always
/// called the full-load path and stripped entities at the output, wasting per-LB
/// <c>LandBlockInfo</c> + <c>SceneryGenerator</c> work.
/// </summary>
public LandblockStreamer(
Func<uint, LandblockStreamJobKind, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
{
_loadLandblock = loadLandblock;
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
// Default: no mesh build (returns null → Failed result). Production
// wires in LandblockMesh.Build via the T12 construction site.
_buildMeshOrNull = buildMeshOrNull ?? ((_, _) => null);
_inbox = Channel.CreateUnbounded<LandblockStreamJob>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
_outbox = Channel.CreateUnbounded<LandblockStreamResult>(
new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
}
/// <summary>
/// No-op in synchronous mode. Preserved on the API surface so callers
/// don't need to change when async loading returns in Phase A.3.
/// Back-compat overload — wraps a kind-agnostic factory so existing test code
/// that doesn't care about the JobKind branch keeps compiling. The wrapper
/// ignores the kind and calls the factory once per LB regardless of tier.
/// New production code should use the primary 2-arg ctor.
/// </summary>
public LandblockStreamer(
Func<uint, LoadedLandblock?> loadLandblock,
Func<uint, LoadedLandblock?, AcDream.Core.Terrain.LandblockMeshData?>? buildMeshOrNull = null)
: this((id, _) => loadLandblock(id), buildMeshOrNull)
{
}
/// <summary>
/// Activate the dedicated background worker thread. Idempotent and
/// thread-safe: concurrent callers will only spawn one worker; subsequent
/// calls are no-ops. Atomic via <see cref="Interlocked.CompareExchange{T}(ref T, T, T)"/>.
/// </summary>
public void Start()
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// No worker thread in synchronous mode.
// A.5 T10-T12 follow-up: atomically install the worker so concurrent
// Start() callers don't both pass the null check and spawn duplicate
// threads. Construct the candidate; CAS it into _worker; if we lost
// the race, the candidate goes unstarted and is GCed.
var candidate = new Thread(WorkerLoop)
{
IsBackground = true,
Name = "acdream.streaming.worker",
};
if (Interlocked.CompareExchange(ref _worker, candidate, null) == null)
candidate.Start();
// else: another caller won the race; their thread is running.
}
public void EnqueueLoad(uint landblockId)
/// <summary>
/// Non-blocking enqueue. The worker drains the inbox and posts a
/// <see cref="LandblockStreamResult.Loaded"/> (or
/// <see cref="LandblockStreamResult.Failed"/>) to the outbox.
/// </summary>
public void EnqueueLoad(uint landblockId, LandblockStreamJobKind kind = LandblockStreamJobKind.LoadNear)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
// Synchronous mode: invoke the load delegate inline. The result lands
// in the outbox and DrainCompletions picks it up later in the same
// (or next) frame.
HandleJob(new LandblockStreamJob.Load(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Load(landblockId, kind));
}
/// <summary>
/// Non-blocking enqueue. The worker posts a
/// <see cref="LandblockStreamResult.Unloaded"/> to the outbox.
/// </summary>
public void EnqueueUnload(uint landblockId)
{
if (System.Threading.Volatile.Read(ref _disposed) != 0)
throw new ObjectDisposedException(nameof(LandblockStreamer));
HandleJob(new LandblockStreamJob.Unload(landblockId));
_inbox.Writer.TryWrite(new LandblockStreamJob.Unload(landblockId));
}
/// <summary>
@ -118,17 +161,14 @@ public sealed class LandblockStreamer : IDisposable
{
try
{
// Synchronous read loop via .WaitToReadAsync + ReadAllAsync
// would be idiomatic but requires async; the blocking reader
// is simpler and the thread is dedicated anyway.
// Safe to block: this is a dedicated worker thread with no
// SynchronizationContext, so .Result/.GetResult cannot deadlock
// against any captured continuation. Using the sync pattern
// here keeps the loop linear; an async-enumerable alternative
// would force WorkerLoop to be async Task and lose the
// simple thread-start shape.
while (!_cancel.Token.IsCancellationRequested)
{
// Safe to block: this is a dedicated worker thread with no
// SynchronizationContext, so .Result/.GetResult cannot deadlock
// against any captured continuation. Using the sync pattern
// here keeps the loop linear; an async-enumerable alternative
// would force WorkerLoop to be async Task and lose the
// simple thread-start shape.
if (!_inbox.Reader.WaitToReadAsync(_cancel.Token).AsTask().GetAwaiter().GetResult())
break;
@ -157,15 +197,44 @@ public sealed class LandblockStreamer : IDisposable
switch (job)
{
case LandblockStreamJob.Load load:
// ISSUE #54 (post-A.5): JobKind is now plumbed through to the
// factory, so far-tier loads can skip LandBlockInfo + scenery
// + interior hydration on the worker thread (heightmap-only).
// The post-load entity-strip below is retained as a Debug
// assertion + Release safety net for the case where a buggy
// factory returns far-tier with entities anyway.
try
{
var lb = _loadLandblock(load.LandblockId);
var lb = _loadLandblock(load.LandblockId, load.Kind);
if (lb is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "LandblockLoader.Load returned null"));
else
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, lb));
break;
}
var mesh = _buildMeshOrNull(load.LandblockId, lb);
if (mesh is null)
{
_outbox.Writer.TryWrite(new LandblockStreamResult.Failed(
load.LandblockId, "buildMeshOrNull returned null"));
break;
}
var tier = load.Kind == LandblockStreamJobKind.LoadFar
? LandblockStreamTier.Far : LandblockStreamTier.Near;
if (tier == LandblockStreamTier.Far && lb.Entities.Count > 0)
{
// Belt-and-suspenders: factory should have skipped
// entity hydration for LoadFar. If it didn't, fail
// loud in Debug builds and strip in Release.
System.Diagnostics.Debug.Assert(false,
$"Far-tier factory should skip entity hydration; got {lb.Entities.Count} entities for LB 0x{load.LandblockId:X8}");
lb = new LoadedLandblock(
lb.LandblockId,
lb.Heightmap,
System.Array.Empty<AcDream.Core.World.WorldEntity>());
}
_outbox.Writer.TryWrite(new LandblockStreamResult.Loaded(
load.LandblockId, tier, lb, mesh));
}
catch (Exception ex)
{

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using AcDream.Core.Terrain;
using AcDream.Core.World;
namespace AcDream.App.Streaming;
@ -16,15 +17,33 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingController
{
private readonly Action<uint> _enqueueLoad;
private readonly Action<uint, LandblockStreamJobKind> _enqueueLoad;
private readonly Action<uint> _enqueueUnload;
private readonly Func<int, IReadOnlyList<LandblockStreamResult>> _drainCompletions;
private readonly Action<LoadedLandblock> _applyTerrain;
private readonly Action<LoadedLandblock, LandblockMeshData> _applyTerrain;
private readonly Action<uint>? _removeTerrain;
private readonly GpuWorldState _state;
private StreamingRegion? _region;
public int Radius { get; set; }
/// <summary>
/// Near-tier radius (LBs from observer that load full detail: terrain +
/// scenery + entities). Set at construction; readable thereafter.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — the
/// internal <see cref="StreamingRegion"/> snapshots both radii on its
/// constructor. Treat as init-only post-Tick.
/// </remarks>
public int NearRadius { get; }
/// <summary>
/// Far-tier radius (LBs from observer that load terrain only). Set at
/// construction; readable thereafter.
/// </summary>
/// <remarks>
/// Mutating after the first <see cref="Tick"/> has no effect — see <see cref="NearRadius"/>.
/// </remarks>
public int FarRadius { get; }
/// <summary>
/// Cap on completions drained per <see cref="Tick"/> call. The cap is
@ -45,12 +64,13 @@ public sealed class StreamingController
public int MaxCompletionsPerFrame { get; set; } = 4;
public StreamingController(
Action<uint> enqueueLoad,
Action<uint, LandblockStreamJobKind> enqueueLoad,
Action<uint> enqueueUnload,
Func<int, IReadOnlyList<LandblockStreamResult>> drainCompletions,
Action<LoadedLandblock> applyTerrain,
Action<LoadedLandblock, LandblockMeshData> applyTerrain,
GpuWorldState state,
int radius,
int nearRadius,
int farRadius,
Action<uint>? removeTerrain = null)
{
_enqueueLoad = enqueueLoad;
@ -59,29 +79,42 @@ public sealed class StreamingController
_applyTerrain = applyTerrain;
_removeTerrain = removeTerrain;
_state = state;
Radius = radius;
NearRadius = nearRadius;
FarRadius = farRadius;
}
/// <summary>
/// Advance one frame. <paramref name="observerCx"/>/<paramref name="observerCy"/>
/// are landblock coordinates (0..255) of the current viewer — the camera
/// in offline mode, the server-sent player position in live.
///
/// <para>Two-tier model (Phase A.5 T13):</para>
/// <list type="bullet">
/// <item><see cref="TwoTierDiff.ToLoadFar"/> → enqueue LoadFar (terrain only, no entities)</item>
/// <item><see cref="TwoTierDiff.ToLoadNear"/> → enqueue LoadNear (terrain + entities)</item>
/// <item><see cref="TwoTierDiff.ToPromote"/> → enqueue PromoteToNear (entity layer for already-loaded terrain)</item>
/// <item><see cref="TwoTierDiff.ToDemote"/> → drop entities on render thread immediately (terrain stays)</item>
/// <item><see cref="TwoTierDiff.ToUnload"/> → enqueue full unload</item>
/// </list>
/// </summary>
public void Tick(int observerCx, int observerCy)
{
// First-tick bootstrap: no region yet, so the whole visible window
// is a load diff.
if (_region is null)
{
_region = new StreamingRegion(observerCx, observerCy, Radius);
foreach (var id in _region.Visible)
_enqueueLoad(id);
_region = new StreamingRegion(observerCx, observerCy, NearRadius, FarRadius);
var bootstrap = _region.ComputeFirstTickDiff();
foreach (var id in bootstrap.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in bootstrap.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
_region.MarkResidentFromBootstrap();
}
else if (_region.CenterX != observerCx || _region.CenterY != observerCy)
{
var diff = _region.RecenterTo(observerCx, observerCy);
foreach (var id in diff.ToLoad) _enqueueLoad(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
foreach (var id in diff.ToLoadFar) _enqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (var id in diff.ToLoadNear) _enqueueLoad(id, LandblockStreamJobKind.LoadNear);
foreach (var id in diff.ToPromote) _enqueueLoad(id, LandblockStreamJobKind.PromoteToNear);
foreach (var id in diff.ToDemote) _state.RemoveEntitiesFromLandblock(id);
foreach (var id in diff.ToUnload) _enqueueUnload(id);
}
// Drain up to N completions per frame so a big diff doesn't spike
@ -92,9 +125,12 @@ public sealed class StreamingController
switch (result)
{
case LandblockStreamResult.Loaded loaded:
_applyTerrain(loaded.Landblock);
_applyTerrain(loaded.Landblock, loaded.MeshData);
_state.AddLandblock(loaded.Landblock);
break;
case LandblockStreamResult.Promoted promoted:
_state.AddEntitiesToExistingLandblock(promoted.LandblockId, promoted.Entities);
break;
case LandblockStreamResult.Unloaded unloaded:
_state.RemoveLandblock(unloaded.LandblockId);
_removeTerrain?.Invoke(unloaded.LandblockId);

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.Streaming;
@ -10,9 +11,11 @@ namespace AcDream.App.Streaming;
/// </summary>
public sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
public int NearRadius { get; }
public int FarRadius { get; }
// Strictly the (2r+1)×(2r+1) window (clamped to world bounds).
private readonly HashSet<uint> _visible = new();
@ -20,6 +23,16 @@ public sealed class StreamingRegion
// Everything currently loaded: window + hysteresis-retained landblocks.
private readonly HashSet<uint> _resident = new();
// Two-tier residence tracking: maps each resident LB to its current tier.
private readonly Dictionary<uint, TierResidence> _tierResidence = new();
// Set true after MarkResidentFromBootstrap. The two-tier RecenterTo
// requires this state to be seeded; calling RecenterTo before the
// bootstrap silently emits the entire window as fresh loads (no demotes,
// no unloads, since _tierResidence is empty), which is a correctness
// hazard. The flag converts that into a loud InvalidOperationException.
private bool _bootstrapped;
/// <summary>
/// Landblock IDs in the current visible window in the AC 8.8 coordinate
/// form: <c>(lbX &lt;&lt; 24) | (lbY &lt;&lt; 16) | 0xFFFF</c>. The trailing
@ -43,12 +56,16 @@ public sealed class StreamingRegion
/// </summary>
public IReadOnlyCollection<uint> Resident => _resident;
public StreamingRegion(int cx, int cy, int radius)
public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius)
{
Radius = radius;
Recenter(cx, cy);
NearRadius = nearRadius;
FarRadius = farRadius;
Radius = farRadius; // outer ring drives Resident bookkeeping
Recenter(centerX, centerY);
}
public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { }
private void Recenter(int cx, int cy)
{
CenterX = cx;
@ -81,13 +98,197 @@ public sealed class StreamingRegion
internal static uint EncodeLandblockId(int lbX, int lbY)
=> ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFFu;
/// <summary>
/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring,
/// ToLoadFar for every LB in the outer ring (between near and far). Used
/// by <see cref="StreamingController.Tick"/> on the first call before any
/// RecenterTo.
/// </summary>
public TwoTierDiff ComputeFirstTickDiff()
{
var near = new List<uint>();
var far = new List<uint>();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = System.Math.Abs(dx);
int absDy = System.Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
if (absDx <= NearRadius && absDy <= NearRadius)
near.Add(id);
else
far.Add(id);
}
}
return new TwoTierDiff(
ToLoadFar: far,
ToLoadNear: near,
ToPromote: System.Array.Empty<uint>(),
ToDemote: System.Array.Empty<uint>(),
ToUnload: System.Array.Empty<uint>());
}
/// <summary>
/// Call once after <see cref="ComputeFirstTickDiff"/> to seed
/// <c>_tierResidence</c> with the initial window. Every LB in the inner
/// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far.
/// </summary>
public void MarkResidentFromBootstrap()
{
if (_bootstrapped)
throw new InvalidOperationException(
"MarkResidentFromBootstrap was already called; calling it again would " +
"reset accumulated tier-residence state and silently drop differential " +
"data built up by interim RecenterTo calls.");
_tierResidence.Clear();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
_tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
? TierResidence.Near
: TierResidence.Far;
}
}
_bootstrapped = true;
}
/// <summary>
/// Test-visible wrapper around <see cref="EncodeLandblockId"/> so test
/// assemblies can build expected IDs without duplicating the encoding rule.
/// </summary>
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
=> EncodeLandblockId(lbX, lbY);
/// <summary>
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
/// unload. Requires <see cref="MarkResidentFromBootstrap"/> (or a prior
/// call to this method) to have seeded <c>_tierResidence</c>.
/// </summary>
public TwoTierDiff RecenterTo(int newCx, int newCy)
{
if (!_bootstrapped)
throw new InvalidOperationException(
"Two-tier RecenterTo called before MarkResidentFromBootstrap. " +
"First call ComputeFirstTickDiff to enqueue the bootstrap loads, " +
"then MarkResidentFromBootstrap to seed _tierResidence, then RecenterTo " +
"for subsequent observer moves.");
int nearUnloadThreshold = NearRadius + 2;
int farUnloadThreshold = FarRadius + 2;
var toLoadFar = new List<uint>();
var toLoadNear = new List<uint>();
var toPromote = new List<uint>();
var toDemote = new List<uint>();
var toUnload = new List<uint>();
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet<uint>();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = newCx + dx;
int ny = newCy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
bool inNear = absDx <= NearRadius && absDy <= NearRadius;
var id = EncodeLandblockId(nx, ny);
newCenterIds.Add(id);
if (!_tierResidence.TryGetValue(id, out var current))
{
// Not resident at all — fresh load.
if (inNear) toLoadNear.Add(id);
else toLoadFar.Add(id);
_tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
}
else if (current == TierResidence.Far && inNear)
{
// Was Far, now inside Near ring — promote.
toPromote.Add(id);
_tierResidence[id] = TierResidence.Near;
}
// Near→Near and Far→Far are no-ops.
}
}
// Pass 2: check previously-resident LBs for demote / unload.
foreach (var kvp in _tierResidence.ToArray())
{
var id = kvp.Key;
var current = kvp.Value;
int lbX = (int)((id >> 24) & 0xFFu);
int lbY = (int)((id >> 16) & 0xFFu);
int absDx = Math.Abs(lbX - newCx);
int absDy = Math.Abs(lbY - newCy);
int distance = Math.Max(absDx, absDy); // Chebyshev
if (newCenterIds.Contains(id))
{
// Still in the far window — only Near→Far demote possible here.
if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
}
}
continue;
}
// Outside the new window — demote / unload by threshold.
if (current == TierResidence.Near)
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
else if (current == TierResidence.Far)
{
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
CenterX = newCx;
CenterY = newCy;
return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload);
}
/// <summary>
/// Recompute the visible window around a new center and return the
/// delta vs. the previous state. Hysteresis: landblocks aren't unloaded
/// until they're further than <c>Radius + 2</c> from the new center,
/// so boundary crossings don't thrash.
/// </summary>
public RegionDiff RecenterTo(int newCx, int newCy)
public RegionDiff RecenterToSingleTier(int newCx, int newCy)
{
// Snapshot the old resident set so we can diff against it.
var oldResident = new HashSet<uint>(_resident);
@ -126,7 +327,7 @@ public sealed class StreamingRegion
}
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/>: the landblocks to
/// Output of <see cref="StreamingRegion.RecenterToSingleTier"/>: the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is <c>Radius + 2</c>).
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
@ -135,3 +336,10 @@ public sealed class StreamingRegion
public readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);
/// <summary>
/// Tracks which tier a landblock currently occupies in the two-tier streaming
/// model. Absence from the dictionary encodes "not resident"; the enum has no
/// None member to avoid suggesting a third runtime state.
/// </summary>
internal enum TierResidence { Far, Near }

View file

@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/> for the two-tier model.
/// Five disjoint lists describe what changed since the previous Tick. Per
/// Phase A.5 spec §4.2.
/// </summary>
public readonly record struct TwoTierDiff(
IReadOnlyList<uint> ToLoadFar, // entered far window from null (terrain only)
IReadOnlyList<uint> ToLoadNear, // entered near window from null (terrain + entities — first-tick or teleport)
IReadOnlyList<uint> ToPromote, // entered near window from far-resident (entities only)
IReadOnlyList<uint> ToDemote, // exited near window past hysteresis (drop entities)
IReadOnlyList<uint> ToUnload); // exited far window past hysteresis (drop terrain)

View file

@ -395,6 +395,41 @@ public static class GameEventWiring
if (dumpPd)
Console.WriteLine($"vitals: PD-ench spell={ench.SpellId} layer={ench.Layer} bucket={ench.Bucket} key={ench.StatModKey} val={ench.StatModValue}");
}
// Issue #13 — register inventory entries with ItemRepository so
// panels (inventory, paperdoll, hotbars) light up after login.
// Equipped entries share the same ObjectId as inventory entries
// (an equipped item is also in inventory) — register both, but
// the equipped record carries the slot mask which we surface via
// MoveItem so paperdoll can render.
foreach (var inv in p.Value.Inventory)
{
if (items.GetItem(inv.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = inv.Guid,
WeenieClassId = inv.ContainerType,
});
}
}
foreach (var eq in p.Value.Equipped)
{
if (items.GetItem(eq.Guid) is null)
{
items.AddOrUpdate(new ItemInstance
{
ObjectId = eq.Guid,
WeenieClassId = 0,
});
}
// Reflect the equip slot — paperdoll uses CurrentlyEquippedLocation.
items.MoveItem(
itemId: eq.Guid,
newContainerId: 0,
newSlot: -1,
newEquipLocation: (EquipMask)eq.EquipLocation);
}
});
}
}

View file

@ -177,6 +177,63 @@ public static class PlayerDescriptionParser
Cooldown = 0x08,
}
/// <summary>Bitmask of which optional trailer sections are present in
/// the PlayerDescription wire payload. Holtburger
/// <c>events.rs:503-607</c>; ACE <c>CharacterOptionDataFlag</c>.</summary>
[Flags]
public enum CharacterOptionDataFlag : uint
{
None = 0,
Shortcut = 0x00000001,
SquelchList = 0x00000002,
MultiSpellList = 0x00000004,
DesiredComps = 0x00000008,
ExtendedMultiSpellLists = 0x00000010,
SpellbookFilters = 0x00000020,
CharacterOptions2 = 0x00000040,
TimestampFormat = 0x00000080,
GenericQualitiesData = 0x00000100,
GameplayOptions = 0x00000200,
SpellLists8 = 0x00000400,
}
/// <summary>One shortcut bar entry. 16 bytes wire size.
/// holtburger <c>shortcuts.rs:13-34</c>. Named <c>ShortcutEntry</c>
/// (not <c>Shortcut</c>) to avoid a homograph with the
/// <see cref="CharacterOptionDataFlag.Shortcut"/> flag bit, which is
/// referenced from the same scope as instances of this type in the
/// trailer walker.</summary>
public readonly record struct ShortcutEntry(
uint Index,
uint ObjectGuid,
ushort SpellId,
ushort Layer);
/// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>
/// in <c>unpack_inventory_and_equipped_strict</c>.</summary>
public readonly record struct InventoryEntry(
uint Guid,
uint ContainerType);
/// <summary>One equipped object entry. Holtburger
/// <c>events.rs:180-190</c>: <c>(Guid guid, u32 loc, u32 prio)</c>.
/// <paramref name="EquipLocation"/> is an <c>EquipMask</c> bitfield;
/// <paramref name="Priority"/> orders overlapping equips in the
/// same slot.</summary>
public readonly record struct EquippedEntry(
uint Guid,
uint EquipLocation,
uint Priority);
/// <summary>Result of <see cref="TryParse"/>. Trailer fields
/// (<c>OptionFlags</c> through <c>Equipped</c>) may be partially
/// populated when <see cref="TrailerTruncated"/> is <c>true</c> —
/// the parse degraded gracefully rather than discarding upstream
/// attribute / skill / spell / enchantment data. Callers that
/// require all-or-nothing trailer semantics should ignore the
/// trailer fields when this flag is set.</summary>
public readonly record struct Parsed(
uint WeenieType,
DescriptionPropertyFlag PropertyFlags,
@ -187,7 +244,18 @@ public static class PlayerDescriptionParser
IReadOnlyList<AttributeEntry> Attributes,
IReadOnlyList<SkillEntry> Skills,
IReadOnlyDictionary<uint, float> Spells,
IReadOnlyList<EnchantmentEntry> Enchantments);
IReadOnlyList<EnchantmentEntry> Enchantments,
CharacterOptionDataFlag OptionFlags,
uint Options1,
uint Options2,
IReadOnlyList<ShortcutEntry> Shortcuts,
IReadOnlyList<IReadOnlyList<uint>> HotbarSpells,
IReadOnlyList<(uint Id, uint Amount)> DesiredComps,
uint SpellbookFilters,
ReadOnlyMemory<byte> GameplayOptions,
IReadOnlyList<InventoryEntry> Inventory,
IReadOnlyList<EquippedEntry> Equipped,
bool TrailerTruncated);
/// <summary>
/// Parse a PlayerDescription payload. The 0xF7B0 envelope has been
@ -249,18 +317,126 @@ public static class PlayerDescriptionParser
ReadSpellTable(payload, ref pos, spells);
// ── Enchantments (Issue #7 / #12) ───────────────────────────────
// Outer EnchantmentMask + per-bucket count + N×Enchantment(60-64 B).
// Holtburger events.rs:462-501. After this block come options /
// shortcuts / hotbars / inventory / equipped — those need a
// heuristic walker for the variable-length gameplay_options blob.
// Filed as ISSUES.md #13 for follow-up; stop here cleanly so
// partial parses still populate enchantments.
if (vectorFlags.HasFlag(DescriptionVectorFlag.Enchantment))
ReadEnchantmentBlock(payload, ref pos, enchantments);
// ── Trailer (Issue #13): options + shortcuts + hotbars + inventory ──
// Wrapped in its own try/catch — a malformed trailer must not destroy
// the attribute / skill / spell / enchantment data we already extracted.
CharacterOptionDataFlag optionFlags = CharacterOptionDataFlag.None;
uint options1 = 0;
uint options2 = 0;
uint spellbookFilters = 0;
List<ShortcutEntry> shortcuts = new();
List<IReadOnlyList<uint>> hotbarSpells = new();
List<(uint, uint)> desiredComps = new();
ReadOnlyMemory<byte> gameplayOptions = ReadOnlyMemory<byte>.Empty;
List<InventoryEntry> inventory = new();
List<EquippedEntry> equipped = new();
bool trailerTruncated = false;
try
{
if (payload.Length - pos >= 8)
{
optionFlags = (CharacterOptionDataFlag)ReadU32(payload, ref pos);
options1 = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.Shortcut))
{
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable shortcut count");
for (uint i = 0; i < count; i++)
{
uint idx = ReadU32(payload, ref pos);
uint guid = ReadU32(payload, ref pos);
ushort spellId = ReadU16(payload, ref pos);
ushort layer = ReadU16(payload, ref pos);
shortcuts.Add(new ShortcutEntry(idx, guid, spellId, layer));
}
}
if (optionFlags.HasFlag(CharacterOptionDataFlag.SpellLists8))
{
for (int b = 0; b < 8; b++)
{
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable hotbar count");
var list = new List<uint>((int)count);
for (uint i = 0; i < count; i++)
list.Add(ReadU32(payload, ref pos));
hotbarSpells.Add(list);
}
}
else if (payload.Length - pos >= 4)
{
// Legacy single-list fallback (holtburger events.rs:544-556).
uint count = ReadU32(payload, ref pos);
if (count > 10_000) throw new FormatException("unreasonable hotbar count");
var list = new List<uint>((int)count);
for (uint i = 0; i < count; i++)
list.Add(ReadU32(payload, ref pos));
hotbarSpells.Add(list);
}
if (optionFlags.HasFlag(CharacterOptionDataFlag.DesiredComps))
{
// holtburger events.rs:558-574 — u16 count + u16 padding (4-byte header).
if (payload.Length - pos < 4) throw new FormatException("truncated desired_comps header");
ushort count = ReadU16(payload, ref pos);
ReadU16(payload, ref pos); // padding/buckets — discarded
if (count > 10_000) throw new FormatException("unreasonable desired_comps count");
for (int i = 0; i < count; i++)
{
uint id = ReadU32(payload, ref pos);
uint amt = ReadU32(payload, ref pos);
desiredComps.Add((id, amt));
}
}
// holtburger events.rs:576-582 — spellbook_filters is optional; defaults
// to 0 if EOF.
if (payload.Length - pos >= 4)
spellbookFilters = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.CharacterOptions2))
options2 = ReadU32(payload, ref pos);
if (optionFlags.HasFlag(CharacterOptionDataFlag.GameplayOptions))
{
int gameplayStart = pos;
if (TryHeuristicInventoryStart(payload, gameplayStart, out int invStart, out int end,
inventory, equipped))
{
gameplayOptions = payload.Slice(gameplayStart, invStart - gameplayStart).ToArray();
pos = end;
}
}
else
{
// Strict path: inventory + equipped follow directly.
TryUnpackInventoryStrict(payload, ref pos, inventory, equipped);
}
}
}
catch (FormatException ex)
{
// Trailer corrupted — keep what we have and flag it. Once
// Tasks 3-9 add list reads inside this try block, partial
// lists may be visible to callers; TrailerTruncated tells
// them so they can ignore the trailer if they need all-or-
// nothing semantics.
trailerTruncated = true;
if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_VITALS") == "1")
System.Console.WriteLine($"PlayerDescriptionParser: trailer FormatException at pos={pos}/{payload.Length}: {ex.Message}");
}
return new Parsed(
weenieType, propertyFlags, vectorFlags, hasHealth,
bundle, positions, attributes, skills, spells, enchantments);
bundle, positions, attributes, skills, spells, enchantments,
optionFlags, options1, options2,
shortcuts, hotbarSpells, desiredComps, spellbookFilters,
gameplayOptions, inventory, equipped, trailerTruncated);
}
catch (FormatException ex)
{
@ -281,7 +457,16 @@ public static class PlayerDescriptionParser
{
return new Parsed(weenieType, pFlags, vFlags, hasHealth,
bundle, positions, attributes, skills, spells,
System.Array.Empty<EnchantmentEntry>());
System.Array.Empty<EnchantmentEntry>(),
CharacterOptionDataFlag.None, 0u, 0u,
System.Array.Empty<ShortcutEntry>(),
System.Array.Empty<IReadOnlyList<uint>>(),
System.Array.Empty<(uint, uint)>(),
0u,
ReadOnlyMemory<byte>.Empty,
System.Array.Empty<InventoryEntry>(),
System.Array.Empty<EquippedEntry>(),
TrailerTruncated: false);
}
// ── Attribute block reader ──────────────────────────────────────────────
@ -542,6 +727,86 @@ public static class PlayerDescriptionParser
bucket);
}
/// <summary>Strict inventory + equipped block reader. Returns true if
/// the bytes from <paramref name="pos"/> parse cleanly per holtburger
/// events.rs:143-193 (<c>unpack_inventory_and_equipped_strict</c>).
/// Counts capped at 10,000; inventory ContainerType must be 0..2
/// (NonContainer / Container / Foci).</summary>
private static bool TryUnpackInventoryStrict(
ReadOnlySpan<byte> src, ref int pos,
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
{
inventory.Clear();
equipped.Clear();
if (pos + 4 > src.Length) return false;
uint invCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
if (invCount > 10_000) return false;
for (uint i = 0; i < invCount; i++)
{
if (pos + 8 > src.Length) return false;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
uint wtype = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
pos += 8;
if (wtype > 2) return false;
inventory.Add(new InventoryEntry(guid, wtype));
}
if (pos + 4 > src.Length) return false;
uint eqCount = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
pos += 4;
if (eqCount > 10_000) return false;
for (uint i = 0; i < eqCount; i++)
{
if (pos + 12 > src.Length) return false;
uint guid = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos));
uint loc = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 4));
uint prio = BinaryPrimitives.ReadUInt32LittleEndian(src.Slice(pos + 8));
pos += 12;
equipped.Add(new EquippedEntry(guid, loc, prio));
}
return true;
}
/// <summary>4-byte-aligned forward scan from <paramref name="start"/>
/// looking for the first offset where <c>TryUnpackInventoryStrict</c>
/// consumes exactly to end-of-buffer. Mirrors holtburger
/// <c>find_inventory_start_after_gameplay_options</c> in events.rs:195-218.</summary>
private static bool TryHeuristicInventoryStart(
ReadOnlySpan<byte> src, int start,
out int invStart, out int end,
List<InventoryEntry> inventory, List<EquippedEntry> equipped)
{
invStart = end = 0;
inventory.Clear();
equipped.Clear();
if (start + 8 > src.Length) return false;
int candidate = start;
int misalign = candidate & 3;
if (misalign != 0) candidate += 4 - misalign;
int last = src.Length - 8;
while (candidate <= last)
{
int tmp = candidate;
var tmpInv = new List<InventoryEntry>();
var tmpEq = new List<EquippedEntry>();
if (TryUnpackInventoryStrict(src, ref tmp, tmpInv, tmpEq) && tmp == src.Length)
{
invStart = candidate;
end = tmp;
inventory.AddRange(tmpInv);
equipped.AddRange(tmpEq);
return true;
}
candidate += 4;
}
return false;
}
private static ushort ReadU16(ReadOnlySpan<byte> src, ref int pos)
{
if (src.Length - pos < 2) throw new FormatException("truncated u16");

View file

@ -46,7 +46,7 @@ public static class LandblockMesh
uint landblockY,
float[] heightTable,
TerrainBlendingContext ctx,
Dictionary<uint, SurfaceInfo> surfaceCache)
System.Collections.Generic.IDictionary<uint, SurfaceInfo> surfaceCache)
{
ArgumentNullException.ThrowIfNull(block);
ArgumentNullException.ThrowIfNull(heightTable);
@ -105,6 +105,10 @@ public static class LandblockMesh
uint palCode = TerrainBlending.GetPalCode(
rBL, rBR, rTR, rTL, ttBL, ttBR, ttTR, ttTL);
// Lookup-or-build pattern. Not atomic under concurrent access
// (TryGetValue then assign), but BuildSurface is deterministic —
// two workers building the same palCode produce equal SurfaceInfo,
// last-write-wins is benign.
if (!surfaceCache.TryGetValue(palCode, out var surf))
{
surf = TerrainBlending.BuildSurface(palCode, ctx);

View file

@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
namespace AcDream.Core.Terrain;
/// <summary>
/// Pure-CPU slot allocator for the terrain modern dispatcher's global VBO/EBO.
/// One slot = one landblock's worth of mesh data (384 verts + 384 indices).
/// Uses a FIFO free-list for slot recycling and a monotonic counter for
/// first-time growth, mirroring WorldBuilder's TerrainRenderManager pattern.
/// All bookkeeping is CPU-side; the GPU buffer growth itself is performed
/// by TerrainModernRenderer when <see cref="Allocate"/> sets needsGrow=true.
/// </summary>
public sealed class TerrainSlotAllocator
{
private readonly Queue<int> _freeSlots = new();
private readonly HashSet<int> _liveSlots = new();
private int _nextFreeSlot;
private int _capacity;
public TerrainSlotAllocator(int initialCapacity = 64)
{
if (initialCapacity <= 0)
throw new ArgumentOutOfRangeException(nameof(initialCapacity), "must be > 0");
_capacity = initialCapacity;
}
/// <summary>Current capacity in slots. Growable via <see cref="GrowTo"/>.</summary>
public int Capacity => _capacity;
/// <summary>Slots currently in use (allocated minus freed).</summary>
public int LoadedCount => _liveSlots.Count;
/// <summary>
/// Allocate a slot index. Reuses a freed slot via FIFO if available,
/// otherwise hands out the next monotonic index. Sets
/// <paramref name="needsGrow"/> to true when the returned slot index is
/// at or beyond current capacity — caller must <see cref="GrowTo"/>
/// before using the slot.
/// </summary>
public int Allocate(out bool needsGrow)
{
int slot;
if (_freeSlots.TryDequeue(out var freed))
{
slot = freed;
}
else
{
slot = _nextFreeSlot++;
}
_liveSlots.Add(slot);
needsGrow = slot >= _capacity;
return slot;
}
/// <summary>
/// Return a slot to the free list. Throws if the slot wasn't currently
/// allocated (catches double-free bugs).
/// </summary>
public void Free(int slot)
{
if (!_liveSlots.Remove(slot))
throw new InvalidOperationException(
$"Slot {slot} was not allocated (double-free or unknown slot).");
_freeSlots.Enqueue(slot);
}
/// <summary>Update capacity counter after the caller has grown the GPU buffers.</summary>
public void GrowTo(int newCapacity)
{
if (newCapacity < _capacity)
throw new ArgumentException("Capacity can only grow", nameof(newCapacity));
_capacity = newCapacity;
}
}

View file

@ -22,7 +22,7 @@ public static class LandblockLoader
var info = dats.Get<LandBlockInfo>((landblockId & 0xFFFF0000u) | 0xFFFEu);
var entities = info is null
? Array.Empty<WorldEntity>()
: BuildEntitiesFromInfo(info);
: BuildEntitiesFromInfo(info, landblockId);
return new LoadedLandblock(landblockId, block, entities);
}
@ -33,37 +33,58 @@ public static class LandblockLoader
/// (neither GfxObj 0x01xxxxxx nor Setup 0x02xxxxxx) are silently skipped.
/// MeshRefs is left empty at this stage — Task 5 populates it.
/// </summary>
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info)
public static IReadOnlyList<WorldEntity> BuildEntitiesFromInfo(LandBlockInfo info, uint landblockId = 0)
{
var result = new List<WorldEntity>(info.Objects.Count + info.Buildings.Count);
uint nextId = 1;
// When landblockId is non-zero, namespace stab Ids globally:
// 0xC0XXYY00 + n, where XX = lbX byte, YY = lbY byte
// matching the scenery (0x80XXYY00) and interior (0x40XXYY00) patterns
// in GameWindow.cs. The 0xC0 top byte distinguishes stabs from those.
//
// Pre-Tier-1 callers (existing tests) pass landblockId=0 and get the
// legacy starting-from-1 monotonic Ids — compatible with their assertions
// which check uniqueness within a single landblock.
//
// Latent: if a landblock has >256 stabs (rare), nextId overflows the
// low byte and bleeds into the lbY byte → cross-LB collision. Same
// pattern + same limitation as scenery/interior. Document but don't
// fix in this commit — out of scope for the Tier 1 cache bug fix.
uint stabIdBase = landblockId == 0
? 0u
: 0xC0000000u | ((landblockId >> 24) & 0xFFu) << 16 | ((landblockId >> 16) & 0xFFu) << 8;
uint nextId = stabIdBase == 0 ? 1u : stabIdBase + 1u;
foreach (var stab in info.Objects)
{
if (!IsSupported(stab.Id))
continue;
result.Add(new WorldEntity
var stabEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = stab.Id,
Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
};
stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(stabEntity);
}
foreach (var building in info.Buildings)
{
if (!IsSupported(building.ModelId))
continue;
result.Add(new WorldEntity
var buildingEntity = new WorldEntity
{
Id = nextId++,
SourceGfxObjOrSetupId = building.ModelId,
Position = building.Frame.Origin,
Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(),
});
};
buildingEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(buildingEntity);
}
return result;

View file

@ -71,6 +71,30 @@ public sealed class WorldEntity
/// present. Zero (no parts hidden) is the default.
/// </summary>
public ulong HiddenPartsMask { get; init; }
// Per Phase A.5 spec §4.6 Change #2 — cache per-entity AABB so the
// dispatcher's frustum cull is a memory read, not a per-frame recompute.
// AabbDirty starts true so the dispatcher calls RefreshAabb on first read
// (AabbMin/AabbMax are Vector3.Zero until refreshed).
public Vector3 AabbMin { get; private set; }
public Vector3 AabbMax { get; private set; }
public bool AabbDirty { get; private set; } = true;
private const float DefaultAabbRadius = 5.0f;
public void RefreshAabb()
{
var p = Position;
AabbMin = new Vector3(p.X - DefaultAabbRadius, p.Y - DefaultAabbRadius, p.Z - DefaultAabbRadius);
AabbMax = new Vector3(p.X + DefaultAabbRadius, p.Y + DefaultAabbRadius, p.Z + DefaultAabbRadius);
AabbDirty = false;
}
public void SetPosition(Vector3 pos)
{
Position = pos;
AabbDirty = true;
}
}
/// <summary>

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
@ -20,7 +21,8 @@ public sealed record DisplaySettings(
bool VSync,
float FieldOfView,
float Gamma,
bool ShowFps)
bool ShowFps,
QualityPreset Quality)
{
/// <summary>Values used on first launch / when settings.json is absent.
/// All defaults pinned to the pre-L.0 runtime state — Resolution
@ -35,7 +37,8 @@ public sealed record DisplaySettings(
VSync: false,
FieldOfView: 60f,
Gamma: 1.0f,
ShowFps: true);
ShowFps: true,
Quality: QualityPreset.High);
/// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[]

View file

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using AcDream.UI.Abstractions.Input;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
@ -219,10 +220,23 @@ public sealed class SettingsPanel : IPanel
if (renderer.Checkbox("Show FPS", ref showFps))
_vm.SetDisplay(d with { ShowFps = showFps });
// A.5 T22.5: Quality preset dropdown. Drives streaming radii, MSAA,
// anisotropic level, A2C, and max completions-per-frame as a unit.
// Resolution + anisotropic + A2C + completions apply immediately via
// ReapplyQualityPreset; MSAA samples require a restart (GL context
// cannot change sample count at runtime).
var presets = s_qualityPresetNames;
int qIdx = (int)d.Quality;
if (qIdx < 0 || qIdx >= presets.Length) qIdx = (int)QualityPreset.High;
if (renderer.Combo("Quality", ref qIdx, presets))
_vm.SetDisplay(d with { Quality = (QualityPreset)qIdx });
renderer.Spacing();
renderer.TextWrapped(
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ "preview live as you drag; Cancel reverts to the saved value.");
+ "preview live as you drag; Cancel reverts to the saved value. "
+ "Quality preset applies streaming radius, anisotropic, and A2C "
+ "immediately on Save; MSAA sample count requires a restart.");
}
/// <summary>
@ -446,6 +460,11 @@ public sealed class SettingsPanel : IPanel
+ "round-trip lands.");
}
// A.5 T22.5: preset label array parallel to QualityPreset enum values.
// Order must match the enum (Low=0, Medium=1, High=2, Ultra=3).
private static readonly string[] s_qualityPresetNames =
{ "Low", "Medium", "High", "Ultra" };
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{
// Movement defaults open; other sections collapsed for first-run UX.

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Nodes;
using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings;
@ -62,12 +63,13 @@ public sealed class SettingsStore
var d = DisplaySettings.Default;
return new DisplaySettings(
Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps));
Resolution: ReadString (disp, "resolution", d.Resolution),
Fullscreen: ReadBool (disp, "fullscreen", d.Fullscreen),
VSync: ReadBool (disp, "vsync", d.VSync),
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality));
}
catch (Exception ex)
{
@ -327,6 +329,7 @@ public sealed class SettingsStore
["fieldOfView"] = d.FieldOfView,
["fullscreen"] = d.Fullscreen,
["gamma"] = d.Gamma,
["quality"] = d.Quality.ToString(),
["resolution"] = d.Resolution,
["showFps"] = d.ShowFps,
["vsync"] = d.VSync,
@ -405,4 +408,12 @@ public sealed class SettingsStore
private static float ReadFloat(JsonElement obj, string name, float fallback)
=> obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.Number
? el.GetSingle() : fallback;
private static QualityPreset ReadQuality(JsonElement obj, string name, QualityPreset fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
return fallback;
var s = el.GetString();
return Enum.TryParse<QualityPreset>(s, ignoreCase: true, out var v) ? v : fallback;
}
}

View file

@ -0,0 +1,67 @@
namespace AcDream.UI.Abstractions.Settings;
/// <summary>
/// A.5 T22.5: single user-facing quality knob that drives streaming radii,
/// MSAA samples, anisotropic level, alpha-to-coverage, and max completions
/// per frame in a single setting. Individual fields can still be overridden
/// by env vars (see <see cref="QualitySettings.WithEnvOverrides"/>).
/// </summary>
public enum QualityPreset { Low, Medium, High, Ultra }
/// <summary>
/// Resolved per-preset quality parameters. Constructed via
/// <see cref="From(QualityPreset)"/> then optionally overridden with
/// <see cref="WithEnvOverrides(QualitySettings)"/> before applying to the
/// renderer and streaming controller.
/// </summary>
public readonly record struct QualitySettings(
int NearRadius,
int FarRadius,
int MsaaSamples, // 0 = off, 2, 4, 8
int AnisotropicLevel, // 1 = off, 4, 8, 16
bool AlphaToCoverage,
int MaxCompletionsPerFrame)
{
/// <summary>
/// Return the default <see cref="QualitySettings"/> for <paramref name="preset"/>.
/// Unknown enum values fall back to <see cref="QualityPreset.High"/>.
/// </summary>
public static QualitySettings From(QualityPreset preset) => preset switch
{
QualityPreset.Low => new(NearRadius: 2, FarRadius: 5, MsaaSamples: 0, AnisotropicLevel: 4, AlphaToCoverage: false, MaxCompletionsPerFrame: 2),
QualityPreset.Medium => new(NearRadius: 3, FarRadius: 8, MsaaSamples: 2, AnisotropicLevel: 8, AlphaToCoverage: false, MaxCompletionsPerFrame: 3),
QualityPreset.High => new(NearRadius: 4, FarRadius: 12, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 4),
QualityPreset.Ultra => new(NearRadius: 5, FarRadius: 15, MsaaSamples: 4, AnisotropicLevel: 16, AlphaToCoverage: true, MaxCompletionsPerFrame: 6),
_ => From(QualityPreset.High),
};
/// <summary>
/// Apply env-var overrides to a preset's resolved settings. Per-field
/// env vars beat the preset (so devs can spot-test a single dimension).
/// Unset or empty env vars leave the preset default unchanged.
/// </summary>
public static QualitySettings WithEnvOverrides(QualitySettings baseSettings)
{
int nearRadius = TryParseEnvInt("ACDREAM_NEAR_RADIUS", baseSettings.NearRadius);
int farRadius = TryParseEnvInt("ACDREAM_FAR_RADIUS", baseSettings.FarRadius);
int msaa = TryParseEnvInt("ACDREAM_MSAA_SAMPLES", baseSettings.MsaaSamples);
int aniso = TryParseEnvInt("ACDREAM_ANISOTROPIC", baseSettings.AnisotropicLevel);
// Bool override: any non-empty value other than "0"/"false" enables A2C.
// Empty / unset → keep preset default.
var a2cEnv = System.Environment.GetEnvironmentVariable("ACDREAM_A2C");
bool a2c = a2cEnv switch
{
null or "" => baseSettings.AlphaToCoverage,
"0" or "false" or "False" or "FALSE" => false,
_ => true,
};
int completions = TryParseEnvInt("ACDREAM_MAX_COMPLETIONS_PER_FRAME", baseSettings.MaxCompletionsPerFrame);
return new QualitySettings(nearRadius, farRadius, msaa, aniso, a2c, completions);
}
private static int TryParseEnvInt(string name, int defaultValue)
{
var s = System.Environment.GetEnvironmentVariable(name);
return s is not null && int.TryParse(s, out var v) ? v : defaultValue;
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Buffers.Binary;
using System.IO;
using System.Text;
using AcDream.Core.Chat;
using AcDream.Core.Combat;
@ -328,4 +329,50 @@ public sealed class GameEventWiringTests
Assert.Contains("Mana Stone", e.Text);
}
[Fact]
public void PlayerDescription_RegistersInventoryEntries_InItemRepository()
{
// Issue #13 acceptance test: after a PlayerDescription with non-empty
// Inventory is dispatched through WireAll, ItemRepository.ItemCount > 0.
// Wire format: strict path (no GAMEPLAY_OPTIONS bit) so inventory +
// equipped follow directly after spellbook_filters.
var dispatcher = new GameEventDispatcher();
var items = new ItemRepository();
var combat = new CombatState();
var spellbook = new Spellbook();
var chat = new ChatLog();
GameEventWiring.WireAll(dispatcher, items, combat, spellbook, chat);
Assert.Equal(0, items.ItemCount); // pre-condition
var sb = new MemoryStream();
using var w = new BinaryWriter(sb);
w.Write(0u); // propertyFlags = 0
w.Write(0x52u); // weenieType
w.Write(0x201u); // vectorFlags = ATTRIBUTE | ENCHANTMENT
w.Write(1u); // has_health
w.Write(0u); // attribute_flags = 0 (no attrs)
w.Write(0u); // enchantment_mask = 0
w.Write(0u); // option_flags = None (no GAMEPLAY_OPTIONS → strict inv path)
w.Write(0u); // options1
w.Write(0u); // legacy hotbar list count = 0
w.Write(0u); // spellbook_filters
// Inventory: 2 entries
w.Write(2u);
w.Write(0x50000A01u); w.Write(0u); // guid, ContainerType=NonContainer
w.Write(0x50000A02u); w.Write(1u); // guid, ContainerType=Container
// Equipped: 0 entries
w.Write(0u);
var env = GameEventEnvelope.TryParse(WrapEnvelope(GameEventType.PlayerDescription, sb.ToArray()));
dispatcher.Dispatch(env!.Value);
Assert.Equal(2, items.ItemCount);
Assert.NotNull(items.GetItem(0x50000A01u));
Assert.NotNull(items.GetItem(0x50000A02u));
}
}

View file

@ -334,4 +334,434 @@ public sealed class PlayerDescriptionParserTests
Assert.Equal(2.0f, parsed.Value.Spells[1234u]);
Assert.Equal(2.0f, parsed.Value.Spells[5678u]);
}
[Fact]
public void TryParse_TrailerOptionFlagsAndOptions1_AreReadAfterEnchantments()
{
// ATTRIBUTE | ENCHANTMENT vector flag; empty enchantment mask (0).
// After mask, trailer adds u32 option_flags + u32 options1.
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // EnchantmentMask = empty
// Trailer header: option_flags + options1
writer.Write(0u); // option_flags = None — no further sections
writer.Write(0xDEADBEEFu); // options1 sentinel
// No more bytes — spellbook_filters is optional (defaults to 0).
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed!.Value.OptionFlags);
Assert.Equal(0xDEADBEEFu, parsed.Value.Options1);
Assert.Empty(parsed.Value.Shortcuts);
Assert.Empty(parsed.Value.Inventory);
// Defaults for the trailer fields not yet read (Tasks 3-9 will
// populate them). Asserting them here gives those tasks a
// pre-existing regression guard if they accidentally consume into
// the wrong field's wire bytes.
Assert.Equal(0u, parsed.Value.Options2);
Assert.Equal(0u, parsed.Value.SpellbookFilters);
Assert.Empty(parsed.Value.HotbarSpells);
Assert.Empty(parsed.Value.DesiredComps);
Assert.True(parsed.Value.GameplayOptions.IsEmpty);
Assert.Empty(parsed.Value.Equipped);
Assert.False(parsed.Value.TrailerTruncated);
}
[Fact]
public void TryParse_TrailerShortcuts_PopulatesList()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0x01u); // option_flags = SHORTCUT
writer.Write(0xCAFEu); // options1 sentinel
// Shortcut count + 2 entries (16 B each).
writer.Write(2u);
writer.Write(0u); writer.Write(0xAABBCCDDu); writer.Write((ushort)0); writer.Write((ushort)0);
writer.Write(7u); writer.Write(0u); writer.Write((ushort)1234); writer.Write((ushort)5);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Shortcuts.Count);
Assert.Equal(0u, parsed.Value.Shortcuts[0].Index);
Assert.Equal(0xAABBCCDDu, parsed.Value.Shortcuts[0].ObjectGuid);
Assert.Equal((ushort)0, parsed.Value.Shortcuts[0].SpellId);
Assert.Equal(7u, parsed.Value.Shortcuts[1].Index);
Assert.Equal((ushort)1234, parsed.Value.Shortcuts[1].SpellId);
Assert.Equal((ushort)5, parsed.Value.Shortcuts[1].Layer);
}
[Fact]
public void TryParse_TrailerShortcuts_TruncatedMidList_FlagsTrailerTruncatedAndPreservesPriorEntries()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0x01u); // option_flags = SHORTCUT
writer.Write(0u); // options1
writer.Write(3u); // claimed shortcut count = 3
// First entry complete (16 B).
writer.Write(1u); writer.Write(0xAAAAu); writer.Write((ushort)10); writer.Write((ushort)1);
// Second entry truncated to 8 bytes — ReadU16 will throw FormatException.
writer.Write(2u); writer.Write(0xBBBBu);
// (no SpellId/Layer — payload ends here)
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
// Inner catch fired — flag set.
Assert.True(parsed!.Value.TrailerTruncated);
// First entry survives in the partial list.
Assert.Single(parsed.Value.Shortcuts);
Assert.Equal(1u, parsed.Value.Shortcuts[0].Index);
}
[Fact]
public void TryParse_TrailerHotbarSpells_SpellLists8_Reads8Lists()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0x400u); // option_flags = SPELL_LISTS8
writer.Write(0u); // options1
// 8 hotbars: counts {2,1,0,0,0,0,0,3}
writer.Write(2u); writer.Write(11u); writer.Write(12u);
writer.Write(1u); writer.Write(21u);
writer.Write(0u);
writer.Write(0u);
writer.Write(0u);
writer.Write(0u);
writer.Write(0u);
writer.Write(3u); writer.Write(81u); writer.Write(82u); writer.Write(83u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(8, parsed!.Value.HotbarSpells.Count);
Assert.Equal(new uint[] { 11u, 12u }, parsed.Value.HotbarSpells[0]);
Assert.Equal(new uint[] { 21u }, parsed.Value.HotbarSpells[1]);
Assert.Empty(parsed.Value.HotbarSpells[2]);
Assert.Equal(new uint[] { 81u, 82u, 83u }, parsed.Value.HotbarSpells[7]);
}
[Fact]
public void TryParse_TrailerHotbarSpells_NoSpellLists8_ReadsSingleLegacyList()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0u); // option_flags = None (no SPELL_LISTS8)
writer.Write(0u); // options1
// Legacy single hotbar list: count=2, two spells.
writer.Write(2u); writer.Write(101u); writer.Write(102u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.HotbarSpells);
Assert.Equal(new uint[] { 101u, 102u }, parsed.Value.HotbarSpells[0]);
}
[Fact]
public void TryParse_TrailerAbsent_LessThan8BytesAfterEnchantments_PreservesUpstreamAndDoesNotFlagTruncation()
{
// Fewer than 8 bytes remain after the enchantment block, so the
// trailer header is treated as absent (no read attempted). Upstream
// attribute data must survive; TrailerTruncated stays false because
// the parser never *started* the trailer — it correctly skipped it.
// (Tasks 3-9 will introduce truncation-mid-section cases that flip
// TrailerTruncated to true.)
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
// Attribute block: only Strength (bit 0).
writer.Write(0x01u);
writer.Write(50u); writer.Write(10u); writer.Write(0u);
// Empty enchantment mask.
writer.Write(0u);
// Truncated trailer: only 4 bytes (would-be option_flags) instead of 8.
writer.Write(0xCAFEu);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
// Upstream attribute survived.
Assert.Single(parsed!.Value.Attributes);
Assert.Equal(1u, parsed.Value.Attributes[0].AtType);
// Trailer was absent (< 8 bytes), so no truncation flag and all
// trailer fields stay at their initial defaults.
Assert.False(parsed.Value.TrailerTruncated);
Assert.Equal(PlayerDescriptionParser.CharacterOptionDataFlag.None, parsed.Value.OptionFlags);
Assert.Equal(0u, parsed.Value.Options1);
}
[Fact]
public void TryParse_TrailerDesiredComps_ReadsIdAmtPairs()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
// option_flags = DESIRED_COMPS (0x08); no SPELL_LISTS8 so legacy hotbar list (count=0).
writer.Write(0x08u);
writer.Write(0u); // options1
// Legacy hotbar list: count=0
writer.Write(0u);
// DESIRED_COMPS: u16 count=2, u16 padding, then 2 (id,amt) pairs of 8 bytes each.
writer.Write((ushort)2);
writer.Write((ushort)0);
writer.Write(0xAAu); writer.Write(50u);
writer.Write(0xBBu); writer.Write(75u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.DesiredComps.Count);
Assert.Equal((0xAAu, 50u), parsed.Value.DesiredComps[0]);
Assert.Equal((0xBBu, 75u), parsed.Value.DesiredComps[1]);
}
[Fact]
public void TryParse_TrailerSpellbookFilters_ReadOptionalU32()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0u); // option_flags = None
writer.Write(0u); // options1
// Legacy hotbar list: count=0
writer.Write(0u);
// spellbook_filters sentinel.
writer.Write(0xF00DBA42u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(0xF00DBA42u, parsed!.Value.SpellbookFilters);
}
[Fact]
public void TryParse_TrailerOptions2_GatedOnCharacterOptions2Bit()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
// option_flags = CHARACTER_OPTIONS2 (0x40)
writer.Write(0x40u);
writer.Write(0u); // options1
// Legacy hotbar list: count=0.
writer.Write(0u);
// spellbook_filters
writer.Write(0u);
// options2 sentinel
writer.Write(0xC0FFEE01u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(0xC0FFEE01u, parsed!.Value.Options2);
}
[Fact]
public void TryParse_TrailerInventoryEquippedStrict_NoGameplayOptionsBit()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
writer.Write(0u); // option_flags = None — no GAMEPLAY_OPTIONS
writer.Write(0u); // options1
writer.Write(0u); // legacy hotbar list count=0
writer.Write(0u); // spellbook_filters
// Inventory: 2 entries
writer.Write(2u);
writer.Write(0x500000A0u); writer.Write(0u); // NonContainer
writer.Write(0x500000A1u); writer.Write(1u); // Container
// Equipped: 1 entry
writer.Write(1u);
writer.Write(0x500000B0u); writer.Write(0x00000200u); writer.Write(1u); // ChestArmor, prio=1
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Equal(2, parsed!.Value.Inventory.Count);
Assert.Equal(0x500000A0u, parsed.Value.Inventory[0].Guid);
Assert.Equal(0u, parsed.Value.Inventory[0].ContainerType);
Assert.Equal(1u, parsed.Value.Inventory[1].ContainerType);
Assert.Single(parsed.Value.Equipped);
Assert.Equal(0x500000B0u, parsed.Value.Equipped[0].Guid);
Assert.Equal(0x00000200u, parsed.Value.Equipped[0].EquipLocation);
Assert.Equal(1u, parsed.Value.Equipped[0].Priority);
}
[Fact]
public void TryParse_TrailerGameplayOptions_HeuristicLocatesInventoryStart()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
// option_flags = GAMEPLAY_OPTIONS (0x200)
writer.Write(0x200u);
writer.Write(0u); // options1
writer.Write(0u); // legacy hotbar count=0
writer.Write(0u); // spellbook_filters
// 16 bytes of opaque gameplay_options blob — values that *almost* look
// like an inventory header but fail validation (wtype > 2 or count too
// big), forcing the heuristic to walk past them.
writer.Write(0xDEADBEEFu); // looks like inv_count = 0xDEADBEEF (> 10_000) — rejected
writer.Write(0xCAFEBABEu);
writer.Write(0x12345678u);
writer.Write(0x87654321u);
// Real inventory: 1 entry, then equipped: 1 entry — must consume to EOF.
writer.Write(1u);
writer.Write(0x50000200u); writer.Write(0u);
writer.Write(1u);
writer.Write(0x50000300u); writer.Write(0x00000200u); writer.Write(1u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
Assert.Single(parsed!.Value.Inventory);
Assert.Equal(0x50000200u, parsed.Value.Inventory[0].Guid);
Assert.Single(parsed.Value.Equipped);
Assert.Equal(0x50000300u, parsed.Value.Equipped[0].Guid);
Assert.Equal(16, parsed.Value.GameplayOptions.Length);
}
[Fact]
public void TryParse_FullTrailer_AllSectionsPopulated()
{
var sb = new MemoryStream();
using var writer = new BinaryWriter(sb);
writer.Write(0u); // propertyFlags
writer.Write(0x52u); // weenieType
writer.Write(0x201u); // ATTRIBUTE | ENCHANTMENT
writer.Write(1u); // has_health
writer.Write(0u); // empty attribute_flags
writer.Write(0u); // empty enchantment mask
// option_flags = SHORTCUT | DESIRED_COMPS | CHARACTER_OPTIONS2 | SPELL_LISTS8
// = 0x01 | 0x08 | 0x40 | 0x400 = 0x449
writer.Write(0x449u);
writer.Write(0xAA000001u); // options1
// Shortcuts: count=1
writer.Write(1u);
writer.Write(3u); writer.Write(0xCAFEFACEu); writer.Write((ushort)100); writer.Write((ushort)2);
// 8 hotbars, all empty for brevity.
for (int i = 0; i < 8; i++) writer.Write(0u);
// Desired comps: count=1
writer.Write((ushort)1); writer.Write((ushort)0);
writer.Write(0xC1u); writer.Write(99u);
// spellbook_filters
writer.Write(0xF11Du);
// options2
writer.Write(0xBB000002u);
// Inventory + equipped (no GAMEPLAY_OPTIONS, strict path)
writer.Write(1u);
writer.Write(0x50000400u); writer.Write(0u);
writer.Write(1u);
writer.Write(0x50000500u); writer.Write(0x00000200u); writer.Write(1u);
var parsed = PlayerDescriptionParser.TryParse(sb.ToArray());
Assert.NotNull(parsed);
var v = parsed!.Value;
Assert.Equal(0xAA000001u, v.Options1);
Assert.Equal(0xBB000002u, v.Options2);
Assert.Equal(0xF11Du, v.SpellbookFilters);
Assert.Single(v.Shortcuts);
Assert.Equal(0xCAFEFACEu, v.Shortcuts[0].ObjectGuid);
Assert.Equal(8, v.HotbarSpells.Count);
Assert.All(v.HotbarSpells, l => Assert.Empty(l));
Assert.Single(v.DesiredComps);
Assert.Equal((0xC1u, 99u), v.DesiredComps[0]);
Assert.Single(v.Inventory);
Assert.Equal(0x50000400u, v.Inventory[0].Guid);
Assert.Single(v.Equipped);
Assert.Equal(0x50000500u, v.Equipped[0].Guid);
}
}

View file

@ -0,0 +1,285 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
public class EntityClassificationCacheTests
{
[Fact]
public void TryGet_EmptyCache_ReturnsFalse()
{
var cache = new EntityClassificationCache();
bool found = cache.TryGet(entityId: 42, landblockHint: 0u, out var entry);
Assert.False(found);
Assert.Null(entry);
}
[Fact]
public void Populate_ThenTryGet_ReturnsBatchesInOrder()
{
var cache = new EntityClassificationCache();
var batches = new[]
{
MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA),
MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB),
};
cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);
Assert.True(cache.TryGet(100, 0xA9B40000u, out var entry));
Assert.NotNull(entry);
Assert.Equal(100u, entry!.EntityId);
Assert.Equal(0xA9B40000u, entry.LandblockHint);
Assert.Equal(batches, entry.Batches);
}
[Fact]
public void Populate_OverridesExistingEntry()
{
var cache = new EntityClassificationCache();
cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
cache.Populate(100, 0u, new[] { MakeCachedBatch(2, 0, 12, 0xCC) });
Assert.True(cache.TryGet(100, 0u, out var entry));
Assert.NotNull(entry);
Assert.Single(entry!.Batches);
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
}
[Fact]
public void Count_TracksLiveEntries()
{
var cache = new EntityClassificationCache();
Assert.Equal(0, cache.Count);
cache.Populate(1, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
Assert.Equal(1, cache.Count);
cache.Populate(2, 0u, new[] { MakeCachedBatch(2, 0, 6, 0xAA) });
Assert.Equal(2, cache.Count);
// Re-populate same id — should not double-count.
cache.Populate(1, 0u, new[] { MakeCachedBatch(3, 0, 6, 0xBB) });
Assert.Equal(2, cache.Count);
}
[Fact]
public void Populate_WithEmptyBatches_StoresEmptyEntry()
{
var cache = new EntityClassificationCache();
cache.Populate(entityId: 7, landblockHint: 0u, System.Array.Empty<CachedBatch>());
Assert.True(cache.TryGet(7, 0u, out var entry));
Assert.NotNull(entry);
Assert.Empty(entry!.Batches);
}
[Fact]
public void Populate_SetupMultiPart_StoresFlatBatchPerSubPart()
{
// Synthetic Setup with 3 subParts × 2 batches each = 6 flat entries.
// This pins the spec §3 Q4 decision: pre-flatten Setup multi-parts at
// populate time so the per-frame hot path is branchless.
var cache = new EntityClassificationCache();
var batches = new CachedBatch[6];
for (int subPart = 0; subPart < 3; subPart++)
for (int b = 0; b < 2; b++)
{
batches[subPart * 2 + b] = MakeCachedBatch(
ibo: (uint)(subPart + 1),
firstIndex: (uint)(b * 6),
indexCount: 6,
texHandle: (ulong)(0x100 + subPart * 2 + b));
}
cache.Populate(99, 0u, batches);
Assert.True(cache.TryGet(99, 0u, out var entry));
Assert.NotNull(entry);
Assert.Equal(6, entry!.Batches.Length);
Assert.Equal(0x100u, entry.Batches[0].BindlessTextureHandle);
Assert.Equal(0x105u, entry.Batches[5].BindlessTextureHandle);
}
[Fact]
public void InvalidateEntity_RemovesEntry()
{
var cache = new EntityClassificationCache();
cache.Populate(100, 0u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
Assert.True(cache.TryGet(100, 0u, out _));
cache.InvalidateEntity(100);
Assert.False(cache.TryGet(100, 0u, out var entry));
Assert.Null(entry);
Assert.Equal(0, cache.Count);
}
[Fact]
public void InvalidateEntity_OnMissingId_NoThrow()
{
var cache = new EntityClassificationCache();
var ex = Record.Exception(() => cache.InvalidateEntity(99999));
Assert.Null(ex);
Assert.Equal(0, cache.Count);
}
[Fact]
public void InvalidateLandblock_RemovesAllMatchingEntries()
{
var cache = new EntityClassificationCache();
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
cache.Populate(2, 0xA9B40000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });
Assert.Equal(3, cache.Count);
cache.InvalidateLandblock(0xA9B40000u);
Assert.Equal(0, cache.Count);
Assert.False(cache.TryGet(1, 0xA9B40000u, out _));
Assert.False(cache.TryGet(2, 0xA9B40000u, out _));
Assert.False(cache.TryGet(3, 0xA9B40000u, out _));
}
[Fact]
public void InvalidateLandblock_LeavesNonMatchingEntries()
{
var cache = new EntityClassificationCache();
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
cache.Populate(2, 0xA9B50000u, new[] { MakeCachedBatch(2, 0, 6, 0xBB) });
cache.Populate(3, 0xA9B40000u, new[] { MakeCachedBatch(3, 0, 6, 0xCC) });
cache.InvalidateLandblock(0xA9B40000u);
Assert.Equal(1, cache.Count);
Assert.False(cache.TryGet(1, 0xA9B40000u, out _));
Assert.True(cache.TryGet(2, 0xA9B50000u, out var keep));
Assert.NotNull(keep);
Assert.Equal(0xA9B50000u, keep!.LandblockHint);
Assert.False(cache.TryGet(3, 0xA9B40000u, out _));
}
[Fact]
public void InvalidateLandblock_OnMissingLb_NoThrow()
{
var cache = new EntityClassificationCache();
cache.Populate(1, 0xA9B40000u, new[] { MakeCachedBatch(1, 0, 6, 0xAA) });
var ex = Record.Exception(() => cache.InvalidateLandblock(0xDEADBEEFu));
Assert.Null(ex);
Assert.Equal(1, cache.Count);
}
[Fact]
public void DespawnRespawn_UnderReusedId_RepopulatesFresh()
{
// Pins the audit's ObjDescEvent contract (audit section 1):
// ObjDescEvent is despawn + respawn (with a NEW local entity.Id),
// never an in-place mutation. Even when an id IS reused
// (theoretical — _liveEntityIdCounter is monotonic in practice),
// the cache must serve fresh data after invalidation.
var cache = new EntityClassificationCache();
var batchesV1 = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
var batchesV2 = new[] { MakeCachedBatch(2, 6, 12, 0xCC) };
cache.Populate(100, 0xA9B40000u, batchesV1);
cache.InvalidateEntity(100);
cache.Populate(100, 0xA9B40000u, batchesV2);
Assert.True(cache.TryGet(100, 0xA9B40000u, out var entry));
Assert.NotNull(entry);
Assert.Equal(batchesV2, entry!.Batches);
Assert.Equal(0xCCu, entry.Batches[0].BindlessTextureHandle);
}
#if DEBUG
[Fact]
public void DebugCrossCheck_BatchCountMismatch_FiresAssert()
{
var cache = new EntityClassificationCache();
cache.Populate(100, 0u, new[]
{
MakeCachedBatch(1, 0, 6, 0xAA),
MakeCachedBatch(1, 6, 6, 0xBB),
});
// Synthetic "live" with fewer batches → should fire Debug.Assert.
var liveBatches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
// Capture Debug.Assert via a custom TraceListener.
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
System.Diagnostics.Trace.Listeners.Clear();
var asserts = new List<string>();
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
try
{
cache.DebugCrossCheck(100, 0u, liveBatches);
}
finally
{
System.Diagnostics.Trace.Listeners.Clear();
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
}
Assert.NotEmpty(asserts);
string joined = string.Join(" ", asserts);
Assert.Contains("batch count mismatch", joined);
}
[Fact]
public void DebugCrossCheck_RestPoseMatch_NoAssert()
{
var cache = new EntityClassificationCache();
var batches = new[] { MakeCachedBatch(1, 0, 6, 0xAA) };
cache.Populate(100, 0u, batches);
var originalListeners = new System.Diagnostics.TraceListener[System.Diagnostics.Trace.Listeners.Count];
System.Diagnostics.Trace.Listeners.CopyTo(originalListeners, 0);
System.Diagnostics.Trace.Listeners.Clear();
var asserts = new List<string>();
System.Diagnostics.Trace.Listeners.Add(new CaptureListener(asserts));
try
{
cache.DebugCrossCheck(100, 0u, batches);
}
finally
{
System.Diagnostics.Trace.Listeners.Clear();
foreach (var l in originalListeners) System.Diagnostics.Trace.Listeners.Add(l);
}
Assert.Empty(asserts);
}
private sealed class CaptureListener : System.Diagnostics.TraceListener
{
private readonly List<string> _captured;
public CaptureListener(List<string> captured) { _captured = captured; }
public override void Write(string? message) { if (message != null) _captured.Add(message); }
public override void WriteLine(string? message) { if (message != null) _captured.Add(message); }
public override void Fail(string? message, string? detailMessage)
{
_captured.Add($"{message}: {detailMessage}");
}
public override void Fail(string? message) { if (message != null) _captured.Add(message); }
}
#endif
private static CachedBatch MakeCachedBatch(
uint ibo, uint firstIndex, int indexCount, ulong texHandle)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
return new CachedBatch(key, texHandle, Matrix4x4.Identity);
}
}

View file

@ -0,0 +1,39 @@
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// A.5 T21: lock in the depth-write attribution per translucency kind.
/// <para>
/// <c>WbDrawDispatcher.Draw</c> uses a two-pass structure:
/// <list type="bullet">
/// <item>Opaque pass — <c>DepthMask(true)</c>: writes depth so that
/// later transparent geometry sorts correctly against solid surfaces.</item>
/// <item>Transparent pass — <c>DepthMask(false)</c>: reads depth but
/// does NOT write it, so alpha-blended surfaces don't occlude each
/// other by Z-fighting.</item>
/// </list>
/// The partition that decides which pass a batch enters is
/// <see cref="WbDrawDispatcher.IsOpaquePublic"/>:
/// <c>Opaque</c> and <c>ClipMap</c> go to the opaque pass (depth write);
/// <c>AlphaBlend</c>, <c>Additive</c>, <c>InvAlpha</c> go to the
/// transparent pass (no depth write).
/// </para>
/// </summary>
public sealed class WbDispatcherDepthMaskTests
{
[Theory]
[InlineData(TranslucencyKind.Opaque, true)] // opaque pass — depth write
[InlineData(TranslucencyKind.ClipMap, true)] // foliage — depth write (binary alpha / A2C)
[InlineData(TranslucencyKind.AlphaBlend, false)] // transparent — no depth write
[InlineData(TranslucencyKind.Additive, false)]
[InlineData(TranslucencyKind.InvAlpha, false)]
public void IsOpaquePartition_ImpliesDepthWriteAttribution(
TranslucencyKind kind, bool expectsDepthWrite)
{
bool isOpaque = WbDrawDispatcher.IsOpaquePublic(kind);
Assert.Equal(expectsDepthWrite, isOpaque);
}
}

View file

@ -0,0 +1,744 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Wb;
using AcDream.Core.Meshing;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Rendering.Wb;
/// <summary>
/// Tests for <see cref="WbDrawDispatcher.WalkEntities"/> — the pure-CPU
/// visibility filter extracted in A.5 T17. These tests exercise the two
/// key perf changes from Phase A.5 spec §4.6:
///
/// <list type="bullet">
/// <item>Change #1 (T17): invisible LB + animated set → iterate
/// <c>animatedEntityIds</c> directly, not the full entity list.</item>
/// <item>Change #2 (T18): per-entity AABB cull reads the cached AABB
/// (<see cref="WorldEntity.AabbMin"/>/<c>AabbMax</c>) rather than
/// recomputing Position±5 per frame.</item>
/// </list>
/// </summary>
public sealed class WbDrawDispatcherBucketingTests
{
// ── helpers ──────────────────────────────────────────────────────────────
private static WorldEntity MakeEntity(uint id, Vector3 position)
=> new WorldEntity
{
Id = id,
SourceGfxObjOrSetupId = 0,
Position = position,
Rotation = Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
private static WorldEntity MakeEntityWithMesh(uint id, Vector3 position)
=> new WorldEntity
{
Id = id,
SourceGfxObjOrSetupId = 0,
Position = position,
Rotation = Quaternion.Identity,
// Single dummy MeshRef so it passes the MeshRefs.Count == 0 guard.
MeshRefs = new[] { new MeshRef { GfxObjId = 0x01000001u } },
};
private static Dictionary<uint, WorldEntity> BuildById(IEnumerable<WorldEntity> entities)
{
var d = new Dictionary<uint, WorldEntity>();
foreach (var e in entities) d[e.Id] = e;
return d;
}
/// <summary>
/// A frustum positioned at (1e6+1, 1e6+1, 1e6+1) looking toward (1e6, 1e6, 1e6)
/// with a very narrow near/far. Any AABB near the origin (0..20000) is
/// far behind the near plane and fails all six planes.
/// </summary>
private static FrustumPlanes MakeFarAwayFrustum()
{
var view = Matrix4x4.CreateLookAt(
new Vector3(1e6f + 1f, 1e6f + 1f, 1e6f + 1f),
new Vector3(1e6f, 1e6f, 1e6f),
Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(
MathF.PI / 4f, 1f, 0.1f, 1f);
return FrustumPlanes.FromViewProjection(view * proj);
}
// ── T17 Change #1 tests ───────────────────────────────────────────────
[Fact]
public void WalkEntities_InvisibleLb_NoAnimated_SkipsEntireBlock()
{
// When LB is invisible AND animatedEntityIds is empty/null,
// WalkEntities should not walk any entities at all.
var entities = new List<WorldEntity>();
for (int i = 0; i < 500; i++)
entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
var byId = BuildById(entities);
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xAAAA_FFFFu,
new Vector3(10000, 10000, 10000),
new Vector3(20000, 20000, 20000),
entities,
byId),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: MakeFarAwayFrustum(),
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: null);
Assert.Equal(0, result.EntitiesWalked);
Assert.Empty(result.ToDraw);
}
[Fact]
public void WalkEntities_InvisibleLb_AnimatedSet_WalksOnlyAnimatedEntities()
{
// 1000 entities in an LB whose AABB is far outside the frustum.
// Only entity Id=42 is in animatedEntityIds.
// Pre-T17 behavior: walk all 1000 entities just to find #42.
// Post-T17: walk only the 1 animated entity (EntitiesWalked == 1).
const int Total = 1000;
var entities = new List<WorldEntity>(Total);
for (int i = 0; i < Total; i++)
entities.Add(MakeEntityWithMesh((uint)i, new Vector3(i, 0, 0)));
var byId = BuildById(entities);
var animatedSet = new HashSet<uint> { 42 };
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xAAAA_FFFFu,
new Vector3(10000, 10000, 10000),
new Vector3(20000, 20000, 20000),
entities,
byId),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: MakeFarAwayFrustum(),
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: animatedSet);
// Only the 1 animated entity should be walked — not 1000.
Assert.Equal(1, result.EntitiesWalked);
Assert.Single(result.ToDraw);
Assert.Equal(42u, result.ToDraw[0].Entity.Id);
}
[Fact]
public void WalkEntities_InvisibleLb_AnimatedIdAbsent_ZeroWalked()
{
// Animated entity ids 200 and 300 are NOT in this LB (which only
// has ids 0..99). Should produce zero walks.
var entities = new List<WorldEntity>();
for (int i = 0; i < 100; i++)
entities.Add(MakeEntityWithMesh((uint)i, Vector3.Zero));
var byId = BuildById(entities);
var animatedSet = new HashSet<uint> { 200, 300 }; // not in this LB
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xBBBB_FFFFu,
new Vector3(10000, 10000, 10000),
new Vector3(20000, 20000, 20000),
entities,
byId),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: MakeFarAwayFrustum(),
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: animatedSet);
Assert.Equal(0, result.EntitiesWalked);
Assert.Empty(result.ToDraw);
}
[Fact]
public void WalkEntities_NeverCullLb_WalksAllEntitiesRegardlessOfFrustum()
{
// neverCullLandblockId bypasses the LB AABB check entirely.
// All entities with at least one MeshRef should be walked.
var entities = new List<WorldEntity>
{
MakeEntityWithMesh(1, Vector3.Zero),
MakeEntityWithMesh(2, Vector3.Zero),
MakeEntityWithMesh(3, Vector3.Zero),
};
var byId = BuildById(entities);
const uint lbId = 0xCCCC_FFFFu;
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
lbId,
new Vector3(10000, 10000, 10000), // AABB would fail frustum
new Vector3(20000, 20000, 20000),
entities,
byId),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: MakeFarAwayFrustum(),
neverCullLandblockId: lbId, // exempt from LB cull
visibleCellIds: null,
animatedEntityIds: null);
Assert.Equal(3, result.EntitiesWalked);
}
[Fact]
public void WalkEntities_NullFrustum_WalksEntitiesWithMeshRefs()
{
// Null frustum means no culling — all entities with MeshRefs pass.
// Entities without MeshRefs are still filtered out.
var entities = new List<WorldEntity>
{
MakeEntityWithMesh(1, Vector3.Zero),
MakeEntity(2, Vector3.Zero), // no MeshRefs — must be skipped
MakeEntityWithMesh(3, Vector3.Zero),
};
var byId = BuildById(entities);
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xDDDD_FFFFu, Vector3.Zero, Vector3.Zero,
entities, byId),
};
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: null,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: null);
Assert.Equal(2, result.EntitiesWalked);
Assert.Equal(2, result.ToDraw.Count);
}
// ── T18 Change #2 tests ───────────────────────────────────────────────
[Fact]
public void WalkEntities_VisibleLb_EntityFarAway_CulledViaCachedAabb()
{
// LB passes the LB-level cull; entity AABB is far from the frustum.
// After RefreshAabb the entity should be culled by the per-entity check.
var entity = MakeEntityWithMesh(1, new Vector3(50000, 50000, 50000));
entity.RefreshAabb(); // populate cached AABB at (50000±5)
var byId = BuildById(new[] { entity });
var entries = new[]
{
// LB AABB near origin so it passes the LB cull; entity is far away.
new WbDrawDispatcher.LandblockEntry(
0xEEEE_FFFFu,
new Vector3(-10, -10, -10),
new Vector3(10, 10, 10),
new List<WorldEntity> { entity },
byId),
};
// Frustum centered at origin, range ±100.
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: tightFrustum,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: null);
// Entity at (50000,50000,50000) is outside the frustum — should be culled.
Assert.Equal(0, result.EntitiesWalked);
}
[Fact]
public void WalkEntities_AnimatedEntity_BypassesPerEntityAabbCull()
{
// Animated entities must always pass even if their AABB would be culled.
var entity = MakeEntityWithMesh(7, new Vector3(50000, 50000, 50000));
entity.RefreshAabb();
var byId = BuildById(new[] { entity });
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xEEEF_FFFFu,
new Vector3(-10, -10, -10),
new Vector3(10, 10, 10),
new List<WorldEntity> { entity },
byId),
};
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.5f, 100f);
var tightFrustum = FrustumPlanes.FromViewProjection(view * proj);
var animatedSet = new HashSet<uint> { 7 };
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: tightFrustum,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: animatedSet);
// Animated entity bypasses per-entity cull.
Assert.Equal(1, result.EntitiesWalked);
Assert.Single(result.ToDraw);
Assert.Equal(7u, result.ToDraw[0].Entity.Id);
}
[Fact]
public void WalkEntities_AabbDirty_RefreshedLazilyBeforeCull()
{
// An entity with AabbDirty=true (initial state) should get its AABB
// refreshed lazily by WalkEntities before the cull check.
var entity = MakeEntityWithMesh(5, new Vector3(0, 0, 0));
// AabbDirty starts true by default — do NOT call RefreshAabb manually.
Assert.True(entity.AabbDirty);
var byId = BuildById(new[] { entity });
var entries = new[]
{
new WbDrawDispatcher.LandblockEntry(
0xF0F0_FFFFu,
new Vector3(-10, -10, -10),
new Vector3(10, 10, 10),
new List<WorldEntity> { entity },
byId),
};
// A frustum that accepts things near origin.
var view = Matrix4x4.CreateLookAt(new Vector3(-50, 0, 0), Vector3.Zero, Vector3.UnitZ);
var proj = Matrix4x4.CreatePerspectiveFieldOfView(MathF.PI / 2f, 1f, 0.1f, 200f);
var nearOriginFrustum = FrustumPlanes.FromViewProjection(view * proj);
var result = WbDrawDispatcher.WalkEntities(
entries,
frustum: nearOriginFrustum,
neverCullLandblockId: null,
visibleCellIds: null,
animatedEntityIds: null);
// Entity at origin is inside the frustum after lazy RefreshAabb.
Assert.Equal(1, result.EntitiesWalked);
// AabbDirty should have been cleared by the lazy refresh.
Assert.False(entity.AabbDirty);
}
// ── Tier 1 cache (#53) dispatcher integration tests ──────────────────────
//
// Tasks 9 & 10 wire the EntityClassificationCache into Draw's per-entity
// loop. These tests exercise the populate + cache-hit fast-path algorithm
// through the static helpers Draw uses (MaybeFlushOnEntityChange,
// FinalFlushPopulate, ApplyCacheHit). The helpers were extracted from
// Draw's foreach for testability — Draw calls them; tests drive them
// directly with deterministic synthesized inputs. This is the same
// pattern WalkEntities follows (extracted from Draw, tested in isolation).
//
// The tests cover spec §7.2 #11 (static populate + reuse) and #12
// (animated bypass), plus a multi-MeshRef regression test that would
// have caught the bug fixed in commit 00fa8ae (per-MeshRef Populate
// overwrites earlier batches because the cache is keyed by entity.Id).
/// <summary>
/// Helper: constructs a CachedBatch with stable group-key inputs so the
/// hit-path test can verify membership. Mirrors the shape ClassifyBatches
/// produces under the collector pattern.
/// </summary>
private static CachedBatch MakeCachedBatch(
uint ibo, uint firstIndex, int indexCount, ulong texHandle, Matrix4x4? restPose = null)
{
var key = new GroupKey(
Ibo: ibo,
FirstIndex: firstIndex,
BaseVertex: 0,
IndexCount: indexCount,
BindlessTextureHandle: texHandle,
TextureLayer: 0,
Translucency: TranslucencyKind.Opaque);
return new CachedBatch(key, texHandle, restPose ?? Matrix4x4.Identity);
}
[Fact]
public void Draw_StaticEntity_PopulatesCacheOnFirstFrameAndHitsOnSecond()
{
// Spec §7.2 test #11.
// Drives Draw's populate + cache-hit algorithm through the production
// static helpers. Verifies that:
// 1. First "frame": cache is empty → populate fires once at the
// end-of-loop final flush (entity.Id=100 has 2 batches).
// 2. Second "frame": cache.TryGet(100) hits → ApplyCacheHit appends
// cached batches to a fresh _groups dict without re-populating.
// cache.Count stays at 1 (Populate is idempotent via overwrite,
// but the hit-path doesn't re-populate at all).
var cache = new EntityClassificationCache();
var scratch = new List<CachedBatch>();
Assert.Equal(0, cache.Count);
// Frame 1: simulate one foreach iteration producing 2 batches for
// entity 100 in landblock 0xA9B40000. With no prior tracker, the
// entity-change flush is a no-op. ClassifyBatches' collector adds
// to scratch. The end-of-loop FinalFlushPopulate commits.
const uint EntityId = 100;
const uint LandblockId = 0xA9B40000u;
// First MeshRef contributes 2 batches (mimics ClassifyBatches output).
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAA));
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 6, indexCount: 6, texHandle: 0xBB));
uint? populateEntityId = null;
uint populateLandblockId = 0u;
// First-tuple boundary check: no flush, sets the tracker.
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, EntityId, cache, scratch);
// After ClassifyBatches the loop sets the tracker (matching Draw).
populateEntityId = EntityId;
populateLandblockId = LandblockId;
// End-of-loop final flush — this is where the cache populates.
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
// First-frame post-conditions: 1 cache entry, 2 batches in it.
Assert.Equal(1, cache.Count);
Assert.True(cache.TryGet(EntityId, LandblockId, out var entry));
Assert.NotNull(entry);
Assert.Equal(2, entry!.Batches.Length);
Assert.Equal(0xAAul, entry.Batches[0].BindlessTextureHandle);
Assert.Equal(0xBBul, entry.Batches[1].BindlessTextureHandle);
// Frame 2: cache hit. ApplyCacheHit walks the cached batches and
// appends RestPose * entityWorld to a per-frame group dict.
// Production code: this is the !isAnimated && _cache.TryGet branch
// at the top of the per-entity loop body in Draw.
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
void AppendInstance(GroupKey k, Matrix4x4 m)
{
if (!groups.TryGetValue(k, out var list))
{
list = new List<Matrix4x4>();
groups[k] = list;
}
list.Add(m);
}
Assert.True(cache.TryGet(EntityId, LandblockId, out var entryHit));
Assert.NotNull(entryHit);
var entityWorld = Matrix4x4.CreateTranslation(new Vector3(10f, 20f, 30f));
WbDrawDispatcher.ApplyCacheHit(entryHit!, entityWorld, AppendInstance);
// Cache state stable — Populate didn't fire on the hit path.
Assert.Equal(1, cache.Count);
// Both groups received exactly one matrix each (the entity is one
// instance contributing once per cached batch).
Assert.Equal(2, groups.Count);
foreach (var (_, list) in groups)
Assert.Single(list);
// Matrix composition is RestPose * entityWorld (NOT the reverse).
// RestPose is Matrix4x4.Identity for the synthesized batches, so the
// appended matrix must equal entityWorld.
foreach (var (_, list) in groups)
Assert.Equal(entityWorld, list[0]);
}
[Fact]
public void Draw_AnimatedEntity_DoesNotPopulateCache()
{
// Spec §7.2 test #12.
// Animated entities take the slow path with collector=null: their
// ClassifyBatches output is NOT routed into _populateScratch and the
// populate-tracking locals stay null. Result: the cache is never
// populated for animated entities, and FinalFlushPopulate is a no-op.
//
// This test models that flow: scratch stays empty, populateEntityId
// stays null, FinalFlushPopulate fires but commits nothing.
var cache = new EntityClassificationCache();
var scratch = new List<CachedBatch>();
const uint AnimatedId = 7;
const uint LandblockId = 0xA9B40000u;
var animatedSet = new HashSet<uint> { AnimatedId };
// Even when the entity has MeshRefs that would produce batches, the
// animated-set membership means collector=null in Draw — scratch
// stays empty and the tracker stays null. Simulating that here:
// we do NOT add to scratch and we do NOT set populateEntityId.
bool isAnimated = animatedSet.Contains(AnimatedId);
Assert.True(isAnimated);
uint? populateEntityId = null;
uint populateLandblockId = 0u;
// Boundary check still runs but is a no-op — tracker is null.
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, AnimatedId, cache, scratch);
// For animated entities, Draw does NOT set populateEntityId after
// ClassifyBatches (the `if (collector is not null)` guard).
// populateEntityId stays null.
// End-of-loop flush — no-op for animated-only iterations.
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
// Cache should never be populated for animated entities.
Assert.Equal(0, cache.Count);
Assert.False(cache.TryGet(AnimatedId, LandblockId, out _));
}
[Fact]
public void Draw_MultiMeshRefStaticEntity_PopulatesAllBatchesIntoSingleCacheEntry()
{
// Regression test for the bug fixed at commit 00fa8ae:
//
// Task 9's first attempt called _cache.Populate per-(entity,
// MeshRefIndex) tuple, but the cache is keyed by entity.Id. For
// multi-MeshRef entities (multi-part Setup buildings, statues,
// NPCs), each iteration's Populate OVERWROTE the previous one
// — only the LAST MeshRef's batches survived in the cache. After
// the fix, Populate fires once per entity at the entity boundary
// (or end-of-loop), with all MeshRefs' batches accumulated into
// _populateScratch.
//
// This test simulates a 3-MeshRef static entity where each MeshRef
// contributes 2 batches (total = 6). It walks through Draw's loop
// structure tuple-by-tuple, calling MaybeFlushOnEntityChange before
// each tuple's classification and FinalFlushPopulate at end-of-loop.
// Asserts the cache entry holds ALL 6 batches, not just the last 2.
//
// If the per-MeshRef Populate bug were reintroduced, this test would
// see Batches.Length == 2 (last MeshRef only).
var cache = new EntityClassificationCache();
var scratch = new List<CachedBatch>();
const uint EntityId = 200;
const uint LandblockId = 0xA9B40000u;
const int MeshRefCount = 3;
const int BatchesPerMeshRef = 2;
const int ExpectedTotalBatches = MeshRefCount * BatchesPerMeshRef;
uint? populateEntityId = null;
uint populateLandblockId = 0u;
// Simulate Draw's foreach over _walkScratch. _walkScratch yields
// (entity, MeshRefIndex, landblockId) — all MeshRefs of one entity
// are contiguous because the walk emits them in entity-order.
for (int meshRefIdx = 0; meshRefIdx < MeshRefCount; meshRefIdx++)
{
// Boundary check: same entity across all 3 iterations, so this
// never fires the flush. populateEntityId stays as is (null on
// first iter; EntityId on subsequent iters after we set it).
(populateEntityId, populateLandblockId) = WbDrawDispatcher.MaybeFlushOnEntityChange(
populateEntityId, populateLandblockId, EntityId, cache, scratch);
// Mimic ClassifyBatches' collector output for THIS MeshRef:
// 2 batches with distinct (ibo, firstIndex, texHandle) so the
// ordering can be verified post-hoc.
for (int b = 0; b < BatchesPerMeshRef; b++)
{
ulong texHandle = (ulong)(0x100 + meshRefIdx * BatchesPerMeshRef + b);
scratch.Add(MakeCachedBatch(
ibo: (uint)(meshRefIdx + 1),
firstIndex: (uint)(b * 6),
indexCount: 6,
texHandle: texHandle));
}
// After ClassifyBatches, Draw sets the tracker (matching the
// `if (collector is not null)` block at line 482-486 in
// WbDrawDispatcher.Draw).
populateEntityId = EntityId;
populateLandblockId = LandblockId;
}
// End-of-loop final flush. Without this call (or if Populate fired
// per-tuple inside the loop), the cache would only hold the last
// 2 batches — exactly the bug class from commit 00fa8ae.
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
// Assertions: ONE cache entry with ALL 6 batches in MeshRef order.
Assert.Equal(1, cache.Count);
Assert.True(cache.TryGet(EntityId, LandblockId, out var entry));
Assert.NotNull(entry);
Assert.Equal(EntityId, entry!.EntityId);
Assert.Equal(LandblockId, entry.LandblockHint);
// KEY ASSERTION: Batches.Length == sum across MeshRefs (6),
// NOT just the last MeshRef's batch count (2).
Assert.Equal(ExpectedTotalBatches, entry.Batches.Length);
// Per-batch ordering check: batches arrived in MeshRef order, so
// texture handles run 0x100..0x105 in the order they were appended.
for (int i = 0; i < ExpectedTotalBatches; i++)
Assert.Equal((ulong)(0x100 + i), entry.Batches[i].BindlessTextureHandle);
// After flush, scratch is cleared so the next entity starts fresh.
Assert.Empty(scratch);
}
[Fact]
public void Cache_Populate_SkipsEntityWithIncompleteClassification()
{
// Regression test for the bug where an entity with >=1 MeshRef whose
// mesh data was still async-decoding at populate time would have a
// PARTIAL set of batches written to the cache. Subsequent frame
// cache-hits served the partial entry indefinitely, leaving parts of
// multi-part entities (drudge statue, etc.) permanently missing.
//
// The fix: track currentEntityIncomplete during the foreach. If any
// tuple's TryGetRenderData returned null, drop the accumulated
// populate scratch at entity boundary instead of caching it. The
// slow path retries on the next frame; once all meshes have loaded,
// the populate fires correctly with the complete classification.
//
// This test simulates Draw's inner-loop logic: 3 MeshRef tuples for
// one entity where tuple 0 produces null renderData (flag the entity
// incomplete + continue, no batches), and tuples 1 and 2 produce
// valid renderData (classify + accumulate). End-of-loop check drops
// scratch + nulls populateEntityId BEFORE FinalFlushPopulate, so the
// cache stays empty for this entity.
var cache = new EntityClassificationCache();
const uint EntityId = 100;
const uint LandblockId = 0xA9B40000u;
// Simulate Draw's per-entity inner-loop logic.
var scratch = new List<CachedBatch>();
bool currentEntityIncomplete = false;
uint? populateEntityId = null;
uint populateLandblockId = 0u;
// Tuple 0 (MeshRef[0]): renderData null -> flag incomplete, skip classify.
currentEntityIncomplete = true;
// Tuple 1 (MeshRef[1]): renderData valid -> classify, accumulate.
scratch.Add(MakeCachedBatch(ibo: 1, firstIndex: 0, indexCount: 6, texHandle: 0xAAul));
populateEntityId = EntityId;
populateLandblockId = LandblockId;
// Tuple 2 (MeshRef[2]): renderData valid -> classify, accumulate.
scratch.Add(MakeCachedBatch(ibo: 2, firstIndex: 0, indexCount: 6, texHandle: 0xBBul));
populateEntityId = EntityId;
populateLandblockId = LandblockId;
// End of loop: check incomplete flag, drop scratch + null tracker
// BEFORE FinalFlushPopulate so the helper sees the cleaned state.
if (currentEntityIncomplete)
{
scratch.Clear();
populateEntityId = null;
}
WbDrawDispatcher.FinalFlushPopulate(populateEntityId, populateLandblockId, cache, scratch);
// Cache should NOT have an entry for this entity — partial population
// would be worse than no cache (cache hit would persist the partial
// render forever; cache miss retries and gets it right next frame).
Assert.Equal(0, cache.Count);
Assert.False(cache.TryGet(EntityId, LandblockId, out _));
}
[Fact]
public void ApplyCacheHit_PerTupleAmplification_DoesNotOccur()
{
// Regression test for the bug fixed at the commit landing alongside
// this test: Task 10's first attempt called ApplyCacheHit per-(entity,
// MeshRefIndex) tuple in Draw's foreach, but cachedEntry.Batches is
// flat across all MeshRefs of the entity. For a 3-MeshRef building on
// frame 2: 3 tuples × 6 cached batches per call = 18 instances drawn
// instead of 6. Severe Z-fighting and 3× 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 came from spec §5.2 describing the foreach as per-entity when
// _walkScratch is per-tuple.
//
// The fix: track lastHitEntityId; the cache-hit fast path fires only
// on the FIRST tuple of each entity. Subsequent tuples of the same
// entity skip the iteration body via continue.
//
// This test simulates the inner-loop logic by directly invoking
// ApplyCacheHit + AppendInstanceToGroup the way Draw would, with N
// tuples for the same entity, asserting that groups[key].Count equals
// the cached batch count (6), NOT N × cached batch count (18).
// Set up a synthetic cache entry with 6 batches (representing 3
// MeshRefs × 2 batches each).
const int CachedBatchCount = 6;
var cache = new EntityClassificationCache();
var batches = new CachedBatch[CachedBatchCount];
for (int i = 0; i < CachedBatchCount; i++)
{
batches[i] = MakeCachedBatch(
ibo: 1u, firstIndex: (uint)i, indexCount: 6, texHandle: (ulong)(0x100 + i));
}
cache.Populate(entityId: 100, landblockHint: 0xA9B40000u, batches);
// Simulate Draw's per-entity loop: 3 tuples for the same entity.
// Track which entity has already cache-hit (mirrors the production
// lastHitEntityId pattern).
var groups = new Dictionary<GroupKey, List<Matrix4x4>>();
uint? lastHitEntityId = null;
var entityWorld = Matrix4x4.Identity; // simplest case for assertion clarity
const uint EntityId = 100;
const int MeshRefCount = 3;
void AppendInstance(GroupKey k, Matrix4x4 m)
{
if (!groups.TryGetValue(k, out var list))
{
list = new List<Matrix4x4>();
groups[k] = list;
}
list.Add(m);
}
for (int partIdx = 0; partIdx < MeshRefCount; partIdx++)
{
// Skip subsequent tuples of an entity that cache-hit (the fix).
if (lastHitEntityId == EntityId) continue;
if (cache.TryGet(EntityId, 0xA9B40000u, out var entry))
{
Assert.NotNull(entry);
WbDrawDispatcher.ApplyCacheHit(entry!, entityWorld, AppendInstance);
lastHitEntityId = EntityId;
}
}
// Assertion: each group's matrix count equals the cached batches matching
// that key, NOT (cached batches × MeshRef count). Here each batch has a
// unique key, so each group has exactly 1 matrix.
int totalMatrices = 0;
foreach (var (_, matrices) in groups) totalMatrices += matrices.Count;
Assert.Equal(CachedBatchCount, totalMatrices); // 6, NOT 18
// Sanity: 6 distinct keys (one per cached batch since FirstIndex differs).
Assert.Equal(CachedBatchCount, groups.Count);
}
}

View file

@ -0,0 +1,125 @@
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class GpuWorldStateTwoTierTests
{
private static LoadedLandblock MakeStubLandblock(uint canonicalId, params WorldEntity[] entities)
=> new(canonicalId, new LandBlock(), entities);
private static WorldEntity MakeStubEntity(uint id)
=> new()
{
Id = id,
SourceGfxObjOrSetupId = 0x01000001u,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
[Fact]
public void RemoveEntitiesFromLandblock_KeepsLandblockButDropsEntities()
{
var state = new GpuWorldState();
var lb = MakeStubLandblock(0xAAAAFFFFu,
MakeStubEntity(1),
MakeStubEntity(2));
state.AddLandblock(lb);
Assert.Equal(2, state.Entities.Count);
state.RemoveEntitiesFromLandblock(0xAAAAFFFFu);
Assert.Empty(state.Entities);
Assert.True(state.IsLoaded(0xAAAAFFFFu)); // landblock still resident
}
[Fact]
public void AddEntitiesToExistingLandblock_MergesIntoExistingRecord()
{
var state = new GpuWorldState();
var lb = MakeStubLandblock(0xAAAAFFFFu, MakeStubEntity(1));
state.AddLandblock(lb);
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
{
MakeStubEntity(2),
MakeStubEntity(3),
});
Assert.Equal(3, state.Entities.Count);
}
[Fact]
public void AddEntitiesToExistingLandblock_LandblockNotYetLoaded_ParksInPending()
{
var state = new GpuWorldState();
// Landblock not loaded yet.
state.AddEntitiesToExistingLandblock(0xAAAAFFFFu, new[]
{
MakeStubEntity(1),
MakeStubEntity(2),
});
// Nothing in the flat view yet.
Assert.Empty(state.Entities);
Assert.Equal(2, state.PendingLiveEntityCount);
// Now load the landblock — pending entities should merge in.
state.AddLandblock(MakeStubLandblock(0xAAAAFFFFu));
Assert.Equal(2, state.Entities.Count);
}
/// <summary>
/// Phase Post-A.5 #53 (Task 12): the optional <c>onLandblockUnloaded</c>
/// callback fires once when <see cref="GpuWorldState.RemoveEntitiesFromLandblock"/>
/// drops a landblock's entity list, and is passed the canonicalized
/// landblock id (matching the <c>LandblockHint</c> the cache stored at
/// <c>Populate</c> time).
/// </summary>
[Fact]
public void RemoveEntitiesFromLandblock_FiresUnloadCallbackWithCanonicalId()
{
uint? observed = null;
int callCount = 0;
var state = new GpuWorldState(
wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: id => { observed = id; callCount++; });
state.AddLandblock(MakeStubLandblock(0xA9B4FFFFu, MakeStubEntity(1)));
// Pass a cell-resolved id (low 16 bits non-FFFF) — the callback must
// receive the canonical (0xFFFF-tail) form, matching what the
// dispatcher's _walkScratch carries from GpuWorldState.LandblockEntries.
state.RemoveEntitiesFromLandblock(0xA9B40042u);
Assert.Equal(1, callCount);
Assert.Equal(0xA9B4FFFFu, observed);
Assert.Empty(state.Entities);
}
/// <summary>
/// Phase Post-A.5 #53 (Task 12): the callback must NOT fire when the
/// landblock isn't loaded — early return path. Symmetric with the
/// existing <c>_wbSpawnAdapter.OnLandblockUnloaded</c> guard.
/// </summary>
[Fact]
public void RemoveEntitiesFromLandblock_NotLoaded_DoesNotFireCallback()
{
int callCount = 0;
var state = new GpuWorldState(
wbSpawnAdapter: null,
wbEntitySpawnAdapter: null,
onLandblockUnloaded: _ => callCount++);
// Landblock never loaded.
state.RemoveEntitiesFromLandblock(0xA9B4FFFFu);
Assert.Equal(0, callCount);
}
}

View file

@ -19,9 +19,13 @@ public class LandblockStreamerTests
0xA9B4FFFEu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
using var streamer = new LandblockStreamer(
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null);
loadLandblock: id => id == 0xA9B4FFFEu ? stubLandblock : null,
buildMeshOrNull: (_, _) => stubMesh);
streamer.Start();
streamer.EnqueueLoad(0xA9B4FFFEu);
@ -62,6 +66,39 @@ public class LandblockStreamerTests
Assert.IsType<LandblockStreamResult.Failed>(result);
}
[Fact]
public async Task Load_WhenBuildMeshReturnsNull_ReportsFailed()
{
// Phase A.5 T10-T12 follow-up: the mesh-build factory may return
// null (e.g., LandBlock dat missing or corrupt). The worker must
// emit Failed in that case instead of constructing Loaded with a
// null MeshData (which would NRE downstream).
var stubLandblock = new LoadedLandblock(
0xABCDFFFEu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
using var streamer = new LandblockStreamer(
loadLandblock: _ => stubLandblock,
buildMeshOrNull: (_, _) => null); // mesh-build returns null
streamer.Start();
streamer.EnqueueLoad(0xABCDFFFEu);
LandblockStreamResult? result = null;
for (int i = 0; i < SpinMaxIterations && result is null; i++)
{
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(SpinStepMs);
}
Assert.NotNull(result);
var failed = Assert.IsType<LandblockStreamResult.Failed>(result);
Assert.Equal(0xABCDFFFEu, failed.LandblockId);
Assert.Contains("mesh", failed.Error, System.StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task Load_WhenLoaderThrows_ReportsFailedWithMessage()
{
@ -104,37 +141,46 @@ public class LandblockStreamerTests
}
[Fact]
public void Load_ExecutesLoaderSynchronously_OnCallingThread()
public async Task Load_ExecutesLoaderOnWorkerThread()
{
// Streamer was made synchronous after Phase A.1 visual verification
// exposed concurrent dat reads as the cause of "ball of spikes"
// terrain corruption — DatReaderWriter's DatCollection isn't
// thread-safe and locking around every dat read on every render-
// thread code path was too invasive. Until Phase A.3 introduces a
// thread-safe dat wrapper, the load delegate runs on the calling
// thread and the result is in the outbox by the time EnqueueLoad
// returns. This test pins that contract.
// Phase A.5 T11: the load delegate now runs on the dedicated worker
// thread (not the calling/render thread). This test verifies the
// async hand-off: EnqueueLoad returns immediately and the result
// appears in the outbox only after the worker processes the inbox.
int testThreadId = System.Environment.CurrentManagedThreadId;
int? loaderThreadId = null;
var stubLandblock = new LoadedLandblock(
0x77770FFEu,
new LandBlock(),
System.Array.Empty<WorldEntity>());
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
using var streamer = new LandblockStreamer(loadLandblock: id =>
{
loaderThreadId = System.Environment.CurrentManagedThreadId;
return stubLandblock;
});
using var streamer = new LandblockStreamer(
loadLandblock: id =>
{
loaderThreadId = System.Environment.CurrentManagedThreadId;
return stubLandblock;
},
buildMeshOrNull: (_, _) => stubMesh);
streamer.Start();
streamer.EnqueueLoad(0x77770FFEu);
// Result is already in the outbox — no spinning needed.
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
// Spin until the worker produces a completion.
LandblockStreamResult? result = null;
for (int i = 0; i < SpinMaxIterations && result is null; i++)
{
var drained = streamer.DrainCompletions(LandblockStreamer.DefaultDrainBatchSize);
if (drained.Count > 0) result = drained[0];
else await Task.Delay(SpinStepMs);
}
Assert.Single(drained);
Assert.IsType<LandblockStreamResult.Loaded>(drained[0]);
Assert.Equal(testThreadId, loaderThreadId);
Assert.NotNull(result);
Assert.IsType<LandblockStreamResult.Loaded>(result);
// The loader MUST have run on a different thread than the test thread.
Assert.NotNull(loaderThreadId);
Assert.NotEqual(testThreadId, loaderThreadId.Value);
}
}

View file

@ -14,7 +14,7 @@ public class StreamingControllerTests
public List<uint> Unloads { get; } = new();
public Queue<LandblockStreamResult> Pending { get; } = new();
public void EnqueueLoad(uint id) => Loads.Add(id);
public void EnqueueLoad(uint id, LandblockStreamJobKind _) => Loads.Add(id);
public void EnqueueUnload(uint id) => Unloads.Add(id);
public IReadOnlyList<LandblockStreamResult> DrainCompletions(int max)
{
@ -34,14 +34,15 @@ public class StreamingControllerTests
enqueueLoad: fake.EnqueueLoad,
enqueueUnload: fake.EnqueueUnload,
drainCompletions: fake.DrainCompletions,
applyTerrain: _ => { },
applyTerrain: (_, _) => { },
state: state,
radius: 2);
nearRadius: 2,
farRadius: 2);
// Center at (50, 50); no landblocks loaded yet.
controller.Tick(observerCx: 50, observerCy: 50);
// 5×5 window = 25 loads enqueued, 0 unloads.
// 5×5 window = 25 loads enqueued (nearRadius==farRadius so all go to ToLoadNear), 0 unloads.
Assert.Equal(25, fake.Loads.Count);
Assert.Empty(fake.Unloads);
}
@ -53,7 +54,7 @@ public class StreamingControllerTests
var fake = new FakeStreamer();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2);
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
controller.Tick(50, 50);
fake.Loads.Clear();
@ -72,13 +73,19 @@ public class StreamingControllerTests
var applied = new List<LoadedLandblock>();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
applied.Add, state, radius: 2);
(lb, _) => applied.Add(lb), state, nearRadius: 2, farRadius: 2);
// Note: LoadedLandblock's actual fields are LandblockId, Heightmap,
// Entities (positional record). Adjust if the first positional arg
// name differs.
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, lb));
// A.5 T10-T12 follow-up: use a real empty mesh instance instead of
// default! so any future test that flows MeshData through the apply
// callback gets a non-null reference to inspect rather than an NRE.
var stubMesh = new AcDream.Core.Terrain.LandblockMeshData(
System.Array.Empty<AcDream.Core.Terrain.TerrainVertex>(),
System.Array.Empty<uint>());
fake.Pending.Enqueue(new LandblockStreamResult.Loaded(0x32320FFEu, LandblockStreamTier.Near, lb, stubMesh));
controller.Tick(50, 50);
@ -93,7 +100,7 @@ public class StreamingControllerTests
var fake = new FakeStreamer();
var controller = new StreamingController(
fake.EnqueueLoad, fake.EnqueueUnload, fake.DrainCompletions,
_ => { }, state, radius: 2);
(_, _) => { }, state, nearRadius: 2, farRadius: 2);
var lb = new LoadedLandblock(0x32320FFEu, new LandBlock(), System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);

View file

@ -0,0 +1,134 @@
using System.Collections.Generic;
using AcDream.App.Streaming;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class StreamingControllerTwoTierTests
{
[Fact]
public void Tick_FirstCall_EnqueuesNearAndFarLoadsByTier()
{
var loads = new List<(uint Id, LandblockStreamJobKind Kind)>();
var unloads = new List<uint>();
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: _ => System.Array.Empty<LandblockStreamResult>(),
applyTerrain: (_, _) => { },
state: state,
nearRadius: 1,
farRadius: 3);
ctrl.Tick(observerCx: 100, observerCy: 100);
int nearCount = 0, farCount = 0;
foreach (var (_, kind) in loads)
{
if (kind == LandblockStreamJobKind.LoadNear) nearCount++;
else if (kind == LandblockStreamJobKind.LoadFar) farCount++;
}
Assert.Equal(9, nearCount); // 3x3 inner ring (radius=1)
Assert.Equal(40, farCount); // 7x7 - 3x3 outer ring (radius=3)
}
[Fact]
public void Tick_PlayerWalksOutOfNear_ToDemoteRoutesToRemoveEntities()
{
// Setup: bootstrap region at (100,100) with near=1, far=3.
// The bootstrap puts LB (100,100) in the near tier.
// Walking 4+ east drops LB (100,100) past the near-hysteresis
// threshold (NearRadius+2 = 3); ToDemote should fire.
var loads = new List<(uint, LandblockStreamJobKind)>();
var unloads = new List<uint>();
var state = new GpuWorldState();
// Pre-load LB (100,100) so RemoveEntitiesFromLandblock has something
// to find. The actual entity content doesn't matter for routing.
var lb100 = new LoadedLandblock(
(100u << 24) | (100u << 16) | 0xFFFFu,
Heightmap: null!,
Entities: new[] { new WorldEntity {
Id = 1, SourceGfxObjOrSetupId = 0,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>() } });
state.AddLandblock(lb100);
Assert.Equal(1, state.Entities.Count);
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: _ => System.Array.Empty<LandblockStreamResult>(),
applyTerrain: (_, _) => { },
state: state,
nearRadius: 1,
farRadius: 3);
ctrl.Tick(observerCx: 100, observerCy: 100); // bootstrap
loads.Clear();
// Walk 4 east — LB (100,100) is now Chebyshev distance 4 from new
// center (104,100). NearRadius+2 = 3, so 4 > 3 fires the demote.
ctrl.Tick(observerCx: 104, observerCy: 100);
// ToDemote runs synchronously on the render thread (no enqueue).
// The visible effect is RemoveEntitiesFromLandblock dropping the entity.
Assert.Empty(state.Entities);
// Terrain stays loaded (demote != unload).
Assert.True(state.IsLoaded((100u << 24) | (100u << 16) | 0xFFFFu));
}
[Fact]
public void Tick_DrainingPromoted_RoutesToAddEntitiesToExisting()
{
var loads = new List<(uint, LandblockStreamJobKind)>();
var unloads = new List<uint>();
var state = new GpuWorldState();
// Pre-load a far-tier-style LB record (terrain only, no entities).
// Id must be in canonical form (low 16 bits = 0xFFFF) since
// AddEntitiesToExistingLandblock canonicalizes incoming ids.
uint lbId = 0x3232FFFFu;
var lb = new LoadedLandblock(lbId, Heightmap: null!, Entities: System.Array.Empty<WorldEntity>());
state.AddLandblock(lb);
Assert.Empty(state.Entities);
// Streamer pushes a Promoted result carrying the entity layer.
var promoted = new LandblockStreamResult.Promoted(
lbId,
new[] { new WorldEntity {
Id = 7, SourceGfxObjOrSetupId = 0,
Position = System.Numerics.Vector3.Zero,
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>() } });
var queue = new Queue<LandblockStreamResult>();
queue.Enqueue(promoted);
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: max =>
{
var batch = new List<LandblockStreamResult>();
while (batch.Count < max && queue.Count > 0) batch.Add(queue.Dequeue());
return batch;
},
applyTerrain: (_, _) => { },
state: state,
nearRadius: 2,
farRadius: 2);
ctrl.Tick(50, 50); // drains the Promoted result
// Promoted routes to AddEntitiesToExistingLandblock — the entity is now
// merged into the existing LB record.
Assert.Equal(1, state.Entities.Count);
Assert.Equal(7u, state.Entities[0].Id);
}
}

View file

@ -36,7 +36,7 @@ public class StreamingRegionTests
{
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(50, 50);
var diff = region.RecenterToSingleTier(50, 50);
Assert.Empty(diff.ToLoad);
Assert.Empty(diff.ToUnload);
@ -52,7 +52,7 @@ public class StreamingRegionTests
// the radius+2 threshold, so it stays loaded (hysteresis keeps radius+2).
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(51, 50);
var diff = region.RecenterToSingleTier(51, 50);
Assert.Equal(5, diff.ToLoad.Count);
Assert.Empty(diff.ToUnload);
@ -71,7 +71,7 @@ public class StreamingRegionTests
// x=48 is now 5 away, > radius+2 = 4 → unload. x=49 is 4 away, not > 4 → keep. x=50 is 3 away, not > 4 → keep.
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(53, 50);
var diff = region.RecenterToSingleTier(53, 50);
Assert.Equal(15, diff.ToLoad.Count);
Assert.Equal(5, diff.ToUnload.Count);
@ -82,7 +82,7 @@ public class StreamingRegionTests
{
var region = new StreamingRegion(cx: 50, cy: 50, radius: 2);
var diff = region.RecenterTo(200, 200);
var diff = region.RecenterToSingleTier(200, 200);
Assert.Equal(25, diff.ToLoad.Count);
Assert.Equal(25, diff.ToUnload.Count);

View file

@ -0,0 +1,166 @@
using AcDream.App.Streaming;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
public class StreamingRegionTwoTierTests
{
[Fact]
public void Constructor_TwoRadii_ExposesNearAndFarRadii()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 4, farRadius: 12);
Assert.Equal(4, region.NearRadius);
Assert.Equal(12, region.FarRadius);
Assert.Equal(100, region.CenterX);
Assert.Equal(100, region.CenterY);
// Radius (used by existing single-radius hysteresis math) must alias to
// FarRadius — the outer ring drives "everything currently loaded" bookkeeping.
// If a future change mistakenly aliases Radius to NearRadius, hysteresis
// becomes (NearRadius+2) for the far-tier unload, which is wrong.
Assert.Equal(region.FarRadius, region.Radius);
}
[Fact]
public void ComputeFirstTickDiff_FirstTick_SplitsLoadIntoNearAndFar()
{
// near=1, far=3 → near window is 3×3=9, far window is 7×7-3×3=40 LBs.
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
var diff = region.ComputeFirstTickDiff();
Assert.Equal(9, diff.ToLoadNear.Count);
Assert.Equal(40, diff.ToLoadFar.Count);
Assert.Empty(diff.ToPromote);
Assert.Empty(diff.ToDemote);
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Walk one LB east — center (100,100) → (101,100). LB column at lbX=104
// (relative dx=+3 from new center) enters the far window from null.
var diff = region.RecenterTo(newCx: 101, newCy: 100);
foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 })
{
var id = StreamingRegion.EncodeLandblockIdForTest(104, y);
Assert.Contains(id, diff.ToLoadFar);
}
Assert.Empty(diff.ToLoadNear);
// The 3 LBs at x=102, y in {99,100,101} were Far from old center
// (distance 2) and are now Near from new center (distance ≤1).
// They should land in ToPromote.
Assert.Equal(3, diff.ToPromote.Count);
// All resident LBs from the old window are within hysteresis of
// the new center (max distance 4 ≤ FarRadius+2=5), so nothing unloads.
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_PlayerWalks_FarToNear_AppearsInToPromote()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Walk 2 east — center (102, 100). LB (102, 100) was at distance 2 (Far)
// from (100,100); now at distance 0 → Near. That's a Promote.
var diff = region.RecenterTo(newCx: 102, newCy: 100);
var promotedId = StreamingRegion.EncodeLandblockIdForTest(102, 100);
Assert.Contains(promotedId, diff.ToPromote);
Assert.DoesNotContain(promotedId, diff.ToLoadNear);
Assert.DoesNotContain(promotedId, diff.ToLoadFar);
}
[Fact]
public void RecenterTo_PlayerTeleports_NullToNear_AppearsInToLoadNear()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Teleport to (200, 200) — entirely new region.
var diff = region.RecenterTo(newCx: 200, newCy: 200);
Assert.Equal(9, diff.ToLoadNear.Count);
Assert.Equal(40, diff.ToLoadFar.Count);
Assert.Empty(diff.ToPromote);
}
[Fact]
public void RecenterTo_NearToFar_DemoteOnlyAfterHysteresis()
{
// near=2, far=4 → near hysteresis threshold = 4.
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 2, farRadius: 4);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// LB (100,100) was Near. Walk 3 east → distance 3 > NearRadius=2 but ≤ 4. No demote yet.
var diff1 = region.RecenterTo(newCx: 103, newCy: 100);
var lb100 = StreamingRegion.EncodeLandblockIdForTest(100, 100);
Assert.DoesNotContain(lb100, diff1.ToDemote);
// Walk 2 more east → distance 5 > 4. Demote.
var diff2 = region.RecenterTo(newCx: 105, newCy: 100);
Assert.Contains(lb100, diff2.ToDemote);
}
[Fact]
public void RecenterTo_FarToNull_UnloadOnlyAfterHysteresis()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// LB (97, 100) was at distance 3 (Far). Walk 1 east → distance 4. ≤ FarRadius+2=5.
var diff1 = region.RecenterTo(newCx: 101, newCy: 100);
var lb97 = StreamingRegion.EncodeLandblockIdForTest(97, 100);
Assert.DoesNotContain(lb97, diff1.ToUnload);
// Walk 2 more east → distance 6 > 5. Unload.
var diff2 = region.RecenterTo(newCx: 103, newCy: 100);
Assert.Contains(lb97, diff2.ToUnload);
}
[Fact]
public void RecenterTo_PlayerOscillates_NoThrashWithinHysteresis()
{
// Start the region centered on (103,100) so the oscillation
// between (102,100) and (103,100) never crosses a hysteresis boundary.
// NearRadius=2, farRadius=4 → nearUnloadThreshold=4.
// Chebyshev distance from (102,100) or (103,100) to any LB in the
// initial 9×9 window of (103,100) is ≤ NearRadius+2=4 for all LBs
// in the near zone, so no demotes should fire during pure oscillation.
var region = new StreamingRegion(centerX: 103, centerY: 100, nearRadius: 2, farRadius: 4);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Bounce between (103,100) and (102,100). All resident LBs stay
// within the hysteresis window — no demotes expected.
int totalDemotes = 0;
int totalPromotes = 0;
for (int i = 0; i < 5; i++)
{
var d1 = region.RecenterTo(102, 100);
totalDemotes += d1.ToDemote.Count;
totalPromotes += d1.ToPromote.Count;
var d2 = region.RecenterTo(103, 100);
totalDemotes += d2.ToDemote.Count;
totalPromotes += d2.ToPromote.Count;
}
// The first step from (103,100) to (102,100) legitimately promotes the
// x=100 near-column (5 LBs) that were Far from (103) into Near. After
// that initial settle they stay Near for all subsequent oscillations.
// So the ceiling is 5 promotes total (not per oscillation).
Assert.Equal(0, totalDemotes);
Assert.True(totalPromotes <= 5,
$"Expected ≤5 promotes across 5 oscillations; got {totalPromotes}");
}
}

View file

@ -0,0 +1,168 @@
using AcDream.Core.Terrain;
using Xunit;
using Xunit.Abstractions;
using WbTerrainUtils = WorldBuilder.Shared.Modules.Landscape.Lib.TerrainUtils;
using WbCellSplitDirection = WorldBuilder.Shared.Modules.Landscape.Models.CellSplitDirection;
namespace AcDream.Core.Tests.Terrain;
/// <summary>
/// Phase N.5b data-collection test: quantifies how often WB's
/// <c>TerrainUtils.CalculateSplitDirection</c> disagrees with acdream's
/// <c>TerrainBlending.CalculateSplitDirection</c> (which retail uses
/// per <c>CLandBlockStruct::ConstructPolygons</c> at retail address
/// <c>00531d10</c>; named-retail decomp lines 316042-316144 contain
/// the exact constants <c>0x0CCAC033 / 0x6C1AC587 / 0x421BE3BD /
/// 0x519B8F25</c>).
///
/// Sweeps every (lbX, lbY, cellX, cellY) tuple in the world map
/// (255 x 255 landblocks x 64 cells = ~4.16M cells) and reports the
/// disagreement rate, per-landblock worst case, and a few named
/// representative landblocks. The number drives the Path A/B/C
/// decision in the N.5b brainstorm:
/// - Low disagreement &lt;5% : Path A's risk is bounded
/// - Medium 5-20% : Path B (fork-patch WB) preferred
/// - High &gt;20% : Path B/C strongly preferred
/// </summary>
public class SplitFormulaDivergenceTest
{
private readonly ITestOutputHelper _out;
public SplitFormulaDivergenceTest(ITestOutputHelper output) => _out = output;
[Fact]
public void Quantify_RetailVsWb_DivergenceRate()
{
// Two divergence flavors are tracked simultaneously:
//
// rawDisagree : retail-enum != wb-enum (pure formula output)
// diagonalDisagree: retail-actually-paints-diagonal !=
// wb-actually-paints-diagonal (effective geometry)
//
// The two differ because the enums are SEMANTICALLY INVERTED:
// - acdream `CellSplitDirection.SWtoNE` -> renderer paints BL->TR
// (SW-NE diagonal). Matches retail per AC2D Landblocks.cpp:400-412
// where FSplitNESW=true wraps a TRIANGLE_FAN [BL, BR, TR, TL] =
// diagonal BL-TR.
// - WB `CellSplitDirection.SWtoNE` -> WB's TerrainGeometryGenerator
// emits triangles {BL,TL,BR}+{BR,TL,TR} which share the BR-TL
// diagonal (SE-NW direction). The enum name is misleading; what
// WB actually draws is the OTHER diagonal.
//
// So the question "would WB's pipeline produce the same diagonals as
// retail's pipeline?" is answered by `diagonalDisagree`, not
// `rawDisagree`. If diagonalDisagree is near 0%, WB's formula +
// renderer happen to compose into a correct pipeline (despite the
// confusing labels). If diagonalDisagree is ~50%, the two pipelines
// truly diverge and Path A would visibly break terrain on every
// landblock.
const int lbCount = 255;
const int cellsPerSide = 8;
long totalCells = 0;
long rawDisagree = 0;
long diagonalDisagree = 0;
int worstLbDiag = 0;
uint worstLbX = 0, worstLbY = 0;
int bestLbDiag = 64;
uint bestLbX = 0, bestLbY = 0;
for (uint lbX = 0; lbX < lbCount; lbX++)
for (uint lbY = 0; lbY < lbCount; lbY++)
{
int lbDiagDisagree = 0;
for (uint cx = 0; cx < cellsPerSide; cx++)
for (uint cy = 0; cy < cellsPerSide; cy++)
{
bool retailEnumSWtoNE =
TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy)
== CellSplitDirection.SWtoNE;
bool wbEnumSWtoNE =
WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy)
== WbCellSplitDirection.SWtoNE;
// What diagonal each pipeline actually paints.
bool retailPaintsBLtoTR = retailEnumSWtoNE; // direct mapping
bool wbPaintsBLtoTR = !wbEnumSWtoNE; // inverted mapping
totalCells++;
if (retailEnumSWtoNE != wbEnumSWtoNE) rawDisagree++;
if (retailPaintsBLtoTR != wbPaintsBLtoTR)
{
diagonalDisagree++;
lbDiagDisagree++;
}
}
if (lbDiagDisagree > worstLbDiag)
{
worstLbDiag = lbDiagDisagree;
worstLbX = lbX;
worstLbY = lbY;
}
if (lbDiagDisagree < bestLbDiag)
{
bestLbDiag = lbDiagDisagree;
bestLbX = lbX;
bestLbY = lbY;
}
}
double rawPct = 100.0 * rawDisagree / totalCells;
double diagPct = 100.0 * diagonalDisagree / totalCells;
_out.WriteLine($"=== Phase N.5b — terrain split formula divergence ===");
_out.WriteLine($"Sweep: {lbCount}x{lbCount} landblocks, {cellsPerSide*cellsPerSide} cells each");
_out.WriteLine($"Total cells: {totalCells:N0}");
_out.WriteLine("");
_out.WriteLine($"RAW enum-output disagreement : {rawDisagree,12:N0} ({rawPct:F2}%)");
_out.WriteLine($" (compares retail-enum vs wb-enum, NOT what each system actually draws)");
_out.WriteLine("");
_out.WriteLine($"DIAGONAL-actually-painted disagreement: {diagonalDisagree,12:N0} ({diagPct:F2}%)");
_out.WriteLine($" (compares retail-paints-BL->TR vs wb-paints-BL->TR; this is the");
_out.WriteLine($" number that determines whether Path A visibly works)");
_out.WriteLine("");
_out.WriteLine($"Worst landblock (diagonal): 0x{worstLbX:X2}{worstLbY:X2} disagrees on {worstLbDiag}/64 cells ({100.0*worstLbDiag/64:F1}%)");
_out.WriteLine($"Best landblock (diagonal): 0x{bestLbX:X2}{bestLbY:X2} disagrees on {bestLbDiag}/64 cells ({100.0*bestLbDiag/64:F1}%)");
// Specific landblocks of interest (per N.5b handoff representative set).
var representative = new (string name, uint lbX, uint lbY)[]
{
("Holtburg town", 0xA9, 0xB0),
("Holtburg LB 0xA9B1", 0xA9, 0xB1),
("Foundry-area", 0x80, 0x80),
("Cragstone", 0xCB, 0x99),
("Direlands sample", 0xC0, 0x40),
("MapOrigin 0x0000", 0x00, 0x00),
("MapCorner 0xFEFE", 0xFE, 0xFE),
("Mid-map 0x7F7F", 0x7F, 0x7F),
("Subway dungeon LB 0x0185 outdoor part", 0x01, 0x85),
};
_out.WriteLine("");
_out.WriteLine("Representative landblocks (diagonal-actually-painted disagreement):");
foreach (var (name, lbX, lbY) in representative)
{
int dis = 0;
for (uint cx = 0; cx < 8; cx++)
for (uint cy = 0; cy < 8; cy++)
{
bool retailEnum = TerrainBlending.CalculateSplitDirection(lbX, cx, lbY, cy) == CellSplitDirection.SWtoNE;
bool wbEnum = WbTerrainUtils.CalculateSplitDirection(lbX, cx, lbY, cy) == WbCellSplitDirection.SWtoNE;
bool retailPaintsBLtoTR = retailEnum;
bool wbPaintsBLtoTR = !wbEnum;
if (retailPaintsBLtoTR != wbPaintsBLtoTR) dis++;
}
_out.WriteLine($" 0x{lbX:X2}{lbY:X2} {dis,2}/64 cells disagree ({100.0*dis/64:F1}%) {name}");
}
// Soft-floor on the DIAGONAL comparison: if diagPct is near 0% the
// formulas are equivalent post-inversion (Path A would just work
// visually; the only "bug" is enum naming). If diagPct is well
// above 0%, Path A truly breaks terrain.
// Soft-ceiling: an inversion of inversion shouldn't push past ~70%.
Assert.True(diagPct >= 0 && diagPct <= 100,
$"Sanity: diagonal disagreement out of range (rate={diagPct:F2}%)");
}
}

View file

@ -0,0 +1,189 @@
using System.Collections.Generic;
using System.IO;
using AcDream.Core.Physics;
using AcDream.Core.Terrain;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
using Env = System.Environment;
namespace AcDream.Core.Tests.Terrain;
/// <summary>
/// Phase N.5b Z-conformance sentinel: proves that the visual terrain mesh
/// produced by <see cref="LandblockMesh.Build"/> agrees with the physics-side
/// <see cref="TerrainSurface.SampleZFromHeightmap"/> at arbitrary (X, Y)
/// within 1 mm. This is the exact bug class issue #51 names — if a future
/// refactor silently changes formula or vertex layout in either path,
/// this test fires before the player floats above (or sinks below) the
/// visible ground.
///
/// The test is dat-data-dependent. If <c>ACDREAM_DAT_DIR</c> isn't set or
/// the directory doesn't exist, the test logs a SKIP and passes — keeps CI
/// (no dat data) green while still firing locally on every developer run.
/// </summary>
public class TerrainModernConformanceTests
{
private readonly ITestOutputHelper _out;
public TerrainModernConformanceTests(ITestOutputHelper output) => _out = output;
private static readonly (string name, uint lbX, uint lbY)[] RepresentativeLandblocks =
{
("Holtburg flat 0xA9B0", 0xA9, 0xB0),
("Holtburg sloped 0xA9B1", 0xA9, 0xB1),
("Foundry-area 0x8080", 0x80, 0x80),
("Cragstone 0xCB99", 0xCB, 0x99),
("Direlands sample 0xC040", 0xC0, 0x40),
("MapOrigin 0x0000", 0x00, 0x00),
("Mid-map 0x7F7F", 0x7F, 0x7F),
("MapCorner 0xFEFE", 0xFE, 0xFE),
("Subway outdoor 0x0185", 0x01, 0x85),
("North continent 0x4D96", 0x4D, 0x96), // worst-case landblock from SplitFormulaDivergenceTest
};
[Fact]
public void VisualMeshZ_AgreesWith_PhysicsZ_WithinOneMillimeter()
{
var datDir = Env.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(Env.GetFolderPath(Env.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir))
{
_out.WriteLine($"SKIP: dat directory not found at {datDir}");
return;
}
using var dats = new DatCollection(datDir, DatAccessType.Read);
var region = dats.Get<Region>(0x13000000u);
Assert.NotNull(region);
var heightTable = region.LandDefs.LandHeightTable;
Assert.NotNull(heightTable);
Assert.True(heightTable.Length >= 256, "heightTable must have at least 256 entries");
// Empty blending context — the conformance test only cares about
// vertex Z values, never the surface info / atlas layers. An empty
// dictionary + empty arrays are sufficient for BuildSurface to
// resolve every cell to a "base only" surface (the Z values come
// from the heightmap, not from the surface info).
var ctx = new TerrainBlendingContext(
TerrainTypeToLayer: new Dictionary<uint, byte>(),
RoadLayer: SurfaceInfo.None,
CornerAlphaLayers: Array.Empty<byte>(),
SideAlphaLayers: Array.Empty<byte>(),
RoadAlphaLayers: Array.Empty<byte>(),
CornerAlphaTCodes: Array.Empty<uint>(),
SideAlphaTCodes: Array.Empty<uint>(),
RoadAlphaRCodes: Array.Empty<uint>());
long totalSamples = 0;
long totalLandblocksTested = 0;
double maxDelta = 0;
(string name, uint lbX, uint lbY, float lx, float ly, float meshZ, float physicsZ) worstCase = default;
// Fixed seed for reproducible sample distribution. If a future change
// makes the test fire, the same (lx, ly) sequence reproduces the
// exact failing point on a follow-up run.
var rng = new Random(42);
foreach (var (name, lbX, lbY) in RepresentativeLandblocks)
{
uint landblockId = (lbX << 24) | (lbY << 16) | 0xFFFFu;
var landblock = dats.Get<LandBlock>(landblockId);
if (landblock is null)
{
_out.WriteLine($" skipped {name}: dat not found (probably water-only)");
continue;
}
totalLandblocksTested++;
var surfaceCache = new Dictionary<uint, SurfaceInfo>();
var meshData = LandblockMesh.Build(landblock, lbX, lbY, heightTable, ctx, surfaceCache);
// Sample 100 (localX, localY) points uniformly in [0, 191.975].
// The physics path clamps fx = localX/24 to (CellsPerSide - 0.001f)
// = 7.999, which corresponds to localX <= 7.999 * 24 = 191.976.
// Sampling beyond that boundary makes physics compute Z at the
// clamped position while the mesh sampler uses the actual
// position — a difference of up to 23 mm at the upper edge,
// which on a steep slope would falsely trip the 1 mm sentinel.
// Stay strictly below the clamp boundary so both oracles
// compute Z at the same (cellX, tx).
for (int s = 0; s < 100; s++)
{
float lx = (float)rng.NextDouble() * 191.975f;
float ly = (float)rng.NextDouble() * 191.975f;
float meshZ = SampleMeshZ(meshData, lx, ly);
float physicsZ = TerrainSurface.SampleZFromHeightmap(
landblock.Height, heightTable, lbX, lbY, lx, ly);
double delta = Math.Abs(meshZ - physicsZ);
if (delta > maxDelta)
{
maxDelta = delta;
worstCase = (name, lbX, lbY, lx, ly, meshZ, physicsZ);
}
totalSamples++;
Assert.True(delta < 0.001,
$"Mesh Z disagrees with physics Z at lb=0x{lbX:X2}{lbY:X2} ({name}) " +
$"local=({lx:F2},{ly:F2}): meshZ={meshZ:F4} physicsZ={physicsZ:F4} delta={delta:F4}m");
}
}
_out.WriteLine($"=== Phase N.5b conformance sweep ===");
_out.WriteLine($"Landblocks tested: {totalLandblocksTested}/{RepresentativeLandblocks.Length}");
_out.WriteLine($"Total samples: {totalSamples}");
_out.WriteLine($"Max |delta|: {maxDelta * 1000:F4} mm (tolerance: 1.0 mm)");
if (totalSamples > 0)
_out.WriteLine($"Worst case: {worstCase.name} local=({worstCase.lx:F2},{worstCase.ly:F2}) " +
$"meshZ={worstCase.meshZ:F4} physicsZ={worstCase.physicsZ:F4}");
Assert.True(totalLandblocksTested >= 5,
$"Expected at least 5 representative landblocks loadable; got {totalLandblocksTested}.");
}
/// <summary>
/// Sample the mesh's triangle-interpolated Z at (localX, localY). Walks
/// the mesh's triangles (3 indices each), tests point-in-triangle in 2D,
/// and barycentric-interpolates Z from the matching triangle's three Zs.
///
/// The mesh has 128 triangles per landblock (64 cells × 2). Every (lx, ly)
/// in [0, 192) lies in exactly one triangle (or on a shared edge — the
/// epsilon makes either side acceptable since they agree at the seam).
/// </summary>
private static float SampleMeshZ(LandblockMeshData mesh, float lx, float ly)
{
for (int triBase = 0; triBase < mesh.Indices.Length; triBase += 3)
{
var v0 = mesh.Vertices[mesh.Indices[triBase + 0]];
var v1 = mesh.Vertices[mesh.Indices[triBase + 1]];
var v2 = mesh.Vertices[mesh.Indices[triBase + 2]];
// Barycentric coords for (lx, ly) wrt triangle v0/v1/v2 in 2D.
float denom = (v1.Position.Y - v2.Position.Y) * (v0.Position.X - v2.Position.X)
+ (v2.Position.X - v1.Position.X) * (v0.Position.Y - v2.Position.Y);
if (Math.Abs(denom) < 1e-9f) continue;
float a = ((v1.Position.Y - v2.Position.Y) * (lx - v2.Position.X)
+ (v2.Position.X - v1.Position.X) * (ly - v2.Position.Y)) / denom;
float b = ((v2.Position.Y - v0.Position.Y) * (lx - v2.Position.X)
+ (v0.Position.X - v2.Position.X) * (ly - v2.Position.Y)) / denom;
float c = 1f - a - b;
// Inside test with epsilon for boundary stability — points that
// land exactly on a shared edge between two triangles still
// resolve, picking whichever the loop hits first (Z agrees on
// the seam either way).
const float eps = 1e-4f;
if (a >= -eps && b >= -eps && c >= -eps)
return a * v0.Position.Z + b * v1.Position.Z + c * v2.Position.Z;
}
// Should not happen for valid mesh + in-bounds (lx, ly).
throw new InvalidOperationException(
$"No triangle found containing local=({lx:F2},{ly:F2}); mesh has {mesh.Indices.Length / 3} triangles.");
}
}

View file

@ -0,0 +1,88 @@
using AcDream.Core.Terrain;
using Xunit;
namespace AcDream.Core.Tests.Terrain;
public class TerrainSlotAllocatorTests
{
[Fact]
public void Allocate_FromFreshAllocator_ReturnsZero()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
Assert.Equal(0, alloc.Allocate(out _));
}
[Fact]
public void Allocate_TwoTimes_ReturnsZeroThenOne()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
Assert.Equal(0, alloc.Allocate(out _));
Assert.Equal(1, alloc.Allocate(out _));
}
[Fact]
public void FreeThenAllocate_ReusesFreedSlot()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
var s0 = alloc.Allocate(out _);
var s1 = alloc.Allocate(out _);
alloc.Free(s0);
Assert.Equal(s0, alloc.Allocate(out _));
}
[Fact]
public void FreeOrderedFreshAllocs_ReturnsInFifoOrder()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
var s0 = alloc.Allocate(out _);
var s1 = alloc.Allocate(out _);
var s2 = alloc.Allocate(out _);
alloc.Free(s0);
alloc.Free(s2);
Assert.Equal(s0, alloc.Allocate(out _));
Assert.Equal(s2, alloc.Allocate(out _));
}
[Fact]
public void Allocate_BeyondInitialCapacity_SignalsNeedsGrow()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 2);
alloc.Allocate(out var grow0);
alloc.Allocate(out var grow1);
alloc.Allocate(out var grow2);
Assert.False(grow0);
Assert.False(grow1);
Assert.True(grow2);
}
[Fact]
public void GrowTo_DoublesCapacityCorrectly()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 4);
alloc.GrowTo(8);
Assert.Equal(8, alloc.Capacity);
alloc.GrowTo(64);
Assert.Equal(64, alloc.Capacity);
}
[Fact]
public void LoadedCount_TracksAllocAndFree()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
Assert.Equal(0, alloc.LoadedCount);
var s0 = alloc.Allocate(out _);
var s1 = alloc.Allocate(out _);
Assert.Equal(2, alloc.LoadedCount);
alloc.Free(s0);
Assert.Equal(1, alloc.LoadedCount);
}
[Fact]
public void Free_TwiceForSameSlot_Throws()
{
var alloc = new TerrainSlotAllocator(initialCapacity: 8);
var s0 = alloc.Allocate(out _);
alloc.Free(s0);
Assert.Throws<InvalidOperationException>(() => alloc.Free(s0));
}
}

View file

@ -116,4 +116,50 @@ public class LandblockLoaderTests
var entities = LandblockLoader.BuildEntitiesFromInfo(new LandBlockInfo());
Assert.Empty(entities);
}
[Fact]
public void BuildEntitiesFromInfo_WithLandblockId_NamespacesIdsForGlobalUniqueness()
{
// Regression: cross-LB stab Id collision was the cause of visual
// glitches in Tier 1 cache (commit <THIS_COMMIT>) — buildings rendered
// up in the air with wrong textures because cache was keyed by
// entity.Id and stab Ids restarted at 1 per landblock.
var info = new LandBlockInfo
{
Objects =
{
new Stab { Id = 0x01000001u, Frame = new Frame() },
new Stab { Id = 0x01000002u, Frame = new Frame() },
},
};
var entitiesLbA = LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B40000u);
var entitiesLbB = LandblockLoader.BuildEntitiesFromInfo(info, landblockId: 0xA9B50000u);
// No two entities across LB A and LB B share the same Id.
var idsA = entitiesLbA.Select(e => e.Id).ToArray();
var idsB = entitiesLbB.Select(e => e.Id).ToArray();
Assert.Empty(idsA.Intersect(idsB));
// The namespace top byte is 0xC0 for stabs (distinct from 0x80 scenery,
// 0x40 interior, low-range live entities).
Assert.All(idsA, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu));
Assert.All(idsB, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu));
}
[Fact]
public void BuildEntitiesFromInfo_LegacyZeroLandblockId_StartsAtOne()
{
// Backward compat: existing callers (tests pre-fix) call without a
// landblockId and get the legacy "starts at 1" behavior.
var info = new LandBlockInfo
{
Objects = { new Stab { Id = 0x01000001u, Frame = new Frame() } },
};
var entities = LandblockLoader.BuildEntitiesFromInfo(info);
Assert.Single(entities);
Assert.Equal(1u, entities[0].Id);
}
}

View file

@ -0,0 +1,47 @@
using System.Numerics;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.World;
public class WorldEntityAabbTests
{
[Fact]
public void Aabb_DefaultRadius_PositionPlusMinus5()
{
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0,
Position = new Vector3(10, 20, 30),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
entity.RefreshAabb();
Assert.Equal(new Vector3(5, 15, 25), entity.AabbMin);
Assert.Equal(new Vector3(15, 25, 35), entity.AabbMax);
}
[Fact]
public void Aabb_DirtyFlag_SetByMutator_ClearedByRefresh()
{
var entity = new WorldEntity
{
Id = 1,
SourceGfxObjOrSetupId = 0,
Position = new Vector3(10, 20, 30),
Rotation = System.Numerics.Quaternion.Identity,
MeshRefs = System.Array.Empty<MeshRef>(),
};
entity.RefreshAabb();
Assert.False(entity.AabbDirty);
entity.SetPosition(new Vector3(100, 200, 300));
Assert.True(entity.AabbDirty);
entity.RefreshAabb();
Assert.False(entity.AabbDirty);
Assert.Equal(new Vector3(95, 195, 295), entity.AabbMin);
}
}

View file

@ -0,0 +1,181 @@
using AcDream.UI.Abstractions.Settings;
using Xunit;
namespace AcDream.UI.Abstractions.Tests.Panels.Settings;
/// <summary>
/// A.5 T22.5: <see cref="QualitySettings"/> preset table + env-var override
/// coverage. Env-var tests clear their variables in <c>finally</c> blocks so
/// parallel runners cannot bleed state between tests.
/// </summary>
public class QualityPresetTests
{
[Theory]
[InlineData(QualityPreset.Low, 2, 5, 0)]
[InlineData(QualityPreset.Medium, 3, 8, 2)]
[InlineData(QualityPreset.High, 4, 12, 4)]
[InlineData(QualityPreset.Ultra, 5, 15, 4)]
public void From_Preset_ProducesExpectedRadiiAndMsaa(
QualityPreset preset, int n1, int n2, int msaa)
{
var s = QualitySettings.From(preset);
Assert.Equal(n1, s.NearRadius);
Assert.Equal(n2, s.FarRadius);
Assert.Equal(msaa, s.MsaaSamples);
}
[Theory]
[InlineData(QualityPreset.Low, 4, false)]
[InlineData(QualityPreset.Medium, 8, false)]
[InlineData(QualityPreset.High, 16, true)]
[InlineData(QualityPreset.Ultra, 16, true)]
public void From_Preset_ProducesExpectedAnisoAndA2C(
QualityPreset preset, int aniso, bool a2c)
{
var s = QualitySettings.From(preset);
Assert.Equal(aniso, s.AnisotropicLevel);
Assert.Equal(a2c, s.AlphaToCoverage);
}
[Theory]
[InlineData(QualityPreset.Low, 2)]
[InlineData(QualityPreset.Medium, 3)]
[InlineData(QualityPreset.High, 4)]
[InlineData(QualityPreset.Ultra, 6)]
public void From_Preset_ProducesExpectedMaxCompletions(
QualityPreset preset, int expected)
{
var s = QualitySettings.From(preset);
Assert.Equal(expected, s.MaxCompletionsPerFrame);
}
[Fact]
public void EnvVar_NearRadius_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", "2");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = NearRadius=4 normally
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(2, resolved.NearRadius);
Assert.Equal(12, resolved.FarRadius); // FarRadius unaffected
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null); }
}
[Fact]
public void EnvVar_FarRadius_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", "20");
try
{
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(4, resolved.NearRadius); // NearRadius unaffected
Assert.Equal(20, resolved.FarRadius);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_BooleanParsing()
{
// Ensure "0" and "false" disable; other values enable.
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "0");
try
{
var s = QualitySettings.From(QualityPreset.High); // High has A2C=true
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.False(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_FalseString_Disables()
{
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "false");
try
{
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.False(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_AlphaToCoverage_NonZeroEnables()
{
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", "1");
try
{
var s = QualitySettings.From(QualityPreset.Low); // Low has A2C=false
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.True(resolved.AlphaToCoverage);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null); }
}
[Fact]
public void EnvVar_Unset_LeavesPresetDefault()
{
// Ensure no env vars are set for this test's fields.
System.Environment.SetEnvironmentVariable("ACDREAM_NEAR_RADIUS", null);
System.Environment.SetEnvironmentVariable("ACDREAM_FAR_RADIUS", null);
System.Environment.SetEnvironmentVariable("ACDREAM_A2C", null);
var s = QualitySettings.From(QualityPreset.High);
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(s, resolved);
}
[Fact]
public void From_UndefinedPreset_FallsBackToHigh()
{
var s = QualitySettings.From((QualityPreset)99);
Assert.Equal(4, s.NearRadius); // High default
Assert.Equal(12, s.FarRadius);
Assert.Equal(4, s.MsaaSamples);
Assert.True(s.AlphaToCoverage);
}
[Fact]
public void EnvVar_MaxCompletionsPerFrame_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", "8");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 4
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(8, resolved.MaxCompletionsPerFrame);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MAX_COMPLETIONS_PER_FRAME", null); }
}
[Fact]
public void EnvVar_MsaaSamples_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", "8");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 4
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(8, resolved.MsaaSamples);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_MSAA_SAMPLES", null); }
}
[Fact]
public void EnvVar_Anisotropic_OverridesPreset()
{
System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", "4");
try
{
var s = QualitySettings.From(QualityPreset.High); // High = 16
var resolved = QualitySettings.WithEnvOverrides(s);
Assert.Equal(4, resolved.AnisotropicLevel);
}
finally { System.Environment.SetEnvironmentVariable("ACDREAM_ANISOTROPIC", null); }
}
}

View file

@ -44,7 +44,8 @@ public sealed class SettingsStoreTests : System.IDisposable
VSync: false,
FieldOfView: 100f,
Gamma: 1.4f,
ShowFps: true);
ShowFps: true,
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra);
store.SaveDisplay(original);
var loaded = store.LoadDisplay();