refactor(net): own live session composition

Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 10:36:06 +02:00
parent 18d4b999de
commit 557eb7ef6b
22 changed files with 1430 additions and 236 deletions

View file

@ -127,11 +127,17 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership.
`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
Slice 8 checkpoints AC are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
synchronized nine-stop soak pass. Slice 8 checkpoints DL—world environment,
input/resize, action routing, settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
#116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
lifestone/particle alpha visual gate.

View file

@ -125,11 +125,17 @@ second GUID dictionary. Slices 57 are complete: landblock presentation,
update-frame orchestration, and render-frame orchestration all have typed
owners. `GameWindow.OnUpdate` and `GameWindow.OnRender` are one-handoff methods;
the frame owners have no direct window or anonymous callback-bag ownership.
`GameWindow` is 4,666 raw lines / 196 fields / 70 methods, down 11,057 lines
(70.3%) from the 15,723-line campaign baseline. The 7,341-test Release suite,
315.6-second lifecycle/reconnect gate, and 395.2-second synchronized nine-stop
soak pass. Final Slice 8 composition/shutdown and Silk callback cleanup is
active. See `docs/architecture/code-structure.md`. **Carried:** #232, #153,
Slice 8 checkpoints AC are also complete: dead host residue is removed,
native-window callback intake has reversible lifetime ownership, and
`LiveSessionHost` owns reset/binding composition over the sole canonical
session controller. `GameWindow` is 4,589 raw lines / 196 fields / 69 methods,
down 11,134 lines (70.8%) from the 15,723-line campaign baseline. The 7,408-test
Release suite, 315.6-second lifecycle/reconnect gate, and 395.2-second
synchronized nine-stop soak pass. Slice 8 checkpoints DL—world environment,
input/resize, action routing, settings, resource ownership, ordered composition,
retryable shutdown, soak snapshots, and final connected gates—remain active.
See `docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md` and
`docs/architecture/code-structure.md`. **Carried:** #232, #153,
#116, remaining
R6 ownership cleanup, TS-50/TS-51/TS-53, Modern Pipeline MP1b+, and #225's
lifestone/particle alpha visual gate.

View file

@ -27,9 +27,11 @@ What does NOT go here:
## Current queue — 2026-07-22
- **Active structural work:** `GameWindow` decomposition. Slices 17 are
complete; `GameWindow.OnUpdate` and `GameWindow.OnRender` are typed
orchestration handoffs and the class is 4,666 lines / 196 fields / 70
methods. Final Slice 8 composition/shutdown cleanup is active in
complete; Slice 8 checkpoints AC now own native callbacks and live-session
composition outside the shell. `GameWindow.OnUpdate` and
`GameWindow.OnRender` are typed orchestration handoffs and the class is 4,589
lines / 196 fields / 69 methods. Remaining Slice 8 composition/shutdown
cleanup is active in
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
This is the behavior-preserving prerequisite before new M4 feature bodies.
- **Connected-gate fidelity:** `#232` adds canonical managed/GPU/cache-owner
@ -73,6 +75,39 @@ Copy this block when adding a new issue:
---
## #233 — Live skill-credit resolver used an ACE shortcut, not retail's formula
**Status:** DONE (2026-07-22, GameWindow Slice 8 Checkpoint C)
**Severity:** MEDIUM
**Filed:** 2026-07-22
**Component:** live character state / DAT skill formulas
**Description:** The live skill-update route calculated a skill's primary-
attribute contribution with an ACE-oriented shortcut. It rejected every
formula whose first multiplier was zero, truncated division, and added `W`
after division. Custom DAT formulas—and any stock boundary landing on a half—
could therefore display the wrong skill value.
**Root cause / status:** The binding cited ACE without first checking named
retail. `SkillFormula::Calculate @ 0x00591960` treats all four arithmetic words
as unsigned, fails only for `Z == 0`, forms the wrapping numerator
`X*a + Y*b + W`, and rounds `numerator/Z` half-up. Checkpoint C moved that
algorithm into `RetailSkillFormula`, added a named `LiveSkillCreditResolver`,
and pinned zero-X, W placement, half rounding, unsigned reinterpretation, and
32-bit wrap with conformance tests.
**Files:** `src/AcDream.App/Net/RetailSkillFormula.cs`;
`tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs`.
**Research:**
[`docs/research/skill_formula_calculate_pseudocode.md`](research/skill_formula_calculate_pseudocode.md).
**Acceptance:** Live skill updates resolve the DAT entry and reproduce the
named retail function for all unsigned field values; missing skills or
attributes remain safely projected as zero.
---
## #232 — Nine-stop soak process-memory gate lacks canonical owner snapshots
**Status:** OPEN

View file

@ -232,7 +232,9 @@ src/AcDream.App/
│ └── Vfx/ # (already exists)
├── Net/
│ ├── LiveSessionController.cs # owns complete WorldSession connect/enter/logout/reconnect lifecycle
│ ├── LiveSessionLifecycleHost.cs # narrow App composition + exact borrowed-session attachment
│ ├── LiveSessionHost.cs # App reset/selection/entry/route-factory composition over the canonical controller
│ ├── LiveSessionLifecycleHost.cs # exact borrowed-session attachment guard
│ ├── RetailSkillFormula.cs # named-retail unsigned skill-credit calculation + DAT lookup
│ └── LiveSessionEventRouter.cs # typed subscriptions → focused domain handlers
├── Physics/
│ ├── ProjectileController.cs # canonical live-record projectile orchestration
@ -436,7 +438,7 @@ useful ordering seam, but its ownership status is **partial**.
| 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`). |
| Network session | **Complete** | `LiveSessionController` remains the sole resolve/create/Connect/selection/EnterWorld/Tick/stop/reconnect/disposal and current-session owner. `LiveSessionHost` owns App reset/selection/entry ordering plus create→attach route factories; `LiveSessionLifecycleHost` guards exact borrowed-session attachment. `LiveSessionEventRouter` and `LiveSessionCommandRouter` own exact inbound/outbound lifetimes, with retryable per-edge teardown before reset or replacement. `GameWindow` retains the controller, focused host, and typed domain binding factories—no mirrored session or reset plan. |
| 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. |

View file

@ -1,6 +1,6 @@
# acdream — strategic roadmap
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 17 are complete and final Slice 8 composition/shutdown cleanup is active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Status:** Living document. Updated 2026-07-22. **M3 landed; M4 is next after the active `GameWindow` structural prerequisite.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. The current program is the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md); Slices 17 and Slice 8 checkpoints AC are complete, with the remaining composition/shutdown checkpoints active. New M4 quest/emote/character-creation feature bodies wait until that campaign is complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@ -79,7 +79,12 @@ second synchronized soak pass with graceful exits, and the six stable PNG
checkpoints preserve Slice 6 presentation. Issue #232 tracks the coarse
process-residency gate's diagnostic blind spot; no tolerance was loosened.
Final Slice 8 composition/shutdown cleanup is now active.
Final Slice 8 composition/shutdown cleanup is active. Checkpoints AC now own
the frozen shell boundary, all nine native callback edges, and live-session
reset/selection/entry/route composition. `GameWindow` is 4,589 lines / 196
fields / 69 methods; the Checkpoint C Release suite passes 7,408 tests / 5
skips. Environment, input, settings, startup/resource transfer, and shutdown
checkpoints DL remain.
This is a behavior-preserving structural program. Severe regressions still get
root-cause fixes in separate commits; ordinary feature work resumes with M4

View file

@ -39,8 +39,10 @@ diagnostic owners preserve the accepted draw/failure graph. `GameWindow` is
campaign baseline. The full Release suite passes 7,341 tests / 5 skips; the
315.6-second capped/reconnect lifecycle gate and 395.2-second synchronized
nine-destination soak pass with graceful exits, and six stable PNG checkpoints
preserve Slice 6 presentation. Final Slice 8 composition/shutdown cleanup is
active. Issue #232 tracks process-residency variance in the soak without
preserve Slice 6 presentation. Final Slice 8 checkpoints AC are complete:
native callbacks and live-session composition now have explicit retryable
owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints
DL remain active. Issue #232 tracks process-residency variance in the soak without
loosening its leak threshold.
Carried:

View file

@ -18,7 +18,7 @@ audit.
test-facade residue.
- [x] B — make native-window callback intake an explicit reversible owner and
define host-quiescence failure semantics.
- [ ] C — extract live-session host/reset/binding callbacks and verify the
- [x] C — extract live-session host/reset/binding callbacks and verify the
embedded skill formula against named retail.
- [ ] D — extract retail world-environment/day/weather behavior.
- [ ] E — extract two-phase raw pointer/camera input, focus, and framebuffer
@ -450,6 +450,19 @@ frozen callback behavior.
- Keep `LiveSessionController.Start` last; both disabled-live and
missing-credential starts must execute the reset path.
Result: `LiveSessionHost` now owns reset-plan construction, exact selection and
entered-world ordering, and create→attach route factories while resolving all
session/command/in-world state through the sole `LiveSessionController`.
Partially attached routes and every individual subscription edge retain failed
cleanup work; successful edges are never replayed, and reset/new generations
remain blocked until teardown converges. Named-retail research corrected the
former ACE-only skill shortcut to exact unsigned `SkillFormula::Calculate @
0x00591960` semantics. Three corrected-diff review loops are clean; focused
session/formula/ledger tests, the App suite (3,048 pass / 3 intentional skips),
Core.Net (548 pass), the Release build, and the full suite (7,408 pass / 5
intentional skips) pass. No connected gate was required for this ownership
checkpoint; the final connected lifecycle gate remains Checkpoint L.
### D — world environment
- Move loaded sky/day state, `RefreshSkyForCurrentDay`, `AdminEnvirons`,

View file

@ -0,0 +1,65 @@
# Retail skill-formula calculation
## Oracle
- Named retail: `SkillFormula::Calculate @ 0x00591960` in
`named-retail/acclient_2013_pseudo_c.txt`.
- Retail layout: `SkillFormula` in `named-retail/acclient.h:40199` stores six
unsigned 32-bit fields in wire order: `W`, `X`, `Y`, `Z`, `Attr1`, `Attr2`.
The signed-looking branches in the recovered x87 code are not signed formula
semantics. They are the compiler's unsigned-32-bit-to-floating-point fix-up:
when the high bit is set, it adds `2^32` after the initial signed conversion.
## Pseudocode
```text
bool SkillFormula.Calculate(uint attribute1, uint attribute2, out uint result)
{
uint divisor = Z;
if (divisor == 0)
return false;
// Every operation on this line is uint32 and wraps modulo 2^32.
uint numerator = X * attribute1 + Y * attribute2 + W;
// Formula values are non-negative. Retail rounds to nearest integer,
// with exact halves rounding upward.
result = uint(floor(double(numerator) / double(divisor) + 0.5));
return true;
}
```
Consequences pinned by the port:
- `Z == 0` is the only failure gate. `X == 0` remains a valid formula.
- `W` participates in the numerator before division; it is not added after
division.
- multiplication and addition wrap as unsigned 32-bit arithmetic before the
floating-point division.
- the division rounds half-up; it does not truncate.
## Reference cross-check
ACE's [`SkillFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.DatLoader/Entity/SkillFormula.cs)
confirms the unsigned DAT order `W, X, Y, Z, Attr1, Attr2`. ACE's
[`AttributeFormula.GetFormula`](https://github.com/ACEmulator/ACE/blob/master/Source/ACE.Server/Entity/AttributeFormula.cs)
is only an interpretation aid: it rejects `X == 0`, assumes unit multipliers,
omits `W`, and rounds a summed-attribute quotient. That is sufficient for
ordinary stock ACE data but is not the general retail algorithm.
Chorizite DatReaderWriter's generated
[`SkillFormula`](https://github.com/Chorizite/DatReaderWriter/blob/master/DatReaderWriter/Generated/Types/SkillFormula.generated.cs)
independently confirms the same six-field order and maps its public names to
`W`, `X`, `Y`, and `Z`. It exposes the four arithmetic words as signed `int`,
so the retail port must reinterpret their bits as `uint`. Its summary places
the additive value after division; the named retail function proves that the
actual client adds `W` to the numerator instead.
## Port decision
`RetailSkillFormula` is the sole App-layer calculation seam. It reinterprets
the DAT words as unsigned, performs the exact unchecked 32-bit numerator, and
uses the retail half-up division. `LiveSkillCreditResolver` performs only the
skill-table lookup and treats a missing attribute as zero, matching the prior
live-property projection behavior.

View file

@ -2,10 +2,11 @@
## Current state
The behavior-preserving App ownership campaign is complete through Slice 7.
`GameWindow.cs` moved from the 2026-07-21 baseline of 15,723 lines / 278 fields /
205 methods to 4,666 lines / 196 fields / 70 methods: 11,057 lines (70.3%) were
removed without changing accepted gameplay or rendering behavior.
The behavior-preserving App ownership campaign is complete through Slice 7 and
Slice 8 checkpoints AC. `GameWindow.cs` moved from the 2026-07-21 baseline of
15,723 lines / 278 fields / 205 methods to 4,589 lines / 196 fields / 69
methods: 11,134 lines (70.8%) were removed without changing accepted gameplay
or rendering behavior.
| Slice | Ownership moved out | Closeout size |
|---|---|---:|
@ -17,6 +18,15 @@ removed without changing accepted gameplay or rendering behavior.
| 6 | complete update-frame orchestration | 7,026 / 241 / 108 |
| 7 | complete render-frame orchestration and failure recovery | 4,666 / 196 / 70 |
Slice 8 is an ordered checkpoint campaign rather than another feature-body
move. A froze/pruned the shell boundary; B gave all nine native window callback
edges one reversible owner plus host quiescence; C added `LiveSessionHost` for
reset/selection/entry/route composition while keeping `LiveSessionController`
the sole session owner. C also replaced the embedded ACE shortcut with the
named-retail `SkillFormula::Calculate @ 0x00591960` port. Partial route
construction and individual detach edges are retained and retried; reset and a
new generation cannot pass an incompletely detached route.
Slice 6 implementation commits are `99a3e819`, `4e4aac2c`, `0bc9fda9`,
`c5570383`, `eeb0f6b4`, `947c61d2`, and production cutover `e91f3102`.
Slice 7 implementation commits are `7e4cfb37`, `733126a2`, `bc6f09f9`,
@ -73,6 +83,9 @@ review rule live in `docs/architecture/code-structure.md`.
## Accepted verification
- Slice 8 Checkpoint C Release suite: 7,408 passed / 5 fixture or environment
skips; App 3,048/3 skips and Core.Net 548/0. All three independent corrected-
diff reviews are clean.
- Release suite: 7,341 passed / 5 fixture or environment skips.
- Connected lifecycle/reconnect: 315.6 seconds, graceful capped close and fresh
process uncapped reconnect passed; only 25 expected world-edge empty
@ -96,7 +109,8 @@ review rule live in `docs/architecture/code-structure.md`.
## Next work
Slice 8 groups composition and shutdown wiring and reduces Silk callbacks to
Slice 8 checkpoints DL group the remaining environment, input, settings,
resource ownership, startup, and shutdown wiring and reduce Silk callbacks to
narrow calls into input, update, render, resize, focus, and shutdown owners.
It removes leftover feature state only where Slices 17 already established a
canonical owner; it does not redesign gameplay, rendering, or resource lifetime.

View file

@ -21,7 +21,7 @@ internal sealed record LiveSessionCommandBindings(
/// itself is the published bus, so a retained reference becomes inert before
/// the displaced transport is disposed.
/// </summary>
internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable
internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting
{
private readonly object _gate = new();
private LiveCommandBus? _commands;

View file

@ -57,12 +57,19 @@ internal sealed record LiveSocialSessionBindings(
/// Owns every inbound subscription for one exact live session. Domain state
/// remains in the supplied sinks; this class owns only routing and teardown.
/// </summary>
internal sealed class LiveSessionEventRouter : IDisposable
internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{
private readonly LiveSessionSubscriptionSet _subscriptions = new();
private readonly Action<int>? _constructionCheckpoint;
private readonly WorldSession _session;
private readonly LiveEntitySessionSink _entities;
private readonly LiveEnvironmentSessionSink _environment;
private readonly LiveInventorySessionBindings _inventory;
private readonly LiveCharacterSessionBindings _character;
private readonly LiveSocialSessionBindings _social;
private int _constructionStep;
private int _accepting = 1;
private int _accepting;
private int _lifecycleState; // 0 created, 1 attaching, 2 attached, 3 disposed
public LiveSessionEventRouter(
WorldSession session,
@ -75,7 +82,28 @@ internal sealed class LiveSessionEventRouter : IDisposable
{
ArgumentNullException.ThrowIfNull(session);
Validate(entities, environment, inventory, character, social);
_session = session;
_entities = entities;
_environment = environment;
_inventory = inventory;
_character = character;
_social = social;
_constructionCheckpoint = constructionCheckpoint;
}
public void Attach()
{
if (Interlocked.CompareExchange(ref _lifecycleState, 1, 0) != 0)
throw new InvalidOperationException(
"Live-session event routing can only attach once.");
Interlocked.Exchange(ref _accepting, 1);
WorldSession session = _session;
LiveEntitySessionSink entities = _entities;
LiveEnvironmentSessionSink environment = _environment;
LiveInventorySessionBindings inventory = _inventory;
LiveCharacterSessionBindings character = _character;
LiveSocialSessionBindings social = _social;
try
{
@ -182,22 +210,14 @@ internal sealed class LiveSessionEventRouter : IDisposable
h => session.VitalCurrentUpdated += h,
h => session.VitalCurrentUpdated -= h,
vital => inventory.LocalPlayer.OnVitalCurrent(vital.VitalId, vital.Current));
if (Interlocked.CompareExchange(ref _lifecycleState, 2, 1) != 1)
throw new ObjectDisposedException(nameof(LiveSessionEventRouter));
}
catch (Exception constructionError)
catch
{
Interlocked.Exchange(ref _accepting, 0);
try
{
_subscriptions.Dispose();
}
catch (Exception cleanupError)
{
throw new AggregateException(
"live-session event routing failed and cleanup also failed",
constructionError,
cleanupError);
}
Interlocked.Exchange(ref _lifecycleState, 3);
throw;
}
}
@ -207,6 +227,7 @@ internal sealed class LiveSessionEventRouter : IDisposable
public void Dispose()
{
Interlocked.Exchange(ref _accepting, 0);
Interlocked.Exchange(ref _lifecycleState, 3);
_subscriptions.Dispose();
}
@ -222,15 +243,10 @@ internal sealed class LiveSessionEventRouter : IDisposable
};
attach(handler);
try
{
_subscriptions.Add(() => detach(handler));
}
catch
{
detach(handler);
throw;
}
// Add assumes cleanup ownership before it can call an external detach.
// If the set is already closing, it retains/retries that exact edge;
// calling detach again here would replay a successful removal.
_subscriptions.Add(() => detach(handler));
ConstructionCheckpoint();
}
@ -290,31 +306,48 @@ internal sealed class LiveSessionEventRouter : IDisposable
internal sealed class LiveSessionSubscriptionSet : IDisposable
{
private List<IDisposable>? _subscriptions = [];
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
List<IDisposable>? subscriptions = _subscriptions;
if (subscriptions is null)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
subscriptions.Add(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe) => Add(new ActionSubscription(unsubscribe));
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(LiveSessionSubscriptionSet));
}
public void Dispose()
{
List<IDisposable>? subscriptions = Interlocked.Exchange(ref _subscriptions, null);
if (subscriptions is null)
return;
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int index = subscriptions.Count - 1; index >= 0; index--)
for (int index = subscriptions.Length - 1; index >= 0; index--)
{
try
{
@ -332,10 +365,59 @@ internal sealed class LiveSessionSubscriptionSet : IDisposable
errors);
}
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Live-session subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}

View file

@ -0,0 +1,242 @@
using System.Runtime.ExceptionServices;
using AcDream.Core.Net;
using AcDream.UI.Abstractions;
namespace AcDream.App.Net;
internal interface ILiveSessionEventRouting : IDisposable
{
void Attach();
}
internal interface ILiveSessionCommandRouting : ICommandBus, IDisposable
{
void Activate();
}
internal sealed record LiveSessionRoutingFactories(
Func<WorldSession, ILiveSessionEventRouting> CreateEvents,
Func<WorldSession, ILiveSessionCommandRouting> CreateCommands);
internal sealed record LiveSessionSelectionBindings(
Action<uint> SetPlayerIdentity,
Action<uint> SetVitalsIdentity,
Action<uint> SetChatIdentity,
Action<uint> MarkPersistent,
Action<uint> SetVanishProbeIdentity,
Action ClearCombat);
internal sealed record LiveSessionEnteredWorldBindings(
Action<string> SetActiveCharacter,
Action RestoreLayout,
Action SyncToolbar,
Action<string> LoadCharacterSettings,
Action ArmPlayerModeAutoEntry);
internal sealed record LiveSessionHostBindings(
LiveSessionRoutingFactories Routing,
LiveSessionResetBindings Reset,
LiveSessionSelectionBindings Selection,
LiveSessionEnteredWorldBindings EnteredWorld,
Action<string, int, string> Connecting,
Action Connected);
/// <summary>
/// App composition owner for the one canonical <see cref="LiveSessionController"/>.
/// It owns callback ordering and per-generation route factories, but never
/// mirrors session, generation, identity, routing, or command state.
/// </summary>
internal sealed class LiveSessionHost
{
private sealed class PendingRouteRollback(
ILiveSessionCommandRouting? commands,
ILiveSessionEventRouting? events)
{
private ILiveSessionCommandRouting? _commands = commands;
private ILiveSessionEventRouting? _events = events;
public bool IsComplete => _commands is null && _events is null;
public void Drain()
{
List<Exception>? failures = null;
TryDispose(ref _commands, ref failures);
TryDispose(ref _events, ref failures);
if (failures is not null)
{
throw new AggregateException(
"Live-session route rollback did not converge.",
failures);
}
}
private static void TryDispose<TOwner>(
ref TOwner? owner,
ref List<Exception>? failures)
where TOwner : class, IDisposable
{
if (owner is null)
return;
try
{
owner.Dispose();
owner = null;
}
catch (Exception error)
{
(failures ??= []).Add(error);
}
}
}
private readonly LiveSessionController _controller;
private readonly LiveSessionRoutingFactories _routing;
private readonly LiveSessionSelectionBindings _selection;
private readonly LiveSessionEnteredWorldBindings _enteredWorld;
private readonly LiveSessionResetPlan _resetPlan;
private readonly LiveSessionLifecycleHost _lifecycle;
private PendingRouteRollback? _pendingRouteRollback;
public LiveSessionHost(
LiveSessionController controller,
LiveSessionHostBindings bindings)
{
_controller = controller ?? throw new ArgumentNullException(nameof(controller));
ArgumentNullException.ThrowIfNull(bindings);
_routing = bindings.Routing ?? throw new ArgumentNullException(nameof(bindings.Routing));
_selection = bindings.Selection ?? throw new ArgumentNullException(nameof(bindings.Selection));
_enteredWorld = bindings.EnteredWorld
?? throw new ArgumentNullException(nameof(bindings.EnteredWorld));
ArgumentNullException.ThrowIfNull(_routing.CreateEvents);
ArgumentNullException.ThrowIfNull(_routing.CreateCommands);
ArgumentNullException.ThrowIfNull(bindings.Connecting);
ArgumentNullException.ThrowIfNull(bindings.Connected);
Validate(_selection, _enteredWorld);
_resetPlan = LiveSessionResetManifest.Create(bindings.Reset);
_lifecycle = new LiveSessionLifecycleHost(new LiveSessionLifecycleBindings(
Bind: BindSession,
Reset: ResetSessionState,
Connecting: bindings.Connecting,
Connected: bindings.Connected,
Selected: ApplySelection,
Entered: ApplyEnteredWorld));
}
public WorldSession? CurrentSession => _controller.CurrentSession;
public ICommandBus Commands => _controller.Commands;
public bool IsInWorld => _controller.IsInWorld;
public LiveSessionStartResult Start(RuntimeOptions options) =>
_controller.Start(options, _lifecycle);
public LiveSessionStartResult Reconnect(RuntimeOptions options) =>
_controller.Reconnect(options, _lifecycle);
private LiveSessionBinding BindSession(WorldSession session)
{
DrainPendingRouteRollback();
ILiveSessionEventRouting? events = null;
ILiveSessionCommandRouting? commands = null;
try
{
events = _routing.CreateEvents(session)
?? throw new InvalidOperationException(
"The live-session event factory returned null.");
events.Attach();
commands = _routing.CreateCommands(session)
?? throw new InvalidOperationException(
"The live-session command factory returned null.");
return new LiveSessionBinding(
session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch (Exception creationError)
{
RethrowWithRetryableRollback(creationError, commands, events);
throw;
}
}
private void ResetSessionState()
{
// An incompletely detached route can still deliver callbacks into the
// state below. Treat physical route convergence as the same hard
// barrier used by normal LiveSessionBinding teardown.
DrainPendingRouteRollback();
_resetPlan.Execute();
}
private void ApplySelection(LiveSessionCharacterSelection selection)
{
uint id = selection.CharacterId;
_selection.SetPlayerIdentity(id);
_selection.SetVitalsIdentity(id);
_selection.SetChatIdentity(id);
_selection.MarkPersistent(id);
_selection.SetVanishProbeIdentity(id);
_selection.ClearCombat();
}
private void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
string name = selection.CharacterName;
_enteredWorld.SetActiveCharacter(name);
_enteredWorld.RestoreLayout();
_enteredWorld.SyncToolbar();
_enteredWorld.LoadCharacterSettings(name);
_enteredWorld.ArmPlayerModeAutoEntry();
}
private void RethrowWithRetryableRollback(
Exception creationError,
ILiveSessionCommandRouting? commands,
ILiveSessionEventRouting? events)
{
_pendingRouteRollback = new PendingRouteRollback(commands, events);
try
{
DrainPendingRouteRollback();
}
catch (AggregateException cleanupError)
{
var failures = new List<Exception> { creationError };
failures.AddRange(cleanupError.InnerExceptions);
throw new AggregateException(
"Live-session route construction and rollback both failed.",
failures);
}
ExceptionDispatchInfo.Capture(creationError).Throw();
}
private void DrainPendingRouteRollback()
{
if (_pendingRouteRollback is not { } rollback)
return;
rollback.Drain();
if (rollback.IsComplete)
_pendingRouteRollback = null;
}
private static void Validate(
LiveSessionSelectionBindings selection,
LiveSessionEnteredWorldBindings entered)
{
ArgumentNullException.ThrowIfNull(selection.SetPlayerIdentity);
ArgumentNullException.ThrowIfNull(selection.SetVitalsIdentity);
ArgumentNullException.ThrowIfNull(selection.SetChatIdentity);
ArgumentNullException.ThrowIfNull(selection.MarkPersistent);
ArgumentNullException.ThrowIfNull(selection.SetVanishProbeIdentity);
ArgumentNullException.ThrowIfNull(selection.ClearCombat);
ArgumentNullException.ThrowIfNull(entered.SetActiveCharacter);
ArgumentNullException.ThrowIfNull(entered.RestoreLayout);
ArgumentNullException.ThrowIfNull(entered.SyncToolbar);
ArgumentNullException.ThrowIfNull(entered.LoadCharacterSettings);
ArgumentNullException.ThrowIfNull(entered.ArmPlayerModeAutoEntry);
}
}

View file

@ -0,0 +1,72 @@
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
namespace AcDream.App.Net;
/// <summary>
/// Exact unsigned formula used by retail <c>SkillFormula::Calculate @
/// 0x00591960</c>. DAT reader fields are signed storage views, so their bits
/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words.
/// </summary>
internal static class RetailSkillFormula
{
public static bool TryCalculate(
SkillFormula formula,
uint attribute1,
uint attribute2,
out uint result)
{
ArgumentNullException.ThrowIfNull(formula);
uint divisor = unchecked((uint)formula.Divisor);
if (divisor == 0u)
{
result = 0u;
return false;
}
uint x = unchecked((uint)formula.Attribute1Multiplier);
uint y = unchecked((uint)formula.Attribute2Multiplier);
uint w = unchecked((uint)formula.AdditiveBonus);
uint numerator = unchecked(x * attribute1 + y * attribute2 + w);
result = (uint)Math.Floor((double)numerator / divisor + 0.5d);
return true;
}
}
/// <summary>
/// Named live-session resolver for the attribute contribution to one skill.
/// Table lookup and missing-property policy stay separate from retail math.
/// </summary>
internal sealed class LiveSkillCreditResolver(SkillTable? skillTable)
{
public uint Resolve(
uint skillId,
IReadOnlyDictionary<uint, uint> attributeCurrents)
{
ArgumentNullException.ThrowIfNull(attributeCurrents);
if (skillTable?.Skills is null
|| !skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
{
return 0u;
}
SkillFormula formula = skillBase.Formula;
attributeCurrents.TryGetValue(
(uint)formula.Attribute1,
out uint attribute1);
attributeCurrents.TryGetValue(
(uint)formula.Attribute2,
out uint attribute2);
return RetailSkillFormula.TryCalculate(
formula,
attribute1,
attribute2,
out uint result)
? result
: 0u;
}
}

View file

@ -380,10 +380,6 @@ public sealed class GameWindow : IDisposable
// DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools;
// Slice 3: the reset transaction remains cached by the host, while the
// lifecycle controller owns each exact event/command/session generation.
private AcDream.App.Net.LiveSessionResetPlan? _liveSessionResetPlan;
// Phase G.1-G.2 world lighting/time state.
public readonly AcDream.Core.World.WorldTimeService WorldTime =
new AcDream.Core.World.WorldTimeService(
@ -485,7 +481,7 @@ public sealed class GameWindow : IDisposable
}
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by ApplyLiveSessionEnteredWorld; ticked from
// by LiveSessionHost's entered-world transition; ticked from
// OnUpdate; disarmed if the user manually enters fly mode (or any
// other path that pre-empts the chase camera). Skipped entirely
// offline (orbit camera stays the foreground). The class internally
@ -529,8 +525,9 @@ public sealed class GameWindow : IDisposable
// Slice 3: the controller is the sole App owner. Outbound feature
// callbacks resolve this borrowed handle at call time and never cache it.
private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.App.Net.LiveSessionHost? _liveSessionHost;
private AcDream.Core.Net.WorldSession? LiveSession =>
_liveSessionController?.CurrentSession;
_liveSessionHost?.CurrentSession;
private readonly AcDream.App.World.LiveWorldOriginState _liveWorldOrigin = new();
private int _liveCenterX => _liveWorldOrigin.CenterX;
private int _liveCenterY => _liveWorldOrigin.CenterY;
@ -1194,7 +1191,7 @@ public sealed class GameWindow : IDisposable
try
{
// _activeToonKey is updated by
// ApplyLiveSessionEnteredWorld
// LiveSessionHost entered-world transition
// so saving character settings always
// writes under the chosen character's
// name (or "default" pre-login).
@ -1483,7 +1480,7 @@ public sealed class GameWindow : IDisposable
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSessionController?.IsInWorld == true,
readyForInventoryRequest: () => _liveSessionHost?.IsInWorld == true,
playerOnGround: GetDebugPlayerOnGround,
inNonCombatMode: () => Combat.CurrentMode
== AcDream.Core.Combat.CombatMode.NonCombat,
@ -1534,7 +1531,7 @@ public sealed class GameWindow : IDisposable
playerGuid: () => _playerServerGuid,
activeToonName: () => _activeToonKey,
fallbackSheet: AcDream.App.Studio.SampleData.SampleCharacter,
canSendRaise: () => _liveSessionController?.IsInWorld == true,
canSendRaise: () => _liveSessionHost?.IsInWorld == true,
sendRaiseAttribute: (statId, cost) => LiveSession?.SendRaiseAttribute(statId, cost),
sendRaiseVital: (statId, cost) => LiveSession?.SendRaiseVital(statId, cost),
sendRaiseSkill: (statId, cost) => LiveSession?.SendRaiseSkill(statId, cost),
@ -1555,7 +1552,7 @@ public sealed class GameWindow : IDisposable
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionController?.IsInWorld == true);
canSend: () => _liveSessionHost?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
@ -1653,7 +1650,7 @@ public sealed class GameWindow : IDisposable
Vitals: new AcDream.App.UI.VitalsRuntimeBindings(_vitalsVm),
Chat: new AcDream.App.UI.ChatRuntimeBindings(
retailChatVm,
() => _liveSessionController?.Commands
() => _liveSessionHost?.Commands
?? AcDream.UI.Abstractions.NullCommandBus.Instance),
Radar: new AcDream.App.UI.RadarRuntimeBindings(
radarSnapshotProvider.BuildSnapshot,
@ -2960,10 +2957,9 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
cameraFrame);
_liveSessionHost = CreateLiveSessionHost();
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
CreateLiveSessionLifecycleHost());
_liveSessionHost.Start(_options);
switch (liveStart.Status)
{
case AcDream.App.Net.LiveSessionStartStatus.MissingCredentials:
@ -2976,11 +2972,27 @@ public sealed class GameWindow : IDisposable
}
}
private AcDream.App.Net.LiveSessionLifecycleHost CreateLiveSessionLifecycleHost() =>
new(new AcDream.App.Net.LiveSessionLifecycleBindings(
Bind: CreateLiveSessionBinding,
Reset: () =>
(_liveSessionResetPlan ??= CreateLiveSessionResetPlan()).Execute(),
private AcDream.App.Net.LiveSessionHost CreateLiveSessionHost() =>
new(_liveSessionController!, new AcDream.App.Net.LiveSessionHostBindings(
Routing: new(
CreateLiveSessionEventRouter,
session => new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session))),
Reset: CreateLiveSessionResetBindings(),
Selection: new(
SetPlayerIdentity: id => _playerServerGuid = id,
SetVitalsIdentity: id => _vitalsVm?.SetLocalPlayerGuid(id),
SetChatIdentity: Chat.SetLocalPlayerGuid,
MarkPersistent: _worldState.MarkPersistent,
SetVanishProbeIdentity: id =>
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = id,
ClearCombat: Combat.Clear),
EnteredWorld: new(
SetActiveCharacter: name => _activeToonKey = name,
RestoreLayout: () => _retailUiRuntime?.RestoreLayout(),
SyncToolbar: SyncToolbarWindowButtons,
LoadCharacterSettings: LoadLiveSessionCharacterSettings,
ArmPlayerModeAutoEntry: () => _playerModeAutoEntry?.Arm()),
Connecting: (host, port, user) =>
Chat.OnSystemMessage(
$"connecting to {host}:{port} as {user}",
@ -2988,38 +3000,21 @@ public sealed class GameWindow : IDisposable
Connected: () =>
Chat.OnSystemMessage(
"connected — character list received",
chatType: 1),
Selected: ApplyLiveSessionSelection,
Entered: ApplyLiveSessionEnteredWorld));
chatType: 1)));
private void ApplyLiveSessionSelection(
AcDream.App.Net.LiveSessionCharacterSelection selection)
private void LoadLiveSessionCharacterSettings(string characterName)
{
_playerServerGuid = selection.CharacterId;
_vitalsVm?.SetLocalPlayerGuid(selection.CharacterId);
Chat.SetLocalPlayerGuid(selection.CharacterId);
_worldState.MarkPersistent(selection.CharacterId);
AcDream.App.Streaming.EntityVanishProbe.PlayerGuid = selection.CharacterId;
Combat.Clear();
}
private void ApplyLiveSessionEnteredWorld(
AcDream.App.Net.LiveSessionCharacterSelection selection)
{
_activeToonKey = selection.CharacterName;
_retailUiRuntime?.RestoreLayout();
SyncToolbarWindowButtons();
if (_settingsStore is not null && _settingsVm is not null)
{
var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
var toonBag = _settingsStore.LoadCharacter(characterName);
_settingsVm.LoadCharacterContext(toonBag);
Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
Console.WriteLine(
$"settings: loaded character[{characterName}] preferences");
}
_playerModeAutoEntry?.Arm();
}
private AcDream.App.Net.LiveSessionResetPlan CreateLiveSessionResetPlan() =>
AcDream.App.Net.LiveSessionResetManifest.Create(new()
private AcDream.App.Net.LiveSessionResetBindings
CreateLiveSessionResetBindings() => new()
{
MouseCapture = ResetSessionMouseCapture,
PlayerPresentation = ResetSessionPlayerPresentation,
@ -3052,7 +3047,7 @@ public sealed class GameWindow : IDisposable
AnimationHookFrames = () => _animationHookFrames?.Clear(),
LivePresentation = () => _liveEntityPresentation?.Clear(),
RemoteMovementDiagnostics = _remoteMovementObservations.Clear,
});
};
private void ResetSessionMouseCapture()
=> _gameplayInputFrame?.ResetSession();
@ -3108,12 +3103,7 @@ public sealed class GameWindow : IDisposable
}
}
/// <summary>
/// Composes the exact inbound and outbound owners for one live session.
/// Registration completes before Connect; outbound commands remain inert
/// until EnterWorld succeeds.
/// </summary>
private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding(
private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter(
AcDream.Core.Net.WorldSession session)
{
var skillTable = _dats?.Get<DatReaderWriter.DBObjs.SkillTable>(0x0E000004u);
@ -3126,39 +3116,19 @@ public sealed class GameWindow : IDisposable
Console.WriteLine);
}
AcDream.App.Net.LiveSessionEventRouter? events = null;
AcDream.App.Net.LiveSessionCommandRouter? commands = null;
try
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
commands = new AcDream.App.Net.LiveSessionCommandRouter(
CreateLiveSessionCommandBindings(session));
return new AcDream.App.Net.LiveSessionBinding(
session,
commands,
activateCommands: commands.Activate,
deactivateCommands: commands.Dispose,
detachEvents: events.Dispose);
}
catch
{
commands?.Dispose();
events?.Dispose();
throw;
}
return new AcDream.App.Net.LiveSessionEventRouter(
session,
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
{
WorldTime.SyncFromServer(ticks);
RefreshSkyForCurrentDay();
}),
CreateLiveInventorySessionBindings(),
CreateLiveCharacterSessionBindings(skillTable),
CreateLiveSocialSessionBindings());
}
private AcDream.App.Net.LiveInventorySessionBindings
@ -3178,37 +3148,14 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Net.LiveCharacterSessionBindings
CreateLiveCharacterSessionBindings(
DatReaderWriter.DBObjs.SkillTable? skillTable) => new(
DatReaderWriter.DBObjs.SkillTable? skillTable)
{
var skillCreditResolver =
new AcDream.App.Net.LiveSkillCreditResolver(skillTable);
return new(
Combat,
SpellBook,
ResolveSkillFormulaBonus: (skillId, attributeCurrents) =>
{
// ACE AttributeFormula.GetFormula
// (references/ACE/Source/ACE.Entity/Models/AttributeFormula.cs:55-):
// Attribute1Multiplier == 0 means no attribute contribution.
// Otherwise the retail data formula is
// (attr1 * mult1 + attr2 * mult2) / divisor + additive.
// Guard a zero divisor because malformed/custom DAT input must not
// take down the live-session dispatcher.
if (skillTable?.Skills is null)
return 0u;
if (!skillTable.Skills.TryGetValue(
(DatReaderWriter.Enums.SkillId)skillId,
out var skillBase))
return 0u;
var formula = skillBase.Formula;
if (formula.Attribute1Multiplier == 0 || formula.Divisor == 0)
return 0u;
attributeCurrents.TryGetValue((uint)formula.Attribute1, out uint attribute1);
attributeCurrents.TryGetValue((uint)formula.Attribute2, out uint attribute2);
long numerator =
(long)attribute1 * formula.Attribute1Multiplier +
(long)attribute2 * formula.Attribute2Multiplier;
long bonus = numerator / formula.Divisor + formula.AdditiveBonus;
return bonus < 0 ? 0u : (uint)bonus;
},
ResolveSkillFormulaBonus: skillCreditResolver.Resolve,
OnSkillsUpdated: (runSkill, jumpSkill) =>
{
_localPlayerSkills.Update(runSkill, jumpSkill, _playerController);
@ -3228,6 +3175,7 @@ public sealed class GameWindow : IDisposable
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1,
ClientTime: ClientTimerNow);
}
private AcDream.App.Net.LiveSocialSessionBindings
CreateLiveSocialSessionBindings() => new(
@ -3676,7 +3624,7 @@ public sealed class GameWindow : IDisposable
// optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
// L.0: settings.json store + active toon key. The store is held as
// a field so ApplyLiveSessionEnteredWorld can re-load the chosen toon's
// a field so LiveSessionHost can re-load the chosen toon's
// bag once we know its name (post-EnterWorld). Toon key starts as
// "default" and gets swapped to the actual character name on the
// first EnterWorld.
@ -3816,7 +3764,7 @@ public sealed class GameWindow : IDisposable
_persistedGameplay = _settingsStore.LoadGameplay();
_persistedChat = _settingsStore.LoadChat();
// _activeToonKey is "default" pre-EnterWorld; the post-login
// ApplyLiveSessionEnteredWorld swaps to the chosen toon's
// LiveSessionHost swaps to the chosen toon's
// name and re-loads via SettingsVM.LoadCharacterContext.
_persistedCharacter = _settingsStore.LoadCharacter(_activeToonKey);
@ -4159,7 +4107,7 @@ public sealed class GameWindow : IDisposable
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
@ -4193,7 +4141,7 @@ public sealed class GameWindow : IDisposable
private void UseItemByGuid(uint guid)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionController?.IsInWorld != true || session is null)
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
uint sequence = session.NextGameActionSequence();
session.SendGameAction(
@ -4418,7 +4366,10 @@ public sealed class GameWindow : IDisposable
}
if (ReferenceEquals(_liveSessionController, controller))
{
_liveSessionHost = null;
_liveSessionController = null;
}
}),
]),
// Frame composition borrows equipped, effect, audio, and render

View file

@ -7,36 +7,48 @@ namespace AcDream.Core.Net;
/// </summary>
internal sealed class SubscriptionSet : IDisposable
{
private List<IDisposable>? _subscriptions = [];
private readonly object _gate = new();
private readonly List<RetryableSubscription> _subscriptions = [];
private bool _disposeRequested;
public void Add(IDisposable subscription)
{
ArgumentNullException.ThrowIfNull(subscription);
List<IDisposable>? subscriptions = _subscriptions;
if (subscriptions is null)
{
subscription.Dispose();
throw new ObjectDisposedException(nameof(SubscriptionSet));
}
subscriptions.Add(subscription);
AddRetained(new RetryableSubscription(subscription.Dispose));
}
public void Add(Action unsubscribe)
{
ArgumentNullException.ThrowIfNull(unsubscribe);
Add(new ActionSubscription(unsubscribe));
AddRetained(new RetryableSubscription(unsubscribe));
}
private void AddRetained(RetryableSubscription retained)
{
bool disposeNow;
lock (_gate)
{
disposeNow = _disposeRequested;
_subscriptions.Add(retained);
}
if (!disposeNow)
return;
retained.Dispose();
throw new ObjectDisposedException(nameof(SubscriptionSet));
}
public void Dispose()
{
List<IDisposable>? subscriptions =
Interlocked.Exchange(ref _subscriptions, null);
if (subscriptions is null)
return;
RetryableSubscription[] subscriptions;
lock (_gate)
{
_disposeRequested = true;
subscriptions = _subscriptions.ToArray();
}
List<Exception>? errors = null;
for (int i = subscriptions.Count - 1; i >= 0; i--)
for (int i = subscriptions.Length - 1; i >= 0; i--)
{
try
{
@ -52,10 +64,59 @@ internal sealed class SubscriptionSet : IDisposable
throw new AggregateException("one or more subscriptions failed to detach", errors);
}
private sealed class ActionSubscription(Action unsubscribe) : IDisposable
private sealed class RetryableSubscription(Action dispose) : IDisposable
{
private Action? _unsubscribe = unsubscribe;
private readonly object _gate = new();
private Action? _dispose = dispose;
private bool _executing;
private int _executingThreadId;
public void Dispose() => Interlocked.Exchange(ref _unsubscribe, null)?.Invoke();
public void Dispose()
{
Action? operation;
int threadId = Environment.CurrentManagedThreadId;
lock (_gate)
{
while (_executing)
{
if (_executingThreadId == threadId)
{
throw new InvalidOperationException(
"Subscription cleanup cannot complete reentrantly.");
}
Monitor.Wait(_gate);
}
operation = _dispose;
if (operation is null)
return;
_executing = true;
_executingThreadId = threadId;
}
try
{
operation();
}
catch
{
CompleteAttempt(succeeded: false);
throw;
}
CompleteAttempt(succeeded: true);
}
private void CompleteAttempt(bool succeeded)
{
lock (_gate)
{
if (succeeded)
_dispose = null;
_executing = false;
_executingThreadId = 0;
Monitor.PulseAll(_gate);
}
}
}
}

View file

@ -11,7 +11,7 @@ public sealed class GameWindowLiveSessionOwnershipTests
BindingFlags.Instance | BindingFlags.NonPublic;
[Fact]
public void GameWindowRetainsOnlyTheLifecycleControllerSessionOwner()
public void GameWindowRetainsControllerAndFocusedHostButNoMirroredSession()
{
FieldInfo[] fields = typeof(GameWindow).GetFields(PrivateInstance);
@ -19,8 +19,13 @@ public sealed class GameWindowLiveSessionOwnershipTests
fields,
field => field.Name == "_liveSessionController"
&& field.FieldType == typeof(LiveSessionController));
Assert.Contains(
fields,
field => field.Name == "_liveSessionHost"
&& field.FieldType == typeof(LiveSessionHost));
Assert.DoesNotContain(fields, field => field.Name == "_liveSession");
Assert.DoesNotContain(fields, field => field.FieldType == typeof(WorldSession));
Assert.DoesNotContain(fields, field => field.FieldType == typeof(LiveSessionResetPlan));
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionEvents");
Assert.DoesNotContain(fields, field => field.Name == "_liveSessionCommands");
}
@ -30,6 +35,9 @@ public sealed class GameWindowLiveSessionOwnershipTests
[InlineData("ClearInboundEntityState")]
[InlineData("WireLiveSessionEvents")]
[InlineData("DisposeLiveSessionRouting")]
[InlineData("CreateLiveSessionBinding")]
[InlineData("ApplyLiveSessionSelection")]
[InlineData("ApplyLiveSessionEnteredWorld")]
public void DisplacedLifecycleBodiesAreAbsent(string methodName)
{
Assert.Null(typeof(GameWindow).GetMethod(methodName, PrivateInstance));

View file

@ -13,6 +13,33 @@ namespace AcDream.App.Tests.Net;
public sealed class LiveSessionEventRouterTests
{
[Fact]
public void CreatedRouterPublishesNoHandlersUntilExplicitAttach()
{
using var session = NewSession();
int baselineGameEvents = session.GameEvents.RegisteredHandlerCount;
var router = new LiveSessionEventRouter(
session,
new LiveEntitySessionSink(
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { },
_ => { }, _ => { }, _ => { }, _ => { }, _ => { }, _ => { }),
new LiveEnvironmentSessionSink(_ => { }, _ => { }),
NewInventoryBindings(),
NewCharacterBindings(),
NewSocialBindings());
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
router.Attach();
AssertSessionHandlerCounts(session, multiplier: 1);
Assert.True(session.GameEvents.RegisteredHandlerCount > baselineGameEvents);
router.Dispose();
AssertSessionHandlerCounts(session, multiplier: 0);
Assert.Equal(baselineGameEvents, session.GameEvents.RegisteredHandlerCount);
}
[Fact]
public void Dispose_DetachesExactHandlersAndSilencesCopiedDelegates()
{
@ -157,33 +184,46 @@ public sealed class LiveSessionEventRouterTests
Counters counters,
Action<int>? constructionCheckpoint = null,
CombatState? combat = null,
ChatLog? chat = null) => new(
session,
new LiveEntitySessionSink(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>
{
if (counters.ThrowOnServerTime)
throw new InvalidOperationException("injected sink failure");
counters.ServerTime++;
}),
NewInventoryBindings(),
NewCharacterBindings(combat),
NewSocialBindings(chat),
constructionCheckpoint);
ChatLog? chat = null)
{
var router = new LiveSessionEventRouter(
session,
new LiveEntitySessionSink(
Spawned: _ => { },
Deleted: _ => { },
PickedUp: _ => { },
MotionUpdated: _ => { },
PositionUpdated: _ => { },
VectorUpdated: _ => { },
StateUpdated: _ => { },
ParentUpdated: _ => { },
TeleportStarted: _ => counters.Teleport++,
AppearanceUpdated: _ => { },
PlayPhysicsScript: _ => { },
PlayPhysicsScriptType: _ => { }),
new LiveEnvironmentSessionSink(
EnvironChanged: _ => { },
ServerTimeUpdated: _ =>
{
if (counters.ThrowOnServerTime)
throw new InvalidOperationException("injected sink failure");
counters.ServerTime++;
}),
NewInventoryBindings(),
NewCharacterBindings(combat),
NewSocialBindings(chat),
constructionCheckpoint);
try
{
router.Attach();
return router;
}
catch
{
router.Dispose();
throw;
}
}
private static LiveInventorySessionBindings NewInventoryBindings() => new(
new ClientObjectTable(),

View file

@ -0,0 +1,391 @@
using System.Net;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionHostTests
{
[Fact]
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
var commands = new TestCommandRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
calls.Add("events");
return events;
},
_ =>
{
calls.Add("commands");
return commands;
});
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"reset", "resolve", "create", "events", "attach-events", "commands",
"connecting", "connect", "connected",
"player:1342177282", "vitals:1342177282",
"chat:1342177282", "persistent:1342177282",
"vanish:1342177282", "clear-combat", "enter:1",
"activate", "active:Ready", "restore-layout",
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
],
calls);
Assert.Same(controller.CurrentSession, host.CurrentSession);
Assert.Same(commands, host.Commands);
Assert.True(host.IsInWorld);
controller.Dispose();
}
[Fact]
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => throw new InvalidOperationException("must not bind"),
_ => throw new InvalidOperationException("must not bind"));
Assert.Equal(
LiveSessionStartStatus.Disabled,
host.Start(LiveOptions(live: false)).Status);
Assert.Equal(
LiveSessionStartStatus.MissingCredentials,
host.Start(LiveOptions(user: null)).Status);
Assert.Equal(["reset", "reset"], calls);
Assert.Null(host.CurrentSession);
Assert.False(host.IsInWorld);
}
[Fact]
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => events,
_ => throw new InvalidOperationException("command failure"));
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.IsType<InvalidOperationException>(result.Error);
Assert.Equal(1, events.DisposeCount);
Assert.Contains("dispose-events", calls);
Assert.Null(host.CurrentSession);
}
[Fact]
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls)
{
AttachFailuresRemaining = 1,
FailuresRemaining = 1,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
commandFactoryCount++;
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
Assert.Equal(1, firstEvents.AttachCount);
Assert.Equal(2, firstEvents.DisposeCount);
Assert.Equal(0, commandFactoryCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
controller.Dispose();
}
[Fact]
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
int eventFactoryCount = 0;
bool failCommands = true;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
if (failCommands)
{
failCommands = false;
throw new InvalidOperationException("command failure");
}
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
Assert.Equal(2, error.InnerExceptions.Count);
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
Assert.Equal(2, firstEvents.DisposeCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(2, operations.Sessions.Count);
controller.Dispose();
}
[Fact]
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls)
{
FailuresRemaining = int.MaxValue,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
eventFactoryCount++;
return events;
},
_ =>
{
commandFactoryCount++;
throw new InvalidOperationException("command failure");
});
LiveSessionStartResult first = host.Start(LiveOptions());
LiveSessionStartResult second = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
Assert.Equal(3, events.DisposeCount);
Assert.Equal(1, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
Assert.Single(operations.Sessions);
Assert.Null(host.CurrentSession);
}
private static LiveSessionHost CreateHost(
LiveSessionController controller,
List<string> calls,
Func<WorldSession, ILiveSessionEventRouting> createEvents,
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: ResetBindings(() => calls.Add("reset")),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),
id => calls.Add($"chat:{id}"),
id => calls.Add($"persistent:{id}"),
id => calls.Add($"vanish:{id}"),
() => calls.Add("clear-combat")),
EnteredWorld: new(
name => calls.Add($"active:{name}"),
() => calls.Add("restore-layout"),
() => calls.Add("sync-toolbar"),
name => calls.Add($"load-settings:{name}"),
() => calls.Add("arm-auto-entry")),
Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected")));
private static LiveSessionResetBindings ResetBindings(Action reset)
{
Action noop = static () => { };
return new LiveSessionResetBindings
{
MouseCapture = reset,
PlayerPresentation = noop,
TeleportTransit = noop,
SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,
MagicRuntime = noop,
CombatAttack = noop,
CombatState = noop,
ItemMana = noop,
LocalPlayer = noop,
Friends = noop,
Squelch = noop,
TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
SessionIdentity = noop,
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,
LivePresentation = noop,
RemoteMovementDiagnostics = noop,
};
}
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
{
var environment = new Dictionary<string, string?>
{
["ACDREAM_LIVE"] = live ? "1" : "0",
["ACDREAM_TEST_HOST"] = "127.0.0.1",
["ACDREAM_TEST_PORT"] = "9000",
["ACDREAM_TEST_USER"] = user,
["ACDREAM_TEST_PASS"] = "password",
};
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
}
private sealed class TestEventRouting(List<string> calls)
: ILiveSessionEventRouting
{
public int FailuresRemaining { get; set; }
public int AttachFailuresRemaining { get; set; }
public int AttachCount { get; private set; }
public int DisposeCount { get; private set; }
public void Attach()
{
AttachCount++;
calls.Add("attach-events");
if (AttachFailuresRemaining > 0)
{
AttachFailuresRemaining--;
throw new InvalidOperationException("event attach failure");
}
}
public void Dispose()
{
DisposeCount++;
calls.Add("dispose-events");
if (FailuresRemaining > 0)
{
FailuresRemaining--;
throw new InvalidOperationException("event cleanup failure");
}
}
}
private sealed class TestCommandRouting(List<string> calls)
: ILiveSessionCommandRouting
{
public void Activate() => calls.Add("activate");
public void Publish<T>(T command) where T : notnull { }
public void Dispose() => calls.Add("dispose-commands");
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
var session = new WorldSession(endpoint, new TestTransport());
Sessions.Add(session);
return session;
}
public void Connect(WorldSession session, string user, string password) =>
calls.Add("connect");
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
0u,
[
new CharacterList.Character(0x50000001u, "Grey", 10u),
new CharacterList.Character(0x50000002u, "Ready", 0u),
],
[],
11,
"Canonical",
true,
true);
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
calls.Add($"enter:{activeCharacterIndex}");
public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
session.Dispose();
}
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return null;
}
public void Dispose() { }
}
}

View file

@ -0,0 +1,60 @@
using AcDream.App.Net;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionSubscriptionSetTests
{
[Fact]
public void Dispose_RetriesOnlyFailedEdgesUntilTheyConverge()
{
var order = new List<int>();
var subscriptions = new LiveSessionSubscriptionSet();
subscriptions.Add(() => order.Add(1));
int secondFailures = 1;
subscriptions.Add(() =>
{
order.Add(2);
if (secondFailures-- > 0)
throw new InvalidOperationException("second failed");
});
int thirdFailures = 1;
subscriptions.Add(() =>
{
order.Add(3);
if (thirdFailures-- > 0)
throw new IOException("third failed");
});
AggregateException error = Assert.Throws<AggregateException>(
subscriptions.Dispose);
Assert.Equal([3, 2, 1], order);
Assert.Collection(
error.InnerExceptions,
exception => Assert.IsType<IOException>(exception),
exception => Assert.IsType<InvalidOperationException>(exception));
subscriptions.Dispose();
subscriptions.Dispose();
Assert.Equal([3, 2, 1, 3, 2], order);
}
[Fact]
public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges()
{
var order = new List<int>();
var subscriptions = new LiveSessionSubscriptionSet();
subscriptions.Add(() => order.Add(1));
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("persistent");
});
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Equal([2, 1, 2], order);
}
}

View file

@ -0,0 +1,112 @@
using AcDream.App.Net;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.Net;
public sealed class RetailSkillFormulaTests
{
[Fact]
public void ZeroDivisorIsTheOnlyFormulaFailureGate()
{
SkillFormula invalid = Formula(w: 7, x: 1, y: 1, z: 0);
Assert.False(RetailSkillFormula.TryCalculate(invalid, 10u, 20u, out uint invalidResult));
Assert.Equal(0u, invalidResult);
SkillFormula zeroX = Formula(w: 7, x: 0, y: 2, z: 3);
Assert.True(RetailSkillFormula.TryCalculate(zeroX, 10u, 4u, out uint validResult));
Assert.Equal(5u, validResult);
}
[Theory]
[InlineData(9u, 4u, 2u)]
[InlineData(10u, 4u, 3u)]
[InlineData(11u, 4u, 3u)]
[InlineData(12u, 4u, 3u)]
public void DivisionRoundsToNearestWithExactHalvesUp(
uint numerator,
uint divisor,
uint expected)
{
SkillFormula formula = Formula(w: 0, x: 1, y: 0, z: divisor);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
numerator,
0u,
out uint result));
Assert.Equal(expected, result);
}
[Fact]
public void AdditiveWordIsInsideTheNumerator()
{
SkillFormula formula = Formula(w: 3, x: 1, y: 1, z: 4);
Assert.True(RetailSkillFormula.TryCalculate(formula, 4u, 2u, out uint result));
Assert.Equal(2u, result);
}
[Fact]
public void NumeratorUsesUnsignedThirtyTwoBitWrap()
{
SkillFormula formula = Formula(w: 1, x: 2, y: 0, z: 2);
Assert.True(RetailSkillFormula.TryCalculate(
formula,
uint.MaxValue,
0u,
out uint result));
Assert.Equal(0x80000000u, result);
}
[Fact]
public void SignedDatStorageIsReinterpretedAsRetailUnsignedWords()
{
SkillFormula formula = Formula(w: -1, x: 0, y: 0, z: 1);
Assert.True(RetailSkillFormula.TryCalculate(formula, 0u, 0u, out uint result));
Assert.Equal(uint.MaxValue, result);
}
[Fact]
public void LiveResolverLooksUpTheDatFormulaAndTreatsMissingAttributesAsZero()
{
const uint skillId = 0x345u;
const uint firstAttributeId = 1u;
const uint secondAttributeId = 2u;
var skillTable = new SkillTable();
skillTable.Skills.Add((SkillId)skillId, new SkillBase
{
Formula = new SkillFormula
{
AdditiveBonus = 1,
Attribute1Multiplier = 1,
Attribute2Multiplier = 2,
Divisor = 3,
Attribute1 = (AttributeId)firstAttributeId,
Attribute2 = (AttributeId)secondAttributeId,
},
});
var resolver = new LiveSkillCreditResolver(skillTable);
uint result = resolver.Resolve(
skillId,
new Dictionary<uint, uint> { [firstAttributeId] = 8u });
Assert.Equal(3u, result);
Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary<uint, uint>()));
}
private static SkillFormula Formula(int w, int x, int y, uint z) => new()
{
AdditiveBonus = w,
Attribute1Multiplier = x,
Attribute2Multiplier = y,
Divisor = unchecked((int)z),
};
}

View file

@ -48,7 +48,7 @@ public sealed class GameWindowSlice8BoundaryTests
{
string body = MethodBody(
"private void OnLoad()",
"private AcDream.App.Net.LiveSessionLifecycleHost");
"private AcDream.App.Net.LiveSessionHost");
AssertAppearsInOrder(
body,
@ -71,9 +71,10 @@ public sealed class GameWindowSlice8BoundaryTests
"_liveEntities = new AcDream.App.World.LiveEntityRuntime(",
"_renderFrameOrchestrator =",
"_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(",
"_liveSessionController.Start(");
Assert.Equal(1, CountOccurrences(body, "_liveSessionController.Start("));
int start = body.IndexOf("_liveSessionController.Start(", StringComparison.Ordinal);
"_liveSessionHost = CreateLiveSessionHost();",
"_liveSessionHost.Start(_options)");
Assert.Equal(1, CountOccurrences(body, "_liveSessionHost.Start(_options)"));
int start = body.IndexOf("_liveSessionHost.Start(_options)", StringComparison.Ordinal);
string postStart = body[start..];
Assert.Contains("switch (liveStart.Status)", postStart, StringComparison.Ordinal);
Assert.Empty(System.Text.RegularExpressions.Regex.Matches(

View file

@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests
}
[Fact]
public void Dispose_AttemptsEveryReleaseAndAggregatesFailures()
public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge()
{
var order = new List<int>();
var subscriptions = new SubscriptionSet();
subscriptions.Add(() => order.Add(1));
int secondFailures = 1;
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("second failed");
if (secondFailures-- > 0)
throw new InvalidOperationException("second failed");
});
int thirdFailures = 1;
subscriptions.Add(() =>
{
order.Add(3);
throw new IOException("third failed");
if (thirdFailures-- > 0)
throw new IOException("third failed");
});
AggregateException error = Assert.Throws<AggregateException>(
@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests
error.InnerExceptions,
exception => Assert.IsType<IOException>(exception),
exception => Assert.IsType<InvalidOperationException>(exception));
subscriptions.Dispose();
subscriptions.Dispose();
Assert.Equal([3, 2, 1, 3, 2], order);
}
[Fact]
public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases()
{
var order = new List<int>();
var subscriptions = new SubscriptionSet();
subscriptions.Add(() => order.Add(1));
subscriptions.Add(() =>
{
order.Add(2);
throw new InvalidOperationException("persistent");
});
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Throws<AggregateException>(subscriptions.Dispose);
Assert.Equal([2, 1, 2], order);
}
}