diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 6bca30bd..2470bb08 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) — 90 active rows (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) — 91 active rows (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
@@ -111,6 +111,7 @@ readiness/requeue adaptation. See
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
+| 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-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).** `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` and, when nonzero, reported by one print-only `[walk-membership]` line at the start of the next `BeginFrame`, gated on `RenderingDiagnostics.ProbeFacilityStairsEnabled`. | `src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs` (`ResolveCellView`, `UnregisteredRenderMembershipCount`, the `BeginFrame` diagnostic line) | 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/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
index a28a8be4..e3ac870d 100644
--- a/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
+++ b/src/AcDream.App/Rendering/RetailPViewPassExecutor.cs
@@ -3,6 +3,7 @@ using AcDream.App.Rendering.Gpu;
using AcDream.App.Rendering.Scene;
using AcDream.App.Rendering.Sky;
using AcDream.App.Rendering.Wb;
+using AcDream.App.Rendering.Walk;
using AcDream.Core.Rendering;
using AcDream.Core.Vfx;
using AcDream.Core.World;
@@ -396,6 +397,16 @@ public RetailPViewPassExecutor(
if (localVertices.Length < 3)
continue;
+ // S4-c1 C1: DrawPortalPolyInternal's degenerate-input guard
+ // (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard's
+ // own doc comment) — tested on the LOCAL portal-polygon
+ // vertices, BEFORE the world-transform loop below. A hit drops
+ // the whole polygon: no transform, no fan submission, no
+ // `submitted` increment (retail's reject -> transform -> clip
+ // -> count order).
+ if (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(localVertices))
+ continue;
+
int count = Math.Min(localVertices.Length, world.Length);
for (int vertex = 0; vertex < count; vertex++)
{
diff --git a/src/AcDream.App/Rendering/Shaders/portal_depth.vert b/src/AcDream.App/Rendering/Shaders/portal_depth.vert
index 47d667cb..23ec031f 100644
--- a/src/AcDream.App/Rendering/Shaders/portal_depth.vert
+++ b/src/AcDream.App/Rendering/Shaders/portal_depth.vert
@@ -50,7 +50,15 @@ void main()
if (uRenderPass == 1)
{
- clipPos.z = clipPos.w * 0.99999988; // retail far-z punch constant (0x0059bc90 tail)
+ // S4-c1 C0: retail's D3DPolyRender::DrawPortalPolyInternal
+ // @0x0059bc90 tail writes the far-Z punch from the EXACT bit
+ // pattern 0x3F7FFFEF (0.999999f-ish, NOT the "obvious" 1-2^-23
+ // literal 0.99999988f/0x3F7FFFFE the punch used to carry — that
+ // decimal is fifteen ULPs farther from the camera than retail's
+ // real constant). uintBitsToFloat keeps the exact bits instead of
+ // trusting a decimal literal to round-trip through the GLSL/SPIR-V
+ // compiler unchanged.
+ clipPos.z = clipPos.w * uintBitsToFloat(0x3F7FFFEFu);
}
gl_Position = clipPos;
}
diff --git a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv
index 5174cf0c..b7cbc5cf 100644
Binary files a/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/portal_depth.vert.spv differ
diff --git a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
index d272f155..bb310ded 100644
--- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
+++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json
@@ -295,7 +295,7 @@
"stages": [
{
"stage": "vert",
- "sourceSha256": "1df7e2009cda8f84ba3d84546bf58baf71d10fe53e696655b4ca7b8292a32fa5",
+ "sourceSha256": "afc70a0716ac9dbb3a514bfca6feeae8d0934f51e677b202b9049634645ba6ce",
"compiled": true
},
{
diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
index be5dfe0d..79a0bbf4 100644
--- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs
@@ -1514,6 +1514,15 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource
ArgumentNullException.ThrowIfNull(polygon);
RequireOpenFrame();
+ // S4-c1 C1: DrawPortalPolyInternal's degenerate-input guard
+ // (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard's own
+ // doc comment) — tested on the polygon's LOCAL vertices, BEFORE the
+ // building -> world transform below. A hit drops the whole polygon:
+ // no punch event, no transform, no counter effect (retail's own
+ // reject -> transform -> clip -> count order).
+ if (WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon.Vertices))
+ return;
+
MarkIfGrown();
Matrix4x4 worldTransform = _worldData.GetBuildingWorldTransform(building);
_events.Add(
diff --git a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
index c447cbdf..90ee8b6f 100644
--- a/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
+++ b/src/AcDream.App/Rendering/Walk/WalkVisibilityMath.cs
@@ -197,6 +197,40 @@ public static class WalkVisibilityMath
}
return partial ? WalkBoundingType.PartiallyInside : WalkBoundingType.EntirelyInside;
}
+
+ ///
+ /// D3DPolyRender::DrawPortalPolyInternal @0x0059bc90's
+ /// degenerate-input guard (Ghidra-arbitrated:
+ /// docs/research/2026-09-01-overhaul/oh1-depth-lifecycle.md's "Ghidra
+ /// branch arbitration table", row 0x59BCD6–0x59BD28 then
+ /// 0x59BD40–0x59BD66 — S4-c1 C1). The pseudo-C's own nested-if
+ /// reading of the four x87 FCOM results (via test ah, 0x44
+ /// against a Binary Ninja-synthesized condition byte) is FPU-flag
+ /// ambiguous and reads backward if taken at face value; the arbitration
+ /// table's sense governs — per feedback_bn_decomp_field_names.md,
+ /// a decompiler's flag-mush around x87 compares is a known artifact
+ /// class, not semantics.
+ ///
+ /// Retail tests every SOURCE vertex's LOCAL x and y — BEFORE
+ /// xformStart, the world transform — against exactly +12
+ /// and -12. ANY hit on ANY vertex rejects the WHOLE polygon: no
+ /// transform, no clip, no portalsDrawnCount increment (retail's
+ /// order is reject → transform → clip → count). Ordinary authored dat
+ /// portal polygons essentially never land a vertex on that exact
+ /// boundary — this is a degenerate-input guard, not a clip rule — but
+ /// it is retail's code, so it is ported as retail's code.
+ ///
+ public static bool IsRejectedByPortalPolygonBoundaryGuard(ReadOnlySpan localVertices)
+ {
+ for (int i = 0; i < localVertices.Length; i++)
+ {
+ float x = localVertices[i].X;
+ float y = localVertices[i].Y;
+ if (x == 12f || x == -12f || y == 12f || y == -12f)
+ return true;
+ }
+ return false;
+ }
}
/// Retail Plane: dot(N, p) + d, positive side = inside.
diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
index f5f2bcd1..627986eb 100644
--- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
+using System.Text.RegularExpressions;
namespace AcDream.App.Tests.Rendering.Gpu.Vk;
@@ -71,7 +73,13 @@ public sealed class VulkanShaderManifestTests
// candidate: ALWAYS / write / no stencil; far-Z punch, true-depth seal).
// Its acceptance belongs to OVERHAUL S4 (gate G3); this pin records the
// binary that is actually on the branch so accidental drift is still caught.
- ["portal_depth.vert.spv"] = "4ac1c452e7ac0d08a32f67fb03f21229af2d1605baa81f407240a3626251dfd7",
+ // Re-pinned 2026-09-03 (S4-c1 C0): the far-Z punch constant now writes
+ // retail's EXACT bits (uintBitsToFloat(0x3F7FFFEFu),
+ // D3DPolyRender::DrawPortalPolyInternal @0x0059bc90's tail) instead of
+ // the decimal literal 0.99999988f, which round-tripped to a DIFFERENT
+ // bit pattern (0x3F7FFFFE, fifteen ULPs nearer the camera than retail's
+ // real constant — see T1 in WalkVisibilityMathTests/the commit body).
+ ["portal_depth.vert.spv"] = "51c60d0924d62c61548efcf5f9e7672a121b1b68ca0a06755e32f1a4d73a8acf",
// sky.frag re-pinned 2026-08-23: the dome's fog blend lost its
// 0.2 floor and is now applied only under an AdminEnvirons fog
// override, retail's GameSky::Draw @0x00506FF0 rule (see
@@ -292,4 +300,75 @@ public sealed class VulkanShaderManifestTests
// pipeline it can build at all.
Assert.True(probe.VulkanReady, "vk_probe must compile — the whole Vulkan backend draws with it.");
}
+
+ ///
+ /// S4-c1 C0 (T1): D3DPolyRender::DrawPortalPolyInternal
+ /// @0x0059bc90's tail writes the far-Z punch depth from the EXACT bit
+ /// pattern 0x3F7FFFEF — the depth-lifecycle spec's "Current
+ /// acdream correspondence" table's "wrong constant" row, and
+ /// docs/research/2026-09-01-overhaul/s4-depth-alpha-packet.md §6's R1/C0.
+ /// This is a SOURCE pin, deliberately independent of
+ /// 's compiled
+ /// hash pin above: it reads the punch line directly out of the GLSL and
+ /// reinterprets whatever numeric literal it finds as raw bits, so a
+ /// future edit that swaps in an "equivalent-looking" decimal literal (or
+ /// any other constant) is caught even before anyone re-runs
+ /// tools/compile-shaders.ps1. Recognizes both the current
+ /// uintBitsToFloat(0x...u) form and a plain decimal float literal
+ /// (the pre-C0 shape), so it can also be pointed at old source to prove
+ /// it used to fail. At `d0c981212` (pre-C0) the line read
+ /// clipPos.z = clipPos.w * 0.99999988;, whose bits are
+ /// 0x3F7FFFFE (confirmed via
+ /// BitConverter.SingleToInt32Bits(0.99999988f)) — FIFTEEN ULPs
+ /// off from retail's 0x3F7FFFEF; running this exact assertion
+ /// against that source produces (verified by hand-editing the line back
+ /// to the old literal and re-running this test during S4-c1's own
+ /// implementation — the commit body carries the same transcript):
+ /// "Assert.Equal() Failure: Values differ\nExpected: 1065353199\nActual: 1065353214"
+ /// (1065353199 = 0x3F7FFFEF, 1065353214 = 0x3F7FFFFE).
+ /// MUTATION: change the punch literal to anything other than bits
+ /// 0x3F7FFFEF (restore the old decimal, or substitute a different
+ /// hex constant) — below fails.
+ ///
+ [Fact]
+ public void PortalDepthVert_FarPunchConstant_MatchesRetailExactBits()
+ {
+ string source = File.ReadAllText(Path.Combine(ShadersDirectory(), "portal_depth.vert"));
+ string line = source
+ .Split('\n')
+ .Select(l => l.Trim())
+ .SingleOrDefault(l => l.StartsWith("clipPos.z = clipPos.w * ", StringComparison.Ordinal))
+ ?? throw new InvalidOperationException(
+ "portal_depth.vert no longer has a 'clipPos.z = clipPos.w * ;' punch "
+ + "line for T1 to read — did the far-Z punch assignment move or get restructured?");
+
+ uint bits = ParsePunchLiteralBits(line);
+ Assert.Equal(0x3F7FFFEFu, bits);
+ }
+
+ private static uint ParsePunchLiteralBits(string assignmentLine)
+ {
+ // assignmentLine looks like:
+ // "clipPos.z = clipPos.w * uintBitsToFloat(0x3F7FFFEFu);" (post-C0)
+ // or:
+ // "clipPos.z = clipPos.w * 0.99999988;" (pre-C0)
+ const string prefix = "clipPos.z = clipPos.w * ";
+ string rhs = assignmentLine[prefix.Length..];
+ int commentStart = rhs.IndexOf("//", StringComparison.Ordinal);
+ if (commentStart >= 0)
+ rhs = rhs[..commentStart];
+ rhs = rhs.Trim().TrimEnd(';', ' ');
+
+ Match hexMatch = Regex.Match(rhs, @"uintBitsToFloat\(\s*0x([0-9A-Fa-f]+)u?\s*\)");
+ if (hexMatch.Success)
+ return Convert.ToUInt32(hexMatch.Groups[1].Value, 16);
+
+ string literal = rhs.TrimEnd('f', 'F');
+ if (float.TryParse(literal, NumberStyles.Float, CultureInfo.InvariantCulture, out float value))
+ return unchecked((uint)BitConverter.SingleToInt32Bits(value));
+
+ throw new InvalidOperationException(
+ $"portal_depth.vert's punch literal '{rhs}' is neither a uintBitsToFloat(0x...) call "
+ + "nor a plain float literal T1 knows how to reinterpret as bits.");
+ }
}
diff --git a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
index f2206913..17bdedee 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailPViewPassExecutorTests.cs
@@ -1,3 +1,4 @@
+using System.Numerics;
using System.Reflection;
using System.Reflection.Emit;
using AcDream.App.Composition;
@@ -418,6 +419,66 @@ public sealed class RetailPViewPassExecutorTests
RetailPViewPassExecutor.ShouldDrawWeatherOnce(renderSky, renderWeather, playerCellId));
}
+ ///
+ /// S4-c1 C1 (T2, seal half): D3DPolyRender::DrawPortalPolyInternal
+ /// @0x0059bc90's degenerate-input guard, ported at the exit-seal
+ /// enumeration (DrawPortalDepthWrite — the SAME loop that reads
+ /// cell.PortalPolygons[index], the LOCAL portal-polygon
+ /// vertices, and is the only production caller of
+ /// ). A live
+ /// functional test of this private method needs a real
+ /// the suite has no fake for (see
+ /// WalkFrameDriverTests.OnPunchGeometry_RejectsWholePolygonOn...
+ /// for the punch-fan half's functional proof instead), so this pin asks
+ /// the compiled-call-graph question this file's other tests already use
+ /// for exactly this situation: does
+ ///
+ /// run BEFORE the vertex loop's calls and BEFORE
+ /// (retail's reject -> transform -> clip -> count order — a hit
+ /// must never reach either), with a conditional branch gating that
+ /// order directly off the guard's own return value.
+ /// MUTATION: move the guard call to AFTER the transform loop (or
+ /// delete it) — either check below fails because the guard call index
+ /// is no longer the smallest, or (deletion)
+ /// throws for finding no call at all.
+ ///
+ [Fact]
+ public void DrawPortalDepthWrite_RejectsDegenerateLocalPolygons_BeforeTransformOrSubmission()
+ {
+ MethodInfo method = typeof(RetailPViewPassExecutor).GetMethod(
+ "DrawPortalDepthWrite",
+ BindingFlags.Instance | BindingFlags.NonPublic)!;
+ IReadOnlyList calls = CompiledCallGraph.Read(method);
+
+ int guardIndex = RequiredCallIndex(
+ calls,
+ typeof(AcDream.App.Rendering.Walk.WalkVisibilityMath),
+ nameof(AcDream.App.Rendering.Walk.WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard));
+ int transformIndex = RequiredCallIndex(calls, typeof(Vector3), nameof(Vector3.Transform));
+ int drawIndex = RequiredCallIndex(
+ calls, typeof(PortalDepthMaskRenderer), nameof(PortalDepthMaskRenderer.DrawDepthFan));
+
+ Assert.True(
+ guardIndex < transformIndex,
+ "The boundary guard must run BEFORE the world-transform loop "
+ + "(retail's reject -> transform -> clip -> count order).");
+ Assert.True(
+ guardIndex < drawIndex,
+ "The boundary guard must run BEFORE the fan is submitted "
+ + "(no draw, no `submitted` increment on a hit).");
+
+ int guardOffset = calls[guardIndex].Offset;
+ int transformOffset = calls[transformIndex].Offset;
+ IReadOnlyList branches = CompiledCallGraph.ReadBranches(method);
+ Assert.Contains(
+ branches,
+ branch => branch.Offset > guardOffset
+ && branch.Offset < transformOffset
+ && (branch.OpCode == OpCodes.Brtrue || branch.OpCode == OpCodes.Brtrue_S
+ || branch.OpCode == OpCodes.Brfalse || branch.OpCode == OpCodes.Brfalse_S));
+ }
+
private static int RequiredCallIndex(
IReadOnlyList calls,
Type declaringType,
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
index 78afdc76..7d9e4e18 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs
@@ -610,6 +610,141 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(0, driver.PortalsDrawnCount);
}
+ // ── S4-c1 C3 (T3): the depth-alpha packet's truth table over (root kind,
+ // draw_landscape, outside-view count, previous count) -> the exact
+ // LFLUSH/stamp/CLEAR/SEALS event subsequence. Chunk 2 already covers
+ // most of the table as direct Facts, so chunk 1 adds only the rows that
+ // were genuinely missing rather than re-deriving them:
+ // - interior, ov==0 (draw_landscape==false), prior==0 ->
+ // RunFrame_InteriorFloodWithNoExitView_SkipsLandscapeAndNeverFlushesClearsOrSeals
+ // - interior, ov>0 (draw_landscape==true), prior==0 ->
+ // RunFrame_InteriorFloodWithExitView_FreshDriverSkipsTheGatedClearThenDrawsSealsAndFloodCells
+ // and OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
+ // (its own frame 1)
+ // - interior, ov>0, prior>0 ->
+ // OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
+ // (its own frame 2) — this pair IS T4, the two-consecutive-frames
+ // latch case, below
+ // The two rows below — root kind == OUTDOOR (where the entire LFLUSH/
+ // stamp/CLEAR/SEALS mechanism cannot fire at all, because
+ // RetailFrameWalk.WalkFrame's outdoor branch never calls DrawInside/
+ // OnInteriorFloodDrawTurn), and interior/ov==0 immediately AFTER a
+ // prior-armed nonzero counter (proving the counter is left completely
+ // UNTOUCHED, not merely "not cleared this frame") — were not yet pinned
+ // anywhere. ─────────────────────────────────────────────────────────
+
+ [Fact]
+ public void WalkFrame_OutdoorRoot_NeverFiresTheInteriorClearSealMachinery()
+ {
+ // Root kind == OUTDOOR: RetailFrameWalk.WalkFrame's
+ // (cameraCellId & 0xFFFF) < 0x100 branch calls DrawLandscape
+ // directly and never calls DrawInside — so OnInteriorFloodDrawTurn,
+ // the sole owner of LFLUSH/stamp/CLEAR/SEALS, never fires at all,
+ // for ANY outside-view-count/previous-count combination (there is
+ // no such combination reachable outdoors — this row of the table
+ // has no ov/prior axis).
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ var leaf = new RecordingLeafRenderer(log);
+ var ctx = new TestContext();
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
+ var walk = new RetailFrameWalk();
+ // Same minimal, no-op 1x1 unpublished landscape the interior exit-
+ // view fixtures use — LScape::draw still runs its full sky/terrain
+ // turn against it; there's nothing published to iterate for.
+ var landscape = new WalkLandscape { MidWidth = 1, Blocks = new WalkLandBlock?[1] };
+
+ using DrawScope draw = fx.BeginDraw();
+ driver.RunFrame(
+ walk, cameraCellId: 0x00000050u, cameraCell: null, landscape: landscape,
+ ctx, draw.Frame, draw.Pass, Matrix4x4.Identity, cameraWorldPosition: Vector3.Zero);
+
+ // MUTATION: add a stray sink.OnInteriorFloodDrawTurn(...) call to
+ // RetailFrameWalk.WalkFrame's outdoor branch (e.g. a copy-paste from
+ // the interior branch) — SKY still appears, but LFLUSH/SEALS would
+ // too, and this fails.
+ Assert.Contains("SKY", log);
+ Assert.DoesNotContain("LFLUSH", log);
+ Assert.DoesNotContain("CLEAR", log);
+ Assert.DoesNotContain("SEALS", log);
+ Assert.Equal(0, driver.PortalsDrawnCount);
+ }
+
+ [Fact]
+ public void OnInteriorFloodDrawTurn_OvZeroAfterAPriorArmedCounter_LeavesTheLatchCompletelyUntouched()
+ {
+ // Interior root, ov==0 immediately after an EARLIER ov>0 frame armed
+ // the counter: S3 §8.1 R3 gates the ENTIRE outside_view.view_count>0
+ // block — including the read-then-zero decision itself — so ov==0
+ // must leave the counter EXACTLY as an earlier frame left it, not
+ // merely "not cleared this frame" (a read-then-zero-back-to-the-
+ // same-nonzero-value mistake would also leave PortalsDrawnCount
+ // looking untouched from the OUTSIDE — this test's real target is
+ // that no clear/seal machinery runs at all, proven by the empty log
+ // alongside the unchanged counter).
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ var leaf = new RecordingLeafRenderer(log);
+ var ctx = new TestContext();
+ const uint cellId = 0xF4180310u;
+ var cell = new WalkCell { CellId = cellId };
+ cell.PushView();
+ WalkCopyView.AppendFullViewportQuad(
+ cell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
+ ctx.Cells[cellId] = cell;
+
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
+ IWalkEventSink sink = driver;
+
+ using DrawScope draw = fx.BeginDraw();
+
+ // Frame 1: an ordinary ov>0 flood arms the counter.
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
+ var views = new WalkPortalView();
+ WalkCopyView.AppendFullViewportQuad(
+ views, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
+ sink.OnLandscapeViews(views);
+ sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 1);
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+ Assert.Equal(1, driver.PortalsDrawnCount);
+ log.Clear();
+
+ // Frame 2: ov==0 — retail's whole clear/seal gate is skipped by
+ // construction (no landscape turn either, matching DrawInside's own
+ // ov==0 shape).
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ sink.OnInteriorFloodDrawTurn([cellId], outsideViewCount: 0);
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+
+ // The ov==0 flood still draws its OWN cell's shell + contents (R1's
+ // "straight to the flood's own cells" tail) — the four LFLUSH/
+ // stamp/CLEAR/SEALS tokens are what must be absent, not the whole
+ // log; see RunFrame_InteriorFloodWithNoExitView_... above for the
+ // same non-empty-but-gate-free shape.
+ Assert.DoesNotContain("SKY", log);
+ Assert.DoesNotContain("LFLUSH", log);
+ Assert.DoesNotContain("CLEAR", log);
+ Assert.DoesNotContain("SEALS", log);
+ // MUTATION: move the `int armed = PortalsDrawnCount; PortalsDrawnCount
+ // = 0;` read-then-zero in WalkFrameDriver.OnInteriorFloodDrawTurn
+ // outside the `if (outsideViewCount > 0)` gate — the counter reads
+ // back as 0 here instead of the untouched 1, and this fails.
+ Assert.Equal(1, driver.PortalsDrawnCount);
+ }
+
+ // ── S4-c1 C3 (T4): the same fixture as
+ // OnInteriorFloodDrawTurn_FirstOvFrameSkipsClear_SecondFrameArmedByFirstsSealsClears
+ // above already proves the "frame 1 seals N>0 -> frame 2 clears" half of
+ // the two-consecutive-frames latch, and
+ // OnInteriorFloodDrawTurn_FloodWithNoExitPortal_NeverClearsAcrossFrames
+ // already proves "frame 1 seals 0 -> frame 2 does not clear" (repeated
+ // across three consecutive ov>0 frames, which subsumes the two-frame
+ // case). No further T4 test is added — see the commit body for the
+ // pre-existing-coverage inventory. ────────────────────────────────────
+
// ── T3 (S3 chunk 2, R1): a building look-in's own DrawCells re-enters
// with ov==0 unconditionally and neither ARMS nor CONSUMES the
// persistent PortalsDrawnCount counter — retail calls DrawCells
@@ -669,6 +804,91 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(1, driver.PortalsDrawnCount);
}
+ // ── S4-c1 C3 (T5): the ABOVE test proves one look-in is isolated from
+ // the root latch; this extends it to MULTIPLE look-ins — two in the
+ // SAME frame (two buildings' own portal passes), then a third in a
+ // LATER, separate frame — since retail's DrawCells re-entry (R1) has no
+ // per-call state of its own that could accumulate across repeats. ────
+
+ [Fact]
+ public void MultipleLookIns_WithinOneFrameAndAcrossFrames_NeverTouchTheRootLatch()
+ {
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ var leaf = new RecordingLeafRenderer(log);
+ var ctx = new TestContext();
+ const uint rootCellId = 0xF4180330u;
+ const uint lookInCellIdA = 0xF4180331u;
+ const uint lookInCellIdB = 0xF4180332u;
+ var rootCell = new WalkCell { CellId = rootCellId };
+ rootCell.PushView();
+ WalkCopyView.AppendFullViewportQuad(
+ rootCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
+ ctx.Cells[rootCellId] = rootCell;
+ foreach (uint lookInId in new[] { lookInCellIdA, lookInCellIdB })
+ {
+ var lookInCell = new WalkCell { CellId = lookInId };
+ lookInCell.PushView();
+ WalkCopyView.AppendFullViewportQuad(
+ lookInCell.TopView, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
+ ctx.Cells[lookInId] = lookInCell;
+ }
+
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
+ IWalkEventSink sink = driver;
+
+ using DrawScope draw = fx.BeginDraw();
+
+ // Arm PortalsDrawnCount with one throwaway ov>0 interior-root flood.
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ sink.Emit(WalkEvent.Landscape(activeViewCount: 1));
+ var rootViews = new WalkPortalView();
+ WalkCopyView.AppendFullViewportQuad(
+ rootViews, ctx.Rays, ctx.WorldViewpoint, ctx.ViewportWidth, ctx.ViewportHeight);
+ sink.OnLandscapeViews(rootViews);
+ sink.OnInteriorFloodDrawTurn([rootCellId], outsideViewCount: 1);
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+ Assert.Equal(1, driver.PortalsDrawnCount);
+ log.Clear();
+
+ // TWO look-ins in the SAME frame.
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ sink.OnBuildingTurn(new WalkBuilding());
+ sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdA]));
+ sink.OnBuildingTurn(new WalkBuilding());
+ sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdB]));
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+
+ Assert.DoesNotContain("LFLUSH", log);
+ Assert.DoesNotContain("CLEAR", log);
+ Assert.DoesNotContain("SEALS", log);
+ // MUTATION (verified during S4-c1's own implementation, then
+ // reverted): a bug that only mishandles a REPEAT look-in call within
+ // one frame (e.g. resetting PortalsDrawnCount on the second
+ // HandleDrawCellsTurn(LookInStatic) call) leaves this exact
+ // assertion at 0 instead of 1, while
+ // LookInDrawCells_NeitherArmsNorConsumesThePortalsDrawnCounter above
+ // — which calls DrawCells exactly once — stays green throughout;
+ // that gap is this test's whole reason to exist over the single-
+ // look-in fact.
+ Assert.Equal(1, driver.PortalsDrawnCount);
+ log.Clear();
+
+ // A THIRD look-in in a LATER, separate frame.
+ driver.BeginFrame(ctx, Matrix4x4.Identity, Vector3.Zero);
+ sink.OnBuildingTurn(new WalkBuilding());
+ sink.Emit(WalkEvent.DrawCells(outsideViewCount: 0, [lookInCellIdA]));
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+
+ Assert.DoesNotContain("LFLUSH", log);
+ Assert.DoesNotContain("CLEAR", log);
+ Assert.DoesNotContain("SEALS", log);
+ Assert.Equal(1, driver.PortalsDrawnCount);
+ }
+
// ── Deliverable: a building turn's alpha barrier precedes its portal
// pass (retail RenderDeviceD3D::DrawBuilding @0x0059f2a0:
// FlushAlphaList(0f) -> CPhysicsPart::Draw(parts,1) [the portal walk]
@@ -812,6 +1032,63 @@ public sealed partial class WalkFrameDriverTests
Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount));
}
+ // ── S4-c1 C1/T2: DrawPortalPolyInternal's degenerate-input guard, ported
+ // at the punch-fan producer (WalkFrameDriver.OnPunchGeometry — the
+ // handler that owns the LOCAL polygon before TransformToWorld). A source
+ // vertex with local x/y exactly +/-12 drops the WHOLE polygon before any
+ // transform, event, or counter effect; the same shape at 11.999 is an
+ // ordinary polygon and punches normally. ─────────────────────────────
+
+ [Fact]
+ public void OnPunchGeometry_RejectsWholePolygonOnExactPlusMinus12LocalVertex_ButPunchesJustInside()
+ {
+ using var fx = new DispatcherFixture();
+ var log = new List();
+ var leaf = new RecordingLeafRenderer(log);
+ var driver = new WalkFrameDriver(fx.Dispatcher, leaf, new FakeWorldData());
+ IWalkEventSink sink = driver;
+ var building = new WalkBuilding { PositionCellId = 0xA9B40040u };
+
+ using DrawScope draw = fx.BeginDraw();
+ driver.BeginFrame(new TestContext(), Matrix4x4.Identity, Vector3.Zero);
+
+ // Degenerate: one vertex sits exactly on the local x == +12 boundary.
+ // MUTATION: relax the guard's exact equality to a tolerance/
+ // inequality (e.g. x >= 12f) and this test's second assertion group
+ // (the admitted 11.999 polygon) starts failing instead — 11.999 is a
+ // real, non-degenerate local coordinate a fifth of a millimeter
+ // (retail units) inside the exact boundary.
+ sink.OnPunchGeometry(
+ building,
+ new WalkPolygon
+ {
+ Vertices = [new(0f, 0f, 3f), new(12f, 0f, 3f), new(5f, 5f, 3f)],
+ Plane = new WalkPlane(Vector3.UnitZ, -3f),
+ },
+ activeViewIndex: 0);
+
+ // Admitted: the nearest-boundary vertex is 11.999, not 12 — an
+ // ordinary polygon that must punch exactly like any other.
+ sink.OnPunchGeometry(
+ building,
+ new WalkPolygon
+ {
+ Vertices = [new(0f, 0f, 3f), new(11.999f, 0f, 3f), new(5f, 5f, 3f)],
+ Plane = new WalkPlane(Vector3.UnitZ, -3f),
+ },
+ activeViewIndex: 0);
+
+ driver.EndFrame();
+ driver.Replay(draw.Frame, draw.Pass);
+
+ // Exactly ONE punch reached the leaf — the rejected polygon produced
+ // no PunchFan event at all (not a punch that draws zero vertices; no
+ // event, full stop).
+ WalkPolygon punched = Assert.Single(leaf.Punches);
+ Assert.Equal(new Vector3(11.999f, 0f, 3f), punched.Vertices[1]);
+ Assert.Equal(1, log.Count(entry => entry == "PUNCH:3@v0"));
+ }
+
[Fact]
public void RepeatedFloodTurns_DrawEnvCellShellWholeOncePerRetailFrameStamp()
{
diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
index b1500aaa..1d5264d9 100644
--- a/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkVisibilityMathTests.cs
@@ -215,4 +215,84 @@ public sealed class WalkVisibilityMathTests
WalkBoundingType.EntirelyInside,
WalkVisibilityMath.ViewconeCheck(new Vector3(3, 5, 0), 1f, Cy, edges));
}
+
+ // ---- DrawPortalPolyInternal @0x0059bc90's degenerate-input guard
+ // (S4-c1 C1, T2): ANY source vertex whose LOCAL x or y lands exactly on
+ // +/-12 rejects the WHOLE polygon. ----
+
+ [Theory]
+ [InlineData(12f, 0f)] // x == +12 exactly
+ [InlineData(-12f, 0f)] // x == -12 exactly
+ [InlineData(0f, 12f)] // y == +12 exactly
+ [InlineData(0f, -12f)] // y == -12 exactly
+ public void Boundary_guard_rejects_a_polygon_with_one_vertex_exactly_on_plus_minus_12(
+ float x, float y)
+ {
+ // MUTATION (verified during S4-c1's own implementation): narrow the
+ // guard's exact equality to a strict `x > 12f` / `x < -12f` (no
+ // boundary-inclusive case at all) — the exact +/-12 boundary this
+ // theory's four rows probe stops rejecting and all four fail.
+ Vector3[] polygon = [new Vector3(0, 0, 3), new Vector3(x, y, 3), new Vector3(5, 5, 3)];
+ Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
+ }
+
+ [Theory]
+ [InlineData(11.999f, 0f)]
+ [InlineData(-11.999f, 0f)]
+ [InlineData(0f, 11.999f)]
+ [InlineData(0f, -11.999f)]
+ public void Boundary_guard_admits_a_polygon_whose_nearest_vertex_is_just_inside_12(
+ float x, float y)
+ {
+ // MUTATION (verified during S4-c1's own implementation): widen the
+ // guard's exact equality to a near-boundary tolerance, e.g.
+ // `MathF.Abs(x) >= 11.99f` instead of `x == 12f` — a "close enough
+ // to the boundary" mistake that still leaves ordinary far-from-12
+ // coordinates alone. 11.999 sits inside that widened band, so this
+ // theory's four rows fail; a strict `x >= 12f` (no tolerance at all)
+ // does NOT catch this test — 11.999 < 12 either way — which is
+ // exactly why Boundary_guard_rejects_a_polygon_with_one_vertex_
+ // exactly_on_plus_minus_12 above exists as the other half of the
+ // pin: it fails instead if the guard is narrowed to a strict `>`
+ // that lets the exact +/-12 boundary through.
+ Vector3[] polygon = [new Vector3(0, 0, 3), new Vector3(x, y, 3), new Vector3(5, 5, 3)];
+ Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
+ }
+
+ [Fact]
+ public void Boundary_guard_rejects_the_whole_polygon_even_when_only_one_of_several_vertices_hits_it()
+ {
+ // Retail's four var_* flags are OR'd across the WHOLE vertex loop
+ // before the single post-loop decision (0x59BD42-0x59BD66) — a
+ // degenerate vertex anywhere in the fan condemns every vertex in it,
+ // not just its own. MUTATION (verified during S4-c1's own
+ // implementation): check only localVertices[0] instead of looping
+ // every vertex — the degenerate vertex here is the LAST of four, so
+ // the guard would wrongly return false and this fails.
+ Vector3[] polygon =
+ [
+ new Vector3(-3f, -3f, 3f),
+ new Vector3(3f, -3f, 3f),
+ new Vector3(3f, 3f, 3f),
+ new Vector3(12f, 3f, 3f), // the one degenerate vertex
+ ];
+ Assert.True(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
+ }
+
+ [Fact]
+ public void Boundary_guard_ignores_the_vertical_z_component()
+ {
+ // Retail's guard reads only the two horizontal FIELDS the decomp
+ // names (arg1->vertices[i] as the x source, ecx_1[1] as the y
+ // source) — a vertex whose HEIGHT happens to be +/-12 is an entirely
+ // ordinary local coordinate and must not trip the guard.
+ Vector3[] polygon = [new Vector3(0, 0, 12f), new Vector3(1, 1, -12f), new Vector3(2, 0, 0)];
+ Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard(polygon));
+ }
+
+ [Fact]
+ public void Boundary_guard_admits_the_empty_polygon()
+ {
+ Assert.False(WalkVisibilityMath.IsRejectedByPortalPolygonBoundaryGuard([]));
+ }
}