From b632672e5ccabfb44c551e08f1c411ab2669c44a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 25 Jul 2026 17:33:11 +0200 Subject: [PATCH] arch(runtime): establish presentation-independent boundary Create the dependency-only Runtime project before moving any gameplay owner, enforce its direct, transitive, source, and load-time closure, and pin coherent lifetime-group extraction plus retryable teardown ordering. No production behavior changes in J0. Validated by the four focused Runtime boundary tests, a zero-error Release solution build, and 8,406 passing Release tests with five pre-existing skips. Co-authored-by: Codex --- AcDream.slnx | 2 + docs/architecture/acdream-architecture.md | 5 + docs/architecture/code-structure.md | 1 + .../2026-07-24-modern-runtime-architecture.md | 42 +++- .../2026-07-25-modern-runtime-slice-j.md | 230 ++++++++++++++++++ src/AcDream.App/AcDream.App.csproj | 1 + src/AcDream.Runtime/AcDream.Runtime.csproj | 19 ++ src/AcDream.Runtime/RuntimeAssemblyMarker.cs | 10 + .../AcDream.Runtime.Tests.csproj | 22 ++ .../RuntimeDependencyBoundaryTests.cs | 127 ++++++++++ 10 files changed, 457 insertions(+), 2 deletions(-) create mode 100644 docs/plans/2026-07-25-modern-runtime-slice-j.md create mode 100644 src/AcDream.Runtime/AcDream.Runtime.csproj create mode 100644 src/AcDream.Runtime/RuntimeAssemblyMarker.cs create mode 100644 tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj create mode 100644 tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs diff --git a/AcDream.slnx b/AcDream.slnx index 238ab20c..5fc96499 100644 --- a/AcDream.slnx +++ b/AcDream.slnx @@ -8,6 +8,7 @@ + @@ -22,6 +23,7 @@ + diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 4bba7034..28f6fc32 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -202,6 +202,11 @@ src/ WorldSession.cs -> done (wire-compatible with ACE) NetClient.cs -> done Messages/ -> done (CreateObject, MoveToState, etc.) + + AcDream.Runtime/ presentation-independent client kernel boundary + RuntimeAssemblyMarker.cs -> J0 dependency boundary; owner moves start after I6/I7 + -> may reference Core, Core.Net, Content, and Plugin.Abstractions only + -> must never reference App, UI, Silk.NET, OpenAL, Arch, or ImGui AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces IAcDreamPlugin.cs -> done diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index a8812cd0..58dea220 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -166,6 +166,7 @@ Today: - `tests/AcDream.Core.Tests/` ← `src/AcDream.Core/` - `tests/AcDream.Core.Net.Tests/` ← `src/AcDream.Core.Net/` +- `tests/AcDream.Runtime.Tests/` ← `src/AcDream.Runtime/` - `tests/AcDream.UI.Abstractions.Tests/` ← `src/AcDream.UI.Abstractions/` - `tests/AcDream.App.Tests/` ← `src/AcDream.App/` diff --git a/docs/plans/2026-07-24-modern-runtime-architecture.md b/docs/plans/2026-07-24-modern-runtime-architecture.md index 87a1ec2a..029e850c 100644 --- a/docs/plans/2026-07-24-modern-runtime-architecture.md +++ b/docs/plans/2026-07-24-modern-runtime-architecture.md @@ -2,8 +2,11 @@ **Date:** 2026-07-24 -**Status:** Slices A–H and I0–I3 are complete. Slices F–L were explicitly -approved 2026-07-24; Slice I4 is active. +**Status:** Slices A–H are complete. Slice I6's flat-authoritative production +cutover is committed and awaits the final user collision/visual gate before +parsed-graph deletion. Slices F–L were explicitly approved 2026-07-24. Slice +J0's dependency-boundary preparation is complete without moving gameplay +owners. J1 remains behind the I6/I7 user collision/visual closeout. **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. @@ -499,6 +502,41 @@ It exposes: - Deterministic `Tick`. - Retryable, complete shutdown. +#### 9.2.1 Structural teardown protocol + +Every graphical or headless host follows one retryable, generation-scoped +teardown transaction. New runtime owners register through the same +`CompositionAcquisitionScope` lease discipline already used by the graphical +composition root; release order is encoded by leases and acknowledgements, +never remembered by callers. + +1. **Cancel scheduler generations.** Make commands inert, cancel the active + session/runtime generation, stop new receive publication, and prevent any + timer, bot, plugin, or worker callback from creating more work for it. +2. **Drain or poison journals and queues.** Detach inbound producers, drain + already-accepted ordered events through the generation boundary, then + poison every command/event/delta queue so delayed callbacks fail against + their exact generation instead of entering a replacement session. +3. **Unpublish presentation.** The graphical host consumes the final ordered + withdrawals and acknowledges that the render scene, retained gameplay UI, + audio/effect projections, selection, radar, and private viewports no longer + expose the runtime generation. Headless hosts acknowledge this stage + immediately because they construct no presentation owners. +4. **Release residency after fences.** Withdraw content owners, finish + streaming/landblock retirement, and allow GPU-backed stores to retire only + after their existing frame-flight fences signal. A failed fence or release + retains the exact unfinished lease and resumes without replaying completed + stages. +5. **Release the shared `ContentStore` last.** Session-scoped collision/content + leases go away before the runtime instance. The immutable mapped content + store outlives every runtime and presentation host and is disposed only by + the process host after all registered sessions have acknowledged teardown. + +Logout, session reset, mid-portal disconnect, reconnect replacement, ordinary +window close, and construction rollback all use this protocol. No stage may +clear another generation's state, and no teardown failure may be converted +into a best-effort purge. + ### 9.3 Hosts `GraphicalGameHost` provides input, camera, rendering, UI, audio, frame pacing, diff --git a/docs/plans/2026-07-25-modern-runtime-slice-j.md b/docs/plans/2026-07-25-modern-runtime-slice-j.md new file mode 100644 index 00000000..863eccd9 --- /dev/null +++ b/docs/plans/2026-07-25-modern-runtime-slice-j.md @@ -0,0 +1,230 @@ +# Modern runtime Slice J — presentation-independent runtime + +**Status:** J0 COMPLETE — lifetime plan, project boundary, and dependency +guards; J1 waits for the Slice I6/I7 user collision/visual closeout +**Parent:** `2026-07-24-modern-runtime-architecture.md`, Slice J +**Authorization:** the user approved Slices F–L, including the otherwise-frozen +Slice J gameplay-owner moves, on 2026-07-24 +**Purpose:** move the existing authoritative client kernel into a +presentation-independent assembly without creating a second world, changing +retail behavior, or making the graphical host a special case. + +Slice J is a lifetime-group campaign, not one large move. Each sub-slice keeps +the graphical client on the same owner instance, establishes host parity, and +deletes the replaced App production path before the next group moves. + +## 1. Fixed invariants + +1. `LiveEntityRuntime` remains the sole server-GUID/incarnation owner. There is + never a second GUID map or synchronized gameplay world. +2. Runtime types do not reference `AcDream.App`, either UI assembly, Silk.NET, + OpenAL, Arch, ImGui, a native window, or an OpenGL resource. +3. App projection state is keyed by runtime-issued local entity identity plus + incarnation. It may not recover or mirror server GUID ownership. +4. The accepted update order, packet ordering, timestamp gates, outbound + cadence, and retail-shaped gameplay algorithms do not change during the + extraction. +5. Contracts expose borrowed/immutable views, typed commands, and ordered + deltas. They do not expose App render components or deep per-frame object + graphs. +6. All clocks, queues, random sources, diagnostics identity, plugin behavior, + and mutable caches that can differ between sessions are instance-scoped. +7. Immutable prepared content may be shared. Session state and mutable query + scratch may not. +8. Every new owner participates in the structural teardown protocol in parent + plan §9.2.1 through acquisition leases and exact acknowledgements. +9. A headless construction path is proved after every coherent move. “No + window” means no App, Silk, OpenAL, retained UI, particle, or render assembly + is loaded—not a hidden graphical process. +10. The user collision/visual gate for Slice I6 still controls parsed-graph + deletion. J0 may land independently; gameplay-owner moves begin only after + the active I6/I7 closeout has reconciled the final collision owner shape. + +## 2. Target dependency direction + +```text +AcDream.App ───────────────► AcDream.Runtime +AcDream.Headless (Slice K) ─► AcDream.Runtime + ├──► AcDream.Core.Net + ├──► AcDream.Core + ├──► AcDream.Content + └──► AcDream.Plugin.Abstractions + +AcDream.Runtime -X► AcDream.App / AcDream.UI.* / Silk.NET / OpenAL / Arch +``` + +Runtime-emitted entity events contain incarnation, transform, flags, and +property deltas only. An App-side projection adapter translates them into +`IRenderScene` journal operations. Render vocabulary never enters Runtime. + +## 3. Teardown transaction + +The exact order is: + +1. cancel scheduler/session generations and make commands inert; +2. detach producers, drain accepted ordered events, and poison queues; +3. withdraw and acknowledge graphical presentation; +4. retire session residence and GPU-backed owners through existing fences; +5. dispose the process-owned immutable `ContentStore` only after every runtime + and host has completed. + +Completed stages never replay. Failure retains the exact remaining suffix. +Logout, reset, mid-portal disconnect, reconnect replacement, construction +rollback, and native close all traverse the same transaction. + +## 4. Execution slices + +### J0 — boundary, plan, and dependency enforcement + +**Completed 2026-07-25.** + +- Add `AcDream.Runtime` and `AcDream.Runtime.Tests`. +- Permit references only to Core, Core.Net, Content, and Plugin.Abstractions. +- Add App → Runtime as the future host dependency; Runtime never references + App. +- Add direct-reference, dependency-closure, source-project, and assembly-load + guards that fail if a presentation/backend dependency enters Runtime. +- Add only an assembly boundary marker; do not introduce a facade + `GameRuntime`, mirror state, or move behavior in J0. +- Pin this lifetime-group plan and parent-plan teardown protocol. + +Gate: Release build and full tests pass; the Runtime test process loads no App, +UI, Silk, OpenAL, Arch, or ImGui assembly; graphical behavior is byte-for-byte +unchanged because no runtime path changed. + +Evidence: four focused dependency tests pass, the Release solution builds with +zero errors, and the complete Release suite passes 8,406 tests with five +pre-existing skips. The exact rollback commit is recorded below after landing. + +### J1 — read, command, event, clock, and lifecycle contracts + +- Define `IGameRuntimeView`, typed command interfaces, ordered event/delta + records, instance clock, lifecycle states, generation tokens, and teardown + acknowledgements. +- Build App adapters over the current owners first. Adapters borrow; they do + not copy mutable collections or own lifetime. +- Add a normalized parity trace of accepted inbound events, runtime commands, + state revisions, and lifecycle edges. +- Prove press-time graphical input reaches the same current owners through the + command contract with no extra frame of latency. + +Gate: adapter and direct-host traces are identical over deterministic session, +entity, inventory, chat, movement, and portal fixtures; no canonical owner has +moved yet. + +### J2 — session lifetime and ordered transport group + +- Remove App presentation dependencies from `LiveSessionController`, + `LiveSessionHost`, lifecycle host, event router, and command router. +- Move the same owner instances into Runtime with Core.Net transport and + instance-scoped clocks/queues. +- Preserve bind-before-connect, publish-after-EnterWorld, exact generation + rechecks, receive ordering, ack placement, graceful F653/disconnect, and + retryable replacement. +- Let App provide only endpoint credentials, presentation bindings, and + graphical lifecycle acknowledgements. + +Gate: connected login/logout/reconnect and malformed/reentrant lifecycle suites +produce the same packet/order trace; no-window construction can connect and +disconnect without loading App or a backend. + +### J3 — canonical identity, properties, and object-table group + +- Strip renderer, particle, Wb, and streaming components from the canonical + record into an App projection store keyed only by local identity/incarnation. +- Move `LiveEntityRuntime`, accepted spawn/state/property timestamps, + `ClientObjectTable`, container/inventory indices, vitals, enchantments, + spell state, target/combat state, and their exact teardown into Runtime. +- Emit ordered create/update/rebucket/hidden/withdraw/delete deltas. +- Keep DAT-backed visual hydration, render registrations, effects, lights, + paperdoll, selection markers, and radar projections in App. + +Gate: GUID reuse, equal-generation updates, parent/child identity, +inventory/container mutations, Hidden, delete/recreate, and portal teardown +match current traces; App has no server-GUID ownership. + +### J4 — chat, inventory, magic, and gameplay-state services + +- Move canonical chat history/channel state, inventory transactions, spell and + component state, enchantments, vitals, skills/attributes, and gameplay + cooldown state by coherent owner groups. +- Keep retained panel layout, text shaping, icons, paperdoll rendering, and + status-bar presentation in App. +- Route plugins and future bots through the same typed command/event surface + as graphical UI. + +Gate: deterministic command/response traces and graphical ViewModel revisions +match; no Runtime type references UI abstractions. + +### J5 — movement, physics, interaction, and combat group + +- Move presentation-free movement interpretation, authoritative/predicted + physics state, selection identity, approach/use transactions, combat intent, + projectiles, and outbound movement cadence. +- Replace input and camera dependencies with typed commands and runtime views. +- Keep camera, mouse look, selection markers/highlight, combat/spell bars, + animated pose composition, sounds, and particles in App. +- Share immutable flat collision content while retaining per-session transition + scratch. + +Gate: bit-identical collision/trajectory fixtures, target-facing and +auto-approach traces, outbound packet timing, combat/magic commands, and +graphical feel gates remain unchanged. + +### J6 — world, portal, environment, and projection handshake + +- Move authoritative teleport/session cell identity, world clock, weather + state, and reveal-generation state required by gameplay. +- Keep streaming publication, terrain/EnvCell rendering, portal tunnel, + materialization VFX, sky drawing, audio, and UI in App. +- Define the destination-ready/withdrawal acknowledgements between Runtime and + the graphical or headless host without making visual readiness gameplay + authority. + +Gate: fresh login, repeated recall/portal, world edge, dungeon, reconnect, and +mid-portal disconnect traces match; the graphical screenshots remain accepted. + +### J7 — one `GameRuntime` composition root + +- Compose all moved lifetime groups into one instance-scoped `GameRuntime`. +- Make `GameWindow`/App own exactly one Runtime instance plus presentation + adapters. +- Delete temporary direct-owner adapters and every superseded App production + path. +- Register all Runtime and App projection owners with structural acquisition + leases and the exact teardown transaction. + +Gate: graphical connected lifecycle/resource routes pass with no mirrored +state, no extra update latency, and no performance regression. + +### J8 — no-window integration and Slice J closeout + +- Build a transport/content test host that constructs Runtime directly. +- Connect, enter world, receive chat/inventory/world updates, move, use, + fight/cast through deterministic fixtures, portal, log out, reconnect, and + tear down without loading App/Silk/OpenAL/UI. +- Add cancellation, failure injection, same-process multiple-instance + isolation, credential-safe diagnostics, and zero-presentation-allocation + gates. +- Update architecture, milestone, roadmap, issue/divergence ledgers, and + durable memory. + +Final gate: Release build/full tests, connected graphical route, no-window +integration, resource teardown, packet/order/timing parity, and the user visual +matrix all pass. Slice K may then build the Linux multi-session host without +another gameplay extraction. + +## 5. Commit order + +1. `arch(runtime): establish presentation-independent boundary` +2. `feat(runtime): define borrowed views commands and ordered events` +3. `refactor(runtime): move session lifetime and ordered transport` +4. `refactor(runtime): move canonical entity and property ownership` +5. `refactor(runtime): move gameplay state services` +6. `refactor(runtime): move movement physics interaction and combat` +7. `refactor(runtime): separate world authority from presentation` +8. `refactor(runtime): compose one graphical game runtime` +9. `test(runtime): close no-window parity and teardown` + +Each commit is independently buildable and retains an exact rollback command +in this plan before its connected gate. diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj index bba735d3..68338dd5 100644 --- a/src/AcDream.App/AcDream.App.csproj +++ b/src/AcDream.App/AcDream.App.csproj @@ -39,6 +39,7 @@ + diff --git a/src/AcDream.Runtime/AcDream.Runtime.csproj b/src/AcDream.Runtime/AcDream.Runtime.csproj new file mode 100644 index 00000000..7b33b2b2 --- /dev/null +++ b/src/AcDream.Runtime/AcDream.Runtime.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + latest + true + AcDream.Runtime + + + + + + + + + + + diff --git a/src/AcDream.Runtime/RuntimeAssemblyMarker.cs b/src/AcDream.Runtime/RuntimeAssemblyMarker.cs new file mode 100644 index 00000000..ee784b90 --- /dev/null +++ b/src/AcDream.Runtime/RuntimeAssemblyMarker.cs @@ -0,0 +1,10 @@ +namespace AcDream.Runtime; + +/// +/// Identifies the presentation-independent runtime assembly. +/// +/// +/// Slice J0 establishes only the dependency boundary. Runtime owners move here +/// in coherent lifetime groups during the later Slice J steps. +/// +internal static class RuntimeAssemblyMarker; diff --git a/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj new file mode 100644 index 00000000..fd27c566 --- /dev/null +++ b/tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + enable + enable + false + latest + true + + + + + + + + + + + + + + diff --git a/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs new file mode 100644 index 00000000..949dc51d --- /dev/null +++ b/tests/AcDream.Runtime.Tests/RuntimeDependencyBoundaryTests.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using System.Xml.Linq; + +namespace AcDream.Runtime.Tests; + +public sealed class RuntimeDependencyBoundaryTests +{ + private static readonly string[] ForbiddenDependencyPrefixes = + [ + "AcDream.App", + "AcDream.UI.", + "Silk.NET", + "OpenAL", + "Arch", + "ImGui", + ]; + + [Fact] + public void RuntimeAssemblyHasNoDirectPresentationOrBackendReferences() + { + var references = typeof(RuntimeAssemblyMarker).Assembly + .GetReferencedAssemblies() + .Select(static reference => reference.Name ?? string.Empty) + .ToArray(); + + Assert.DoesNotContain(references, IsForbidden); + } + + [Fact] + public void RuntimeDependencyClosureHasNoPresentationOrBackendLibraries() + { + var depsPath = Path.ChangeExtension( + typeof(RuntimeDependencyBoundaryTests).Assembly.Location, + ".deps.json"); + + using var document = JsonDocument.Parse(File.ReadAllText(depsPath)); + var libraries = document.RootElement + .GetProperty("libraries") + .EnumerateObject() + .Select(static library => LibraryName(library.Name)) + .ToArray(); + + Assert.DoesNotContain(libraries, IsForbidden); + } + + [Fact] + public void RuntimeProjectDeclaresOnlyApprovedProjectDependencies() + { + var repositoryRoot = FindRepositoryRoot(); + var projectPath = Path.Combine( + repositoryRoot, + "src", + "AcDream.Runtime", + "AcDream.Runtime.csproj"); + var project = XDocument.Load(projectPath); + var projectDirectory = Path.GetDirectoryName(projectPath)!; + + var actualReferences = project + .Descendants("ProjectReference") + .Select(reference => reference.Attribute("Include")?.Value) + .Where(static include => !string.IsNullOrWhiteSpace(include)) + .Select(include => Path.GetFullPath(Path.Combine(projectDirectory, include!))) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + var expectedReferences = new[] + { + "AcDream.Content", + "AcDream.Core", + "AcDream.Core.Net", + "AcDream.Plugin.Abstractions", + } + .Select(projectName => Path.Combine( + repositoryRoot, + "src", + projectName, + $"{projectName}.csproj")) + .Order(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(expectedReferences, actualReferences); + Assert.Empty(project.Descendants("PackageReference")); + } + + [Fact] + public void LoadingRuntimeMarkerDoesNotLoadPresentationOrBackendAssemblies() + { + _ = typeof(RuntimeAssemblyMarker).Assembly; + + var loadedAssemblies = AppDomain.CurrentDomain + .GetAssemblies() + .Select(static assembly => assembly.GetName().Name ?? string.Empty) + .ToArray(); + + Assert.DoesNotContain(loadedAssemblies, IsForbidden); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"Could not find AcDream.slnx above {AppContext.BaseDirectory}."); + } + + private static string LibraryName(string libraryIdentity) + { + var separatorIndex = libraryIdentity.IndexOf('/'); + return separatorIndex < 0 + ? libraryIdentity + : libraryIdentity[..separatorIndex]; + } + + private static bool IsForbidden(string assemblyName) + { + return ForbiddenDependencyPrefixes.Any(prefix => + assemblyName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); + } +}