diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index 099f8f1af..e9c4f4179 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1092,8 +1092,18 @@ the exact retail `Client::UseTime` order. See the completed Slice 6 ledger and `RenderFrameOrchestrator`. Its frozen graph begins per-resource frames and render-thread uploads; draws world/PView and its two shared-alpha scopes; renders portal and paperdoll private viewports, retained gameplay UI, -devtools, and screenshots; then closes the GPU-flight transaction in -`finally`. There is one UI stack: the retained tree. +and devtools; closes GPU measurement and exactly one GPU-flight transaction; +then captures the successfully submitted complete frame before CPU diagnostics, +shadow comparison, and lifecycle checkpoint publication. Zero-area and failed- +close frames do not consume queued captures. Vulkan retained-copy validity is +earned only by a successful submission containing that frame's copy, never by +allocation, resize, or command recording alone. The retained-copy command emits +an exact-range COPY/TRANSFER_WRITE to HOST/HOST_READ buffer dependency; capture +uses the existing device-idle completion wait before reading. This opt-in path +requires the actual capture allocation to be host-coherent and rejects an +unsupported noncoherent allocation before wait/read (AD-121); it does not change +shared heap selection or generic buffer invalidation. There is one UI stack: +the retained tree. --- diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 89bf176d2..5ac4e4412 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -67,7 +67,7 @@ accepted-divergence entries (#96, #49, #50). --- -## 2. Adaptation (AD) — 91 active rows (AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- +## 2. Adaptation (AD) — 92 active rows (AD-120 filed 2026-09-04 at the S4-c2 fix round 1 (M3) — a translucent building-shell instance under building detail draws immediately at its own walk-stream alpha-submission mark rather than "in place" mid-mesh-call, since acdream's opaque instances are stream-batched and retail's mesh call has no equivalent; AD-119 filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2) — the portal-depth color path substitutes a `ColorWrite=false` write mask for retail's zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend, a provably pixel-identical no-op either way; AD-117 filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round — three residual Contract A/B approximations the S2 retail-lens review named (visual-AABB circumsphere cheap reject, part rows published into unloaded neighbour cells, the unported `state & 0x1000` particle branch) — its original item 1, the render-only destination-cell move rule, was VERIFIED the same night as retail's own zero-sphere `CObjCell::find_cell_list` 0x0052b4e0 mechanism and is not a deviation; AD-116 filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 — `WalkProductionWorldData`'s borrowed per-cell view contributes NO cell for an entity the registry has flooded but the presentation scene cannot resolve yet (the deleted parent-cell/root-position fallbacks are gone), counted once per distinct entity per frame in `UnregisteredRenderMembershipCount`; AD-115 filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16) — `BuildCharacterTitleDisplay` clears the Profession element (`0x10000151`) when neither Int 261 CharacterTitleId nor String 5 Template resolves, where retail never clears `0x10000150`/`51`/`52` anywhere and would instead show the PREVIOUS target's stale title; AD-114 filed 2026-08-25 at Campaign AS slice AS2, owner-ruled ("we animate it, and I like it") — the examination window's preview clone tracks the assessed creature's live current animated pose every frame, where retail's clone plays its own private `CreatureMode` cycle decoupled from the live target's actual motion; AD-113 filed 2026-08-25 at Campaign CT slice CT-GF1 — `UiMenu`'s inline-drawn popup opts out of the new client-wide ancestor-clip default (`ExpandsClipForPopup`), standing in for retail's separate top-level popup region; AD-112 filed 2026-08-23 with the sky default-script port — camera-anchored synthetic script owners instead of retail's sky-cell physics objects; AD-110 filed 2026-08-17 at the entry/exit presentation round — the in-world logoff's single confirmed-echo handoff edge versus retail's two independent ExecuteLogOff/CharacterList edges, and the Tunnel-hold tail; AD-74 RETIRED 2026-08-17 at the same round — the Exit to Character Selection "behaves as Exit Game" adaptation is deleted: the confirmed grounded exit now runs the REAL retail flow (0xF653 request, server LogOut motion, 3 s hold, reverse wormhole, return to the live-connection character-select screen via LiveSessionController.CompleteCharacterLogOff), and the previously-missing indicator-bar grounded gate now runs retail's shared three-way branch; AD-109 filed 2026-08-17 at the entry/exit presentation round — the click-armed login tunnel: the wormhole presentation + enter cue now begin at the character-select Enter click instead of retail's black CreatePlayer wait, USER-DIRECTED; AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 RETIRED 2026-08-28 — #386's named-retail message trace confirmed the vendor popup is content-sized and installed-DAT property 0x79 hides its disabled scrollbar; both behaviors are now ported; AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice; AD-111 (renumbered from a parallel-round AD-109 collision) filed 2026-08-17 at the systemic escape-normalization round — the appraisal report's wire-domain literal- -to-line-break shaping, which retail's `ItemExamineUI::AddItemInfo @0x004AC050` does not do (wire text appends verbatim; the escape decode retail runs at `StringInfo` resolution now lives at our string source, `DatStringResolver` → `RetailStringEscapes`); AD-108 filed 2026-08-17 at the night-round review fix round (F9), mechanism REPLACED same day at the overnight round's final fix — the Map tab's player/house icons, swallowed as `UiButton` dat children by `m_pMap`'s own Type-1 authoring, are now found in the panel-slot resolve's own info tree and rebuilt via `MapPageController.Bindings.IconBuilder` (the original standalone re-import resolved nothing on the live DAT); AD-107 RETIRED 2026-08-17 at the night-round review fix round (F2) — HouseQuery now fires once at the canonical local-player first-placement-completion edge (the same "initial session bootstrap" moment `GameActionLoginComplete`'s non-portal send sites already use), matching the byte-decoded retail truth that `CM_House::Event_QueryHouse @0x006aaa00` is tail-called, unconditionally, from the END of `CPlayerSystem::InitializePlayer @0x00563570` — the ONE-TIME-per-session function `AttemptSendLoginCompleteNotification` also lives in, guarded by the same `player_initialized` flag — right after that notification, not from any tab-open UI event; the invented tab-open trigger this row described is deleted outright, not merely narrowed; AD-106 filed 2026-08-16 at #409 (client-wide retail tooltip system) — RetailTooltipPresenter mounts the popup as an ordinary UiRoot sibling and keeps it topmost via its own per-tick BringToFront, scheduled after both RetailDialogFactory.Tick and Host.Tick, rather than porting retail's separate always-on-top presentation layer (m_pTooltipElement) — same adaptation shape AP-229 already accepted for dialogs-vs-screens, extended one layer further; AD-105 filed 2026-08-16 at Campaign CC gate round 1 re-test 3, finding R4-3 — the Skills info-box description-pane Height clamp to the SIBLING gold frame's own authored bottom edge, since retail's `ShowSkillsText` has no code relationship between the pane and the frame to cite directly. AD-104 filed 2026-08-16 at Campaign CC gate round 1 re-test 2, finding R3-3 — the Skills info-box title/description VerticalJustify page-scoped override, ISSUES.md #410 tracks the shared client-wide VJustify-default fix this compensates for. F12 correction, Campaign CC gate round 1 closeout, 2026-08-16: this header undercounted by 2 — a direct count of the physical `| AD-` rows below found 79, not the 77 this header carried; corrected to the counted total, matching AP-213's own row-count reconciliation the same closeout. AD-103 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-4a) — the swallowed Type-12 value child (`0x100002f1`/`0x100002f3` under the avail/health/stamina/mana/credits badge buttons) is now surfaced as its OWN addressable `UiButton.ValueLabel`/`ValueBox`/`ValueFont`/`ValueColor` slot, built from the child's OWN authored rect/font/color (`DatWidgetFactory.BuildButton`) — closing both the container-Label-substitution shape AND F5's unmeasured-pixel-equivalence concern outright, since the value now renders at the child's own dat-local geometry instead of discarding it for the button's own Label font/rect; AD-101 RETIRED 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Heritage-page auto-gender-select interim default is deleted outright now that the Appearance page's real gender buttons (`0x100003a7`/`0x100003a8`) exist; AD-102/AD-103 filed 2026-08-15 at Campaign CC slice CC4 — the Viamontian/Sanamar ToD-account-ownership gate omission, and the avail/health/stamina/mana/credits-meter UiButton-Label substitution for retail's swallowed Text-child overlays; AD-100 filed 2026-08-15 at the Campaign CC CC2 review (F2) — an unrequested `0xF643` CharGenVerificationResponse is DROPPED with a once-per-session log, where retail's handler has no armed-request gate and processes whatever arrives; AD-99 filed 2026-08-15 at Campaign LA gate round 2 finding 1 — the char-select Exit-confirmed close routes through the existing graceful window-close seam instead of retail's post-confirm `gmEpilogueUI` transition; AD-98 filed 2026-08-15 at Campaign LA gate round 2, COMPLETED same day — the char-select screen keeps its authored 800x600 root and the whole tree (widgets, glyphs, art, dialogs) stretches as one canvas via `UiRoot.FixedCanvasSize` scaling every quad at `TextRenderer.AppendQuad` with inverse mouse mapping, substituting one stage earlier for retail's fixed-canvas-stretched-at-presentation mechanism (the first resize-the-root substitution was deleted at 73041d70); AD-95 RETIRED same-day 2026-08-14 at trade gate round 3 — ID_SecureTrade_TotalItemsLabel probe-verified token-free (fragments ["Total Items: ", ""], one ITEMS variable) and now composed via ResolveTemplate; AD-94 filed 2026-08-14 at the secure-trade feature — the ACE-discarded AcceptTrade echo's zero-count item lists; AD-93 filed 2026-08-13 at social gate round 2 item 5 — the refused-drop notice port's two narrow gaps: wire-guid-match instead of retail's latched-guid preference, and no Move/Wield latch kinds; AD-85 NARROWED + AD-81 AMENDED 2026-08-13 at social gate round 2 — the five confirmation-dialog templates now compose exactly via the new `DatStringResolver.ResolveTemplate` port of `StringTable::GetString @0x004300D0`'s token-free fragment/PLAYER interleave; AD-85 keeps only its numeric-field item, AD-81 keeps the meta-token engine + `FormatName`; AD-92 filed 2026-08-13 at the #376/#388 fix round — highest-refresh-for-WxH selection + refuse-and-log invalid fullscreen requests, versus retail's pass-through-and-error `ForceDisplayResolution`; AD-91 filed 2026-08-13 at the #390 port — the display-change clamp covers floating chats too, which retail leaves unclamped/strandable; AD-90 filed 2026-08-13 at the #389 fix round — retail's smartbox aspect runs through the `Render.AspectRatio` preference (`ComputeAspectForViewport @0x0054f150`), exactly raw w/h at its default, which is what acdream assumes; AD-89 RETIRED same-day 2026-08-13 — the SmartboxFOV port landed (#389): `RetailFieldOfView` + `CameraController.SetGameFov` now apply retail's `gameFOV/(aspect−0.1)` law with the 90°-degrees option semantics, and the invented 60° camera constants are deleted; AD-88 filed 2026-08-13 at the #385 dropdown fix — the vendor category dropdown keeps G5's fixed 6-row scrollable window although its authored popup ListBox is edge-docked, the condition that arms retail's `RecalculatePopupSize` size-to-content resize; classification UNCLEAR pending a retail side-by-side (ISSUES #386); AD-87 filed 2026-08-12 at Campaign FA slice FA6 — the allegiance-swear half of the two-bot headless gate is written+wired but `AllegianceGateEnabled=false` (disabled by default), unverified end-to-end over the wire because ACE returns nothing to the `0x001D` swear (ISSUES #384); the FELLOWSHIP two-session gate passed live and ships as FA6's automated proof; AD-86 filed 2026-08-12 at Campaign FA slice FA5, item 4 — ACE's deliberate zeroing of officers/officer titles/MOTD/MOTD-set-by/name-last-set-time/lock/approved-vassal/timeOnline/allegianceAge, dropped past acdream's own parse layer to match retail's own no-widget presentation; AD-85 filed 2026-08-12 at Campaign FA slice FA5 — the Allegiance page's numeric-only fields and its three local confirmation dialogs' unsubstituted-verbatim-or-bare-name text, the same unported `StringInfo` gap AD-81 filed for Fellowship; AD-84 filed 2026-08-12 at Campaign FA slice FA5 — the Swear button's missing "target is a player" gate, the same class as AD-83's Recruit-button gap; AD-83 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 5) — the Recruit button's missing "target is a player" gate, previously an inline comment not a row; AD-82 filed 2026-08-12 at the Campaign FA slice FA4 fix round (mechanism MUST-FIX 4/5) — the invented leader-tint/selection-tint colors, the name-text-only row click target, and the page-local (not generic-`UiTemplateListBox`) world→panel selection sync; AD-81 filed 2026-08-12 at Campaign FA slice FA4 — the fellowship roster/create-flow text-composition gap (unported `StringInfo` variable substitution + `ACCharGenData::FormatName`); AD-80 filed 2026-08-12 at Campaign FA slice FA4, D5 — the panel's retail-exact XP-share percentage display versus the currently-targeted ACE server's slightly different actual grant; AD-79 filed 2026-08-12 at Campaign FA slice FA3, D1 — the social panel's Friends/Squelch page action buttons (add/remove friend, appear offline, squelch add/remove/clear) are honest INERT, no wire implemented this campaign; AD-78 filed 2026-08-11 at Campaign OP's gate-2 follow-up (user-directed, verbatim "mark all options that are not implemented now, so I can clearly see what is not implemented") — the shared store-only-caption-dimming convention across the Character/Config option tabs and Configure Keyboard; AD-77 filed 2026-08-11 at the Campaign OP OP3 review-fix round — the client-wide floating-only `gmPanelUI` host divergence (retail also exposes a docked `0x21000017` host) the plan's §5 delegated to the OP3 dual review, scoped to every main panel not just Options; AD-76/AD-75/AD-74 filed 2026-08-11 at Campaign OP slice OP3 — the Options panel's Exit to Character Selection "behaves as Exit Game" adaptation (D6), the Urgent Assistance/Report Abuse dead-URL interface-text short-circuit (D5), and In-Game Help Files' asset-missing inert button (D5); AD-73 filed 2026-08-11 at the Campaign OP OP2 rework — `UiTabPanel`'s dormant-until-`ActivateTabBehavior()` activation model, replacing retail's unconditional per-instance tab-table wiring, so the four already-shipped Type-8 hosts keep their existing controller-owned switching without a double-driver race; AD-72 filed 2026-08-08 at the Slice 5.3 review corrections — `VendorPricing`'s double-precision narrowing versus retail's x87 extended precision, same class as AD-33; AD-65 RETIRED and AD-69 FILED 2026-08-07 at Campaign S S4 — the away-arm now snaps per retail @0x00509c50, while AD-66's byte-confirmed sibling landing is WITHHELD pending #341's measurement-anomaly apparatus, and AD-69 records the seam-frame dist gap the same pass discovered; AD-56 RESTORED 2026-08-07 — the a8a7d64b revert had collaterally DELETED it, the inverse of the AD-55 zombie it also created; its plumb-fall-freeze condition is live again since TS-4’s real retirement at Slice 2B; AD-55 RE-RETIRED 2026-08-07 — its 2026-07-30 retirement at 252e8068 was collaterally resurrected by the a8a7d64b revert of the unrelated TS-4 commit; the code kept the cos(10°) fix throughout; AD-68 filed 2026-08-07 at the #338 closure — the async-residency placeholder mover shape (0.4/0.4 steps + capsule) has no retail counterpart because retail loads synchronously; AD-67 filed 2026-08-07 at the #32 closeout — the narrowed `SetContactPlane` keeps its per-write `ContactPlaneCellId`, which retail writes only at `init_contact_plane`; AD-49 filed 2026-08-06 at the #334 fix — the BSP part-array flood runs its outdoor cell rectangle at seed time rather than only from retail’s residency-gated walk, keeping both registration floods on one residency rule; AD-64 filed 2026-08-05 at the C5b architecture review's D1 fix — AD-60's W2 wire-cell REACHABILITY decision is expressed once per host because the two hosts run parallel non-shared inbound routes; the committed VALUE is single-sourced at `RuntimeEntityObjectLifetime.CommitWireCellRebucket`, and unification is filed as #324; AD-60 CORRECTED the same day — its surviving-channel enumeration presented "the local force path, the missile arm" as exhaustive when the entire no-window host belonged in it; AD-1 RETIRED 2026-08-05, C5a deletion sweep — the legacy outdoor demote/restore lift this row described was `PhysicsEngine.Resolve`'s own body, deleted with zero production callers; AD-42 DELETED 2026-08-04, C4 route 3 — its last surviving citation, the headless portal-arrival resync's two-call Resolve/ResolvePlacement split, was retired by the canonical `RuntimeAcceptedPositionDriveController` portal arm; AD-2 amended same route with the deferred-place timing adaptation, the T8 tolerated-overwrite note, and the leash-anchor nuance; AD-63 filed 2026-08-04, cancelled-park presentation rollback — the rollback restores every presentation registration the park's Withdraw removed EXCEPT the player's selection, which is user intent rather than a projection; AD-62 filed 2026-08-03, C4 route 2 round 2 — a deferred ForcePosition retired without committing is not re-applied and its ack is not sent; AD-61 filed 2026-08-02, C3c review round 1 — the #270 settle compression now covers the local player; AD-59/AD-60 filed 2026-08-02, continuation-executor slice) Recent retirements: AD-3/AD-4 retired 2026-07-31 by exact active/per-candidate @@ -113,6 +113,7 @@ readiness/requeue adaptation. See |---|---|---|---|---|---| | AD-119 | **Filed 2026-09-03 at Campaign OVERHAUL v2 S4 chunk 1 (S4-c1 C2; `docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md` §6 R3).** Retail's portal-depth draws (`D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90, the `BLEND_SRCALPHA`/`BLEND_INVSRCALPHA` `SetBlendFunction` call) keep color writes ENABLED with a zero-source-alpha `SRCALPHA`/`INVSRCALPHA` blend — every OTHER piece of R3's state (`DEPTHTEST_ALWAYS`, depth write on, `CULLMODE_NONE`, no stencil) is ported exactly. acdream instead disables the color-write mask outright on the SAME pipeline (`ColorWrite = false` alongside `Blend = GpuBlendMode.None`) and `portal_depth.frag`'s `main()` writes no color output at all — a write-mask substituting for a zero-alpha blend. | `src/AcDream.App/Rendering/PortalDepthMaskRenderer.Rhi.cs:92,100` (`CreatePortalPipeline`'s `Blend`/`ColorWrite` fields); `src/AcDream.App/Rendering/Shaders/portal_depth.frag` (empty `main()`, no color output) | Retail's blend equation is `dst' = src*srcAlpha + dst*(1-srcAlpha)`; with `srcAlpha` fixed at 0 this collapses to `dst' = dst` for every fragment regardless of its RGB — the destination color buffer is left byte-identical either way. A write mask reaches the SAME outcome (the destination is never touched) through a structurally simpler path — no blend-unit work per fragment, no fragment color output to author or keep in sync with a "must stay zero" alpha invariant — so the two are pixel-identical, not merely usually-equivalent. | None expected: the equivalence is provable from the blend algebra above, not measured, so no capture, transcript, or visual gate can distinguish the two. The write mask is in fact the SAFER of the two going forward — a future edit that gives `portal_depth.frag` a real color output (e.g. an authored debug tint) still writes nothing under today's mask, where a ported zero-alpha blend would depend on that same edit remembering to keep alpha at exactly 0. | `D3DPolyRender::DrawPortalPolyInternal` @0x0059bc90 (`SetBlendFunction(BLEND_SRCALPHA, BLEND_INVSRCALPHA, BLENDOP_ADD)`, `SetDepthBufferMode(DEPTHTEST_ALWAYS, ...)`, `SetCullMode(CULLMODE_NONE)`); `PortalDepthMaskRenderer.Rhi.cs` | | AD-120 | **Filed 2026-09-04 at the S4-c2 fix round 1 (M3).** Retail's `DrawMesh` row 1 immediate draw happens IN PLACE, inside the mesh draw call itself, the instant `curr_detail_surface != null` is observed for a translucent building-shell subset — there is no separate "later" point at which it could draw. acdream's walk stream-batches ORDINARY opaque instances into one deferred submission, so a translucent building-shell instance under building detail cannot draw "in place" in that same sense; `WbDrawDispatcher.SubmitToAlphaQueue`'s `RetailAlphaMeshAction.Immediate` branch instead draws it via `DrawImmediateAlphaInstance`/`DrawImmediateAlphaInstanceRhi` at the entity's own `AlphaSubmitMark` — the point `WalkFrameDriver.Replay` reaches that instance's alpha submission within the walk's existing turn order (after the entity's building/land-cell turn, same relative position an ordinary deferred instance would occupy). | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`SubmitToAlphaQueue`, `DrawImmediateAlphaInstance`); `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (`DrawImmediateAlphaInstanceRhi`) | The walk's turn order already places a building's alpha-eligible instances at the correct relative position among that turn's other content (retail draws building shells within `DrawBuilding`, itself ordered exactly where the walk visits that building) — moving the draw from "mid-mesh-call" to "at the turn's alpha submission point" changes WHEN within one turn it executes, never WHICH turn, so it still lands between the same neighboring opaque/translucent content retail's own turn order would bracket it with. | A translucent building-shell instance under detail could theoretically composite differently than retail if some OTHER alpha content is submitted between the entity's own opaque stream mark and its alpha submission mark within the SAME turn — no such intervening content exists in the current walk (an entity's alpha submission immediately follows its own classification), so this is a structural note, not an observed symptom. | `D3DPolyRender::DrawMesh` @0x0059d4a0 (row 1); `RenderDeviceD3D::DrawBuilding` @0x0059f2a0 (`curr_detail_surface` install/clear) | +| AD-121 | **Filed 2026-09-05 at #476 F1.** The opt-in retained Vulkan screenshot path requires the actual capture allocation to be host-coherent. CaptureBackbuffer rejects noncoherent memory with NotSupportedException before waiting or reading; it does not implement atom-safe isolated cache invalidation. | src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs (RecordBackbufferCapture, CaptureBackbuffer); VulkanBackbufferCaptureValidityTests | After the copy, COPY/TRANSFER_WRITE to HOST/HOST_READ publishes the exact capture range; the existing DeviceWaitIdle supplies completion. A capture-local coherence check avoids stale mapped bytes without changing shared heap preferences, generic buffer reads or neighboring suballocations. Default capture-off gameplay is unchanged. | A device whose capture allocation is only host-visible cannot take these opt-in screenshots until a separately bounded isolated invalidation path exists. Failure is explicit; no successful PNG is fabricated. This is a diagnostic capability restriction, not a retail equivalence claim. | Device::GetScreenshotSurface00439160 and RenderDeviceD3D::GenerateSurfaceFromFrontBuffer005A0210 capture completed retail front-buffer contents; #476 paired proof is in476-capture-lifetime-diagnosis.md. Vulkan host visibility/completion requirements are in476-fix-round1-host-visibility.md. | | AD-117 | **Filed 2026-09-03 at the Campaign OVERHAUL S2 review fix round.** Three residual Contract A/B readings (the row's original item 1 — a render-only owner's no-cell-array SetPosition commit republishing at its destination cell alone, `ShadowObjectRegistry.RefreshPositionRows` — was verified statically the same night as retail's own mechanism: `CObjCell::find_cell_list` 0x0052b4e0 with `num_sphere == 0` adds only the current cell (interior `add_cell` at 0x0052b563; outdoor `CLandCell::add_all_outside_cells` 0x00533630 `arg2 <= 0` branch) and skips the transit walk (`arg2 != 0` gate at 0x0052b576); it is a port, not a deviation). (2) `ShadowShapeBuilder.FromStaticRenderParts` uses the visual-AABB circumsphere as the per-portal cheap-reject sphere for a part with no physics BSP, where retail uses `gfxobj->physics_sphere` else `drawing_sphere` (pc:310147-310152) — strictly larger, so it can only WIDEN membership. (3) `PublishRetailPartEntries` publishes part rows into every CELLARRAY id, including an unloaded neighbour cell `CEnvCell::find_transit_cells` added with a null owner, where retail's `add_shadows_to_cells` (pc:282850) zeroes that shadow's cell and skips `AddPartsShadow` until the cell loads. (4) PORTED at Campaign OVERHAUL S2 chunk 6: an emitter now owns exactly one draw membership in its own current cell via `ParticleSystem`'s per-pass cell index (`CopyRenderableEmittersInCell`), matching `add_particle_shadow_to_cell` 0x00514a70's own-cell-only, no-clip-planes rule and drawn at that cell's own walk turn independent of its attached owner's registry membership. The residual is architectural, not behavioral: the membership index lives in `ParticleSystem` rather than as a `ShadowObjectRegistry` row, because an emitter is not a `CPhysicsObj` in acdream and never registers with the shadow registry at all. Two behavioral residuals remain: acdream's emitter cell is its OWNER's pose cell (`ParticleEmitter.OwnerCellId` ← `EntityEffectPoseRegistry.CellId`), where retail's emitter is a standalone `CPhysicsObj` (`makeParticleObject` 0x00512640) with its own position and cell; and retail re-evaluates membership per emission (`ParticleEmitter::EmitParticle` → `CPhysicsObj::AddPartToShadowCells` at 0x0051d126), which acdream does not — a caster in a doorway draws the cloud at the caster's cell turn only. | `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (`RefreshPositionRows` render-only branch, `PublishRetailPartEntries`), `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`FromStaticRenderParts` non-BSP arm), `src/AcDream.Core/Vfx/ParticleSystem.cs` (per-pass cell index, item 4) | (2) A cheap reject that fires less often admits a superset; the admitting tests (`Plane::intersect_box`, `box_intersects_cell`) are ported exactly. (3) The extra rows are unreachable by the walk until the cell is resident and `RefloodLandblock` converges them at hydration. (4) `ParticleSystem`'s cell index is maintained at every point renderable state or `OwnerCellId` changes (`RefreshRenderableIndex`, `UpdateEmitterOwnerCell`), so an emitter's draw membership always matches its live cell regardless of its attached owner's suspended/hidden registry state. | (2) A decorative non-BSP part admitted to a neighbouring cell retail's cheap reject would have dropped — a draw clipped by that cell's portal planes, at worst a sliver. (3) A one-frame draw into a cell that just hydrated before its reflood ran. (4) None behavioral — a code-location note only: if particle emitters are ever modeled as registry-backed physics objects, this cell index should be retired in favor of a genuine `ShadowObjectRegistry` row rather than kept as a parallel mechanism. | `CPhysicsObj::SetPositionInternal` 0x00515330 (pc:283530-283541), `CPhysicsObj::add_shadows_to_cells` 0x00514ae0 (pc:282837-282875), `CEnvCell::find_transit_cells` 0x0052cae0 (pc:310147-310217), `CPhysicsObj::add_particle_shadow_to_cell` 0x00514a70, `CObjCell::find_cell_list` 0x0052b4e0. | | AD-116 | **Filed 2026-09-03 at Campaign OVERHAUL S2 chunk 5 (consumer cutover); amended 2026-09-05 at S5-c5 when the spent Facility print probe was deleted.** `WalkProductionWorldData.ResolveCellView`'s borrowed per-cell view treats an entity the registry HAS flooded into its retail CELLARRAY (so `ShadowObjectRegistry.GetRetailPartEntriesInCell` names it) but whose `RenderProjectionRecord` `RenderSceneQuery.TryGetByLocalEntityId` cannot resolve yet as contributing to NO cell for that frame — it is silently skipped rather than falling back to its authored parent cell or an outdoor root-position cell (both deleted this chunk). Every distinct entity id this happens for in one frame is counted once in `WalkProductionWorldData.UnregisteredRenderMembershipCount`; the invariant remains test-visible, but the former next-frame `[walk-membership]` output and `ProbeFacilityStairsEnabled` gate no longer exist. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`) | Retail has no such gap at all: `CEnvCell::init_static_objects` installs the CELLARRAY before a static is ever drawable, and Contract B's collision (`shadow_object_list`) and render (`shadow_part_list`) products are ONE transaction, so they can never race. acdream's registry (the physics publisher) and its presentation scene (the projection journal) are two independently incremental pipelines fed off the same Create/appearance edge, so a transient one-frame window where the registry runs first is possible during streaming — the same class of race AD-49's residency reasoning already accepts for `CellTransit`'s own outdoor seed. Contributing NOTHING for that one frame matches retail's own rule ("an object not yet in a cell is not drawn") more closely than the deleted parent-cell/root-position fallbacks did, which could draw an object at a cell its real CELLARRAY does not actually include. | If the presentation journal's apply cadence ever falls more than one frame behind the registry's registration (not merely a same-frame ordering race), an entity would stay missing for several consecutive frames instead of appearing on the very next one — `UnregisteredRenderMembershipCount` staying nonzero across consecutive frames (not a single one-frame spike) is the signal that this row's "transient" premise has broken and needs re-investigation, not a widened fallback. | `CEnvCell::init_static_objects`; `CPartArray::AddPartsShadow` 0x00517e40 (`docs/research/2026-09-01-overhaul/oh1-construction-landscape-contract.md` Contract B) | | AD-115 | **Filed 2026-08-25 at Campaign AS slice AS2 review fix round (F16), classification: intentional.** `AppraisalUiController.BuildCharacterTitleDisplay` composes examination element `0x10000151` (Profession/title): when Int 261 `CharacterTitleId` is absent/unresolvable AND String 5 `Template` is also absent, it returns an empty string, and `ClearCreatureText` has already blanked the element for this `ApplyCreature` call, so the element stays cleared. Retail never clears `0x10000150`/`0x10000151`/`0x10000152` anywhere — neither `CharExamineUI::Show @0x004AB5D0` nor `BasicCreatureExamineUI::Init @0x004AB9C0` writes an empty string to those elements — so in this exact case retail would keep showing the PREVIOUS assessed target's title text on screen instead of clearing it. | `src/AcDream.App/UI/Layout/AppraisalUiController.cs` (`BuildCharacterTitleDisplay`, `ClearCreatureText`) | Deliberate improvement over retail's quirk: a stale leftover title from a prior target reads as more confusing/wrong to a player than a blank line for the current one; review F16 (2026-08-25) accepted the clear-on-no-source behavior as intentional. | None expected — this is a deliberate, reviewed divergence, not a game-feel regression; a future retail-faithfulness audit assuming `0x10000151` always mirrors retail's persistent stale-text behavior would be surprised to see it clear instead when the current target's title can't be resolved. | `CharExamineUI::Show @0x004AB5D0`; `BasicCreatureExamineUI::Init @0x004AB9C0` | diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 132e6ad34..3605592e9 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -759,6 +759,13 @@ are deferred, not passed. Continue crash/stability, dungeon lighting, object placement and hand-item verification, with #476 capture as their immediate prerequisite. C1a remains correctness-only. No main merge or G4 acceptance. +**#476 current (packet§55):** reviewed scratch02b707133 integrates with this +ledger, both lenses closed including narrow F1 re-review3. Root Release0W0E, +affected179/179, portableGPU307/307, two native mutations independently fail/ +restore. #477/#480 preserved; AD-121 added with AD-118 still retired and92active +AD rows. Fresh integrated Release/tests and first-current-frame/resize/hand +graphics next, not yet PASS. No FPS work or main merge. + ## 14. Risk register (condensed) - **R1 second scene graph.** Graph records own only topology/membership facts; diff --git a/docs/research/2026-09-01-overhaul/476-lead-verification.md b/docs/research/2026-09-01-overhaul/476-lead-verification.md index fb826cf8c..a924177ef 100644 --- a/docs/research/2026-09-01-overhaul/476-lead-verification.md +++ b/docs/research/2026-09-01-overhaul/476-lead-verification.md @@ -81,3 +81,55 @@ capture evidence. Both toggles use the real input action; no attack or inventory mutation is requested. Gate after accepted integration and green Release, with both clients absent, ACE up, agent builds/tests paused, one runner invocation, same session-v10/High profile, core+sync validation retained to guard #477 merge. + +## F1 lead completion and independent verification + +James froze the two F1 source/test edits before compiling or running them; +no implementer F1 gate is claimed. Root's first Release build found one test +handle type error CS1503 (uint passed to Device's nint constructor). Root +corrected only that cast, then six capture tests passed. Root completed the +bounded architecture/AD-121 documentation; no other source behavior changed. + +Root independently read the actual native copy/barrier/read methods and all +four added tests. RecordBackbufferCapture emits an image transition, real +copy and exact-buffer/range COPY/TRANSFER_WRITE→HOST/HOST_READ dependency in +that order. Copy-off/mismatch emits none. Actual CaptureBackbuffer reads and +swizzles distinct coherent mapped bytes after one native idle wait; its +noncoherent allocation is rejected before wait/read. These are intercepted +native-call witnesses, not physical GPU results. + +Two separate root production mutations, with exact raw restoration: + +- Omit the real host-read barrier call: actual native command collection + fails expected3/actual2 (image barrier and copy only). +- Invert the actual coherence guard: the noncoherent witness fails because + the expected NotSupportedException is not thrown. + +After restoration full Release0W0E4.98s; the exact twelve prior affected +classes give179PASS/0FAIL/0SKIP (175 plus four native witnesses). Portable GPU +filter `FullyQualifiedName~AcDream.App.Tests.Rendering.Gpu&Lane!=Vulkan` +gives307PASS/0FAIL/0SKIP on this pre-#477 scratch base. Do not compare this +count to integrated#477's314 as a regression: that repair adds different tests. +Existing early-capture/premature-validity pins remain in the affected run; +their original root mutations above were not repeated again for F1. + +| Frozen F1 item | SHA256 | +| --- | --- | +| Resources.cs | 8782D8DDE8201FFCAFE0DFD55E949E5E058F6CEE20A0774D2B24989A4613070E | +| VulkanBackbufferCaptureValidityTests.cs | BFAE818A74BC4C4C09F643BE9885ED3F42B42CDA21CC225AF2D8B80FDF95E541 | +| Architecture | 06543E3145D26D3576BF8770F22C895DD0F6DB69F74B7CFDCCA305E525E4BFF2 | +| Divergence register | 61E8AAE1D8A483A817B77114E51ED93BD299369344F8689126532C0353BBD37C | +| artifacts/476-root/f1-m1/root-476-f1-m1.trx | 7EF4A88E0B579BC4009E441D3E63DA4299F05CF264AE93E375C78E556CE9509B | +| artifacts/476-root/f1-m2/root-476-f1-m2.trx | FC24D369242E0179756AF5F0B342EB204F27DD6FBBFF4C58A37704D59956548C | +| artifacts/476-root/f1-final/root-476-f1-affected.trx | BB98BEB2108981AC0453175AFC646D8F4A38FDF491E25B355CF5CE6C0541D232 | +| artifacts/476-root/f1-gpu/root-476-f1-gpu.trx | E84997D224244F06CA2B980239FD7C22D8437670D97472B6053449CE316690CC | + +Wegener's narrow F1 re-review PASS, no findings; unchanged broad retail review1 +was not restarted. Reviewed scratch02b70713338f5513a2fd19c26c90298e474f57dc +lands with this evidence. Nine nonoverlapping source/test blobs are identical; +the two Vulkan partials differ only by the preserved#477 changes, independently +read in the integration diff. Architecture preserves#477/#480; AD-121 lands +alongside preserved AD-118 retirement, counted105total/13retired/92active. +Fresh integrated Release/tests and current-frame graphical PASS are still owed. +Owner12:53 FPS deferral remains binding; no profiling/optimization is part +of this capture repair or its graphical correctness run. diff --git a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md index 985ed7dbf..508023dcb 100644 --- a/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md +++ b/docs/research/2026-09-01-overhaul/s5-consumers-material-closeout-packet.md @@ -4840,3 +4840,21 @@ assertion, isolate the setup difference, and do not weaken/pin a golden over the mismatch. No production change follows from it without a separate proof. G4 remains FAIL; FPS deferral is not acceptance of the other owner findings. No merge to main. + +## 55. Completed-frame capture accepted for integration + +#476 reviewed scratch02b707133 lands with this ledger. Rawls retail review1 +PASS; Wegener production2's inherited host-visibility gap is closed by F1 and +narrow re-review3 PASS. Root six native-state tests, affected179/179 and +portableGPU307/307 on scratch; full Release0W0E; two actual missing-barrier/ +coherence mutations fail as intended and restore exactly. Root corrected one +test handle cast before compiling; no implementer F1 execution is claimed. +476-lead-verification.md gives the exact source/TRX seals and scope limits. + +Integration preserves#477 GPU synchronization and#480 held-item publication. +AD-121 records opt-in capture's explicit noncoherent-memory rejection; combined +register105total/13retired/92active preserves C1c's AD-118 retirement. Root's +fresh integrated Release/tests and first-request/two-resize/held-item graphics +are next. Rawls confirms no command active; all agent builds/tests are paused +for the graphical window. Owner FPS deferral still applies. No graphical PASS +or G4 acceptance is inferred from the offline checks. diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 926d94799..1bb087282 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -742,8 +742,7 @@ internal sealed class FrameRootCompositionPhase // slice V11; this optional hook is unbound until a follow-up // re-homes it onto the retained UI (IDevToolsFrameLifecycle stays // as the seam). - devTools: null, - privateScreenshot); + devTools: null); var framePreparation = new RenderFramePreparationController( renderFrameResources, devTools: null, @@ -792,7 +791,8 @@ internal sealed class FrameRootCompositionPhase live.FrameDiagnostics, postDiagnostics, NullRenderFrameFailureRecovery.Instance, - d.BuildingDegrades); + d.BuildingDegrades, + privateScreenshot); Fault(FrameRootCompositionPoint.RenderRootCreated); var liveFrameCoordinator = new RetailLiveFrameCoordinator( diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs index 779fb929a..d3c594bc6 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.Resources.cs @@ -1431,7 +1431,7 @@ internal sealed unsafe partial class VulkanGpuDevice } /// - /// Reads the last presented frame back as tightly packed top-left-origin + /// Reads the last successfully submitted retained frame as tightly packed top-left-origin /// RGBA8. /// /// The swapchain is B8G8R8A8_UNORM (plan §4.9), so the channels @@ -1476,8 +1476,20 @@ internal sealed unsafe partial class VulkanGpuDevice "The capture buffer is sized with the swapchain, so a mismatch means the caller " + "and the backbuffer disagree about the frame that was just presented."); } + if (!_captureValidity.HasSubmittedCopy) + { + throw new InvalidOperationException( + "No retained capture copy has completed submission for the current backbuffer size."); + } + if (!_captureBuffer.HostWritesAreCoherent) + { + throw new NotSupportedException( + "Retained backbuffer capture requires host-coherent memory; " + + "capture-local invalidation for non-coherent memory is not implemented."); + } - // Everything that could still be writing the buffer is a submitted frame. + // The validity owner proves this buffer's copy belongs to a successful + // submission; wait for that submitted work before reading host memory. VulkanInterop.Check(_vk.DeviceWaitIdle(_device), "vkDeviceWaitIdle (capture)"); var pixels = new byte[(long)_captureWidth * _captureHeight * 4]; _captureBuffer.Read(0, pixels); @@ -1531,6 +1543,28 @@ internal sealed unsafe partial class VulkanGpuDevice _captureBuffer.Handle, 1, ®ion); + ulong copiedByteCount = checked((ulong)_captureWidth * _captureHeight * 4); + var hostReadBarrier = new BufferMemoryBarrier2 + { + SType = StructureType.BufferMemoryBarrier2, + SrcStageMask = PipelineStageFlags2.CopyBit, + SrcAccessMask = AccessFlags2.TransferWriteBit, + DstStageMask = PipelineStageFlags2.HostBit, + DstAccessMask = AccessFlags2.HostReadBit, + SrcQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + DstQueueFamilyIndex = Silk.NET.Vulkan.Vk.QueueFamilyIgnored, + Buffer = _captureBuffer.Handle, + Offset = 0, + Size = copiedByteCount, + }; + var hostReadDependency = new DependencyInfo + { + SType = StructureType.DependencyInfo, + BufferMemoryBarrierCount = 1, + PBufferMemoryBarriers = &hostReadBarrier, + }; + _vk.CmdPipelineBarrier2(commands, &hostReadDependency); + _captureValidity.RecordCopy(); return ImageLayout.TransferSrcOptimal; } @@ -1546,6 +1580,7 @@ internal sealed unsafe partial class VulkanGpuDevice if (_captureBuffer is not null && _captureWidth == width && _captureHeight == height) return; + _captureValidity.Invalidate(); _captureBuffer?.Dispose(); _captureBuffer = null; _captureWidth = width; @@ -1571,5 +1606,38 @@ internal sealed unsafe partial class VulkanGpuDevice private VulkanGpuBuffer? _captureBuffer; private uint _captureWidth; private uint _captureHeight; + private VulkanBackbufferCaptureValidity _captureValidity; private bool _backbufferRenderingReady; } + +/// +/// Submission-bound validity for the retained backbuffer copy. Allocation and +/// command recording are deliberately insufficient: only the successful queue +/// submission that contains the recorded copy makes its bytes readable. +/// +internal struct VulkanBackbufferCaptureValidity +{ + internal bool CopyRecorded { get; private set; } + + internal bool HasSubmittedCopy { get; private set; } + + internal void BeginFrame() + { + CopyRecorded = false; + HasSubmittedCopy = false; + } + + internal void RecordCopy() => CopyRecorded = true; + + internal void CompleteSubmission() + { + HasSubmittedCopy = CopyRecorded; + CopyRecorded = false; + } + + internal void Invalidate() + { + CopyRecorded = false; + HasSubmittedCopy = false; + } +} diff --git a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs index 4db562ba2..e9a82c69d 100644 --- a/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs +++ b/src/AcDream.App/Rendering/Gpu/Vk/VulkanGpuDevice.cs @@ -343,6 +343,7 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineF throw new InvalidOperationException("A frame is already open; end it before beginning another."); long serial = _flights.BeginFrame(); + _captureValidity.BeginFrame(); int slot = _flights.CurrentSlot; _uploads.ReleaseCompleted(CompletedSerial()); @@ -489,6 +490,7 @@ internal sealed unsafe partial class VulkanGpuDevice : IGpuDevice, IGpuPipelineF VulkanInterop.Check( _vk.QueueSubmit2(_graphicsQueue, 1, &submit, default), "vkQueueSubmit2 (frame)"); + _captureValidity.CompleteSubmission(); _flights.EndFrame(); _openFrame = null; diff --git a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs index fa59bf914..1abe1a9b1 100644 --- a/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs +++ b/src/AcDream.App/Rendering/PrivatePresentationRenderer.cs @@ -42,7 +42,8 @@ internal interface IPrivateFrameScreenshot /// /// Draws the private and 2-D presentation layers after the world alpha scope /// has closed: portal CreatureMode, paperdoll FBO, retained gameplay UI, -/// developer UI, then the complete-frame screenshot. +/// then developer UI. Complete-frame capture belongs to the outer orchestrator +/// after the GPU frame has closed and submitted these draws. /// internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePhase { @@ -51,15 +52,13 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha private readonly IPrivateEntityViewportFrame? _entityViewports; private readonly IRetainedGameplayUiFrame? _gameplayUi; private readonly IDevToolsFrameLifecycle? _devTools; - private readonly IPrivateFrameScreenshot? _screenshots; public PrivatePresentationRenderer( IPrivatePortalViewport portal, IRenderFrameFoundationSource foundation, IPrivateEntityViewportFrame? entityViewports, IRetainedGameplayUiFrame? gameplayUi, - IDevToolsFrameLifecycle? devTools, - IPrivateFrameScreenshot? screenshots) + IDevToolsFrameLifecycle? devTools) { _portal = portal ?? throw new ArgumentNullException(nameof(portal)); _foundation = foundation @@ -67,7 +66,6 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha _entityViewports = entityViewports; _gameplayUi = gameplayUi; _devTools = devTools; - _screenshots = screenshots; } public PrivatePresentationFrameOutcome Render( @@ -90,12 +88,9 @@ internal sealed class PrivatePresentationRenderer : IPrivatePresentationFramePha input.DeltaSeconds, input.ViewportWidth, input.ViewportHeight); - bool screenshotCaptured = _screenshots?.CapturePending( - input.ViewportWidth, - input.ViewportHeight) == true; return new PrivatePresentationFrameOutcome( portalViewportVisible, - screenshotCaptured); + ScreenshotCaptured: false); } } diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 2f12a6824..84da51525 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -20,7 +20,7 @@ internal readonly record struct WorldRenderFrameOutcome( /// /// Private-presentation facts published after portal, paperdoll, retained UI, -/// developer UI, and screenshot work has completed. +/// developer UI, GPU submission, and optional complete-frame capture. /// internal readonly record struct PrivatePresentationFrameOutcome( bool PortalViewportDrawn, @@ -186,6 +186,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot private readonly IRenderFramePostDiagnosticsPhase _postDiagnostics; private readonly IRenderFrameFailureRecovery _recovery; private readonly IBuildingDegradeFrameTick? _buildingDegrades; + private readonly IPrivateFrameScreenshot? _screenshots; public RenderFrameOrchestrator( IRenderFrameLifetime lifetime, @@ -196,7 +197,8 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot IRenderFrameDiagnosticsPhase diagnostics, IRenderFramePostDiagnosticsPhase postDiagnostics, IRenderFrameFailureRecovery recovery, - IBuildingDegradeFrameTick? buildingDegrades = null) + IBuildingDegradeFrameTick? buildingDegrades = null, + IPrivateFrameScreenshot? screenshots = null) { _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _gpuMeasurement = gpuMeasurement @@ -209,6 +211,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot ?? throw new ArgumentNullException(nameof(postDiagnostics)); _recovery = recovery ?? throw new ArgumentNullException(nameof(recovery)); _buildingDegrades = buildingDegrades; + _screenshots = screenshots; } public RenderFrameOutcome Render(RenderFrameInput input) @@ -222,11 +225,10 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot _buildingDegrades?.Tick(input.DeltaSeconds); _lifetime.BeginFrame(); - Exception? renderFailure = null; + WorldRenderFrameOutcome world; + PrivatePresentationFrameOutcome presentation; try { - WorldRenderFrameOutcome world; - PrivatePresentationFrameOutcome presentation; Exception? measuredRenderFailure = null; _gpuMeasurement.BeginFrame(); try @@ -255,63 +257,67 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot measurementFailure); } } + } + catch (Exception renderFailure) + { + HandleRenderFailure(renderFailure); + throw; + } - var outcome = new RenderFrameOutcome(world, presentation); - _diagnostics.Publish(input, outcome); - _postDiagnostics.Process(input, outcome); - return outcome; + _lifetime.EndFrame(); + bool screenshotCaptured = _screenshots?.CapturePending( + input.ViewportWidth, + input.ViewportHeight) == true; + var outcome = new RenderFrameOutcome( + world, + presentation with { ScreenshotCaptured = screenshotCaptured }); + _diagnostics.Publish(input, outcome); + _postDiagnostics.Process(input, outcome); + return outcome; + } + + private void HandleRenderFailure(Exception renderFailure) + { + Exception? recoveryFailure = null; + try + { + // ImGui NewFrame is opened during preparation but normally closed + // during private presentation. Abort it before the GPU flight + // closes when any intervening render/measurement phase fails. + _recovery.AbortFrame(); } catch (Exception error) { - renderFailure = error; - throw; + recoveryFailure = error; } - finally + + try { - Exception? recoveryFailure = null; - if (renderFailure is not null) + _lifetime.EndFrame(); + } + catch (Exception closeFailure) + { + if (recoveryFailure is not null) { - try - { - // ImGui NewFrame is opened during preparation but normally - // closed during private presentation. Abort it before the - // GPU flight closes when any intervening phase fails. - _recovery.AbortFrame(); - } - catch (Exception error) - { - recoveryFailure = error; - } - } - - try - { - _lifetime.EndFrame(); - } - catch (Exception closeFailure) when (renderFailure is not null) - { - if (recoveryFailure is not null) - { - throw new AggregateException( - "Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.", - renderFailure, - recoveryFailure, - closeFailure); - } - throw new AggregateException( - "Rendering failed and the in-flight GPU frame could not be closed.", + "Rendering failed and neither presentation recovery nor the in-flight GPU frame could be closed.", renderFailure, + recoveryFailure, closeFailure); } - if (renderFailure is not null && recoveryFailure is not null) - { - throw new AggregateException( - "Rendering failed and the presentation frame could not be aborted.", - renderFailure, - recoveryFailure); - } + throw new AggregateException( + "Rendering failed and the in-flight GPU frame could not be closed.", + renderFailure, + closeFailure); + } + + if (recoveryFailure is not null) + { + throw new AggregateException( + "Rendering failed and the presentation frame could not be aborted.", + renderFailure, + recoveryFailure); } } } diff --git a/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs b/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs index d59a1eec0..d70ff2347 100644 --- a/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/FrameRootCompositionTests.cs @@ -77,6 +77,40 @@ public sealed class FrameRootCompositionTests Assert.True(secondTransfer > firstTransfer); } + [Fact] + public void ProductionWiresTheOptionalScreenshotAdapterIntoTheOuterRenderOwner() + { + MethodInfo compose = typeof(FrameRootCompositionPhase).GetMethod( + "ComposeCore", + BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new MissingMethodException( + typeof(FrameRootCompositionPhase).FullName, + "ComposeCore"); + IReadOnlyList calls = CompiledCallGraph.Read(compose); + + int screenshot = CallIndex(calls, typeof(PrivateFrameScreenshot), ".ctor"); + int presentation = CallIndex( + calls, + typeof(PrivatePresentationRenderer), + ".ctor", + screenshot + 1); + int orchestrator = CallIndex( + calls, + typeof(RenderFrameOrchestrator), + ".ctor", + presentation + 1); + + Assert.True(orchestrator > presentation); + Assert.Contains( + typeof(RenderFrameOrchestrator).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(IPrivateFrameScreenshot)); + Assert.DoesNotContain( + typeof(PrivatePresentationRenderer).GetFields( + BindingFlags.Instance | BindingFlags.NonPublic), + field => field.FieldType == typeof(IPrivateFrameScreenshot)); + } + [Fact] public void GameWindowRetainsOnlyThePhaseBoundaryAndFrameHandoffs() { diff --git a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs index 151fda1f7..4b7cf53a7 100644 --- a/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs +++ b/tests/AcDream.App.Tests/Rendering/GameWindowRenderLeafCompositionTests.cs @@ -25,8 +25,7 @@ public sealed class GameWindowRenderLeafCompositionTests (typeof(IPrivatePortalViewport), nameof(IPrivatePortalViewport.Draw)), (typeof(IPrivateEntityViewportFrame), nameof(IPrivateEntityViewportFrame.Render)), (typeof(IRetainedGameplayUiFrame), nameof(IRetainedGameplayUiFrame.Render)), - (typeof(IDevToolsFrameLifecycle), nameof(IDevToolsFrameLifecycle.Render)), - (typeof(IPrivateFrameScreenshot), nameof(IPrivateFrameScreenshot.CapturePending))); + (typeof(IDevToolsFrameLifecycle), nameof(IDevToolsFrameLifecycle.Render))); MethodInfo orchestrator = RequiredMethod( typeof(RenderFrameOrchestrator), @@ -35,6 +34,8 @@ public sealed class GameWindowRenderLeafCompositionTests orchestrator, (typeof(IWorldSceneFramePhase), nameof(IWorldSceneFramePhase.Render)), (typeof(IPrivatePresentationFramePhase), nameof(IPrivatePresentationFramePhase.Render)), + (typeof(IRenderFrameLifetime), nameof(IRenderFrameLifetime.EndFrame)), + (typeof(IPrivateFrameScreenshot), nameof(IPrivateFrameScreenshot.CapturePending)), (typeof(IRenderFrameDiagnosticsPhase), nameof(IRenderFrameDiagnosticsPhase.Publish))); } @@ -215,7 +216,6 @@ public sealed class GameWindowRenderLeafCompositionTests presentation, (typeof(RenderFrameFoundation), "get_PortalViewportVisible"), (typeof(IPrivatePortalViewport), nameof(IPrivatePortalViewport.Draw)), - (typeof(IPrivateFrameScreenshot), nameof(IPrivateFrameScreenshot.CapturePending)), (typeof(PrivatePresentationFrameOutcome), ".ctor")); MethodInfo orchestrator = RequiredMethod( @@ -227,6 +227,8 @@ public sealed class GameWindowRenderLeafCompositionTests (typeof(IWorldSceneFramePhase), nameof(IWorldSceneFramePhase.Render)), (typeof(IPrivatePresentationFramePhase), nameof(IPrivatePresentationFramePhase.Render)), (typeof(IRenderFrameGpuMeasurement), nameof(IRenderFrameGpuMeasurement.EndFrame)), + (typeof(IRenderFrameLifetime), nameof(IRenderFrameLifetime.EndFrame)), + (typeof(IPrivateFrameScreenshot), nameof(IPrivateFrameScreenshot.CapturePending)), (typeof(RenderFrameOutcome), ".ctor"), (typeof(IRenderFrameDiagnosticsPhase), nameof(IRenderFrameDiagnosticsPhase.Publish))); } diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBackbufferCaptureValidityTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBackbufferCaptureValidityTests.cs new file mode 100644 index 000000000..9bfb89b8a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanBackbufferCaptureValidityTests.cs @@ -0,0 +1,466 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Gpu; +using Silk.NET.Core.Contexts; +using Silk.NET.Vulkan; +using VkBuffer = Silk.NET.Vulkan.Buffer; +using VkImage = Silk.NET.Vulkan.Image; +using VkSemaphore = Silk.NET.Vulkan.Semaphore; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed unsafe class VulkanBackbufferCaptureValidityTests +{ + [Fact] + public void ValidityRequiresTheSuccessfulSubmissionContainingTheRecordedCopy() + { + var validity = new VulkanBackbufferCaptureValidity(); + + Assert.False(validity.CopyRecorded); + Assert.False(validity.HasSubmittedCopy); + + validity.Invalidate(); + Assert.False(validity.HasSubmittedCopy); + + validity.RecordCopy(); + Assert.True(validity.CopyRecorded); + Assert.False(validity.HasSubmittedCopy); + + // Recording without the successful-submit transition represents an + // open frame or a QueueSubmit2 failure: old/new bytes remain unreadable. + validity.BeginFrame(); + Assert.False(validity.CopyRecorded); + Assert.False(validity.HasSubmittedCopy); + + // A submission which contained no retained copy is not a capture. + validity.CompleteSubmission(); + Assert.False(validity.HasSubmittedCopy); + + validity.BeginFrame(); + validity.RecordCopy(); + validity.CompleteSubmission(); + Assert.False(validity.CopyRecorded); + Assert.True(validity.HasSubmittedCopy); + + // Opening the next frame prevents its still-open work from advertising + // the prior frame as the newly completed requested frame. + validity.BeginFrame(); + Assert.False(validity.HasSubmittedCopy); + } + + [Fact] + public void ProductionBindsValidityToResizeOpenCopySubmitAndReadEdges() + { + string device = Source( + "src", "AcDream.App", "Rendering", "Gpu", "Vk", "VulkanGpuDevice.cs"); + string resources = Source( + "src", "AcDream.App", "Rendering", "Gpu", "Vk", "VulkanGpuDevice.Resources.cs"); + + string begin = MethodBody(device, "internal bool TryBeginFrame(", "private long CompletedSerial("); + AssertOrdered( + begin, + "long serial = _flights.BeginFrame();", + "_captureValidity.BeginFrame();", + "_backbuffer.TryAcquire("); + + string end = MethodBody(device, "internal void EndFrame(", "internal bool PresentSucceeded"); + AssertOrdered( + end, + "ImageLayout current = RecordBackbufferCapture(commands, presentable);", + "_vk.EndCommandBuffer(commands)", + "_vk.QueueSubmit2(_graphicsQueue, 1, &submit, default)", + "_captureValidity.CompleteSubmission();"); + + string record = MethodBody( + resources, + "internal ImageLayout RecordBackbufferCapture(", + "private void ConfigureBackbufferCapture("); + AssertOrdered( + record, + "_vk.CmdCopyImageToBuffer(", + "_vk.CmdPipelineBarrier2(commands, &hostReadDependency);", + "_captureValidity.RecordCopy();"); + + string configure = MethodBody( + resources, + "private void ConfigureBackbufferCapture(", + "private readonly bool _retainBackbufferCapture;"); + AssertOrdered( + configure, + "_captureValidity.Invalidate();", + "_captureBuffer?.Dispose();", + "_captureBuffer = new VulkanGpuBuffer("); + + string capture = MethodBody( + resources, + "public byte[] CaptureBackbuffer(", + "internal ImageLayout RecordBackbufferCapture("); + AssertOrdered( + capture, + "if (!_captureValidity.HasSubmittedCopy)", + "if (!_captureBuffer.HostWritesAreCoherent)", + "_vk.DeviceWaitIdle(_device)", + "_captureBuffer.Read(0, pixels);"); + } + + [Fact] + public void RecordBackbufferCapture_ActualNativeCallsPublishExactRangeAfterCopy() + { + using var native = new RecordingNativeContext(); + VulkanGpuBuffer capture = CreateCaptureBuffer( + new VkBuffer(0x4761u), + width: 7, + height: 5, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + mapped: null); + var backbuffer = new Backbuffer(width: 7, height: 5); + VulkanGpuDevice device = CreateDevice(native, capture, backbuffer, width: 7, height: 5); + var commands = new CommandBuffer((nint)0xC04761u); + var image = new VkImage(0xA04761u); + + Assert.Equal(ImageLayout.TransferSrcOptimal, device.RecordBackbufferCapture(commands, image)); + Assert.Collection( + native.Commands, + command => + { + Assert.Equal(NativeCommandKind.ImageBarrier, command.Kind); + Assert.Equal(commands, command.Commands); + Assert.Equal(image, command.ImageBarrier.Image); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, command.ImageBarrier.OldLayout); + Assert.Equal(ImageLayout.TransferSrcOptimal, command.ImageBarrier.NewLayout); + }, + command => + { + Assert.Equal(NativeCommandKind.CopyImageToBuffer, command.Kind); + Assert.Equal(commands, command.Commands); + Assert.Equal(image, command.Image); + Assert.Equal(ImageLayout.TransferSrcOptimal, command.ImageLayout); + Assert.Equal(capture.Handle, command.Buffer); + Assert.Equal(1u, command.RegionCount); + Assert.Equal(new Extent3D(7, 5, 1), command.Copy.ImageExtent); + }, + command => AssertHostReadBarrier(command, commands, capture.Handle, 7ul * 5 * 4)); + + Assert.True(GetValidity(device).CopyRecorded); + } + + [Fact] + public void RecordBackbufferCapture_AbsentOrMismatchedCaptureRecordsNothing() + { + using var native = new RecordingNativeContext(); + var commands = new CommandBuffer((nint)0xC04762u); + var image = new VkImage(0xA04762u); + VulkanGpuDevice absent = CreateDevice(native, null, new Backbuffer(4, 3), 4, 3); + + Assert.Equal(ImageLayout.ColorAttachmentOptimal, absent.RecordBackbufferCapture(commands, image)); + Assert.Empty(native.Commands); + + VulkanGpuBuffer capture = CreateCaptureBuffer( + new VkBuffer(0x4762u), 4, 3, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + mapped: null); + VulkanGpuDevice mismatched = CreateDevice(native, capture, new Backbuffer(5, 3), 4, 3); + Assert.Equal(ImageLayout.ColorAttachmentOptimal, mismatched.RecordBackbufferCapture(commands, image)); + Assert.Empty(native.Commands); + Assert.False(GetValidity(mismatched).CopyRecorded); + } + + [Fact] + public void CaptureBackbuffer_CoherentSubmittedCopyWaitsReadsAndSwizzles() + { + using var native = new RecordingNativeContext(); + byte* mapped = (byte*)NativeMemory.Alloc(4); + try + { + byte[] source = [11, 22, 33, 44]; + source.CopyTo(new Span(mapped, 4)); + VulkanGpuBuffer capture = CreateCaptureBuffer( + new VkBuffer(0x4763u), 1, 1, + MemoryPropertyFlags.HostVisibleBit | MemoryPropertyFlags.HostCoherentBit, + mapped); + VulkanGpuDevice device = CreateDevice(native, capture, new Backbuffer(1, 1), 1, 1); + MarkSubmittedCopy(device); + + Assert.Equal(new byte[] { 33, 22, 11, 44 }, device.CaptureBackbuffer(1, 1)); + Assert.Equal(1, native.DeviceWaitIdleCount); + } + finally + { + NativeMemory.Free(mapped); + } + } + + [Fact] + public void CaptureBackbuffer_NonCoherentSubmittedCopyFailsBeforeNativeWaitOrRead() + { + using var native = new RecordingNativeContext(); + byte* mapped = (byte*)NativeMemory.Alloc(4); + try + { + new Span(mapped, 4).Fill(0xA5); + VulkanGpuBuffer capture = CreateCaptureBuffer( + new VkBuffer(0x4764u), 1, 1, + MemoryPropertyFlags.HostVisibleBit, + mapped); + VulkanGpuDevice device = CreateDevice(native, capture, new Backbuffer(1, 1), 1, 1); + MarkSubmittedCopy(device); + + NotSupportedException error = Assert.Throws( + () => device.CaptureBackbuffer(1, 1)); + Assert.Contains("host-coherent memory", error.Message, StringComparison.Ordinal); + Assert.Equal(0, native.DeviceWaitIdleCount); + Assert.Equal(new byte[] { 0xA5, 0xA5, 0xA5, 0xA5 }, new Span(mapped, 4).ToArray()); + } + finally + { + NativeMemory.Free(mapped); + } + } + + private static VulkanGpuDevice CreateDevice( + RecordingNativeContext native, + VulkanGpuBuffer? capture, + IVulkanBackbuffer backbuffer, + uint width, + uint height) + { + var device = (VulkanGpuDevice)RuntimeHelpers.GetUninitializedObject(typeof(VulkanGpuDevice)); + SetField(device, "_vk", new Silk.NET.Vulkan.Vk(native)); + SetField(device, "_device", new Device((nint)0x4760)); + SetField(device, "_captureBuffer", capture); + SetField(device, "_backbuffer", backbuffer); + SetField(device, "_captureWidth", width); + SetField(device, "_captureHeight", height); + SetField(device, "_captureValidity", new VulkanBackbufferCaptureValidity()); + return device; + } + + private static VulkanGpuBuffer CreateCaptureBuffer( + VkBuffer handle, + uint width, + uint height, + MemoryPropertyFlags properties, + void* mapped) + { + ulong size = checked((ulong)width * height * 4); + var buffer = (VulkanGpuBuffer)RuntimeHelpers.GetUninitializedObject(typeof(VulkanGpuBuffer)); + SetField(buffer, "k__BackingField", "capture-witness"); + SetField(buffer, "k__BackingField", checked((long)size)); + SetField(buffer, "k__BackingField", GpuBufferUsage.TransferDestination); + SetField(buffer, "k__BackingField", GpuMemoryResidency.HostReadable); + SetField(buffer, "k__BackingField", handle); + SetField(buffer, "_allocation", new VulkanAllocation( + default, + 0, + size, + 0, + properties, + default, + mapped)); + return buffer; + } + + private static void MarkSubmittedCopy(VulkanGpuDevice device) + { + VulkanBackbufferCaptureValidity validity = GetValidity(device); + validity.RecordCopy(); + validity.CompleteSubmission(); + SetField(device, "_captureValidity", validity); + } + + private static VulkanBackbufferCaptureValidity GetValidity(VulkanGpuDevice device) => + (VulkanBackbufferCaptureValidity)(Field(typeof(VulkanGpuDevice), "_captureValidity").GetValue(device) + ?? throw new InvalidOperationException("Missing capture validity.")); + + private static void SetField(object target, string name, object? value) => + Field(target.GetType(), name).SetValue(target, value); + + private static FieldInfo Field(Type type, string name) => + type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Missing {type.Name} field {name}."); + + private static void AssertHostReadBarrier( + RecordedNativeCommand command, + CommandBuffer commands, + VkBuffer buffer, + ulong byteCount) + { + Assert.Equal(NativeCommandKind.BufferBarrier, command.Kind); + Assert.Equal(commands, command.Commands); + BufferMemoryBarrier2 barrier = command.BufferBarrier; + Assert.Equal(StructureType.BufferMemoryBarrier2, barrier.SType); + Assert.Equal(PipelineStageFlags2.CopyBit, barrier.SrcStageMask); + Assert.Equal(AccessFlags2.TransferWriteBit, barrier.SrcAccessMask); + Assert.Equal(PipelineStageFlags2.HostBit, barrier.DstStageMask); + Assert.Equal(AccessFlags2.HostReadBit, barrier.DstAccessMask); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.SrcQueueFamilyIndex); + Assert.Equal(Silk.NET.Vulkan.Vk.QueueFamilyIgnored, barrier.DstQueueFamilyIndex); + Assert.Equal(buffer, barrier.Buffer); + Assert.Equal(0ul, barrier.Offset); + Assert.Equal(byteCount, barrier.Size); + } + + private enum NativeCommandKind + { + ImageBarrier, + CopyImageToBuffer, + BufferBarrier, + } + + private readonly record struct RecordedNativeCommand( + NativeCommandKind Kind, + CommandBuffer Commands, + VkImage Image, + ImageLayout ImageLayout, + VkBuffer Buffer, + uint RegionCount, + BufferImageCopy Copy, + ImageMemoryBarrier2 ImageBarrier, + BufferMemoryBarrier2 BufferBarrier); + + private sealed unsafe class RecordingNativeContext : INativeContext + { + private static RecordingNativeContext? s_active; + + internal RecordingNativeContext() + { + Assert.Null(s_active); + s_active = this; + } + + internal List Commands { get; } = []; + internal int DeviceWaitIdleCount { get; private set; } + + public nint GetProcAddress(string proc, int? slot = null) => proc switch + { + "vkCmdCopyImageToBuffer" => + (nint)(delegate* unmanaged) + &CaptureCopyImageToBuffer, + "vkCmdPipelineBarrier2" => + (nint)(delegate* unmanaged)&CapturePipelineBarrier, + "vkDeviceWaitIdle" => + (nint)(delegate* unmanaged)&CaptureDeviceWaitIdle, + _ => (nint)(delegate* unmanaged)&NoOp, + }; + + public bool TryGetProcAddress(string proc, out nint addr, int? slot = null) + { + addr = GetProcAddress(proc, slot); + return true; + } + + public void Dispose() + { + if (ReferenceEquals(s_active, this)) + s_active = null; + } + + [UnmanagedCallersOnly] + private static void CaptureCopyImageToBuffer( + CommandBuffer commands, + VkImage image, + ImageLayout layout, + VkBuffer buffer, + uint regionCount, + BufferImageCopy* regions) + { + s_active!.Commands.Add(new RecordedNativeCommand( + NativeCommandKind.CopyImageToBuffer, + commands, + image, + layout, + buffer, + regionCount, + regionCount == 0 ? default : regions[0], + default, + default)); + } + + [UnmanagedCallersOnly] + private static void CapturePipelineBarrier(CommandBuffer commands, DependencyInfo* dependency) + { + if (dependency->ImageMemoryBarrierCount == 1) + { + s_active!.Commands.Add(new RecordedNativeCommand( + NativeCommandKind.ImageBarrier, + commands, + default, + default, + default, + 0, + default, + dependency->PImageMemoryBarriers[0], + default)); + return; + } + + s_active!.Commands.Add(new RecordedNativeCommand( + NativeCommandKind.BufferBarrier, + commands, + default, + default, + default, + 0, + default, + default, + dependency->PBufferMemoryBarriers[0])); + } + + [UnmanagedCallersOnly] + private static Result CaptureDeviceWaitIdle(Device device) + { + s_active!.DeviceWaitIdleCount++; + return Result.Success; + } + + [UnmanagedCallersOnly] + private static void NoOp() + { + } + } + + private sealed class Backbuffer(uint width, uint height) : IVulkanBackbuffer + { + public Format ImageFormat => Format.B8G8R8A8Unorm; + public uint Width => width; + public uint Height => height; + public bool TryAcquire(VkSemaphore acquired, out uint imageIndex) => throw new NotSupportedException(); + public VkImage ImageAt(uint imageIndex) => throw new NotSupportedException(); + public ImageView ViewAt(uint imageIndex) => throw new NotSupportedException(); + public VkSemaphore RenderCompleteAt(uint imageIndex) => throw new NotSupportedException(); + public bool Present(uint imageIndex) => throw new NotSupportedException(); + } + + private static void AssertOrdered(string source, params string[] tokens) + { + int previous = -1; + foreach (string token in tokens) + { + int current = source.IndexOf(token, previous + 1, StringComparison.Ordinal); + Assert.True(current > previous, $"Missing or out-of-order production token: {token}"); + previous = current; + } + } + + private static string MethodBody(string source, string startToken, string endToken) + { + int start = source.IndexOf(startToken, StringComparison.Ordinal); + int end = source.IndexOf(endToken, start, StringComparison.Ordinal); + Assert.True(start >= 0 && end > start); + return source[start..end]; + } + + private static string Source(params string[] path) => + File.ReadAllText(Path.Combine([RepositoryRoot(), .. path])); + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + directory = directory.Parent; + return directory?.FullName + ?? throw new InvalidOperationException("Could not locate the repository root."); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs index de878f73b..a24f3c7ab 100644 --- a/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/PrivatePresentationRendererTests.cs @@ -9,38 +9,31 @@ public sealed class PrivatePresentationRendererTests private static readonly WorldRenderFrameOutcome World = new(3, 12, true); [Theory] - [InlineData(false, false)] - [InlineData(true, false)] - [InlineData(false, true)] - [InlineData(true, true)] - public void Render_PreservesPortalPaperdollGameplayDevToolsScreenshotOrder( - bool portalVisible, - bool screenshotCaptured) + [InlineData(false)] + [InlineData(true)] + public void Render_PreservesPortalPaperdollGameplayDevToolsOrder(bool portalVisible) { List calls = []; var portal = new Portal(calls); var paperdoll = new Paperdoll(calls); var gameplay = new GameplayUi(calls); var devTools = new DevTools(calls); - var screenshots = new Screenshots(calls, screenshotCaptured); var presentation = new PrivatePresentationRenderer( portal, new FoundationSource(portalVisible), paperdoll, gameplay, - devTools, - screenshots); + devTools); PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); Assert.Equal( - ["portal", "paperdoll", "gameplay-ui", "devtools-render", "screenshot"], + ["portal", "paperdoll", "gameplay-ui", "devtools-render"], calls); Assert.Equal(new PrivatePresentationFrameOutcome( portalVisible, - screenshotCaptured), outcome); + ScreenshotCaptured: false), outcome); Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), portal.Size); - Assert.Equal((Input.ViewportWidth, Input.ViewportHeight), screenshots.Size); Assert.Equal(Input, gameplay.Input); Assert.Equal(Input, devTools.Input); } @@ -54,8 +47,7 @@ public sealed class PrivatePresentationRendererTests new FoundationSource(portalVisible: true), entityViewports: null, gameplayUi: null, - devTools: null, - screenshots: null); + devTools: null); PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); @@ -88,8 +80,7 @@ public sealed class PrivatePresentationRendererTests foundation, entityViewports: null, gameplayUi: null, - devTools: null, - screenshots: null); + devTools: null); PrivatePresentationFrameOutcome outcome = presentation.Render(Input, World); @@ -102,9 +93,9 @@ public sealed class PrivatePresentationRendererTests public void Constructor_RequiresTheCanonicalPortalOwner() { Assert.Throws(() => - new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null, null)); + new PrivatePresentationRenderer(null!, new FoundationSource(false), null, null, null)); Assert.Throws(() => - new PrivatePresentationRenderer(new Portal([]), null!, null, null, null, null)); + new PrivatePresentationRenderer(new Portal([]), null!, null, null, null)); } [Fact] @@ -117,6 +108,7 @@ public sealed class PrivatePresentationRendererTests Assert.DoesNotContain( fields, field => typeof(Delegate).IsAssignableFrom(field.FieldType)); + Assert.DoesNotContain(fields, field => field.FieldType == typeof(IPrivateFrameScreenshot)); } [Fact] @@ -257,16 +249,4 @@ public sealed class PrivatePresentationRendererTests } } - private sealed class Screenshots(List calls, bool result) : - IPrivateFrameScreenshot - { - public (int Width, int Height) Size { get; private set; } - - public bool CapturePending(int width, int height) - { - Size = (width, height); - calls.Add("screenshot"); - return result; - } - } } diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs index df613a346..25106f9b7 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs @@ -1,6 +1,9 @@ using System.Reflection; +using AcDream.App.Diagnostics; using AcDream.App.Rendering; using AcDream.UI.Abstractions.Panels.Settings; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; namespace AcDream.App.Tests.Rendering; @@ -32,7 +35,8 @@ public sealed class RenderFrameOrchestratorTests normalWorldDrawn), Presentation = new PrivatePresentationFrameOutcome( portalViewportDrawn, - screenshotCaptured), + ScreenshotCaptured: false), + CaptureResult = screenshotCaptured, }; var orchestrator = Create(phases); @@ -41,12 +45,14 @@ public sealed class RenderFrameOrchestratorTests Assert.Equal( [ "gpu-begin", "measure-begin", "resources", "world", "presentation", - "measure-end", - "diagnostics", "post-diagnostics", "gpu-end", + "measure-end", "gpu-end", "screenshot", + "diagnostics", "post-diagnostics", ], calls); Assert.Equal(phases.World, outcome.World); - Assert.Equal(phases.Presentation, outcome.Presentation); + Assert.Equal( + phases.Presentation with { ScreenshotCaptured = screenshotCaptured }, + outcome.Presentation); Assert.Equal( [ ("resources", Input), @@ -85,6 +91,243 @@ public sealed class RenderFrameOrchestratorTests Assert.Empty(phases.ObservedInputs); } + [Fact] + public void RealScreenshotComposition_CapturesSubmittedStartupAndResizeFramesInExactOrientation() + { + string directory = Path.Combine( + Path.GetTempPath(), + "acdream-476-" + Guid.NewGuid().ToString("N")); + try + { + var calls = new List(); + var lifetime = new SubmittingPixelLifetime(calls); + var phases = new RecordingPhases(calls); + var controller = new FrameScreenshotController( + (width, height) => + { + calls.Add("capture-read"); + Assert.Equal((width, height), lifetime.SubmittedSize); + return FrameScreenshotController.FlipRows( + lifetime.SubmittedPixels, + width, + height); + }, + directory); + var presentation = new PrivatePresentationRenderer( + new RecordingPortal(calls), + new StaticFoundation(), + entityViewports: null, + gameplayUi: null, + devTools: null); + var orchestrator = new RenderFrameOrchestrator( + lifetime, + phases, + phases, + phases, + presentation, + phases, + phases, + phases, + buildingDegrades: null, + screenshots: new PrivateFrameScreenshot(controller)); + + Rgba32[] startup = + [ + new(1, 2, 3, 4), new(5, 6, 7, 8), + new(9, 10, 11, 12), new(13, 14, 15, 16), + ]; + lifetime.NextFrame(2, 2, startup); + Assert.True(controller.TryRequest("startup", out string startupError), startupError); + RenderFrameOutcome startupOutcome = orchestrator.Render( + Input with { ViewportWidth = 2, ViewportHeight = 2 }); + + Assert.True(startupOutcome.Presentation.ScreenshotCaptured); + Assert.True(controller.IsComplete("startup")); + AssertPng(Path.Combine(directory, "startup.png"), 2, 2, startup); + AssertSubmittedCaptureOrder(calls); + + calls.Clear(); + Rgba32[] resized = + [ + new(21, 22, 23, 24), new(25, 26, 27, 28), new(29, 30, 31, 32), + new(33, 34, 35, 36), new(37, 38, 39, 40), new(41, 42, 43, 44), + ]; + lifetime.NextFrame(3, 2, resized); + Assert.True(controller.TryRequest("resized", out string resizeError), resizeError); + RenderFrameOutcome resizedOutcome = orchestrator.Render( + Input with { ViewportWidth = 3, ViewportHeight = 2 }); + + Assert.True(resizedOutcome.Presentation.ScreenshotCaptured); + Assert.True(controller.IsComplete("resized")); + AssertPng(Path.Combine(directory, "resized.png"), 3, 2, resized); + AssertSubmittedCaptureOrder(calls); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void ZeroAreaLeavesRealScreenshotRequestQueuedUntilTheNextRenderableFrame() + { + string directory = Path.Combine( + Path.GetTempPath(), + "acdream-476-zero-" + Guid.NewGuid().ToString("N")); + try + { + var calls = new List(); + var lifetime = new SubmittingPixelLifetime(calls); + var phases = new RecordingPhases(calls); + var controller = new FrameScreenshotController( + (width, height) => FrameScreenshotController.FlipRows( + lifetime.SubmittedPixels, + width, + height), + directory); + var orchestrator = new RenderFrameOrchestrator( + lifetime, + phases, + phases, + phases, + phases, + phases, + phases, + phases, + buildingDegrades: null, + screenshots: new PrivateFrameScreenshot(controller)); + Rgba32[] pixels = [new(51, 52, 53, 54)]; + lifetime.NextFrame(1, 1, pixels); + Assert.True(controller.TryRequest("queued", out string error), error); + + RenderFrameOutcome skipped = orchestrator.Render( + Input with { ViewportWidth = 0, ViewportHeight = 1 }); + + Assert.True(skipped.SkippedZeroArea); + Assert.False(controller.IsComplete("queued")); + Assert.Empty(calls); + + RenderFrameOutcome rendered = orchestrator.Render( + Input with { ViewportWidth = 1, ViewportHeight = 1 }); + + Assert.True(rendered.Presentation.ScreenshotCaptured); + Assert.True(controller.IsComplete("queued")); + AssertPng(Path.Combine(directory, "queued.png"), 1, 1, pixels); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + + [Fact] + public void CaptureOffClosesThenRunsDiagnosticsWithoutCaptureWork() + { + var calls = new List(); + var phases = new RecordingPhases(calls); + var orchestrator = new RenderFrameOrchestrator( + phases, phases, phases, phases, phases, phases, phases, phases); + + RenderFrameOutcome outcome = orchestrator.Render(Input); + + Assert.False(outcome.Presentation.ScreenshotCaptured); + Assert.Equal( + [ + "gpu-begin", "measure-begin", "resources", "world", "presentation", + "measure-end", "gpu-end", "diagnostics", "post-diagnostics", + ], + calls); + } + + [Fact] + public void ScreenshotOutcomeIsThePostCloseCaptureResultNotAStalePresentationClaim() + { + var calls = new List(); + var phases = new RecordingPhases(calls) + { + Presentation = new PrivatePresentationFrameOutcome( + PortalViewportDrawn: true, + ScreenshotCaptured: true), + CaptureResult = false, + }; + + RenderFrameOutcome outcome = Create(phases).Render(Input); + + Assert.True(outcome.Presentation.PortalViewportDrawn); + Assert.False(outcome.Presentation.ScreenshotCaptured); + Assert.True(calls.IndexOf("gpu-end") < calls.IndexOf("screenshot")); + Assert.Equal(outcome, phases.ObservedOutcome); + } + + [Fact] + public void CloseFailureDoesNotConsumeTheRealQueuedScreenshotOrRunPostCloseConsumers() + { + string directory = Path.Combine( + Path.GetTempPath(), + "acdream-476-close-" + Guid.NewGuid().ToString("N")); + try + { + var calls = new List(); + var lifetime = new SubmittingPixelLifetime(calls) + { + CloseFailure = new InvalidOperationException("close"), + }; + var phases = new RecordingPhases(calls); + int reads = 0; + var controller = new FrameScreenshotController( + (width, height) => + { + reads++; + return FrameScreenshotController.FlipRows( + lifetime.SubmittedPixels, + width, + height); + }, + directory); + var orchestrator = new RenderFrameOrchestrator( + lifetime, + phases, + phases, + phases, + phases, + phases, + phases, + phases, + buildingDegrades: null, + screenshots: new PrivateFrameScreenshot(controller)); + Rgba32[] pixels = [new(61, 62, 63, 64)]; + lifetime.NextFrame(1, 1, pixels); + Assert.True(controller.TryRequest("after-close", out string error), error); + + Assert.Same( + lifetime.CloseFailure, + Assert.Throws(() => orchestrator.Render( + Input with { ViewportWidth = 1, ViewportHeight = 1 }))); + + Assert.Equal(0, reads); + Assert.False(controller.IsComplete("after-close")); + Assert.DoesNotContain("diagnostics", calls); + Assert.DoesNotContain("post-diagnostics", calls); + + calls.Clear(); + lifetime.CloseFailure = null; + RenderFrameOutcome outcome = orchestrator.Render( + Input with { ViewportWidth = 1, ViewportHeight = 1 }); + + Assert.True(outcome.Presentation.ScreenshotCaptured); + Assert.Equal(1, reads); + Assert.True(controller.IsComplete("after-close")); + AssertPng(Path.Combine(directory, "after-close.png"), 1, 1, pixels); + } + finally + { + if (Directory.Exists(directory)) + Directory.Delete(directory, recursive: true); + } + } + [Fact] public void AcceptedRenderTicksSharedDegradeOwnerExactlyOnceAndZeroAreaDoesNot() { @@ -141,11 +384,11 @@ public sealed class RenderFrameOrchestratorTests } [Theory] + [InlineData("measure-begin")] [InlineData("resources")] [InlineData("world")] [InlineData("presentation")] - [InlineData("diagnostics")] - [InlineData("post-diagnostics")] + [InlineData("measure-end")] public void RenderFailure_ClosesExactlyOnceAndPropagates(string failurePoint) { var calls = new List(); @@ -166,6 +409,29 @@ public sealed class RenderFrameOrchestratorTests phases.ObservedInputs); } + [Theory] + [InlineData("screenshot")] + [InlineData("diagnostics")] + [InlineData("post-diagnostics")] + public void PostCloseFailure_PropagatesWithoutRecoveryOrASecondClose(string failurePoint) + { + var calls = new List(); + var expected = new InvalidOperationException(failurePoint); + var phases = new RecordingPhases(calls) + { + FailurePoint = failurePoint, + Failure = expected, + }; + + InvalidOperationException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Same(expected, actual); + Assert.Equal(1, calls.Count(static call => call == "gpu-end")); + Assert.DoesNotContain("recovery-abort", calls); + Assert.Equal(ExpectedPostCloseFailureCalls(failurePoint), calls); + } + [Fact] public void CloseOnlyFailure_PropagatesDirectly() { @@ -183,18 +449,17 @@ public sealed class RenderFrameOrchestratorTests Assert.Equal( [ "gpu-begin", "measure-begin", "resources", "world", "presentation", - "measure-end", - "diagnostics", "post-diagnostics", "gpu-end", + "measure-end", "gpu-end", ], calls); } [Theory] + [InlineData("measure-begin")] [InlineData("resources")] [InlineData("world")] [InlineData("presentation")] - [InlineData("diagnostics")] - [InlineData("post-diagnostics")] + [InlineData("measure-end")] public void RenderAndCloseFailure_AreAggregatedInCausalOrder(string failurePoint) { var calls = new List(); @@ -222,6 +487,33 @@ public sealed class RenderFrameOrchestratorTests phases.ObservedInputs); } + [Fact] + public void RenderAndMeasurementCloseFailures_AreAggregatedBeforeRecoveryAndClose() + { + var calls = new List(); + var renderFailure = new InvalidOperationException("render"); + var measurementFailure = new InvalidOperationException("measurement"); + var phases = new RecordingPhases(calls) + { + FailurePoint = "world", + Failure = renderFailure, + MeasurementCloseFailure = measurementFailure, + }; + + AggregateException actual = Assert.Throws( + () => Create(phases).Render(Input)); + + Assert.Equal(2, actual.InnerExceptions.Count); + Assert.Same(renderFailure, actual.InnerExceptions[0]); + Assert.Same(measurementFailure, actual.InnerExceptions[1]); + Assert.Equal( + [ + "gpu-begin", "measure-begin", "resources", "world", + "measure-end", "recovery-abort", "gpu-end", + ], + calls); + } + [Fact] public void RenderAndRecoveryFailure_AreAggregatedBeforeGpuClose() { @@ -317,6 +609,7 @@ public sealed class RenderFrameOrchestratorTests typeof(IRenderFramePostDiagnosticsPhase), typeof(IRenderFrameFailureRecovery), typeof(IBuildingDegradeFrameTick), + typeof(IPrivateFrameScreenshot), ]; FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( BindingFlags.Instance | BindingFlags.NonPublic); @@ -379,8 +672,92 @@ public sealed class RenderFrameOrchestratorTests } } + private static void AssertSubmittedCaptureOrder(List calls) + { + int measurement = calls.IndexOf("measure-end"); + int close = calls.IndexOf("gpu-end"); + int capture = calls.IndexOf("capture-read"); + int diagnostics = calls.IndexOf("diagnostics"); + int postDiagnostics = calls.IndexOf("post-diagnostics"); + Assert.True(measurement >= 0); + Assert.True(close > measurement); + Assert.True(capture > close); + Assert.True(diagnostics > capture); + Assert.True(postDiagnostics > diagnostics); + Assert.Equal(1, calls.Count(static call => call == "gpu-end")); + } + + private static void AssertPng( + string path, + int width, + int height, + Rgba32[] expected) + { + using Image image = Image.Load(path); + Assert.Equal(width, image.Width); + Assert.Equal(height, image.Height); + var actual = new Rgba32[checked(width * height)]; + image.CopyPixelDataTo(actual); + Assert.Equal(expected, actual); + } + + private sealed class SubmittingPixelLifetime(List calls) : IRenderFrameLifetime + { + private byte[] _nextPixels = []; + + public bool IsOpen { get; private set; } + public Exception? CloseFailure { get; set; } + public byte[] SubmittedPixels { get; private set; } = []; + public (int Width, int Height) SubmittedSize { get; private set; } + + public void NextFrame(int width, int height, Rgba32[] pixels) + { + Assert.Equal(checked(width * height), pixels.Length); + if (SubmittedSize != (width, height) || SubmittedPixels.Length == 0) + { + SubmittedPixels = Enumerable.Repeat( + (byte)0xEE, + checked(width * height * 4)) + .ToArray(); + } + _nextPixels = pixels.SelectMany(static pixel => + new[] { pixel.R, pixel.G, pixel.B, pixel.A }) + .ToArray(); + SubmittedSize = (width, height); + } + + public void BeginFrame() + { + Assert.False(IsOpen); + IsOpen = true; + calls.Add("gpu-begin"); + } + + public void EndFrame() + { + Assert.True(IsOpen); + IsOpen = false; + calls.Add("gpu-end"); + if (CloseFailure is not null) + throw CloseFailure; + SubmittedPixels = [.. _nextPixels]; + } + } + + private sealed class RecordingPortal(List calls) : IPrivatePortalViewport + { + public void Draw(int width, int height) => calls.Add("portal"); + } + + private sealed class StaticFoundation : IRenderFrameFoundationSource + { + public RenderFrameFoundation Foundation { get; } = new(false, default, default); + } + private static string[] ExpectedFailureCalls(string failurePoint) => failurePoint switch { + "measure-begin" => + ["gpu-begin", "measure-begin", "recovery-abort", "gpu-end"], "resources" => [ "gpu-begin", "measure-begin", "resources", "measure-end", @@ -391,23 +768,31 @@ public sealed class RenderFrameOrchestratorTests "gpu-begin", "measure-begin", "resources", "world", "measure-end", "recovery-abort", "gpu-end", ], - "presentation" => + "presentation" or "measure-end" => [ "gpu-begin", "measure-begin", "resources", "world", "presentation", - "measure-end", - "recovery-abort", "gpu-end", + "measure-end", "recovery-abort", "gpu-end", + ], + _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), + }; + + private static string[] ExpectedPostCloseFailureCalls(string failurePoint) => failurePoint switch + { + "screenshot" => + [ + "gpu-begin", "measure-begin", "resources", "world", "presentation", + "measure-end", "gpu-end", "screenshot", ], "diagnostics" => [ "gpu-begin", "measure-begin", "resources", "world", "presentation", - "measure-end", "diagnostics", - "recovery-abort", "gpu-end", + "measure-end", "gpu-end", "screenshot", "diagnostics", ], "post-diagnostics" => [ "gpu-begin", "measure-begin", "resources", "world", "presentation", - "measure-end", "diagnostics", "post-diagnostics", - "recovery-abort", "gpu-end", + "measure-end", "gpu-end", "screenshot", "diagnostics", + "post-diagnostics", ], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; @@ -417,22 +802,17 @@ public sealed class RenderFrameOrchestratorTests { string[] phases = failurePoint switch { + "measure-begin" => [], "resources" => ["resources"], "world" => ["resources", "world"], - "presentation" => ["resources", "world", "presentation"], - "diagnostics" => ["resources", "world", "presentation", "diagnostics"], - "post-diagnostics" => - [ - "resources", "world", "presentation", "diagnostics", - "post-diagnostics", - ], + "presentation" or "measure-end" => ["resources", "world", "presentation"], _ => throw new ArgumentOutOfRangeException(nameof(failurePoint)), }; return phases.Select(phase => (phase, Input)).ToArray(); } private static RenderFrameOrchestrator Create(RecordingPhases phases) => - new(phases, phases, phases, phases, phases, phases, phases, phases); + new(phases, phases, phases, phases, phases, phases, phases, phases, null, phases); private sealed class RecordingDegradeTick : IBuildingDegradeFrameTick { @@ -461,7 +841,8 @@ public sealed class RenderFrameOrchestratorTests IPrivatePresentationFramePhase, IRenderFrameDiagnosticsPhase, IRenderFramePostDiagnosticsPhase, - IRenderFrameFailureRecovery + IRenderFrameFailureRecovery, + IPrivateFrameScreenshot { private readonly List _calls; @@ -474,8 +855,10 @@ public sealed class RenderFrameOrchestratorTests public Exception? Failure { get; init; } public Exception? CloseFailure { get; init; } public Exception? RecoveryFailure { get; init; } + public Exception? MeasurementCloseFailure { get; init; } public WorldRenderFrameOutcome World { get; init; } = new(7, 19, true); public PrivatePresentationFrameOutcome Presentation { get; init; } = new(false, false); + public bool CaptureResult { get; init; } public List<(string Phase, RenderFrameInput Input)> ObservedInputs { get; } = []; public WorldRenderFrameOutcome ObservedWorld { get; private set; } public RenderFrameOutcome ObservedOutcome { get; private set; } @@ -491,7 +874,12 @@ public sealed class RenderFrameOrchestratorTests void IRenderFrameGpuMeasurement.BeginFrame() => Record("measure-begin"); - void IRenderFrameGpuMeasurement.EndFrame() => _calls.Add("measure-end"); + void IRenderFrameGpuMeasurement.EndFrame() + { + Record("measure-end"); + if (MeasurementCloseFailure is not null) + throw MeasurementCloseFailure; + } public void AbortFrame() { @@ -537,6 +925,12 @@ public sealed class RenderFrameOrchestratorTests Record("post-diagnostics"); } + public bool CapturePending(int width, int height) + { + Record("screenshot"); + return CaptureResult; + } + private void Record(string call) { _calls.Add(call); diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs index 8efe150b9..af852bf0d 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameRecoveryIntegrationTests.cs @@ -29,8 +29,7 @@ public sealed class RenderFrameRecoveryIntegrationTests new FoundationSource(), new Paperdoll(failure), new GameplayUi(failure), - devTools, - new Screenshots()); + devTools); var orchestrator = new RenderFrameOrchestrator( gpu, new GpuMeasurement(), @@ -39,7 +38,9 @@ public sealed class RenderFrameRecoveryIntegrationTests presentation, new Diagnostics(), NullRenderFramePostDiagnosticsPhase.Instance, - devTools); + devTools, + buildingDegrades: null, + screenshots: new Screenshots()); Assert.Throws(() => orchestrator.Render(Input));