Add a canonical documentation map, modernize the public README, and align milestone, roadmap, architecture, issue, divergence, and session guidance with the July 20 baseline. Correct the far-teleport residual to issue #153, close visually accepted indicator and terrain-tiling work, record the remaining detail-overlay and build-warning debt, and deprecate the duplicate legacy bug ledger. Co-authored-by: OpenAI Codex <codex@openai.com>
25 KiB
acdream — code structure & extraction sequence
Status: Living document. Created 2026-05-16 as the companion to the
"Code Structure Rules" section in CLAUDE.md.
Purpose: Describe the desired structural state of the App layer,
explain the rules we've adopted, and lay out the safe extraction
sequence from today's reality (one 14,557-line GameWindow.cs at the
2026-07-20 audit) to the
target (thin GameWindow, small focused collaborators).
Companion to: acdream-architecture.md
(the layered architecture) and
worldbuilder-inventory.md (what we take
from WB vs port ourselves).
1. The structural problem we're solving
The layered architecture works: AcDream.Core is GL-free, the network
layer is wire-compatible, the UI has a stable contract, plugins load.
The structural debt is concentrated in one file:
src/AcDream.App/Rendering/GameWindow.cs 14,557 lines (2026-07-20)
GameWindow is the single object that:
- Owns the GL context, the window, input, and shaders.
- Reads ~40 different environment variables across its lifetime.
- Hosts the live network session (
WorldSession) and the offline pre-login state. - Owns parallel dictionaries for entity lookup (
_entitiesByServerGuid, the per-landblock entity lists inGpuWorldState, plus the player controller's own state). - Drives selection / interaction (
WorldPicker,SendUse,SendPickUp). - Drives per-frame render orchestration (sky → terrain → opaque mesh → transparent mesh → particles → debug lines → UI).
- Wires up every plugin hook sink, every diagnostic, every panel.
Almost every M1 / M2 bug touches this file. Every new feature adds a field plus a method plus a wiring call. It is not getting better on its own.
The fix is not "rewrite GameWindow in one pass" — that's a
high-risk change that would block M2. The fix is to extract one
collaborator at a time, verify behavior is unchanged, ship, and
move on. This document defines that sequence.
2. Code Structure Rules — the discipline
Recap of the rules from CLAUDE.md with the rationale:
Rule 1: No new substantial feature bodies in GameWindow.cs
Why: Every line we add to GameWindow makes the eventual decomposition
harder. New features that "live in" GameWindow instead of being
extracted are the reason the file is 10k lines.
How to apply: A new feature gets its own class under
src/AcDream.App/<Subsystem>/ (or deeper in AcDream.Core if it's pure
logic). GameWindow owns a field and a wiring call, nothing more. If
you find yourself adding a 200-line method to GameWindow, stop and
extract.
Exemption: Trivial wiring that must stay in GameWindow because
it touches GL state during OnLoad is acceptable, but should still
delegate to a collaborator for the substance.
Rule 2: AcDream.Core must not depend on window / GL / backend projects
Why: Core is the GL-free, testable layer. The moment Core imports a GL or windowing namespace, we've lost the ability to test it without a graphics context, and the layer split becomes fiction.
How to apply: Phase O removed both external WorldBuilder/backend project
references. The only currently allowed seams are the GL-free helpers owned in
our tree under src/AcDream.Core/Rendering/Wb/: TerrainUtils,
TerrainEntry, RegionInfo, SceneryHelpers, and TextureHelpers.
ObjectMeshManager and every GL resource owner remain in App. If Core needs a
new capability, define a narrow Core interface and implement it in App; adding
a new project reference requires an inventory-doc update explaining why.
Rule 3: UI panels target AcDream.UI.Abstractions only
Why: This is the one rule that keeps D.2b (the future retail-look
backend) viable. Every panel that imports ImGuiNET directly is a panel
we'd have to rewrite when the backend swaps.
How to apply: A panel's using block must mention
AcDream.UI.Abstractions.* and nothing from AcDream.UI.ImGui. The
panel writes against IPanelRenderer. The ImGuiPanelRenderer
translates those calls to ImGui at runtime. Plugin-facing UI follows the
same rule.
Rule 4: Startup env vars enter through RuntimeOptions
Why: Environment variables are global mutable state. Reading them
at random call sites means (a) duplicated Environment.GetEnvironmentVariable
boilerplate, (b) no single place to see "what flags does the client
respond to?", (c) impossible to unit-test parsing.
How to apply: src/AcDream.App/RuntimeOptions.cs is the typed
options object. Program.cs builds it once from args + env and passes
it to GameWindow. New startup flags add a field to RuntimeOptions
and a parser in RuntimeOptions.FromEnvironment. They don't add
Environment.GetEnvironmentVariable reads.
Scope: RuntimeOptions is for startup-time configuration —
things that don't change once the window is up. Runtime diagnostic
toggles are Rule 5's domain.
Rule 5: Runtime diagnostic toggles live in diagnostic owner classes
Why: Diagnostic flags (ACDREAM_DUMP_MOTION, ACDREAM_PROBE_*,
etc.) need to be both env-readable at startup and runtime-toggleable
from the DebugPanel. Per-call-site env reads can't be runtime-toggled.
How to apply: Today's template is
src/AcDream.Core/Physics/PhysicsDiagnostics.cs — one static class with
typed Probe* properties read from env vars once at startup, plus
runtime setters that the DebugPanel binds. New diagnostic flags follow
this shape, not the per-call-site pattern that dominates GameWindow.cs.
Cleanup direction: The dozens of existing ACDREAM_DUMP_* reads
inside GameWindow.cs are tech debt. We do NOT bulk-migrate them as
part of this refactor — they're working, they're scattered, and
moving them carries risk without a current acceptor. We migrate them
opportunistically: when a GameWindow extraction lands and a diagnostic
moves with it, route it through the new owner's diagnostic class.
Rule 6: Tests live in the project matching the layer
Why: Test discoverability + dependency hygiene. A test for a Core class belongs next to other Core tests; a test for an App class belongs in an App test project. Co-locating tests across layers makes the dependency graph dishonest.
How to apply: One test project per source project that has tests. Today:
tests/AcDream.Core.Tests/←src/AcDream.Core/tests/AcDream.Core.Net.Tests/←src/AcDream.Core.Net/tests/AcDream.UI.Abstractions.Tests/←src/AcDream.UI.Abstractions/tests/AcDream.App.Tests/←src/AcDream.App/
tests/AcDream.App.Tests/ now exists and owns App-layer controller, streaming,
render-resource lifetime, retained-UI, and RuntimeOptions tests. New App tests
belong there; do not place GL-free Core behavior in that project merely because
App currently wires it.
3. Target structure of the App layer
The end state — not what we're shipping in one pass, but the shape we're aiming at.
src/AcDream.App/
├── Program.cs # parse args + env → RuntimeOptions, build GameWindow
├── RuntimeOptions.cs # typed startup options (Rule 4)
├── Rendering/
│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI)
│ ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset
│ ├── RetailStaticAnimatingObjectScheduler.cs # shipped: separate static-animation workset
│ ├── StaticLiveRootCommitter.cs # live static root → pose + collision boundary
│ ├── TerrainModernRenderer.cs # (already exists)
│ ├── TextureCache.cs # (already exists)
│ ├── ParticleRenderer.cs # (already exists)
│ ├── Sky/ # (already exists)
│ ├── Wb/ # WB seam + EnvCellLandblockBuild transaction
│ └── Vfx/ # (already exists)
├── Net/
│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
├── Physics/
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
│ ├── RemotePhysicsUpdater.cs # ordinary/Hidden remote narrow-tick integration
│ ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
│ ├── LiveEntityShadowPublisher.cs # authoritative exact-owner/residency collision gate
│ ├── RemoteInboundMotionDispatcher.cs # shared animated/headless UpdateMotion funnel
│ ├── RemoteTeleportController.cs # loaded/pending teleport placement ownership
│ ├── RemoteTeleportHook.cs # ordered retail teleport teardown actions
│ └── RemoteTeleportPlacement.cs # collision-seated SetPosition transition commit
├── World/
│ ├── InboundPhysicsStateController.cs # timestamps + accepted spawn snapshots
│ ├── LiveEntityRuntime.cs # shipped: logical lifetime + ServerGuid↔entity.Id translation
│ ├── RetailInboundEventDispatcher.cs # update-thread packet/frame FIFO barrier
│ ├── LiveEntityPresentationController.cs # ordered Hidden/NoDraw/effect/collision side effects
│ ├── LiveEntityTeardown.cs # failure-isolated multi-owner lifecycle drain
│ └── ParentAttachmentState.cs # parent generations + pending ParentEvent relations
├── Interaction/
│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
├── Streaming/ # LandblockStreamer + immutable LandblockBuild completion
├── Input/ # (already exists)
├── Audio/ # (already exists)
└── Plugins/ # (already exists)
What GameWindow keeps:
IWindow/GL/IInputContextlifecycle (constructor +OnLoad+Run+OnClosing).RuntimeOptionsreference (the typed startup config).- One field per collaborator (
_liveSessionController,_liveEntityRuntime,_selectionInteraction,_renderFrameOrchestrator). - The Silk.NET event-handler stubs that delegate to collaborators.
What GameWindow loses:
- The 7 startup-time env var fields → moved into
RuntimeOptions. TryStartLiveSession+ the post-login network drain → moved intoLiveSessionController._entitiesByServerGuid+ per-entity dictionaries + ServerGuid↔Id translation → moved intoLiveEntityRuntime.WorldPicker+ selection-drivenSendUse/SendPickUporchestration → moved intoSelectionInteractionController; CoreSelectionStateremains the already-shipped session owner and is injected into that controller.- Per-frame draw orchestration → moved into
RenderFrameOrchestrator.
The eventual GameEntity aggregation (target state described in
acdream-architecture.md §"GameEntity: The Unified Entity") happens
after LiveEntityRuntime is the single owner of entity state.
Until then, the parallel-dicts problem is bounded inside one class
instead of spread across GameWindow.
LiveEntityRuntime is now that single boundary. It composes
InboundPhysicsStateController for the nine-channel retail timestamp gates and
latest accepted immutable CreateObject snapshot, owns the canonical local ID
and optional runtime components, and separates logical registration from
spatial projection. It also retains raw PhysicsState separately from the final
state produced by retail's ordered side effects;
LiveEntityPresentationController projects those transitions into draw,
collision, effect, child-NoDraw, and target visibility without becoming a
second lifetime or GUID owner. ParentAttachmentState is runtime-owned and keys unresolved
relations by child and parent generation. Rendering/Vfx/EntityEffectController
owns the focused mixed F754/F755 pending FIFO, effect profiles, typed-table
resolution, and a readiness set; canonical ServerGuid-to-local-ID translation
always stays in LiveEntityRuntime. EntityScriptActivator uses the same
canonical WorldEntity.Id as rendering and physics; the disjoint static ID
allocators fail fast instead of wrapping into another landblock's namespace.
All other non-Parent packet
families still need the future general queue tracked by divergence AD-32.
The per-frame object body is no longer an animation-dictionary loop inside
GameWindow. LiveEntityAnimationScheduler snapshots canonical spatial root
records and advances the incarnation-stable object clock, PartArray, hooks,
one selected movement owner, and manager tail in retail order. Manager-less
bodies delegate their candidate/Transition/cell/shadow commit to
LiveEntityOrdinaryPhysicsUpdater; retained projectile bodies and remote
MovementManagers remain mutually exclusive movement owners. Static animation
is deliberately separate: RetailStaticAnimatingObjectScheduler owns the
CPhysics::static_animating_objects workset for DAT and live PhysicsState-
Static owners with Setup DefaultAnimation. Both schedulers are wired by
GameWindow, but neither owns GUID identity or logical resources.
The typed animation view fills its reusable snapshot and render-ID set from
LiveEntityRuntime's concrete spatial dictionary, avoiding interface-enumerator
boxing on both update and render hot paths.
RemoteInboundMotionDispatcher similarly keeps UpdateMotion protocol behavior
outside GameWindow: GameWindow resolves the canonical record/body and the
optional PartArray sink, while one dispatcher owns retail's interrupt, style,
MoveTo/type-0, sticky, and standing-long-jump order. Static root projection is
bounded by StaticLiveRootCommitter, which synchronizes changed roots to
effects and collision without rebuilding zero-omega shadows or resurrecting a
Hidden/withdrawn registration.
Synchronous network and lifecycle callbacks are bounded by
RetailInboundEventDispatcher. It owns no wire state and no identity; it only
serializes nested live-object operations until the current packet or full
object-frame tail completes. State-bearing direct dispatch is allocation-free;
only a genuinely nested operation allocates its retained queue wrapper.
LiveEntityRecord then supplies exact-incarnation
and per-channel authority versions at callback boundaries. Position, State,
Vector, and Movement remain independent, while a separate velocity version
invalidates only an older operation that would overwrite a newer velocity
installed by Position, Vector, or Movement. This prevents re-entrant App
observers from creating call-stack ordering that retail's update-thread packet
FIFO cannot produce.
Pose-dependent hook deferral is similarly incarnation-scoped rather than GUID-
or local-ID-scoped. EntityEffectPoseRegistry publishes a monotonic pose-owner
lifetime, and AnimationHookFrameQueue captures it before semantic callbacks
and rechecks it before each semantic AnimationDone and each routed hook. Static animation retains process_hooks
until its root, live parts, and children are published; withdrawal invalidates
both its prepared pose and pending hook tail.
Resolved ordinary motion commits its body/root/contact state before the
canonical full-cell setter enters LiveEntityRuntime.RebucketLiveEntity.
Because that setter may synchronously move the projection to pending or replace
the GUID, both RemotePhysicsUpdater and LiveEntityOrdinaryPhysicsUpdater
revalidate the exact incarnation before collision or manager-tail publication.
Collision residency itself is projection-owned, not updater-owned:
LiveEntityPresentationController suspends retained non-projectile shadows on
every unavailable-projection edge, restores them on hydration, and reconciles
the pending-first case at OnLiveEntityReady after collision registration.
Local projection and both authoritative remote UpdatePosition tails commit the
complete root before rebucketing, then publish collision only through an
exact-record/spatial-residency gate. Remote reflood tracks translation,
sign-invariant complete orientation, and cell changes so an in-place turn or a
same-pose EnvCell crossing cannot leave offset Setup shapes stale.
The projectile controller retains its separate body/InWorld/shadow edge owner.
Remote teleport placement is bounded in Physics/RemoteTeleportController,
not GameWindow: it retains at most one pending request per materialized
incarnation, scopes it by the live generation and accepted PositionSequence,
and asks RemoteTeleportPlacement to collision-seat the current body when the
destination projection is available. GameWindow supplies lifecycle and
shadow-sync callbacks only; canonical identity remains in LiveEntityRuntime.
Failed hydration restores the captured source and delegates an
incarnation-scoped shadow restore to LiveEntityPresentationController while
that source is unloaded, so Hidden/UnHide and teleport never become competing
restore owners. A newer placement transfers that restore into an explicit
generation-scoped active-placement state before its rebucket visibility edge
even while Hidden. All intervening Hidden/UnHide and projection edges defer to
that owner until stable success or rollback completes; only then can it restore,
re-defer the source, or hand a Hidden result back for UnHide. The
ILiveEntityRemotePlacementRuntime seam keeps the complete cell/contact
handoff available across same-body runtime-wrapper replacement; replacing the
canonical body or dropping the placement contract within one incarnation is
rejected even after an operational component clear. RemoteMotion.Body is
constructor-owned; hydration compares pending/current wrappers directly to the
record body rather than trusting wrapper-to-wrapper equality. Binding reads an
interface Body getter once and reuses that snapshot. GpuWorldState
performs remove+place as one spatial rebucket,
then commits and serially drains visibility edges; LiveEntityRuntime filters
delayed duplicates. A rollback inside an observer cannot race the outer
destination-visible notification or expose an intermediate false pulse.
LiveEntityTeardown executes those independent owner callbacks to completion
and aggregates failures afterwards, so a throwing effect/plugin sink cannot
strand teleport, movement, shadow, light, or GUID-scoped state.
4. Extraction sequence — safest first
Each step is one PR-sized refactor. Each must build clean, all tests pass, and visual verification at Holtburg looks identical to the previous step. Don't bundle two steps.
Step 1 — RuntimeOptions — SHIPPED (eda936dc)
Scope: Replace startup-time env var reads with a typed options
object built once in Program.cs.
Behavior change: None. Same env vars, same defaults, same effects.
Risk: Low. Mechanical substitution at ~10-15 call sites in
GameWindow.cs + one constructor signature change.
Test: Unit tests for RuntimeOptions.FromEnvironment parsing (the
new tests/AcDream.App.Tests/ project).
Verification: dotnet build + dotnet test green. Visual launch
verifies live mode + dat dir resolution still work.
Step 2 — LiveSessionController — SHIPPED (0b25df53)
Scope: Extract TryStartLiveSession + the WorldSession ownership +
the post-EnterWorld drain (OnLiveStateUpdated, OnLiveEntityDeleted,
etc.) into a controller class.
Behavior change: None. Same wire behavior, same handshake.
Risk: Medium. WorldSession lifecycle is load-bearing — every session-state crash would surface here. The change is a class extraction with the same event subscriptions, not a rewrite.
Test: Existing AcDream.Core.Net.Tests already cover the wire
layer. The controller itself gets a smoke test that verifies it can be
constructed without a live socket (offline mode).
Verification: Visual login + Holtburg traversal + door interaction identical to pre-extraction.
Step 3 — LiveEntityRuntime — SHIPPED 2026-07-14
Shipped scope: One LiveEntityRecord per server-object incarnation now owns
ServerGuid↔local-ID translation, accepted state, runtime components, parent
relations, logical resource activation, exact teardown, and spatial projection
state. RegisterLiveEntity, RebucketLiveEntity, and UnregisterLiveEntity
make the lifetime boundary explicit. Landblock unload/reload moves the same
WorldEntity; it cannot reconstruct from a stale CreateObject or replay setup
scripts. Equipped children use an attached projection and never enter the
top-level target/radar/status view. Canonical materialized lookup remains
available while a projection is pending; the separate visible view is the only
surface radar, picking, status, and targeting consume. Pickup/parent leave-world
clears cell membership and pauses root movement/animation without destroying the
retained owners. Top-level spawn publication is one-shot per incarnation, so
leave/re-entry restores presentation without duplicating plugin event replay.
Remaining target: the player-specific controller is still a separate aggregation. The focused effect queue is shipped; AD-32 now tracks only the future non-effect, non-Parent packet queue.
Behavior change: Spatial withdrawal and re-entry now preserve logical identity and active resources instead of replaying create-time effects.
Risk: Medium-high. Entity lookup is in every hot path. The change is structural (one owner instead of three) but the lookup semantics must be byte-identical.
Test: LiveEntityRuntimeTests cover duplicate CreateObject, generation
replacement, appearance mutation, loaded/pending rebucketing, attached
projection, pickup leave/re-entry, canonical-versus-visible lookup, resource
rollback, GUID reuse, and idempotent session teardown.
Verification: Walk Holtburg, click NPC, open door, pick up item. All four M1 demo targets must still work.
Step 4 — SelectionInteractionController
Scope: Extract WorldPicker, the Core SelectionState consumers, SendUse,
SendPickUp, and the InputAction.Select* / UseSelected /
SelectionPickUp switch cases into one controller. Depends on Step 3
(uses LiveEntityRuntime).
Behavior change: None.
Risk: Low-medium. Selection state is local to interactions; the
network outbound side is well-defined (InteractRequests.BuildUse /
BuildPickUp).
Test: Selection state machine tests in tests/AcDream.App.Tests/.
Verification: Click-to-select, double-click-to-Use, F-key pickup all still work.
Step 5 — RenderFrameOrchestrator
Scope: Extract the per-frame draw sequence (sky → terrain →
opaque mesh → translucent mesh → particles → debug → UI) into a
dedicated orchestrator that GameWindow.OnRender delegates to.
Behavior change: None. Same draw order, same GL state.
Risk: Medium. GL state management is touchy; the orchestrator must hand the GL context to the same renderers in the same order with the same per-pass state setup.
Test: Visual verification only. Render orchestration is hard to unit-test without a GL context.
Verification: Holtburg at radius 4, radius 8, radius 12 looks identical across all four quality presets.
Step 6 — GameEntity aggregation (the big one)
Scope: Consolidate WorldEntity + AnimatedEntity + the per-entity
state in LiveEntityRuntime into one GameEntity class (the target
described in acdream-architecture.md). Every entity in the world —
player, NPC, monster, door, item — becomes a single GameEntity.
Behavior change: None at the wire / visual level; substantial at the call-site level (everyone moves to the new entity API).
Risk: High. Touches every system that reads entity state.
Test: All existing tests + the new AcDream.App.Tests suite. Visual
verification at every M1 / M2 scenario.
Verification: Full M2 demo loop (equip sword, kill drudge, pick up loot, open inventory) works identically.
5. Rules of the road during the extraction
- One step at a time. A PR that ships Step 1 ships only Step 1. Bundling steps makes failures hard to isolate.
- Behavior preservation is the acceptance criterion. Every step must build clean, all tests pass, and visual verification at the appropriate M1 / M2 scenarios must succeed. We're moving code, not changing it.
- No new features during an extraction step. If you spot a real
bug while extracting, file it in
docs/ISSUES.mdand address it in a separate commit (before or after the extraction, not folded into it). - Diagnostic toggle migrations are opportunistic. When a method moves to a new owner, the diagnostic flag inside it can move to a diagnostic class as part of the same commit. We do not do a bulk diagnostic-cleanup pass.
- Update this document when the plan changes. If Step 3 turns out to need a different shape than described above, update §4 in the same session you discover the divergence.
6. What this document is not
- Not a full rewrite plan. The point is the opposite — small steps, verified at each boundary.
- Not blocking M2. Step 1 is small enough to ship without disrupting M2 work. Later steps interleave with M2 / M3 phases as the corresponding code paths come into focus.
- Not a substitute for the milestones / roadmap. Those drive the feature work. This drives the structural work that runs underneath.