# 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 15,288-line `GameWindow.cs`) to the target (thin `GameWindow`, small focused collaborators). **Companion to:** [`acdream-architecture.md`](acdream-architecture.md) (the layered architecture) and [`worldbuilder-inventory.md`](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 15,288 lines ``` `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 in `GpuWorldState`, 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//` (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) │ ├── 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 │ ├── 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 │ ├── 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` / `IInputContext` lifecycle (constructor + `OnLoad` + `Run` + `OnClosing`). - `RuntimeOptions` reference (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 into `LiveSessionController`. - `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id translation → moved into `LiveEntityRuntime`. - `WorldPicker` + selection-driven `SendUse` / `SendPickUp` orchestration → moved into `SelectionInteractionController`; Core `SelectionState` remains 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. 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` (this PR) **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` **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 1. **One step at a time.** A PR that ships Step 1 ships only Step 1. Bundling steps makes failures hard to isolate. 2. **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. 3. **No new features during an extraction step.** If you spot a real bug while extracting, file it in `docs/ISSUES.md` and address it in a separate commit (before or after the extraction, not folded into it). 4. **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. 5. **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.