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 <codex@openai.com>
This commit is contained in:
Erik 2026-07-25 17:33:11 +02:00
parent 9f37d17936
commit b632672e5c
10 changed files with 457 additions and 2 deletions

View file

@ -8,6 +8,7 @@
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" /> <Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" /> <Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" /> <Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
<Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" /> <Project Path="src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj" />
<Project Path="src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj" /> <Project Path="src/AcDream.UI.ImGui/AcDream.UI.ImGui.csproj" />
</Folder> </Folder>
@ -22,6 +23,7 @@
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" /> <Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" /> <Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" /> <Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" /> <Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View file

@ -203,6 +203,11 @@ src/
NetClient.cs -> done NetClient.cs -> done
Messages/ -> done (CreateObject, MoveToState, etc.) 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 AcDream.Plugin.Abstractions/ Layer 5: plugin interfaces
IAcDreamPlugin.cs -> done IAcDreamPlugin.cs -> done
IPluginHost.cs -> done IPluginHost.cs -> done

View file

@ -166,6 +166,7 @@ Today:
- `tests/AcDream.Core.Tests/``src/AcDream.Core/` - `tests/AcDream.Core.Tests/``src/AcDream.Core/`
- `tests/AcDream.Core.Net.Tests/``src/AcDream.Core.Net/` - `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.UI.Abstractions.Tests/``src/AcDream.UI.Abstractions/`
- `tests/AcDream.App.Tests/``src/AcDream.App/` - `tests/AcDream.App.Tests/``src/AcDream.App/`

View file

@ -2,8 +2,11 @@
**Date:** 2026-07-24 **Date:** 2026-07-24
**Status:** Slices AH and I0I3 are complete. Slices FL were explicitly **Status:** Slices AH are complete. Slice I6's flat-authoritative production
approved 2026-07-24; Slice I4 is active. cutover is committed and awaits the final user collision/visual gate before
parsed-graph deletion. Slices FL 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 **Scope:** Reconcile and sequence the existing Modern Pipeline (`MP`) and
Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit. Linux/headless (`LH`) tracks using the 2026-07-24 connected performance audit.
@ -499,6 +502,41 @@ It exposes:
- Deterministic `Tick`. - Deterministic `Tick`.
- Retryable, complete shutdown. - 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 ### 9.3 Hosts
`GraphicalGameHost` provides input, camera, rendering, UI, audio, frame pacing, `GraphicalGameHost` provides input, camera, rendering, UI, audio, frame pacing,

View file

@ -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 FL, 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.

View file

@ -39,6 +39,7 @@
<PackageReference Include="StbTrueTypeSharp" Version="1.26.12" /> <PackageReference Include="StbTrueTypeSharp" Version="1.26.12" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" /> <ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" /> <ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" /> <ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />

View file

@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<RootNamespace>AcDream.Runtime</RootNamespace>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Runtime.Tests" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,10 @@
namespace AcDream.Runtime;
/// <summary>
/// Identifies the presentation-independent runtime assembly.
/// </summary>
/// <remarks>
/// Slice J0 establishes only the dependency boundary. Runtime owners move here
/// in coherent lifetime groups during the later Slice J steps.
/// </remarks>
internal static class RuntimeAssemblyMarker;

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -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));
}
}