From 557eb7ef6bffecd67c72150f5d011627b61f7252 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 22 Jul 2026 10:36:06 +0200 Subject: [PATCH] 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 --- AGENTS.md | 16 +- CLAUDE.md | 16 +- docs/ISSUES.md | 41 +- docs/architecture/code-structure.md | 6 +- docs/plans/2026-04-11-roadmap.md | 9 +- docs/plans/2026-05-12-milestones.md | 6 +- ...amewindow-slice-8-composition-lifecycle.md | 15 +- .../skill_formula_calculate_pseudocode.md | 65 +++ memory/project_gamewindow_decomposition.md | 24 +- .../Net/LiveSessionCommandRouter.cs | 2 +- src/AcDream.App/Net/LiveSessionEventRouter.cs | 164 ++++++-- src/AcDream.App/Net/LiveSessionHost.cs | 242 +++++++++++ src/AcDream.App/Net/RetailSkillFormula.cs | 72 ++++ src/AcDream.App/Rendering/GameWindow.cs | 183 +++----- src/AcDream.Core.Net/SubscriptionSet.cs | 97 ++++- .../GameWindowLiveSessionOwnershipTests.cs | 10 +- .../Net/LiveSessionEventRouterTests.cs | 94 +++-- .../Net/LiveSessionHostTests.cs | 391 ++++++++++++++++++ .../Net/LiveSessionSubscriptionSetTests.cs | 60 +++ .../Net/RetailSkillFormulaTests.cs | 112 +++++ .../GameWindowSlice8BoundaryTests.cs | 9 +- .../SubscriptionSetTests.cs | 32 +- 22 files changed, 1430 insertions(+), 236 deletions(-) create mode 100644 docs/research/skill_formula_calculate_pseudocode.md create mode 100644 src/AcDream.App/Net/LiveSessionHost.cs create mode 100644 src/AcDream.App/Net/RetailSkillFormula.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs create mode 100644 tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs create mode 100644 tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs diff --git a/AGENTS.md b/AGENTS.md index 8122d41e..6df5a163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,11 +127,17 @@ second GUID dictionary. Slices 5–7 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 A–C 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 D–L—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. diff --git a/CLAUDE.md b/CLAUDE.md index 4a332db0..d78eec38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -125,11 +125,17 @@ second GUID dictionary. Slices 5–7 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 A–C 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 D–L—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. diff --git a/docs/ISSUES.md b/docs/ISSUES.md index c0ca02ae..331b0497 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -27,9 +27,11 @@ What does NOT go here: ## Current queue — 2026-07-22 - **Active structural work:** `GameWindow` decomposition. Slices 1–7 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 A–C 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 diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md index 82eac7fa..e967c89d 100644 --- a/docs/architecture/code-structure.md +++ b/docs/architecture/code-structure.md @@ -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. | diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 8e405354..68735a60 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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 1–7 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 1–7 and Slice 8 checkpoints A–C 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 A–C 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 D–L remain. This is a behavior-preserving structural program. Severe regressions still get root-cause fixes in separate commits; ordinary feature work resumes with M4 diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 91eb7ccc..403b3a17 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 A–C are complete: +native callbacks and live-session composition now have explicit retryable +owners, and `GameWindow` is 4,589 lines / 196 fields / 69 methods. Checkpoints +D–L remain active. Issue #232 tracks process-residency variance in the soak without loosening its leak threshold. Carried: diff --git a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md index b60e4e37..d6410f63 100644 --- a/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md +++ b/docs/plans/2026-07-22-gamewindow-slice-8-composition-lifecycle.md @@ -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`, diff --git a/docs/research/skill_formula_calculate_pseudocode.md b/docs/research/skill_formula_calculate_pseudocode.md new file mode 100644 index 00000000..08e7a6ad --- /dev/null +++ b/docs/research/skill_formula_calculate_pseudocode.md @@ -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. diff --git a/memory/project_gamewindow_decomposition.md b/memory/project_gamewindow_decomposition.md index 130392b4..a6f535d4 100644 --- a/memory/project_gamewindow_decomposition.md +++ b/memory/project_gamewindow_decomposition.md @@ -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 A–C. `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 D–L 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 1–7 already established a canonical owner; it does not redesign gameplay, rendering, or resource lifetime. diff --git a/src/AcDream.App/Net/LiveSessionCommandRouter.cs b/src/AcDream.App/Net/LiveSessionCommandRouter.cs index 3390b7b2..50ab0949 100644 --- a/src/AcDream.App/Net/LiveSessionCommandRouter.cs +++ b/src/AcDream.App/Net/LiveSessionCommandRouter.cs @@ -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. /// -internal sealed class LiveSessionCommandRouter : ICommandBus, IDisposable +internal sealed class LiveSessionCommandRouter : ILiveSessionCommandRouting { private readonly object _gate = new(); private LiveCommandBus? _commands; diff --git a/src/AcDream.App/Net/LiveSessionEventRouter.cs b/src/AcDream.App/Net/LiveSessionEventRouter.cs index 585dbe08..084f0ea3 100644 --- a/src/AcDream.App/Net/LiveSessionEventRouter.cs +++ b/src/AcDream.App/Net/LiveSessionEventRouter.cs @@ -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. /// -internal sealed class LiveSessionEventRouter : IDisposable +internal sealed class LiveSessionEventRouter : ILiveSessionEventRouting { private readonly LiveSessionSubscriptionSet _subscriptions = new(); private readonly Action? _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? _subscriptions = []; + private readonly object _gate = new(); + private readonly List _subscriptions = []; + private bool _disposeRequested; public void Add(IDisposable subscription) { ArgumentNullException.ThrowIfNull(subscription); - List? 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? subscriptions = Interlocked.Exchange(ref _subscriptions, null); - if (subscriptions is null) - return; + RetryableSubscription[] subscriptions; + lock (_gate) + { + _disposeRequested = true; + subscriptions = _subscriptions.ToArray(); + } List? 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); + } + } } } diff --git a/src/AcDream.App/Net/LiveSessionHost.cs b/src/AcDream.App/Net/LiveSessionHost.cs new file mode 100644 index 00000000..80523456 --- /dev/null +++ b/src/AcDream.App/Net/LiveSessionHost.cs @@ -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 CreateEvents, + Func CreateCommands); + +internal sealed record LiveSessionSelectionBindings( + Action SetPlayerIdentity, + Action SetVitalsIdentity, + Action SetChatIdentity, + Action MarkPersistent, + Action SetVanishProbeIdentity, + Action ClearCombat); + +internal sealed record LiveSessionEnteredWorldBindings( + Action SetActiveCharacter, + Action RestoreLayout, + Action SyncToolbar, + Action LoadCharacterSettings, + Action ArmPlayerModeAutoEntry); + +internal sealed record LiveSessionHostBindings( + LiveSessionRoutingFactories Routing, + LiveSessionResetBindings Reset, + LiveSessionSelectionBindings Selection, + LiveSessionEnteredWorldBindings EnteredWorld, + Action Connecting, + Action Connected); + +/// +/// App composition owner for the one canonical . +/// It owns callback ordering and per-generation route factories, but never +/// mirrors session, generation, identity, routing, or command state. +/// +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? 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( + ref TOwner? owner, + ref List? 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 { 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); + } +} diff --git a/src/AcDream.App/Net/RetailSkillFormula.cs b/src/AcDream.App/Net/RetailSkillFormula.cs new file mode 100644 index 00000000..72688bae --- /dev/null +++ b/src/AcDream.App/Net/RetailSkillFormula.cs @@ -0,0 +1,72 @@ +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Net; + +/// +/// Exact unsigned formula used by retail SkillFormula::Calculate @ +/// 0x00591960. DAT reader fields are signed storage views, so their bits +/// are deliberately reinterpreted as retail's unsigned W/X/Y/Z words. +/// +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; + } +} + +/// +/// Named live-session resolver for the attribute contribution to one skill. +/// Table lookup and missing-property policy stay separate from retail math. +/// +internal sealed class LiveSkillCreditResolver(SkillTable? skillTable) +{ + public uint Resolve( + uint skillId, + IReadOnlyDictionary 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; + } +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index b1cefe90..3e3eb48b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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 } } - /// - /// Composes the exact inbound and outbound owners for one live session. - /// Registration completes before Connect; outbound commands remain inert - /// until EnterWorld succeeds. - /// - private AcDream.App.Net.LiveSessionBinding CreateLiveSessionBinding( + private AcDream.App.Net.LiveSessionEventRouter CreateLiveSessionEventRouter( AcDream.Core.Net.WorldSession session) { var skillTable = _dats?.Get(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 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 diff --git a/src/AcDream.Core.Net/SubscriptionSet.cs b/src/AcDream.Core.Net/SubscriptionSet.cs index 5f271c02..9ba63f6d 100644 --- a/src/AcDream.Core.Net/SubscriptionSet.cs +++ b/src/AcDream.Core.Net/SubscriptionSet.cs @@ -7,36 +7,48 @@ namespace AcDream.Core.Net; /// internal sealed class SubscriptionSet : IDisposable { - private List? _subscriptions = []; + private readonly object _gate = new(); + private readonly List _subscriptions = []; + private bool _disposeRequested; public void Add(IDisposable subscription) { ArgumentNullException.ThrowIfNull(subscription); - List? 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? subscriptions = - Interlocked.Exchange(ref _subscriptions, null); - if (subscriptions is null) - return; + RetryableSubscription[] subscriptions; + lock (_gate) + { + _disposeRequested = true; + subscriptions = _subscriptions.ToArray(); + } List? 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); + } + } } } diff --git a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs index 8d65f140..6eb45476 100644 --- a/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs +++ b/tests/AcDream.App.Tests/Net/GameWindowLiveSessionOwnershipTests.cs @@ -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)); diff --git a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs index 8402cbfb..bda1e946 100644 --- a/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs +++ b/tests/AcDream.App.Tests/Net/LiveSessionEventRouterTests.cs @@ -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? 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(), diff --git a/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs new file mode 100644 index 00000000..1bf94684 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs @@ -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(); + 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(); + 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(); + 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(result.Error); + Assert.Equal(1, events.DisposeCount); + Assert.Contains("dispose-events", calls); + Assert.Null(host.CurrentSession); + } + + [Fact] + public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges() + { + var calls = new List(); + 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(); + 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(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(); + 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 calls, + Func createEvents, + Func 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 + { + ["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 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 calls) + : ILiveSessionCommandRouting + { + public void Activate() => calls.Add("activate"); + public void Publish(T command) where T : notnull { } + public void Dispose() => calls.Add("dispose-commands"); + } + + private sealed class TestOperations(List calls) : ILiveSessionOperations + { + public List 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 datagram) { } + public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } + + public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from) + { + from = null; + return null; + } + + public void Dispose() { } + } +} diff --git a/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs b/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs new file mode 100644 index 00000000..e120aabc --- /dev/null +++ b/tests/AcDream.App.Tests/Net/LiveSessionSubscriptionSetTests.cs @@ -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(); + 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( + subscriptions.Dispose); + + Assert.Equal([3, 2, 1], order); + Assert.Collection( + error.InnerExceptions, + exception => Assert.IsType(exception), + exception => Assert.IsType(exception)); + + subscriptions.Dispose(); + subscriptions.Dispose(); + + Assert.Equal([3, 2, 1, 3, 2], order); + } + + [Fact] + public void Dispose_PersistentFailureNeverReplaysSuccessfulEdges() + { + var order = new List(); + var subscriptions = new LiveSessionSubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => + { + order.Add(2); + throw new InvalidOperationException("persistent"); + }); + + Assert.Throws(subscriptions.Dispose); + Assert.Throws(subscriptions.Dispose); + + Assert.Equal([2, 1, 2], order); + } +} diff --git a/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs new file mode 100644 index 00000000..50db95f1 --- /dev/null +++ b/tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs @@ -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 { [firstAttributeId] = 8u }); + + Assert.Equal(3u, result); + Assert.Equal(0u, resolver.Resolve(0x999u, new Dictionary())); + } + + private static SkillFormula Formula(int w, int x, int y, uint z) => new() + { + AdditiveBonus = w, + Attribute1Multiplier = x, + Attribute2Multiplier = y, + Divisor = unchecked((int)z), + }; +} diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs index e2f0da7d..e2e915b1 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowSlice8BoundaryTests.cs @@ -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( diff --git a/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs index 658983b1..c0d95557 100644 --- a/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs +++ b/tests/AcDream.Core.Net.Tests/SubscriptionSetTests.cs @@ -18,20 +18,24 @@ public sealed class SubscriptionSetTests } [Fact] - public void Dispose_AttemptsEveryReleaseAndAggregatesFailures() + public void Dispose_RetriesOnlyFailedReleasesUntilTheyConverge() { var order = new List(); 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( @@ -42,6 +46,28 @@ public sealed class SubscriptionSetTests error.InnerExceptions, exception => Assert.IsType(exception), exception => Assert.IsType(exception)); + subscriptions.Dispose(); + subscriptions.Dispose(); + + Assert.Equal([3, 2, 1, 3, 2], order); + } + + [Fact] + public void Dispose_PersistentFailureNeverReplaysSuccessfulReleases() + { + var order = new List(); + var subscriptions = new SubscriptionSet(); + subscriptions.Add(() => order.Add(1)); + subscriptions.Add(() => + { + order.Add(2); + throw new InvalidOperationException("persistent"); + }); + + Assert.Throws(subscriptions.Dispose); + Assert.Throws(subscriptions.Dispose); + + Assert.Equal([2, 1, 2], order); } }