acdream/docs/architecture/code-structure.md
Erik 20ebf217e2 docs(architecture): plan GameWindow lifecycle shell
Freeze host ordering, resource ownership, callback quiescence, partial-startup rollback, and canonical soak evidence as twelve reviewed, bisectable Slice 8 checkpoints.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 09:14:24 +02:00

44 KiB
Raw Blame History

acdream — code structure & extraction sequence

Status: Living document. Created 2026-05-16; implementation reconciliation completed 2026-07-21; Slices 17 landed by 2026-07-22. The final Slice 8 composition/shutdown cleanup is active before new M4 subsystems enter the App layer. 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,723-line GameWindow.cs at the 2026-07-21 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:

baseline cf50ee3d                         15,723 lines / 278 fields / 205 methods
after Slice 1                             14,912 lines / 278 fields / 191 methods
after Slice 2                             14,546 lines / 277 fields / 190 methods
after Slice 3                             14,310 lines / 274 fields / 190 methods
after Slice 4                             10,301 lines / 267 fields / 163 methods
after Slice 5 closeout                     8,811 lines / 247 fields / 153 methods
after Slice 6 closeout                     7,026 lines / 241 fields / 108 methods
after Slice 7 draw-frame cutover           4,666 lines / 196 fields /  70 methods

GameWindow is the single object that:

  • Owns the GL context, the window, input, and shaders.
  • Reads ~40 different environment variables across its lifetime.
  • Composes the shipped LiveSessionController/host/router boundary; it no longer owns a parallel session, command bus, subscription lifetime, connect, character-entry, reconnect, or graceful-close body.
  • Owns the adapters that hydrate canonical LiveEntityRuntime records into animation, collision, rendering, and DAT-backed appearance resources.
  • Composes the shipped WorldSelectionQuery and SelectionInteractionController; it no longer owns world-picking, selection intent, Use/PickUp, or auto-walk deferral algorithms.
  • Drives per-frame render orchestration (sky → terrain → opaque mesh → transparent mesh → particles → debug lines → UI).
  • Builds and applies streamed landblock presentation (DAT decode, scenery, EnvCells, mesh publication, collision, and retirement) around the shipped StreamingController and GpuWorldState owners.
  • Wires up every plugin hook sink, every diagnostic, every panel.

The extracted controllers are valuable and tested, but line count alone proves that creating collaborators has not yet made the window thin. A collaborator is a completed extraction only when it owns the state and behavior body; wrapping a GameWindow method in a delegate preserves ordering but remains a partial extraction.

The fix is not "rewrite GameWindow in one pass" — that is a high-risk change. 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.


Render-frame ownership versus recursive reachability

The typed RenderFrameOrchestrator graph owns frame sequencing and borrows long-lived runtime/presentation owners. The enforceable ownership invariant is that the orchestrator and its immediate phase owners retain neither GameWindow, an anonymous callback bag, nor direct window delegates. It is not an invariant that recursively walking every borrowed canonical owner can never reach an event subscriber or callback that was composed by GameWindow; that claim would be false and would confuse object-graph reachability with frame ownership.

Known recursive callback-bearing paths include (this is evidence and design documentation, not an exhaustive allowlist):

  • RetainedGameplayUiFrame -> RetailUiRuntime -> RetailUiRuntimeBindings for retained gameplay UI state and commands.
  • PrivatePresentationRenderer -> PaperdollFramePresenter -> RetailPaperdollFrameView/PaperdollInventoryVisibility -> UiElement tree for private paperdoll visibility and texture publication. This is a second, independent retained-UI entry because UiElement.Parent links back to the shared root and sibling controls.
  • DevToolsFramePresenter -> DevToolsPanelSet -> panel/ViewModel bindings for the optional developer UI.
  • WorldRenderFrameBuilder -> RuntimeWorldFrameSettingsPreview -> SettingsVM for the live settings draft preview applied before world drawing.
  • LocalPlayerPortalViewport -> LocalPlayerTeleportController -> GameplayInputFrameController -> InputDispatcher.Fired -> GameWindow for the canonical portal/input lifetime and the host's input-action subscription.

These are presentation state/command seams, not alternative owners of the window or frame transaction. The screenshot path is deliberately not on this list: viewport width/height now travel in RenderFrameInput, so FrameScreenshotController no longer stores a window-size callback. New immediate phase owners must still satisfy the direct ownership invariant; recursive shared-owner paths are reviewed against their canonical subsystem lifetime rather than an impossible global no-callback assertion.


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      # GPU-flight boundary + typed world/private/UI render phases
│   ├── LiveEntityAnimationScheduler.cs # shipped: ordinary live-object update workset
│   ├── LiveEntityAnimationPresenter.cs # final part-pose/mesh/effect composition after scheduler output
│   ├── 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 complete WorldSession connect/enter/logout/reconnect lifecycle
│   ├── LiveSessionLifecycleHost.cs     # narrow App composition + exact borrowed-session attachment
│   └── LiveSessionEventRouter.cs       # typed subscriptions → focused domain handlers
├── Physics/
│   ├── ProjectileController.cs          # canonical live-record projectile orchestration
│   ├── RemotePhysicsUpdater.cs          # ordinary/Hidden remote narrow-tick integration
│   ├── LiveEntityOrdinaryPhysicsUpdater.cs # manager-less canonical body Transition path
│   ├── LiveEntityNetworkUpdateController.cs # Position/Vector/State/Motion App integration
│   ├── LiveEntityInboundAuthorityGate.cs # exact per-channel timestamp/version admission
│   ├── LiveEntityMotionRuntimeController.cs # shared host/setup/MoveTo/Sticky runtime policy
│   ├── DeferredLiveEntityMotionRuntimeBindings.cs # fail-fast construction-order bridge
│   ├── 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
│   ├── LiveEntityHydrationController.cs # spawn/appearance/parent/delete resource integration
│   ├── RetailInboundEventDispatcher.cs  # update-thread packet/frame FIFO barrier
│   ├── RetailLiveFrameCoordinator.cs    # shipped: object/network/command/reconcile phase order
│   ├── 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/
│   ├── WorldSelectionQuery.cs           # read-only picking/classification/description queries
│   └── SelectionInteractionController.cs # owns selection intents, Use/PickUp, auto-walk deferral
├── Streaming/
│   ├── LandblockPresentationPipeline.cs # DAT build/apply/retire transaction and resource publication
│   └── ...                              # shipped streamer/world-state/reveal owners
├── 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).
  • GL resource construction and top-level collaborator composition. Construction is allowed here; feature algorithms and mutable subsystem state are not.
  • One field per top-level collaborator (_liveSessionController, _liveEntityRuntime, _selectionInteraction, _streamingPresentation, _liveObjectFrame, _renderFrameOrchestrator).
  • The Silk.NET event-handler stubs that delegate to collaborators.

What GameWindow loses:

  • Live connect/enter/logout and event-subscription bodies → completed inside LiveSessionController / LiveSessionEventRouter.
  • Live-object hydration adapters and final animated-part presentation → focused world/render owners over canonical LiveEntityRuntime records.
  • WorldPicker, target queries, and selection-driven Use/PickUp/auto-walk → WorldSelectionQuery + SelectionInteractionController. Core SelectionState remains the injected session owner.
  • Landblock DAT build/apply/retirement presentation → LandblockPresentationPipeline; StreamingController remains the residency scheduler and GpuWorldState remains the spatial registry.
  • Per-frame draw orchestration and its frame-local scratch state → 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 scheduler is extracted, but final animated-part presentation is not. 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. The remaining GameWindow.TickAnimations loop still composes part transforms, drawable MeshRefs, and effect poses from the scheduler output; moving that body into LiveEntityAnimationPresenter is an explicit pending slice. 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. Reconciled ownership ledger — 2026-07-21

This section replaces the original six-step sketch. That sketch correctly identified the target but overstated some extractions and omitted large bodies added during M2/M3. The current audit is based on implementation ownership, not whether a class with the planned name exists.

4.1 What counts as extracted

An extraction is complete only when:

  1. the collaborator owns the mutable state and behavior body;
  2. GameWindow constructs it and delegates through a narrow method;
  3. the collaborator does not call back into arbitrary GameWindow methods;
  4. focused tests exercise the collaborator without constructing a Silk window;
  5. the accepted connected/visual behavior is unchanged.

A class that stores delegates back to substantial GameWindow methods is a useful ordering seam, but its ownership status is partial.

4.2 Current implementation truth

Area Status Current truth
Startup options Complete RuntimeOptions owns startup configuration (eda936dc). Remaining direct environment reads are legacy runtime diagnostics, not startup configuration.
Network session Complete LiveSessionController owns resolve/create/bind/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal; LiveSessionLifecycleHost is the narrow App composition boundary; LiveSessionEventRouter and LiveSessionCommandRouter own exact inbound/outbound lifetimes. GameWindow retains one controller field and focused domain sink factories only (d9ccf8a6, 6a5d9e2e).
Live identity/lifetime Complete App integration LiveEntityRuntime owns incarnation identity, accepted snapshots/timestamps, runtime components, and logical/spatial lifetime. LiveEntityHydrationController, LiveEntityRuntimeTeardownController, and LiveEntityNetworkUpdateController own DAT-backed hydration, retryable cleanup, and accepted Position/Vector/State/Movement presentation without a second GUID owner (aa90c646).
Inbound/object-frame order Complete App orchestration UpdateFrameOrchestrator owns the complete typed host phase graph; RetailInboundEventDispatcher, RetailLiveFrameCoordinator, LiveObjectFrameController, LiveSpatialPresentationReconciler, streaming/input/teleport/player-mode/camera owners preserve the accepted order. GameWindow.OnUpdate is one profiler-scoped handoff (e91f3102).
World reveal Complete WorldRevealCoordinator owns login/portal readiness and reveal lifetime (a4ef5788). The accepted deterministic lifecycle trace did not change after extraction.
Retained gameplay UI Mostly complete feature ownership RetailUiRuntime and focused panel/controllers own layout and behavior. GameWindow.OnLoad still performs substantial service composition, which is allowed until the final composition cleanup.
Selection/interaction Complete WorldSelectionQuery owns read-only picking/classification/range queries; SelectionInteractionController owns selection intent, Use/PickUp transport, exact-incarnation queues, and auto-walk deferral; ItemInteractionController owns ItemHolder policy plus the shared retail inventory-request transaction. GameWindow retains construction and narrow lifecycle forwarding only.
Landblock presentation Complete LandblockBuildFactory owns the captured-origin DAT transaction; concrete render/physics/DAT-static publishers and LandblockPresentationPipeline own publication and exact retryable retirement. StreamingOriginRecenterCoordinator serializes old-window retirement with teleport/session origin lifetimes. GameWindow retains construction and one pipeline field only (c79d0a49, closeout 4a205a3e).
Render-frame orchestration Not extracted OnRender plus its portal/PView/alpha/particle helpers still own the draw graph and frame-local scratch state. There is no RenderFrameOrchestrator yet.
Unified GameEntity Deferred target LiveEntityRuntime already supplies the critical canonical owner. Full type aggregation is a separate high-risk migration and is not required to make GameWindow thin.

4.3 Revised extraction sequence

Every numbered slice is behavior-preserving and independently committed. A slice does not include feature work or opportunistic gameplay fixes.

Gate 0 — deterministic baseline — COMPLETE

The Release suite, connected R6 soak, world-lifecycle screenshots/checkpoints, graceful reconnect, local locomotion/collision/projectile/teleport gate, and two-client portal observer gate form the pre-refactor baseline. Each later slice runs the subset capable of detecting its risk; render/session slices run the complete connected lifecycle gate.

Slice 1 — finish selection/interaction ownership — COMPLETE 2026-07-21

Detailed execution plan: docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md.

Split the old Step 4 into three reviewable commits:

  1. WorldSelectionQuery receives camera/scene/live-object read seams and owns picking, target classification, selection bounds, closest-target lookup, names, and descriptions. It cannot mutate selection or send packets.
  2. SelectionInteractionController owns selection intents, double-click Use, Use/PickUp packet requests, range decisions, speculative facing, and the pending auto-walk action. It composes the existing item/combat controllers rather than duplicating their rules.
  3. Selection/Use/PickUp/combat-target InputAction cases delegate to the new controller; the old methods and fields are deleted from GameWindow.

Tests cover read-only query classification separately from stateful intents, including direct use, distant auto-walk completion, corpse/container opening, pickup placement, hostile-only targeting, Hidden objects, and same-GUID reuse. The cutover landed in 047a4c83, 52dbb574, e74f2ca9, fa8d5232, d2bb5af4, and review-correction commit 5acc3f01. The final review also ported the shared ACCWeenieObject::prevRequest transaction shape across inventory, toolbar, paperdoll, external-container, give, split, merge, and drop routes. Queued actions, pending placements, and responses are bound to exact object incarnations/tokens; optimistic projection, rollback projection, and authoritative response notices are distinct.

Measured against cf50ee3d, GameWindow fell from 15,723 to 14,912 lines and from 205 to 191 methods; field count stayed at 278 because the slice exchanged legacy state for composed owners. Three independent retail, architecture, and adversarial review loops finished clean. Release build passed; 6,558 tests passed and five fixture/conformance tests skipped intentionally.

Slice 2 — finish live animation presentation — COMPLETE 2026-07-21

Move TickAnimations, final rigid/visual part composition, effect-pose publication, and motion-done binding into LiveEntityAnimationPresenter. LiveEntityAnimationScheduler remains the time/movement owner; the presenter only consumes one scheduler result and publishes the final draw/effect pose. Move the surviving motion diagnostics with this owner instead of adding more environment reads to GameWindow.

Tests pin legacy and sequencer paths, Hidden/static eligibility, ObjScale versus rigid effect poses, part availability, same-frame hook pose publication, and incarnation replacement. Run the R6 object-frame and projectile/effect suites plus the connected locomotion/projectile gate.

Result: LiveEntityAnimationPresenter owns final visual/rigid part composition, effect-pose publication, MotionDone binding, and typed diagnostics. Schedules carry exact record/entity/component/epoch/projection/presentation identity and own their authored frame prefix until the next scheduler tick. Appearance and static-owner rebinding invalidate stale work. Named-retail CPartArray::UpdateParts short-frame retention now preserves both visual and rigid trailing poses. GameWindow lost 366 lines, two methods, and one net field. Three independent retail, architecture, and adversarial review loops finished clean; focused App/Core tests and the complete App suite pass.

Slice 3 — complete live-session ownership — COMPLETE 2026-07-21

Detailed execution plan: docs/plans/2026-07-21-gamewindow-slice-3-live-session.md.

LiveSessionController now owns the complete resolve/create/bind/Connect/ CharacterList/select/EnterWorld/Tick/stop/reconnect/dispose transaction. Exact generation and operation-depth gates prevent synchronous lifecycle callbacks from resurrecting a superseded session. Staged teardown makes commands inert, detaches events, gracefully disposes the exact WorldSession, detaches the host, and runs the full reset manifest to convergence before reuse.

LiveSessionLifecycleHost is the narrow App adapter; the router and command owners are transactional and session-bound. GameWindow lost _liveSession, the router/command fields, and the old startup/reset/wiring/disposal methods. Both UI stacks resolve controller.Commands; direct feature sends borrow CurrentSession at call time. Forty-seven controller cases plus host, transport-allocation, and structural gates cover failure/reentrancy/reuse. Three independent retail, architecture, and adversarial reviews finished clean. The Release suite passes 6,714 tests with five intentional skips. The 306-second connected lifecycle gate passed capped login, five travel/revisit checkpoints, exact graceful close, and uncapped fresh-process reconnect; both client processes exited normally.

GameWindow fell another 236 lines and three fields to 14,310 lines / 274 fields / 190 methods. Slice 4 is next.

Slice 4 — extract live-entity App integration — COMPLETE 2026-07-21

Detailed execution ledger: docs/plans/2026-07-21-gamewindow-slice-4-live-entity-integration.md.

This is two owners, not one replacement god object:

  • LiveEntityHydrationController owns CreateObject/ObjDesc/Parent/Pickup/Delete integration, DAT-backed appearance hydration, resource registration, and exact teardown callbacks over LiveEntityRuntime.
  • LiveEntityNetworkUpdateController owns App routing for accepted Position/Vector/State/Movement updates into the existing remote physics, motion, projectile, presentation, and teleport controllers.

Neither may own a GUID dictionary. Both resolve the current incarnation only through LiveEntityRuntime and must preserve the inbound FIFO/authority-token rules. Run the complete live-entity stress suite, R6 connected route, inventory equip/parenting, death/corpse, and portal gates.

Checkpoints A and B are complete. d68c83d1, 5882b308, and fcb66198 established the shared origin, lifecycle, remote-motion, and exact-record physics-host seams. 69a2ca0c extracted appearance/collision/default-pose and projection-withdrawal mechanics, including exact recursive leave_world subtree handling and retained retry ownership. Three independent reviews were clean and the Release suite passes 6,799 tests with five intentional skips. Checkpoints C and D are complete. d10c5f2d moved CreateObject, first materialization, origin bootstrap, and landblock recovery into LiveEntityHydrationController; fe551496 moved ObjDesc, Parent, Pickup, child-ready, and exact leave-world/re-entry routing. Appearance and attached subtree recovery now use independent authority transactions and preserve the same logical/runtime identities. Checkpoint E is complete in f38822c4: Delete, visibility prune, retryable exact-incarnation component cleanup, and derived remote stop-observation lifetime now have focused owners. Checkpoint F completed in aa90c646: accepted Position/Vector/State/Movement and the equal-generation CreateObject tail now route through focused owners. Independent authority/version captures survive reentrant callbacks and GUID reuse; shared motion-runtime policy is one-way bound and no extracted owner keeps a second identity dictionary. ForcePosition serializes and stamps one exact canonical outbound Position. Three corrected-diff reviews, the complete 6,940-test Release suite, and the 310-second seven-destination connected R6 route all passed. GameWindow is 10,301 lines / 267 fields / 163 methods. Slice 5 checkpoints AG are complete. The pipeline owns the serialized worker build, concrete render/physics/static publication, and retryable Near/full retirement. The direct cutover deleted the legacy apply/facade/scratch body and added one retained shared-origin lifetime transaction for teleport, logout, and fast relogin. GameWindow.cs was 8,793 raw lines at checkpoint G and no longer owns landblock build/apply/retirement behavior. H then corrected native shutdown ordering so live-session reset converges before the streamer and other reset dependencies are disposed. The final measurement is 8,811 raw lines / 247 fields / 153 methods. The complete Release suite (7,054 pass / 5 skip), the 311-second capped/reconnect lifecycle gate, and the synchronized 394-second nine-stop resource soak all pass (4a205a3e).

Slice 5 — extract landblock presentation — COMPLETE

Detailed execution ledger: docs/plans/2026-07-21-gamewindow-slice-5-landblock-presentation.md (complete).

Create LandblockPresentationPipeline around the existing immutable LandblockBuild transaction. It owns DAT build context, scenery/EnvCell construction, mesh/collision/light publication, demotion/full-retirement, and the single-reader DAT lock contract. StreamingController continues deciding what is resident; GpuWorldState continues owning spatial buckets.

Tests pin loaded/pending/demoted/unloaded symmetry, stale build generations, resource pin/release balance, collision footprints across landblock seams, and first-login bootstrap replacement. The deterministic world-lifecycle gate and nine-destination resource soak pass.

Slice 6 — extract update-frame orchestration — COMPLETE 2026-07-22

Detailed execution ledger: docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md (complete).

After the stateful bodies above have owners, make the update path a real orchestrator instead of delegates back into the window. It preserves the accepted production order—not an overclaimed retail host order—covering teardown/clock/streaming convergence, input, the retail-shaped object → inbound network → command barrier, conditional spatial reconciles, liveness, local teleport, player-mode entry, and camera presentation. The exact twelve-phase graph and registered TS-53 host-order adaptation live in the detailed ledger. GameWindow.OnUpdate becomes a short time/input handoff.

Frame-order tests and the existing R6 gate must produce the same lifecycle and movement traces before and after extraction.

The production cutover is complete in e91f3102. GameWindow.OnUpdate now contains the profiler scope and one UpdateFrameOrchestrator.Tick handoff; streaming, input, the object/network barrier, liveness, teleport, player-mode, and camera phases have typed owners with no substantial callback facade into the window. The complete Release suite passes 7,182 tests with five intentional skips. The 314-second lifecycle/reconnect gate and 394-second synchronized nine-stop resource soak both pass with graceful exits, stable live-owner counts, and a 0.8 ms update-frame p95 at the final Caul checkpoint. GameWindow is 7,026 raw lines / 241 fields / 108 methods: 8,697 lines (55.3%) smaller than the campaign baseline. TS-50, TS-51, and TS-53 remain accurately registered; this behavior-preserving extraction introduced no new divergence.

Slice 7 — extract RenderFrameOrchestrator — COMPLETE 2026-07-22

Detailed execution ledger: docs/plans/2026-07-22-gamewindow-slice-7-render-frame-orchestration.md (complete).

Move the complete draw graph and its reusable frame-local scratch state into a GL-owning App collaborator. Preserve the exact modern pipeline order, clip routing, PView flood, landscape/opaque/shared-alpha flush boundaries, particles, debug draw, paperdoll, retained UI, and frame fences. Do not pass a hundred individual delegates or let the orchestrator reach back into GameWindow; inject a small immutable service set plus explicit per-frame input.

Automated acceptance uses framebuffer artifacts and render/resource checkpoints. Visual acceptance compares outdoor, building, dungeon, portal exit, translucent lifestone/particles, and UI at the same camera positions.

The production draw cutover is complete. GameWindow.OnRender now performs one value-only handoff to RenderFrameOrchestrator; focused resource, world, private-presentation, and diagnostic owners contain the former frame body and failure recovery. The Release suite passes 7,341 tests with five intentional skips. GameWindow is 4,666 raw lines / 196 fields / 70 methods: 11,057 lines (70.3%) smaller than the campaign baseline. The 315.6-second lifecycle and 395.2-second synchronized nine-stop soak pass with code-zero graceful exits; the six stable framebuffer checkpoints preserve the Slice 6 presentation. Issue #232 records the coarse process-residency gate's run-to-run variance so future diagnostics can distinguish canonical owner growth from OS/driver/GC residency without weakening leak detection.

Slice 8 — composition and shutdown cleanup — ACTIVE

Detailed execution ledger: docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md.

Keep GL/window construction in GameWindow.OnLoad, but group creation into small composition functions and delete feature state left behind by prior slices. OnClosing delegates to the existing retryable shutdown transaction. Silk callbacks become narrow calls into the input, update, render, resize, focus, and shutdown owners.

4.4 Exit criteria

The campaign is complete when:

  • GameWindow contains no AC gameplay algorithm, entity scan, packet builder, DAT landblock builder, animation-part composer, or draw-graph body;
  • OnUpdate, OnRender, OnInputAction, and live-session callbacks are short delegation methods;
  • every extracted owner has App-layer tests and symmetric teardown;
  • the Release suite and connected lifecycle/resource gates stay green after every slice;
  • the final local visual matrix passes unchanged.

Line count is a progress signal, not the acceptance test. The expected result is below roughly 5,000 lines, but ownership and dependency direction decide completion. Full GameEntity type aggregation is evaluated only afterwards as a separate migration; it is not folded into this campaign.


5. Rules of the road during the extraction

  1. One slice at a time. Each commit ships one ownership boundary or mechanical call-site cutover. Bundling slices makes failures hard to isolate.
  2. Behavior preservation is the acceptance criterion. Every slice must build clean, all tests pass, and visual verification at the appropriate accepted milestone 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 a slice turns out to need a different ownership shape than described above, update §4 in the same session you discover the divergence.
  6. No façade-only completion claims. A delegate from a new class back to a substantial GameWindow method is a useful intermediate seam, not a completed extraction.
  7. No duplicate ownership. New collaborators query LiveEntityRuntime, GpuWorldState, SelectionState, or the relevant existing owner. They do not create replacement GUID, visibility, session, or resource maps.

6. What this document is not

  • Not a full rewrite plan. The point is the opposite — small steps, verified at each boundary.
  • Not a feature phase. Following the 2026-07-21 user decision, this behavior-preserving campaign is the structural prerequisite before new M4 subsystem bodies are added. Severe regressions remain fixable in separate commits; ordinary feature work waits.
  • Not a substitute for the milestones / roadmap. Those drive the feature work. This drives the structural work that runs underneath.