diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md index e9c4f417..e0783246 100644 --- a/docs/architecture/acdream-architecture.md +++ b/docs/architecture/acdream-architecture.md @@ -1195,6 +1195,17 @@ no reconstructed fallback. Login and portal-space retain last-frame point-light feedback seam are deleted. Directional shadows remain the next typed landscape consumer (S5-c2). +Point-light retention follows retail's two independent products: seven dynamic +and forty static candidates, with POINT lights ranked from the light owner's +explicit root origin using stable forward insertion; SPOT/non-point lights rank +at zero. Authored offsets affect the final light position but not the rank. The viewer light is offered to the dynamic product +first even when its registration is late. EnvCell shells consume the complete +47-entry product through their mode-specific binding-5 stride, while ordinary +Gfx/building objects retain the separate eight-entry hardware selection and +stride. Both ordinary and atmospheric mesh shaders select that stride from the +existing lighting mode; the attenuation, ambient, packing, and per-object +selection formulas are otherwise shared and unchanged. + WorldBuilder contributes DAT decoding, mesh preparation, residency, and Vulkan batch mechanics only; it makes no visibility or ordering decision. Forced merge breaks at walk event boundaries preserve retail order while compatible draws diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 5ac4e441..be2530e0 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -221,7 +221,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 160 active rows (AP-19 retired 2026-09-05 at Campaign OVERHAUL S5-c5; AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4 — exact one-pass detail material with authored surface opacity and squared final alpha replaces the two-draw approximation; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 159 active rows (AP-68 retired 2026-09-05 by Campaign OVERHAUL #478; AP-19 retired 2026-09-05 at Campaign OVERHAUL S5-c5; AP-117 retired 2026-09-04 at Campaign OVERHAUL S5-c1; AP-241/AP-242/AP-243 filed 2026-09-04 at S4-c3a; recounted honestly 2026-09-04 at the S4-c2 fix round 1 — the prior "161" was stale; AP-240 filed 2026-09-04 at the S4-c2 fix round 1 (M2) — clip-mapped GfxObj/scenery/building subsets draw immediately outside retail's CLIP FIFO, `WbDrawDispatcher.IsOpaque` filtering them out before they ever reach the alpha queue; AP-34 RETIRED 2026-09-04 at the S4-c2 fix round 1 — its residuals continue as the renumbered AP-238/AP-239 (both were filed as the colliding/dangling "AP-236"/"AP-237"/"AP-236b" ids the S4-c2 chunk 2 commit introduced; ids are never reused, so the fix round renumbered them and restored AP-34's own row as struck-through rather than deleted outright — see A2/M8c); AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 RETIRED 2026-09-04 at Campaign OVERHAUL S5-c4 — exact one-pass detail material with authored surface opacity and squared final alpha replaces the two-draw approximation; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) **2026-08-28 count correction:** #408 retired AP-230 by moving property `0x3B` visibility into the shared importer and every retained stateful widget; @@ -275,7 +275,8 @@ only EnvCell pure Clip uses `ONE/INVSRCALPHA`. The retired row's recipe-9-only and `None` residual wording is superseded by this correction and AP-244; AP-238/AP-239/AP-240 remain separate queue/token/raw-mask/placement residuals. Campaign OVERHAUL S5-c5 subsequently retired AP-19 with its deleted research -builder, reducing the current active total to **160**. +builder, and Campaign OVERHAUL #478 retired AP-68's obsolete 128-light policy, +reducing the current active total to **159**. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| @@ -337,7 +338,7 @@ builder, reducing the current active total to **160**. | AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager | | AP-14 | Encumbrance multiplier is a rough piecewise-linear stand-in (1.0→50%, ~0.7@100%, 0.1@300%) for retail's exact curve | `src/AcDream.Core/Items/ItemInstance.cs:187` | Hand-fit segments capture the curve's shape for scaffolding | Client-side burden-scaled effects (speed prediction) differ from retail at most burden ratios when loaded | r06 §6 (retail encumbered multiplier curve) | | AP-15 | WeenieError **sentence** table covers only ~30 common codes (from ACE enum docs, not retail string_table.bin); unknown codes render raw hex. The row's older *code-catalog* caveat is superseded: `WeenieError` carried a curated 16-member subset until 2026-07-29 and now holds the full 372-code table, so a code being unnamed is no longer a way for this to bite | `src/AcDream.Core/Chat/WeenieErrorMessages.cs:26` | Untranslated codes are rare, fall back losslessly, 30-second add when reported | Server messages outside the table show as raw hex instead of the retail sentence | retail string_table.bin; ACE WeenieError*.cs; `src/AcDream.Core/Physics/WeenieError.cs` | -| AP-16 | Point/spot lights selected per-object / per-cell as the **8 nearest reaching lights** (sphere-overlap, nearest-first) via `LightManager.SelectForObject`, capped at `MaxLightsPerObject=8`; called from `WbDrawDispatcher.ComputeEntityLightSet` (objects) and `EnvCellRenderer.GetCellLightSet` (cell shells). Retail's bake (`SetStaticLightingVertexColors`) sums ALL reaching static lights per vertex with no count cap. Retail's *hardware* path (`minimize_object_lighting` 0x0054d480) DOES cap at 8 per object, so the cap is faithful to retail's hardware path — not to its bake path. The `LightManager.Tick` UBO path survives for DIRECTIONAL (sun) lights only; `mesh_modern.vert`'s UBO loop skips point/spot entries (`posAndKind.w != 0 → continue`) — point lights reach the shader exclusively via the per-object SSBO (binding 5) | `src/AcDream.Core/Lighting/LightManager.cs:234` (`SelectForObject`); `MaxLightsPerObject` ~line 174; call sites `WbDrawDispatcher.ComputeEntityLightSet` + `EnvCellRenderer.GetCellLightSet` | Matches retail's hardware constraint (8 lights per object/cell); selection is nearest-sphere-overlap which faithfully allocates lights to the surfaces that actually see them | Surfaces reached by >8 point lights are dimmer than retail's uncapped bake — rare (a dungeon room has a handful of torches), but real; see AP-35 for the bake-vs-GPU-evaluate architecture difference | `minimize_object_lighting` 0x0054d480 (retail's 8-light hardware cap); `SetStaticLightingVertexColors` 0x0059cfe0 (retail's bake, no count cap) | +| AP-16 | Ordinary Gfx/building objects retain the existing `LightManager.SelectForObject` selection: up to eight reaching point/spot lights, consumed through binding 5 with the ordinary-object stride `MaxLightsPerObject=8`. That capacity matches retail's hardware `minimize_object_lighting` ceiling, but acdream's mixed static/dynamic nearest-sphere-overlap selection is not claimed as a complete port of that routine. EnvCell shells no longer share this approximation: #478 gives them all retained seven dynamic plus forty static candidates through their independent 47-index binding-5 stride. The `LightManager.Tick` UBO path remains directional-only; point/spot lights reach these shaders through binding 5. | `src/AcDream.Core/Lighting/LightManager.cs` (`SelectForObject`, `MaxLightsPerObject`, `SelectForCell`); `WbDrawDispatcher.ComputeEntityLightSet`; `EnvCellRenderer.GetCellLightSet`; `mesh_modern.vert`; `mesh_atmospheric.vert` | The ordinary-object eight-entry capacity matches retail hardware; EnvCell static-light starvation from reusing that object limit is removed. | An ordinary object influenced by more than eight retained lights, or whose retail `minimize_object_lighting` ordering differs from acdream's nearest-sphere-overlap choice, can still select a different subset. EnvCells instead consume the complete retained 7+40 products; see AP-35 for the remaining bake-vs-GPU architecture difference. | `minimize_object_lighting` 0x0054d480; `SetStaticLightingVertexColors` 0x0059cfe0; Campaign OVERHAUL #478 | | ~~AP-18~~ | **RETIRED 2026-07-10 — faithful retail radar port.** Exact `RGBAColor_Radar*` floats were recovered from named static data and `gmRadarUI::GetBlipColor` was re-ported with `_blipColor` overrides plus portal/vendor/attackable-creature/admin/PK/PKLite/free-PK/fellowship precedence. The old implementation was not merely hue-tuned: it also had wrong portal/vendor colors and an incomplete dispatch matrix. | `src/AcDream.Core/Ui/RadarBlipColors.cs` + radar classification tests | — | — | `gmRadarUI::GetBlipColor` 0x004d76f0; static RGBA initializers at named decomp pc:1089736-1089804 | | ~~AP-19~~ | **RETIRED 2026-09-05, Campaign OVERHAUL S5-c5.** The superseded `PortalVisibilityBuilder` and its acdream-only 0.01 m `PortalSideEpsilon` were deleted outright. The retained `RetailFrameWalk`/`WalkPView` production graph owns portal traversal and never consumed this tolerance; no replacement constant was introduced. | deleted `src/AcDream.App/Rendering/PortalVisibilityBuilder.cs` | — | — | F_EPSILON @0x007c8c70; `PView::InitCell` 0x005a4b70 | | AP-20 | Sub-pixel view-polygon vertex merge fixed at 1080p-reference NDC units (2/1080); retail merges at ~1 actual screen pixel | `src/AcDream.App/Rendering/PortalProjection.cs:179` | Unit approximation whose coarseness only strengthens convergence — the merge is the flood's fixpoint floor (replaced MaxReprocessPerCell=16) | At 4K+ a legitimately visible 1–2 px sliver aperture collapses to degenerate and rejects — a thin/distant doorway stops admitting its flood slightly earlier than retail | `Render::copy_view` 0x0054dfc0 | @@ -358,7 +359,7 @@ builder, reducing the current active total to **160**. | AP-243 | **Filed 2026-09-04 at Campaign OVERHAUL S4-c3a after paired-binary correction.** Acdream always computes the per-part authored `SortCenter` key for a cell turn. Retail does that only in `UpdateObjCell`'s near branch. When cell viewer distance is greater than 50 m, retail normalizes the cell heading and calls `CPhysicsObj::UpdateViewerDistance(obj, cellDistance, heading)`, propagating the same cell CYpt/heading to every part; equal keys then retain shadow-list insertion order. | `src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs` (`PopulateCellObjects`) | Porting `UpdateObjCell`'s >50 m cell-distance optimization also requires the cell heading/distance inputs and belongs with a later LOD/distance chunk. Always using the already-authored per-part center is deterministic and more spatially exact, but not mechanism-identical. | In far cells, acdream may reorder parts within that cell by their individual centers where retail gives them one shared key and preserves stable insertion order. Cell boundaries and FIFO-list behavior remain unchanged. | `RenderDeviceD3D::UpdateObjCell` 0x005A0690, paired bytes 0x005A06B7..0x005A0720 (`test ah,0x41; jne` near branch); `CPhysicsObj::UpdateViewerDistance` 0x0050F340; `CPartArray::UpdateViewerDistance` 0x00518290 | | AP-36 | Dungeon streaming gate triggers on the player's CURRENT cell being a sealed EnvCell (`CurrCell.IsEnv && !SeenOutside`), an approximation of ACE's full landblock `IsDungeon` (all-heights-zero + NumCells>0 + Buildings.Count==0). The retail BEHAVIOR (a dungeon loads no adjacent landblocks) is faithful — only the runtime TRIGGER is the cheap cell predicate instead of classifying the center landblock. **#135 pre-collapse:** at login/teleport the same collapse is triggered EARLY (the instant the streaming center is recentered onto the spawn/dest cell) via `IsSealedDungeonCell` reading the EnvCell **dat** `SeenOutside` flag — because the physics `CurrCell` is null until placement, which waits for hydration; without the early trigger the full 25×25 ocean-grid window loads then unloads (the ~30 s login FPS ramp). **#215 cell identity:** the pre-collapse/recenter decision compares the player's current `Position.objcell_id` landblock with the received destination `objcell_id`; it never reconstructs the source from XYZ because dungeon frame origins may be negative. **#145/#138 teleport-hold suppression:** during a teleport arrival HOLD the player is unplaced, so `CurrCell` is the frozen SOURCE cell, not the destination; the gate is suppressed for the hold (`DungeonStreamingGate.Compute(isTeleportHold:true)` → not-inside-dungeon) so a teleport OUT of a dungeon follows the destination (the PortalSpace observer pin) and `ExitDungeonExpand`s, instead of re-pinning streaming onto the source dungeon (which left the outdoor destination un-hydrated → 600-frame readiness timeout → force-snap to ocean — the #145 "second teleport does nothing" + #138 incomplete-world) | `src/AcDream.App/Streaming/TeleportLandblockTransition.cs` (source/destination cell-ID classification) + `src/AcDream.App/Streaming/DungeonStreamingGate.cs` (`Compute` — per-frame predicate + teleport-hold suppression) + `src/AcDream.App/World/LiveEntityHydrationPorts.cs` (`LiveEntityWorldOriginCoordinator.TryInitialize` — login pre-collapse) + `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`OnPosition` — first accepted canonical Position) + `GameWindow:AimTeleportDestination`/`IsSealedDungeonCell` (teleport pre-collapse and DAT predicate) + `src/AcDream.App/Streaming/StreamingController.cs` (collapse/expand/`PreCollapseToDungeon`) | The predicate is already computed for sun/sky gating (playerInsideCell) and exactly matches for sealed dungeons vs windowed building interiors (SeenOutside=true → not gated); no landblock re-classification needed. The dat-flag read is the same `EnvCellFlags.SeenOutside` the hydrated `ObjCell.SeenOutside` is built from (`EnvCell.cs:72`/`PhysicsDataCache.cs:224`), so the pre-collapse decision matches the eventual per-frame gate exactly. The cell-ID comparison matches retail's complete `Position` flow. | A dungeon cell that reports SeenOutside (an entrance cell open to the surface) briefly un-collapses and re-streams the window; a hypothetical windowless building back-room (IsEnv && !SeenOutside but HasBuildings) would wrongly collapse its outdoor neighbors; a sealed-dungeon entrance cell that is itself SeenOutside is simply MISSED by the early trigger and falls back to the existing late collapse (no worse than before #135) | ACE `LandblockManager.GetAdjacentIDs` (dungeons→empty) Landblock.cs:577-582; `IsDungeon` Landblock.cs:1264-1277; retail `SmartBox::TeleportPlayer` 0x00453910 | | AP-43 | Per-object torch (point/spot) lighting AND sun are both gated on the OBJECT's own cell via the same `IndoorObjectReceivesTorches(ParentCellId)` predicate (`(id & 0xFFFF) >= 0x0100`): indoor objects (EnvCell-parented) get torches + NO sun; outdoor objects get the SUN + ambient + NO torches. This is the faithful per-draw port of retail's `useSunlight` gate — `DrawMeshInternal` (0x0059f398) calls `minimize_object_lighting` only `if (Render::useSunlight == 0)`, and `PView::DrawCells` (0x005a4840) calls `useSunlightSet(1)` (0x005a485a) for the outdoor stage and `useSunlightSet(0)` (0x005a49f3) for the interior-cell stage. **#142 (2026-06-20):** the sun gate is now PER-INSTANCE in the shader (binding=6 `instanceIndoor[]` flag in `mesh_modern.vert`, filled by `AppendCurrentLightSet`) — it was previously a per-FRAME global keyed on the PLAYER cell (`UpdateSunFromSky`). The per-frame global is retained for sealed dungeons (correctly kills the sun frame-wide when no sky is visible). **Residual:** the `ebp_2` second seen-outside test in `CellManager::ChangePosition` (0x004559B0) is unaudited — unclear whether it changes the ambient/sun regime for a subset of cells. No observed behavioral impact in tested cells. | `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (`IndoorObjectReceivesTorches`, `ComputeEntityLightSet`, `AppendCurrentLightSet`, `_instIndoorSsbo`/`_indoorData`/`InstanceGroup.IndoorFlags`); `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (binding=6 `instanceIndoor[]` gate on sun loop); per-frame sun `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.UpdateSunFromSky`) | Torches: outdoor objects never torch-lit (exact retail). Sun: indoor objects (furniture, NPCs, player in a windowed building) never sun-lit (exact retail per-stage). Ambient: per-player-cell regime unchanged (exact retail `ChangePosition`). | The `ebp_2` unaudited test in `ChangePosition` could affect a narrow class of cells (entrance cells? sub-cells with special flags?) — no symptom observed; audit it if a lighting edge case arises in an unusual cell type | `useSunlight` gate `DrawMeshInternal` 0x0059f398; `useSunlightSet` 0x0054d450; per-stage `PView::DrawCells` 0x005a4840 (`useSunlightSet(1)` 0x005a485a / `useSunlightSet(0)` 0x005a49f3); `minimize_object_lighting` 0x0054d480; `CellManager::ChangePosition` 0x004559B0 (ambient + seen_outside) | -| AP-35 | Point/spot lights are now PER-VERTEX Gouraud (`pointContribution` ~line 153 of `mesh_modern.vert`) matching retail's `SetStaticLightingVertexColors` bake path. Half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`) AND norm distance attenuation (`distsq>1 ? distsq·d : d`) ARE ported (A7 Fix A, `aa94ced`). Point-light sum clamped to [0,1] on its own accumulator before adding ambient+sun (A7 Fix D D-1, mirrors retail's per-vertex bake clamp). CPU oracle: `src/AcDream.Core/Lighting/LightBake.cs`, locked by `tests/AcDream.Core.Tests/Lighting/LightBakeConformanceTests.cs`. **Residual (two parts):** (a) acdream lights in-shader each frame (per-frame GPU evaluate); retail bakes into the vertex buffer ONCE — an architecture/performance difference; the wrap + norm + clamp formula is the same, but bake-once is cheaper for static geometry; (b) acdream's `SelectForObject` keeps only the 8 NEAREST reaching point/spot lights per object/cell (`MaxLightsPerObject=8`, see AP-16), whereas retail's bake sums ALL reaching static lights per vertex — a surface reached by >8 point lights is dimmer in acdream than retail's bake result (rare in practice; a room has a handful of torches) | `src/AcDream.App/Rendering/Shaders/mesh_modern.vert` (`pointContribution` ~line 153; wrap ~line 163; norm ~line 167; point-sum clamp line 210) | Per-vertex Gouraud + wrap + norm + clamp all match retail. The two residuals are: (a) per-frame GPU vs bake-once — architecture/perf only; (b) 8-light cap dimming when >8 lights reach one surface — rare. `LightInfoLoader.cs:81` folds static_light_factor 1.3 into Range | (a) A new frame-time consumer bypassing `accumulateLights` would need to replicate the wrap + norm formula; per-frame GPU re-evaluate has higher per-frame cost than bake for static geometry. (b) A densely lit scene (>8 torches reaching one wall) renders dimmer than retail — see AP-16 for the 8-cap ownership | `calc_point_light` 0x0059c8b0 (line 0x0059c9a2 ramp; 0x0059c925 wrap); `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24 | +| AP-35 | Static point lights are PER-VERTEX Gouraud (`pointContribution` in both mesh vertex shaders). The existing static-point pins cover retail's half-Lambert wrap (`(1/1.5)·(N·D + 0.5·d)`), normalized-distance attenuation (`distsq>1 ? distsq·d : d`), and point-light accumulator clamp; #478 changes none of those formulas. #478 removes the former EnvCell eight-light truncation: each EnvCell vertex now evaluates every retained seven-dynamic plus forty-static candidate. **Residuals:** acdream evaluates float lighting in the GPU each frame while retail bakes static lighting into eight-bit vertex colors, an architecture/precision/performance difference; ordinary objects also retain AP-16's eight-entry selection; the pre-existing cone-derived kind projection remains under AP-85. | `src/AcDream.Core/Lighting/LightBake.cs`; `src/AcDream.App/Rendering/Shaders/mesh_modern.vert`; `mesh_atmospheric.vert`; `EnvCellRenderer` | The existing static-point wrap, attenuation and clamp pins remain unchanged, and EnvCells no longer lose retained statics merely because binding 5 used an object-sized stride. No broader point/spot or bake numerical equivalence is claimed. | GPU-float reevaluation can differ from retail's baked eight-bit result and costs more than bake-once. Ordinary objects can still differ through AP-16, and type projection through AP-85; EnvCells do not carry the former eight-entry truncation. | `calc_point_light` 0x0059c8b0; `SetStaticLightingVertexColors` 0x0059cfe0; static_light_factor 0x00820e24; Campaign OVERHAUL #478 | | AP-37 | LayoutDesc meters collapse Type-3 slice descendants into `UiMeter.BackLeft..FrontRight` and reuse `UiMeter.DrawHBar` rather than building those media descendants and dispatching retail `UIElement_Meter::DrawChildren`. Non-Type-3 meter children are imported normally. | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`); `LayoutImporter.cs` meter child predicate | The current vitals/character meter shapes are visually accepted and fixture-pinned; this is a representation adaptation, not a controller overlay. | A meter with a different descendant/media structure can render empty or with incorrect clipping/direction | `UIElement_Meter::DrawChildren @ 0x0046FBD0`; production meter LayoutDesc fixtures | | AP-39 | Chat lines carry one solid color per line (retail's exact 34-value `LogTextType` table as of Campaign CH slice CH1, 2026-08-09 — see `RetailChatColorTable`, no longer the earlier synthetic per-`ChatKind` approximation); retail `UIElement_Text` supports per-glyph styled runs (bold, different hue per segment) | `src/AcDream.UI.Abstractions/Panels/Chat/RetailChatColorTable.cs`; consumers `src/AcDream.App/UI/Layout/ChatWindowController.cs`, `src/AcDream.UI.Abstractions/Panels/Chat/ChatPanel.cs` | Retail glyph-run parsing lives inside keystone.dll with no PDB/decomp; per-line coloring is now the exact retail tonal palette (`ChatInterface::BuildChatColorLookupTable @0x004F31C0`), not an approximation of it | Chat lines retail renders with multiple colors or bold names (e.g. "PlayerName says: text") render as one flat color; subtle visual difference but functionally complete | `UIElement_Text` glyph-run styling (keystone.dll, no decomp); `docs/research/2026-08-09-chat-retail-color-table.md` | | AP-175 | PopUpString (`GameEvent 0x0004`) renders as an ordinary chat-log line (`ChatKind.Popup`) instead of retail's MODAL DIALOG. Filed 2026-08-09, Campaign CH slice CH1 (color table) — the color-table work routes this entry through the new 34-value `LogTextType` table (fixed at `0x00` Default/green, unchanged from the entry's pre-existing color) but does not change WHERE it renders; a modal-dialog port is out of this slice's scope | `src/AcDream.Core/Chat/ChatLog.cs` (`OnPopup`); `src/AcDream.Core.Net/GameEventWiring.cs:126` | Informational popup text still reaches the player via the chat transcript; a full modal-dialog port is deferred work, not a color-table concern | Any retail-specific PopUpString behavior contingent on being a blocking modal (e.g. must-acknowledge) is not reproduced; acdream's chat-log line can be missed or scrolled past instead | `ClientCommunicationSystem::Handle_Communication__PopUpString @0x0057FE80`; `docs/research/2026-08-09-chat-retail-color-table.md` §5.1 | @@ -386,7 +387,7 @@ builder, reducing the current active total to **160**. | AP-64 | **Wield-reject rollback assumes `InventoryServerSaveFailed 0x00A0`** — an optimistic wield rolls back only if ACE emits `0x00A0` for a `GetAndWieldItem` rejection; otherwise corrected by the next authoritative message. Gate-verify via WireMCP. | `src/AcDream.Core.Net/GameEventWiring.cs` (0x00A0 handler) | If ACE uses a different reject opcode for wield, the optimistic state is left dangling until the next full update. | Rejected wield briefly shows the item as equipped in the doll — cosmetic flicker or stuck state if `0x00A0` is not the rejection path. | `InventoryServerSaveFailed 0x00A0`; WireMCP gate-verify | | AP-65 | **PickupEvent (0xF74A) no longer evicts the weenie from `ClientObjectTable`** — only `DeleteObject (0xF747)` evicts, matching the retail `object_table`-vs-`weenie_object_table` split. An item another player picks up near you (only ever gets `PickupEvent`, never `DeleteObject`) lingers as a data-only entry (`ContainerId 0`, not in any view) until teleport/relog `Clear()`. | `src/AcDream.Core.Net/ObjectTableWiring.cs` (EntityDeleted handler); `src/AcDream.Core.Net/WorldSession.cs` (PickupEvent branch) | The weenie entry for nearby pickups is a harmless data ghost — no UI shows it (no container, not wielded). Retail evicts it from `weenie_object_table` when the object fully leaves interest range via `DeleteObject`; acdream defers to teleport/relog clear. | Slight memory growth in long sessions if many world items are picked up by other players near you; item data ghosts cannot cause functional issues because no UI queries `ContainerId 0`. | `CACObjectMaint::DeleteObject` / `SmartBox::HandleDeleteObject`; ACE `Player_Inventory.cs TryDequipObjectWithNetworking` | | ~~AP-66~~ | **RETIRED 2026-07-13 — authored paperdoll empty-slot presentation.** The earlier “no silhouettes” conclusion inspected the ItemList elements' own media but missed `UIElement_ItemList::InternalCreateItem`, which clones a distinct `UIElement_UIItem` catalog prototype for each location. All 21 supported jewelry, weapon, ammo, shield, clothing, cloak, trinket, and armor lists now resolve their exact `ItemSlot_Empty` surface from live DAT; `PostInit` confirms non-armor lists remain visible while the nine armor lists toggle with Slots. | `src/AcDream.App/UI/Layout/PaperdollSlotBackgrounds.cs`; `ItemListCellTemplate.cs`; `PaperdollController.cs` | — | — | `gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0`; `PostInit @ 0x004A5360`; `UIElement_ItemList::InternalCreateItem @ 0x004E3570`; `LayoutDesc 0x21000037` | -| AP-68 | acdream keeps the 128 nearest-to-CAMERA point lights live (`MaxGlobalLights=128`, `BuildPointLightSnapshot`) and selects per cell CAMERA-INDEPENDENTLY (by the cell's own bounds), so a building interior stays lit at any distance within a town; retail keeps only the 40 nearest-to-PLAYER static lights (`Render::max_static_lights=0x28`, distance-sorted replace-farthest `insert_light`) and re-bakes a cell when its live light set changes, so distant interiors are baked dark and "light up" only as the player approaches and their torches enter the live 40. INTENTIONAL — acdream's always-lit interiors are the preferred behavior (no 1999-era light-budget pop-in); user-confirmed 2026-06-20. | `src/AcDream.Core/Lighting/LightManager.cs` (`MaxGlobalLights=128`, `BuildPointLightSnapshot`, `SelectForObject`) | The retail pop-in is a fixed-function light-budget artifact, not an intended aesthetic; revert to retail by clamping the global set to 40 + distance-to-player sort if ever desired | Distant town interiors are lit in acdream where retail's are dark until approached — a deliberate, user-preferred divergence | `Render::max_static_lights` 0x28; `insert_light` 0x0054d1b0 (distance-sorted, replace-farthest); bake re-trigger `SetStaticLightingVertexColors` cache `burnedInStaticLights != num_static_lights` | +| ~~AP-68~~ | **RETIRED 2026-09-05 by Campaign OVERHAUL #478.** The former intentional 128-light policy is deleted (the base implementation was player-anchored on overflow, not nearest-camera). `BuildPointLightSnapshot` now reproduces retail's independent seven-dynamic and forty-static retained products, root-frame distance ranking, stable strict forward insertion, and viewer-first dynamic insertion. EnvCells consume the complete retained 47-entry product; ordinary objects retain their separate eight-entry hardware selection under AP-16. The observed Town Network failure combined two old behaviors: below the 128 cap registration order was unsorted, and the shared eight-slot cell consumer admitted the viewer plus seven distant portal dynamics before every local static. | `src/AcDream.Core/Lighting/LightManager.cs`; `EnvCellRenderer`; `mesh_modern.vert`; `mesh_atmospheric.vert` | No active approximation remains in this row. | None for the retired policy; surviving residency/degrade/numerical limits are AP-85. | `Render::insert_light` 0x0054D1B0; `LIGHTLIST::set_frame` 0x00517C60; `CObjCell::add_static_to_global_lights` 0x0052B350; `CObjCell::add_dynamic_to_global_lights` 0x0052B390; Campaign OVERHAUL #478 | | AP-69 | acdream preserves one accepted active record across rebucketing and ports retail's 25-second leave-visibility destruction lifecycle. Spatially resident records cancel expiry; otherwise the ACE compatibility boundary uses holtburger's conservative 384-unit distance envelope and retains attached/container/wielder/parent-owned objects. Expiry uses the exact generation-safe active teardown, then retains only a cold `EntitySpawn` because ACE can keep the GUID in `KnownObjects` and omit CreateObject on revisit; explicit F747/new generation/session reset removes it. DIVERGENCE: retail can delete the complete object under its visibility protocol; the fallback does not yet derive visibility from retail/ACE ObjCell PVS (`SeenOutside` plus `VisibleCells`), and trade/container preview retention has no separate lifecycle flag. | `src/AcDream.App/World/LiveEntityRuntime.cs`; `src/AcDream.App/World/LiveEntityLivenessController.cs`; `src/AcDream.App/World/DormantLiveEntityStore.cs`; `LiveEntityHydrationController.OnPrune` | Prevents stale portal destinations from accumulating animation/effect/render owners while still allowing doors, signs, portals, and other ACE-known objects to rematerialize when the server does not resend them | Dormant data-only snapshots can grow with every unique ACE destination until F747 or session reset; a nonresident object outside 384 units that remains visible through an unusual long EnvCell PVS could expire early; a future preview-only object with no parent/container ownership could also expire. Replace the compatibility predicate when exact ObjCell PVS and preview lifetimes are available | `CPhysicsObj::prepare_to_leave_visibility` 0x00511F40; `CPhysicsObj::prepare_to_enter_world` 0x00511FA0; `CObjectMaint::AddObjectToBeDestroyed` 0x00508F70; `CObjectMaint::UseTime` 0x005089B0; ACE `KnownObjects`; `docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md` | | ~~AP-71~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the `check_entry_restrictions` gate is now ported at the head of the indoor branch of `Transition.FindEnvCollisions`.** `ObjectInfo.CheckEntryRestrictions` (`src/AcDream.Core/Physics/TransitionTypes.cs`) reproduces retail's exact order: NPCs/props bypass, a mover with `CanBypassMoveRestrictions` (new PWD-bitfield decode, `BF_ADMIN 0x100000` AND `BF_IMMUNE_CELL_RESTRICTIONS 0x400000`, `acclient.h:6452-6454`) bypasses, an ordinary cell (`RestrictionObj == 0`) is a no-op. `CellPhysics.RestrictionObj` is now wired from the DAT-baked `EnvCell.RestrictionObj` field (§4.3's old open question — RESOLVED via `references/ACE/Source/ACE.DatLoader/FileTypes/EnvCell.cs:32,66-67` and an independent reflection probe of `Chorizite.DatReaderWriter` 2.1.7's own `EnvCell.RestrictionObj` field: it is a plain per-cell DAT field gated by `EnvCellFlags.HasRestrictionObj (0x8)`, NOT a live wire override; the BN pseudo-C's "count for an array alloc" reading at the same `UnPack` offset was the mis-attributed field-name collision `feedback_bn_decomp_field_names` warned about). Wired in BOTH the dev/graph-fixture path (`PhysicsDataCache.CacheCellStruct`) and the production/prepared path (`CachePreparedCellStruct`) — the latter already receives a live parsed `envCell` for `Position`/`EnvironmentId`, so no bake-format change was needed. See AP-129 for the narrower remaining gap this leaves. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`, `Transition.FindEnvCollisions`); `src/AcDream.Core/Physics/PhysicsDataCache.cs` (`CellPhysics.RestrictionObj`) | — | — | `CObjCell::check_entry_restrictions` pc:308873-308912 (0x0052b6d0); `CEnvCell::find_env_collisions` pc:309573-309597; `ACCWeenieObject::CanBypassMoveRestrictions` 0x0058c500; `ACCWeenieObject::CanMoveInto` 0x0058da40; `references/ACE/Source/ACE.Server/Physics/Common/ObjCell.cs:286-333` | | AP-129 | **NARROWED 2026-07-30 (P4 Opus review fix) — `CanMoveInto`/`IsAllowedIn` are now ported and fed; two narrow gaps remain.** `ObjectInfo.CheckEntryRestrictions` resolves the cell's `RestrictionObj` via `PhysicsEngine.Objects` (a `ClientObjectTable`, acdream's `GetObjectA` equivalent) and evaluates the real owner IID / `HouseRestrictionRecord` (open flag, allegiance monarch, guest table) fed from CreateObject's `HouseOwner`/`HouseRestrictions`/`Monarch` PWD-tail fields and live `House_UpdateRestrictions (0x0248)` refreshes — see `RestrictionObjPrevalenceInspectionTests` (103,766 of 729,888 installed EnvCells, 1,293 landblocks, carry a baked `RestrictionObj`; this is the whole housing estate, not a rare case, which is why the OLD unconditional-fail-closed row was upgraded to FIX-FIRST rather than shipped). Remaining gaps: (1) `House_UpdateRestrictions`'s `Sequence` byte is parsed but not used for staleness/reordering rejection — a lost-then-late UDP delivery could transiently apply an older restriction snapshot over a newer one (low-probability; the next full CreateObject or another update self-corrects). (2) Outdoor `CLandCell` restriction (`LandblockInfo.RestrictionTables`, a separate per-landblock packed hash table) remains entirely unported — unaffected by this fix, since the gate only reads the indoor/EnvCell `CellPhysics.RestrictionObj` field. `HouseData (0x0225)`/`HouseStatus (0x0226)` and the guest-management opcode family (`House_AddPermanentGuest`, `House_UpdateHAR`, etc.) remain unparsed but are NOT consulted by this entry gate (they carry rent/ownership-transfer UI data, not the owner-iid/guest-list pair `CanMoveInto` needs) — noted for future house-UI work, not a residual of this row. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ObjectInfo.CheckEntryRestrictions`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`Objects`); `src/AcDream.Core/Items/{ClientObject,ClientObjectTable,HouseRestrictions}.cs`; `src/AcDream.Core.Net/{Messages/CreateObject.cs,Messages/GameEvents.cs,GameEventWiring.cs}`; `src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs` (production wiring) | A reordered `House_UpdateRestrictions` pair could transiently apply the older snapshot; self-corrects on the next update or CreateObject. An outdoor restricted cell (if that content ever exists) is not gated at all. | `ACCWeenieObject::CanMoveInto` 0x0058da40 (pc:407982-408056); `RestrictionDB::IsAllowedIn` 0x005ae8f0 (pc:444493-444516); `references/Chorizite.ACProtocol/Chorizite.ACProtocol/Types/RestrictionDB.generated.cs`; `references/ACE/Source/ACE.Server/Network/GameEvent/Events/GameEventHouseUpdateRestrictions.cs` | @@ -400,7 +401,7 @@ builder, reducing the current active total to **160**. | AP-80 | **PlanFromVelocity survives for velocity-only NPC cycles** (M16): UpdatePosition-derived speed picks Ready/Walk/Run cycles for server-controlled creatures whose UMs never arrive (scripted-path NPCs); retail derives every cycle from motion messages through the motion tables. The adaptation is now structurally limited to replacing Ready/Walk/Run-family states, so authoritative actions/substates (especially Dead) always win. | `src/AcDream.Core/Physics/ServerControlledLocomotion.cs` (`PlanFromVelocity`, `CanApplyVelocityCycle`); consumer `GameWindow.ApplyServerControlledVelocityCycle` | Some ACE entities move by position updates alone — without this, they slide in T-pose; constants (StopSpeed 0.2, RunThreshold 1.25) tuned against live ACE traffic | Cycle-pick thresholds are acdream inventions — a creature intended to walk fast may show run legs near the threshold | retire in R6 (root motion + full per-tick order) | | AP-81 | **NARROWED 2026-08-04 (Bug B). The remote VectorUpdate handler still pre-clears the two ground transients and seeds the client Airborne flag one frame ahead of the sweep.** The GRAVITY half of this row is RETIRED: the handler no longer writes `Body.State |= Gravity`, and neither landing block clears it, so GRAVITY_PS is wire-owned for the object's whole life exactly as retail has it (`CPhysicsObj` constructor state `0x400C08` @0x00512508; `set_state` @0x00514DD0 post-processes only lighting/nodraw/hidden and never masks GRAVITY). The per-tick force this row's sibling sites used to apply is also gone — see the Bug B entry in `docs/ISSUES.md` #32. What remains is the handler's `TransientState &= ~(Contact | OnWalkable)` plus `rm.Airborne = true` on a `Velocity.Z > 0.5f` vector. Retail reaches the identical state one frame later: `check_contact` (0x0050F5B0) fails on the ascending velocity, the transition runs contact-free, `SetPositionInternal` clears CONTACT_TS and `set_on_walkable(0)` fires LeaveGround. The pre-clear is deliberately KEPT because it is what makes the per-tick `set_on_walkable` edge observe `previousOnWalkable == false` and therefore NOT fire a second LeaveGround for the same departure, and because `CMotionInterp::LeaveGround` (0x00528B00) writes `set_local_velocity(GetLeaveGroundVelocity(), autonomous)` — relocating it into the tick would overwrite the authoritative launch vector mid-arc | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ApplyOrdinaryVector`, the `Velocity.Z > 0.5f` branch) | One frame of head start on a state the sweep derives anyway. Both landing blocks now derive Contact/OnWalkable from the committed contact plane and neither touches the Gravity state bit, so the flag dance no longer decides whether gravity is delivered | A VectorUpdate whose vertical component clears the 0.5 m/s threshold on a body the sweep would still find in contact marks that body airborne one frame early. Retire when the remote departure edge is owned solely by the per-tick `set_on_walkable` commit and LeaveGround's velocity write is ordered after the authoritative vector | `SmartBox::DoVectorUpdate @ 0x004521C0`; `CPhysicsObj::check_contact @ 0x0050F5B0`; `CPhysicsObj::calc_acceleration @ 0x00510950`; `CPhysicsObj::SetPositionInternal @ 0x00515330`; `set_on_walkable @ 0x00511310`; `CMotionInterp::LeaveGround @ 0x00528B00` | | AP-82 | **StickyManager deep-overlap back-off sign pin**: when the stick-gap overlap exceeds one tick's step (`speed×quantum < \|dist\|`, `dist < 0`), acdream applies `delta = −(speed×quantum)` (rate-limited back-off); ACE's literal port keeps `+delta` there — a runaway that steers INTO the target with equilibrium at centers-coincident. The BN mush (0x00555554-0x00555597) is unreadable on exactly this compare; the pin is refuted-by-evidence against ACE-literal: #171 gate-3 probe showed 1661 deep-overlap ticks all steering inward (monsters converged to centerDist≈0 — "monster inside the player") while retail side-by-side on the same ACE shows separation. ACE servers essentially never reach the branch (quantum ≥1/30 → threshold ~1 m; render-rate quanta → ~0.13 m) | `src/AcDream.Core/Physics/Motion/StickyManager.cs` (`AdjustOffset` delta clamp; conformance `StickyManagerTests.AdjustOffset_DeepOverlap_BacksOff_RateLimited`) | Minimal interpretation consistent with the mush structure AND observed retail; identical to ACE-literal in every shallow/outside case | If retail's true deep-overlap behavior differs (e.g. no movement at all), our back-off rate diverges in that rare state; verify via cdb `StickyManager::adjust_offset` trace with a forced overlap when convenient | `StickyManager::adjust_offset` 0x00555430 (x87 mush); ACE StickyManager.cs:117-121 (the literal branch this pin overrides) | -| AP-85 | **CORRECTED 2026-09-04 at Campaign OVERHAUL S5-c1. Point-light pool = one resident, player-nearest 128-cap list vs retail's separate 7-dynamic/40-static degrade-scaled pools and DBObj-granular residency.** Retail walks `CEnvCell::visible_cell_table`, populated on demand by `add_visible_cell`/`DBObj::Get` and pruned by `flush_cells`. acdream registers lights at broader landblock publication lifetime, then `BuildPointLightSnapshot(playerWorldPos)` selects from that complete resident registry with dynamics-first/player-distance ordering. The stale last-frame drawable-cell feedback description was false: that filter had already become a no-op and S5-c1 deleted its interfaces/calls. **[Campaign OVERHAUL v2 S5-c3 clarification, 2026-09-04: the automatic/manual building-degrade multiplier now drives building selection only; it does not scale or select this fixed acdream point-light list, so adaptive light degradation remains part of this row.]** | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.Prepare`); `src/AcDream.App/Rendering/BuildingDegradeController.cs`; pins `PointSnapshot_HubScaleLightCount_ObjectSelectionIsCameraInvariant`, `PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics`, `PointSnapshot_ResidentCollection_CellTagDoesNotFilter`, `BuildPointLightSnapshot_UsesAllResidentLights`, `Runtime_environment_uses_all_resident_lights` | The one retained registry avoids a second camera flood and matches retail's resident-table ownership, but acdream's streaming publication granularity does not reproduce DBObj load/flush residency and its RHI contract exposes one fixed 128-entry list not driven by `SetDegradeLevelInternal`'s multiplier. | A large one-landblock dungeon may admit lights from farther or wall-disconnected resident rooms that retail's DBObj-granular table would not retain; a room with more than seven resident dynamic lights can keep more than retail; automatic building degradation cannot reduce that list. | `insert_light` 0x0054d1b0; `add_visible_cell` 0x0052de40; `add_dynamic_lights` 0x0052d410; caller 0x00452d30; `calc_point_light` 0x0059c8b0; Campaign OVERHAUL S5-c1/S5-c3 | +| AP-85 | **NARROWED 2026-09-05 by Campaign OVERHAUL #478.** The retained-product algorithm now matches retail's independent seven-dynamic/forty-static caps, viewer-first insertion, explicit root-frame rank origin, authored-offset final position, and stable strict forward insertion (equal and NaN ranks advance). Four residuals remain: (1) acdream registers lights at broader landblock publication lifetime instead of retail's DBObj-granular visible-cell load/flush ownership; (2) the building-degrade multiplier still does not scale or select the light products; (3) modern `Vector3` common-origin float arithmetic is not claimed bit-identical to retail's x87/cell-offset path at cutoff/tie boundaries; (4) the current parser projection still derives `LightKind.Point` versus `Spot` from `ConeAngle`, while the DAT dictionary key corresponds to native `LIGHTINFO.type` and is not carried as a typed production field. #478 deliberately leaves that pre-existing kind projection unchanged. | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`); `LightInfoLoader.cs`; `WorldRenderFrameBuilder.cs`; `BuildingDegradeController.cs`; #478 root/order/pool and installed-DAT pins | The exact retained-product shape and root-coordinate ordering are ported without adding a camera flood. Broader publication lifetime remains the canonical modern owner. | Wall-disconnected lights in one published landblock may enter the retained pool where retail would not retain them; adaptive degradation cannot reduce the products; exact numerical cutoff/tie behavior can differ; a cone-derived Point/Spot classification can rank differently from native authored type. | `Render::insert_light` 0x0054D1B0; `CEnvCell::add_visible_cell` 0x0052DE40; `CObjCell::add_static_to_global_lights` 0x0052B350; `CObjCell::add_dynamic_to_global_lights` 0x0052B390; `LIGHTLIST::set_frame` 0x00517C60; Campaign OVERHAUL #478 | | AP-84 | **BSP shadow-shape part poses = motion-table default-state frame snapshot at registration, not retail's live CPhysicsPart pose** (#175): server entities with a wire MotionTableId register their BSP part shapes at the default style's first-cycle LowFrame pose through `LiveEntityDefaultPoseResolver`; retail collision reads each part's CURRENT pose every test. Equivalent for the door lifecycle (closed = default pose; open = ETHEREAL bypasses collision entirely, #150) and for idle statics | `src/AcDream.App/Physics/LiveEntityDefaultPoseResolver.cs`; `src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs`; `src/AcDream.Core/Physics/ShadowShapeBuilder.cs` (`partPoseOverride`) | Registration is one-shot in acdream (retail re-poses parts per frame); the default-state pose is the correct idle pose and the only non-ethereal pose doors ever collide in | An entity whose server-driven motion state materially MOVES a BSP-bearing part while NON-ethereal would collide at the stale default pose (no known case — doors are the dominant BSP-part weenies); revisit if animated non-ethereal BSP movers appear | `CPhysicsPart` live pose (see #150 notes); motion-table default state = CPartArray init; ShadowShapeBuilder placement-frame fallback for table-less entities | | AP-83 | **CONTAINED, not dormant (Campaign S S6, 2026-08-07) — the row's 'no current mover sets PerfectClip' premise was FALSE.** The camera (`PhysicsCameraCollisionProbe.SweepEye`, the sole production PerfectClip setter) reaches this tail LIVE: neither `CollisionExemption.ShouldSkip` (creature-only viewer exemption) nor `FindObjCollisionsInCell` (unconditional shadow-list walk) cuts the chain for a non-creature Cyl-shaped shadow entry — a real population (static scenery with an authored primitive and no physics BSP). The tail head now records every reach (`PhysicsDiagnostics.RecordCylPerfectClipTailReach`): viewer movers count camera-live silently; any NON-viewer mover reaching it logs loudly one-shot, so a future flag change cannot exercise this ACE-derived math unreviewed. Four containment tests drive the camera's exact call shape both ways, sabotage-verified on the creature-exemption axis the proof depends on. **Severity narrowed to camera-feel only**: the probe never commits a PhysicsBody, so a wrong TOI can only mispull the spring-arm camera. The math itself remains ACE-derived and the row stays ACTIVE for that reason alone. Original text: **CylCollideWithPoint PerfectClip TOI sub-branches decoded via ACE, not the binary**: the CCylSphere family port (2026-07-05, retires AP-6) reads `collide_with_point`'s PerfectClip time-of-impact math (0x0053adb6+) from ACE `CylSphere.CollideWithPoint` because the BN x87 mush is unreadable there; two ACE-verbatim quirks ported as-is (`movement.Z + radius` in the not-definite ascending case; `GlobalCurrCenter[0]` used even for head-sphere hits — the latter matches the raw decomp read). No current mover sets PerfectClip: players never do, and shipped ordinary missiles add PathClipped only. The non-PerfectClip path — SetCollisionNormal + Collided — is decomp-verified. Separately, the grounded head-sphere slide passes the HEAD disp per retail 0x0053b843 where ACE passes the foot disp — retail wins (ACE bug, not copied) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CylCollideWithPoint`; pseudocode doc `docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md` §7-8) | The load-bearing paths (non-PerfectClip Collided; the family's step-up/step-down/land) are decomp-verified; the TOI tail remains dormant unless a future mover explicitly enables PerfectClip | **Risk restated at S6:** the camera ALREADY reaches this tail — an ACE/retail TOI delta here is a live, currently-unverified camera-feel risk (a prop the camera pulls in slightly off), not a dormant one. If a future mover explicitly enables PerfectClip, the two ACE quirks may diverge from retail — clip-through or wrong deflection on cylinder targets; re-decompile 0x0053acb0 in Ghidra before shipping that mover | `CCylSphere::collide_with_point` 0x0053acb0 (pc:324173, x87 mush from 0x0053adb6); ACE CylSphere.cs `CollideWithPoint` | | AP-91 | **CONTAINED, not dormant (Campaign S S6, 2026-08-07) — the row's 'no current mover sets PerfectClip' premise was FALSE.** The camera (`PhysicsCameraCollisionProbe.SweepEye`, the sole production PerfectClip setter) reaches this tail LIVE: neither `CollisionExemption.ShouldSkip` (creature-only viewer exemption) nor `FindObjCollisionsInCell` (unconditional shadow-list walk) cuts the chain for a non-creature Sphere-shaped shadow entry — a real population (static scenery with an authored primitive and no physics BSP). The tail head now records every reach (`PhysicsDiagnostics.RecordSpherePerfectClipTailReach`): viewer movers count camera-live silently; any NON-viewer mover reaching it logs loudly one-shot, so a future flag change cannot exercise this ACE-derived math unreviewed. Four containment tests drive the camera's exact call shape both ways, sabotage-verified on the creature-exemption axis the proof depends on. **Severity narrowed to camera-feel only**: the probe never commits a PhysicsBody, so a wrong TOI can only mispull the spring-arm camera. The math itself remains ACE-derived and the row stays ACTIVE for that reason alone. Original text: **CSphere `collide_with_point` PerfectClip TOI decoded via ACE, not the binary**: the CSphere family port reads the unreadable x87 tail from ACE `Sphere.CollideWithPoint`/`FindTimeOfCollision`; no current mover sets PerfectClip, and shipped ordinary missiles add PathClipped only | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollideWithPoint`; `FindSphereTimeOfCollision`) | Load-bearing non-PerfectClip behavior is named-decomp verified; the adapted branch remains dormant unless a future mover explicitly enables PerfectClip | **Risk restated at S6:** the camera ALREADY reaches this tail — an ACE/retail TOI delta here is a live, currently-unverified camera-feel risk (a prop the camera pulls in slightly off), not a dormant one. If a future mover explicitly enables PerfectClip, an ACE/retail TOI delta could cause clip-through or wrong sphere-target deflection | `CSphere::collide_with_point @ 0x00537230`; ACE `Sphere.CollideWithPoint` | 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 caa802f8..0d8a092b 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -797,8 +797,14 @@ separation design follows exact retail ranking-frame trace; no FPS work. **#478 repair contract:** `478-room-light-selection-repair-contract.md` authorizes independent default7/40 root-ranked light products and complete 47-slot EnvCell consumption, ordinary objects8 unchanged. Same-pose evidence -precedes temporary observer removal. Ambient/formulas/FPS untouched; not yet -implemented or accepted. G4FAIL/no merge. +precedes temporary observer removal. Reviewed scratch621b41fa3 now integrates +all26 blobs unchanged: independent retail and production/lifetime/ABI PASS, +lead69Core/176App/2native pixels, three additional discriminating mutations +and exact restoration69PASS. AP68retired; AP16/35/85 and architecture current. +See `478-room-lights-lead-verification.md`. Fresh campaign Release and exact +Town/control graphical evidence remain owed; observer removal contract is +prepared but inactive until those frames pass. Ambient/formulas/FPS untouched. +G4FAIL/no merge. ## 14. Risk register (condensed) diff --git a/docs/research/2026-09-01-overhaul/478-observer-removal-contract.md b/docs/research/2026-09-01-overhaul/478-observer-removal-contract.md new file mode 100644 index 00000000..70b59b75 --- /dev/null +++ b/docs/research/2026-09-01-overhaul/478-observer-removal-contract.md @@ -0,0 +1,32 @@ +# #478 temporary lighting observer removal + +Prepared only; activate after the repaired candidate's unchanged Town +High/off/High and ordinary-dungeon control PNGs are inspected. Name that +candidate and its evidence before dispatch. A failed visual check returns to +the lighting chunk's bounded fix-round rule; this cleanup cannot hide failure. + +Remove only the temporary checkpoint observer introduced at59b0b6e72 and its +#478 shape adaptation: EnvCellRenderer.LightingObservation.cs, the optional +resource snapshot field, the snapshot-source renderer/LightManager arguments, +capture helper and composition wiring. Remove only corresponding observer +assertions/tests from FrameRootCompositionTests, WorldLifecycleAutomation- +ControllerTests and EnvCellRendererTests. Preserve unrelated lifecycle and +render-scene checkpoint evidence. + +Keep all permanent #478 correctness coverage, especially +SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration and its +required seeding helpers, ordinary eight-index upload, both committed-shader +pixel witnesses, root/order/7+40 fixtures and actual Town DAT pin. No light +selection, formula, shader, generated binary, pack, geometry or FPS changes. + +One bounded OpenAI implementer; no client launches or commits. Return exact +diff plus affected Release tests and full Release build, checking both client +names before each command. Lead checks removal against59b0b6e72 and reviews +the retained regression paths; no new retail algorithm is introduced. No new +deviation is expected; disclose any actual deviation before proceeding. + +Lead commits, builds Release green and repeats the unchanged lighting/control +routes on the observer-free binary. Record PNG PASS/FAIL provisionally. The +earlier candidate's consumed-light membership remains evidence for THAT +candidate, never relabeled as a measurement of the observer-free binary. +No main merge before G4; FPS remains deferred. diff --git a/docs/research/2026-09-01-overhaul/478-room-lights-lead-verification.md b/docs/research/2026-09-01-overhaul/478-room-lights-lead-verification.md new file mode 100644 index 00000000..f1d9057b --- /dev/null +++ b/docs/research/2026-09-01-overhaul/478-room-lights-lead-verification.md @@ -0,0 +1,127 @@ +# #478 room-light repair — lead verification + +REVIEW-CLOSED: scratch621b41fa3 independently checked; all26 reviewed blobs +integrate unchanged with this record. Not graphically accepted yet. +Contract4bc208b57, scratch `s5-478-room-lights`; +campaign route-only follow-up e8efe1131. FPS deferred; G4FAIL/no merge. + +## Independently established inputs and boundaries + +The contract and `478-480-readonly-diagnosis.md` seal the named-retail / paired +binary proof for separate40static/7dynamic caps, every-insertion ordering, +root-frame ranking before authored offset composition, viewer-first insertion, +and EnvCell all-dynamic plus independent all-static consumption. The activated +Town Network defect and exact bound inputs are in +`478-479-graphical-observations.md` R2. These are not matched-retail pixel proof. + +Lead separately traced the modern production inputs: static publication calls +LightInfoLoader with entity.Position/Rotation; LiveEntityLightController does +the same and LightingHookSink refreshes attached lights from the canonical +root pose. The only production LightSource constructors are that loader, +the viewer and the sun. WorldRenderFrameBuilder calls UpdateViewerLight, +then Tick(camera), then BuildPointLightSnapshot(player) before lending the +same snapshot to the dispatcher and EnvCell renderer. Thus Build may safely +replace DistSq with the retained root rank before selection/observation. +No shader reads DistSq; final light position still feeds contribution math. + +EnvCell RHI uses LightingMode1 and TextureIndexB0; ordinary Wb paths remain +MaxLightsPerObject8. Shader internal light-array stride is a real changed +contract and is pinned together with its CPU upload. Set numbers, buffer +element format and push/uniform layouts do not change. The built-in pack's +declared mesh receiver is WorldMesh and ordinary mode remains8; no public +RenderPipelineBaseSemantic.EnvCell production use was found in src/tests. +Both built-in vertex families must nevertheless pass the explicit mode1/mode0 +compiled-module witness. No general pack API expansion is part of this repair. + +## Existing authored-type projection limitation (not repaired here) + +Lead inspected the installed Chorizite.DatReaderWriter2.1.7 assembly metadata +and actual Setup.Unpack IL. Setup.Lights is Dictionary: +IL0236 ReadInt32 → IL0243 ReadItem → IL0254 dictionary Add. +LightInfo exposes only ViewSpaceLocation, Color, Intensity, Falloff, ConeAngle. +Named CSetup::UnPack00521307 stores that leading word in LIGHTINFO.type, +then00521316 unpacks Frame at+4. Native span00521301/raw00121301/77bytes has +SHA2564A45A458A56C043ECA0B8CC824843D632E2A4E67698BA6194B1499549CA97EC7; +its leading bytes72488B0E8B11895500 implement the guarded word read/store. +Parser DLL SHA2565DF9E0220EB5674FE8AC7C9477C9DF85DB10875FAD3726FAA9BEDB9D26A58F7E. + +The existing loader derives modern Kind from ConeAngle instead of that key. +The #478 contract deliberately leaves that projection unchanged; AP-85 must +disclose it alongside numerical/cell-offset and residency/degrade residuals. +POINT-versus-SPOT rank tests prove behavior for supplied modern kinds, NOT +faithful projection of every authored type. The actual Town fixture must +report key and cone; no speculative extra kind/type port is authorized. + +## Review and execution pending + +Lead read the initial source delta and inspected both compiled modules with +the installed spirv-dis: mode1 selects constant47, otherwise8; that selected +value supplies instance multiplication. At that inspection the module hashes +were9909CA4729DBE4FC7FBFFB11C73481977D8F593D37F130FCDD44A7803B47944F +(modern) andAE5DD5F4B9B2891A669340FD9712169BD1EC58BCF8C596E1ABDAF71997420231 +(atmospheric). Final hashes and complete frozen diff still need rechecking. +This static inspection is not the pending actual-pixel result. + +Before native shader mutations, lead required padding the synthetic ordinary +light-index witness to94 entries with -1 sentinels. Correct indices remain7/15; +the wrong47 mutation must fail by color/index behavior, not execute an +out-of-bounds GPU read. Production ordinary upload extent is checked separately. +Permanent RHI regression tests must survive removal of the temporary observer. + +## Frozen return and initial lead evidence corrections + +The implementer froze26 files at14:47+02. Lead verified all26 against +`artifacts/478-room-lights/final-source-hashes.json`, checked the complete +source/generated/test/doc diff, and independently counted159 active AP rows. +Only AP-68 retires; AP-16/AP-35/AP-85 carry the disclosed limitations. +Both final SPIR-V hashes match the values above. No contribution-formula, +ambient, scene-specific, package, or FPS edits are present. + +Initial lead review corrected evidence, not the lighting algorithm: a purported +viewer-root tie actually used different roots; Clear's assertion was delayed +until after a rebuild; a NaN-first fixture did not discriminate forward from +backward insertion. The final tests now use a genuine root tie, assert empty +immediately after Clear, and pin finite-far/NaN/finite-near to near/far/NaN. +The table-walker citation was corrected to CEnvCell::add_dynamic_lights0052D410 +(which calls the per-cell feeder0052B390); architecture now states POINT-only +distance rank. No frozen production fix round has been required. + +Actual final implementer artifacts: full Release0W0E; Core69/69 (all Lighting +plus installed Town fixture), App176/176, committed-module native pixels2/2. +The first solution build lacked project.assets.json in the fresh scratch; +normal restore resolved39NETSDK1004 errors. No compilation repair was hidden. +Seven intended mutations each failed their named assertion. The first +mutation4/5 invocations discovered zero tests due to typoed filters; only +their r2 failures count. Mutation6 used94 in-bounds sentinel entries and failed +by wrong color, not unsafe out-of-bounds GPU access. + +Lead independently reran the frozen positives, both clients absent before +each invocation: `root-positive-core/root-core.trx`69/69, +`root-positive-app/root-app.trx`176/176, and +`root-positive-pixels/root-shader-pixels.trx`2/2, all under the scratch's +`artifacts/478-room-lights/`. These are narrow correctness suites, not full +solution or FPS gates. Native pixels exercise actual committed shaders on +synthetic quads; they are not an installed-world visual acceptance. + +Wegener's independent production/lifetime/ABI review PASS: no findings, +all26seals checked, no reviewer executions or edits. Independent retail +review PASS: all26seals, paired executable and ten native spans independently +matched, no findings. The stale prior retail-agent continuation was unavailable; +one fresh bounded OpenAI retail reviewer took that same lens, not an extra +implementation or review round. + +Lead additionally executed three independent negative controls on the final +source: ordinary-registry viewer insertion failed the actual equal-root test +(`Assert.NotSame`); omitting Clear's snapshot clearing failed the immediate +`Assert.Empty`; backward insertion failed the finite-far/NaN/finite-near exact +order. Each ran1test/1intended failure, with exact raw source restoration +SHA256C141B2B2234BB94D25663B588A0142A3D520859276CE70A41F9F3659D989C0D5 +after EACH control. Artifacts: `root-mutation-viewer`, `root-mutation-clear`, +`root-mutation-nan` under the same scratch artifact directory. Final rebuilt +`root-restored-core/root-restored-core.trx`69/69; all26file seals rechecked. +Lead did not re-execute the implementer's seven other mutations: their actual +TRX failure text and source restoration evidence were inspected independently. + +Pending: fresh integration Release, +unchanged Town High/off/High graphical proof, ordinary dungeon controls, +observer cleanup and final observer-free re-gate. No PASS inferred in advance. 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 397f4d87..7eecd5d3 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 @@ -4957,3 +4957,18 @@ same Town route remains unchanged. Prepared `route-478-dungeon-controls.txt` uses the already selected0125/8C04 poses, High/off still frames and Holtburg return after the repair. This is a lighting/reveal smoke, not full dungeon stairs/camera-seam coverage and not an FPS benchmark. No run/result yet. + +Reviewed return621b41fa3 now integrates with all26 blobs unchanged. Separate +independent OpenAI retail and production/lifetime/ABI lenses PASS; lead +checked every source/generated/doc change and native identity, independently +repeated69Core/176App/2committed-shader pixels. Seven implementer controls +have actual intended failures (two zero-test typo-filter precursors excluded). +Lead additionally mutated viewer-first tie, immediate Clear and forward NaN +ordering: each1/1intended failure, exact raw restoration, rebuilt69PASS. +Full scratch Release0W0E after normal fresh-worktree restore. Evidence: +`478-room-lights-lead-verification.md`. AP68retired,159active AP rows; +AP16/35/85 preserve ordinary-selection/bake/residency/degrade/numerical/type +projection limits. No ambient/formula/FPS edits or graphical PASS yet. +Fresh integration Release and unchanged Town/control runs next. Prepared +`478-observer-removal-contract.md` activates only after inspected candidate +frames; then final observer-free re-gate. G4FAIL/no merge. diff --git a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs index 618848cf..194f08de 100644 --- a/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs +++ b/src/AcDream.App/Rendering/Gpu/GpuBindingModel.cs @@ -50,7 +50,10 @@ internal static class GpuBindingModel /// A7 Fix B global point/spot light array. public const uint StorageGlobalLights = 4; - /// A7 Fix B per-instance light set: 8 indices into the global light array, -1 = unused. + /// + /// Per-instance indices into the global light array: object submissions use + /// stride 8; EnvCell submissions use stride 47. Unused entries are -1. + /// public const uint StorageInstanceLightSets = 5; /// #142 per-instance indoor flag (1 = parented to an EnvCell, skip the sun). @@ -195,4 +198,7 @@ internal static class GpuBindingModel /// Lights selected per object by retail's minimize_object_lighting. public const int MaxLightsPerObject = 8; + + /// Complete retained dynamic + static products consumed by EnvCells. + public const int MaxLightsPerEnvCell = 47; } diff --git a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert index c45f9fda..da13417f 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_atmospheric.vert @@ -85,7 +85,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf { GlobalLight gLights[]; }; layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { - int instanceLightIdx[]; // 8 per instance; -1 = unused + int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused }; // #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object @@ -274,8 +274,9 @@ vec3 accumulateAmbientLocalLights( // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged. vec3 pointAcc = vec3(0.0); - int base = instanceIndex * 8; - for (int k = 0; k < 8; ++k) { + int lightStride = (uLightingMode == 1) ? 47 : 8; + int base = instanceIndex * lightStride; + for (int k = 0; k < lightStride; ++k) { int gi = instanceLightIdx[base + k]; if (gi < 0) continue; pointAcc += pointContribution(N, worldPos, gLights[gi]); diff --git a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert index 02833a07..9da97bb5 100644 --- a/src/AcDream.App/Rendering/Shaders/mesh_modern.vert +++ b/src/AcDream.App/Rendering/Shaders/mesh_modern.vert @@ -81,7 +81,7 @@ layout(std430, binding = 4) readonly buffer GlobalLightBuf { GlobalLight gLights[]; }; layout(std430, binding = 5) readonly buffer InstanceLightSetBuf { - int instanceLightIdx[]; // 8 per instance; -1 = unused + int instanceLightIdx[]; // object: 8; EnvCell: 47; -1 = unused }; // #142: LOCAL per-submission "indoor" flag, 1 per instance. 1 = object @@ -257,8 +257,9 @@ vec3 accumulateLights(vec3 N, vec3 worldPos, int instanceIndex) { // folding them into ambient+sun did. Mirrors LightBake.ComputeVertexColor // (LightBakeConformanceTests). Per-light cap inside pointContribution is unchanged. vec3 pointAcc = vec3(0.0); - int base = instanceIndex * 8; - for (int k = 0; k < 8; ++k) { + int lightStride = (uLightingMode == 1) ? 47 : 8; + int base = instanceIndex * lightStride; + for (int k = 0; k < lightStride; ++k) { int gi = instanceLightIdx[base + k]; if (gi < 0) continue; pointAcc += pointContribution(N, worldPos, gLights[gi]); diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv index 4600c0bf..6cbee02b 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_atmospheric.vert.spv differ diff --git a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv index 7ec7b978..f607c771 100644 Binary files a/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.vert.spv and b/src/AcDream.App/Rendering/Shaders/spv/mesh_modern.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 e8e3ddca..1e4c2aa7 100644 --- a/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json +++ b/src/AcDream.App/Rendering/Shaders/spv/shaders.manifest.json @@ -215,7 +215,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "b1e8e52b0b3dedec6c7dfd29ce3e70deb44d6b18197d6bd312e1a34e543f8337", + "sourceSha256": "16c29168aedead79da28151e2cec863634b7818d28c72619f9905b81dabd8571", "compiled": true }, { @@ -231,7 +231,7 @@ "stages": [ { "stage": "vert", - "sourceSha256": "d5af0cbe995bcd10acea92ac7140363f0941105452a89039a7b54c5217c505a2", + "sourceSha256": "33d5563db8bcd7dc74e4836df2c6064cbd5112d074da5380b0bab333f1d7ece7", "compiled": true }, { diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.LightingObservation.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.LightingObservation.cs index 5e2163e8..00d1bc90 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.LightingObservation.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.LightingObservation.cs @@ -20,6 +20,7 @@ internal sealed record EnvCellLightingSourceSnapshot( bool IsDynamic, bool IsLit, bool TracksOwnerPose, + EnvCellLightingVectorBits RankingOrigin, EnvCellLightingVectorBits Position, EnvCellLightingVectorBits Forward, EnvCellLightingVectorBits Color, @@ -37,7 +38,7 @@ internal sealed record EnvCellLightingCellSetSnapshot( /// Checkpoint-only copy of the point-light inputs the EnvCell RHI submission /// already consumed. PackedGlobalLightBits is the authoritative binding-4 /// payload; Sources records its identity/provenance and CellSets records the -/// exact cached eight-index values copied to binding 5. +/// exact cached 47-index values copied to binding 5. /// internal sealed record EnvCellLightingConsumptionSnapshot( bool HasCurrentEnvCellInput, @@ -141,6 +142,7 @@ public sealed partial class EnvCellRenderer IsDynamic: source.IsDynamic, IsLit: source.IsLit, TracksOwnerPose: source.TracksOwnerPose, + RankingOrigin: CaptureVector(source.RankingOrigin), Position: CaptureVector(source.WorldPosition), Forward: CaptureVector(source.WorldForward), Color: CaptureVector(source.ColorLinear), diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs index 56cbdc8e..166f2ec4 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs @@ -205,9 +205,8 @@ public sealed unsafe partial class EnvCellRenderer } Array.Fill(_instanceAlphaData, 1f, 0, uniqueInstanceCount); - // A7 Fix D (D-2): per-instance 8-int light set, keyed on the cell each - // shell instance belongs to. - int lightStride = LightManager.MaxLightsPerObject; + // Complete retained 7-dynamic + 40-static product per EnvCell instance. + int lightStride = LightManager.MaxLightsPerEnvCell; if (_lightSetData.Length < uniqueInstanceCount * lightStride) { _lightSetData = new int[Math.Max( diff --git a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs index 9ea94e1c..dd394943 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellRenderer.cs @@ -108,14 +108,15 @@ public sealed partial class EnvCellRenderer : // A7 Fix D (D-2): this renderer owns its lighting (self-contained state, // like uViewProjection) instead of reading whatever WbDrawDispatcher last // bound. Global point-light snapshot (same data/indices as the dispatcher, - // via GlobalLightPacker) plus 8 int indices per instance. + // via GlobalLightPacker) plus the complete 47-index retained product per + // EnvCell instance. Ordinary objects retain their separate 8-index stride. private float[] _globalLightData = new float[AcDream.Core.Lighting.GlobalLightPacker.FloatsPerLight * 16]; - private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + private int[] _lightSetData = new int[1024 * AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell]; private System.Collections.Generic.IReadOnlyList? _pointSnapshot; private sealed class CachedCellLightSet { public int FrameGeneration; - public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerObject]; + public readonly int[] Indices = new int[AcDream.Core.Lighting.LightManager.MaxLightsPerEnvCell]; } private readonly System.Collections.Generic.Dictionary _cellLightSetCache = new(); @@ -1101,13 +1102,11 @@ public sealed partial class EnvCellRenderer : // --------------------------------------------------------------------------- // GetCellLightSet (A7 Fix D D-2 helper) - // Per-cell up-to-8 point lights, cached per frame. Camera-independent, like - // WbDrawDispatcher.ComputeEntityLightSet — keyed on the cell's world bounds. + // Complete per-cell retained point-light product, cached per frame. // --------------------------------------------------------------------------- - // A7 Fix D (D-2): the up-to-8 point lights reaching a cell, by the cell's world - // bounding sphere (camera-independent, like WbDrawDispatcher.ComputeEntityLightSet). - // Cached per frame; unused slots are -1 (shader adds no point light there). + // A7 Fix D (D-2): all retained 7 dynamic + 40 static candidates. The shader + // applies the unchanged per-vertex range cutoff. Unused slots are -1. private int[] GetCellLightSet(uint cellId) { if (!_cellLightSetCache.TryGetValue(cellId, out CachedCellLightSet? cached)) @@ -1122,24 +1121,8 @@ public sealed partial class EnvCellRenderer : System.Array.Fill(set, -1); var snap = _pointSnapshot; - // Landblocks are keyed by the streaming landblock id 0xXXYYFFFF - // (GameWindow: (x<<24)|(y<<16)|0xFFFF), NOT 0xXXYY0000 — so the landblock - // key is (cellId & 0xFFFF0000) | 0xFFFF. The old `cellId & 0xFFFF0000` key - // (0xXXYY0000) NEVER matched a registered landblock, so this lookup always - // missed: SelectForObject never ran and every EnvCell wall received ZERO - // point lights (the entire "indoor torches/lanterns don't light the room" - // bug — confirmed by the [cell-light] probe: inBounds=False for every cell). - if (snap is { Count: > 0 } && - _landblocks.TryGetValue((cellId & 0xFFFF0000u) | 0xFFFFu, out var lb) && - lb.EnvCellBounds.TryGetValue(cellId, out var b)) - { - Vector3 center = (b.Min + b.Max) * 0.5f; - float radius = (b.Max - b.Min).Length() * 0.5f; - // #176 flap fix: cells use SelectForCell (retail minimize_envcell_lighting) — ALL - // dynamic lights on every cell (stable), not the per-object sphere-overlap cull that - // let the portal set flip as the flood shifted → floor-lighting flap. - AcDream.Core.Lighting.LightManager.SelectForCell(snap, center, radius, set); - } + if (snap is { Count: > 0 }) + AcDream.Core.Lighting.LightManager.SelectForCell(snap, set); cached.FrameGeneration = _lightFrameGeneration; return set; } diff --git a/src/AcDream.Core/Lighting/LightInfoLoader.cs b/src/AcDream.Core/Lighting/LightInfoLoader.cs index 32f4a5d8..bd0ff7c2 100644 --- a/src/AcDream.Core/Lighting/LightInfoLoader.cs +++ b/src/AcDream.Core/Lighting/LightInfoLoader.cs @@ -76,6 +76,7 @@ public static class LightInfoLoader { Kind = info.ConeAngle > 0f ? LightKind.Spot : LightKind.Point, WorldPosition = worldPos, + RankingOrigin = entityPosition, WorldForward = forward, ColorLinear = new Vector3( (info.Color?.Red ?? 255) / 255f, diff --git a/src/AcDream.Core/Lighting/LightManager.cs b/src/AcDream.Core/Lighting/LightManager.cs index 8c0ddbbd..98fbc101 100644 --- a/src/AcDream.Core/Lighting/LightManager.cs +++ b/src/AcDream.Core/Lighting/LightManager.cs @@ -5,10 +5,10 @@ using System.Numerics; namespace AcDream.Core.Lighting; /// -/// Manages the registered dynamic lights in the world and picks the 8 -/// most relevant ones each frame for the shader to consume. Matches -/// retail's fixed-function-era "8 hardware lights" constraint (r13 -/// §12.2). +/// Owns the registered world-light set. retains the legacy +/// directional-plus-eight UBO view, while +/// builds retail's independent seven-dynamic and forty-static point-light +/// products for binding 4/5 consumers. /// /// /// Active-light selection algorithm (r13 §12.2), as implemented by @@ -65,6 +65,10 @@ public sealed class LightManager public void Register(LightSource light) { ArgumentNullException.ThrowIfNull(light); + if (light.Kind != LightKind.Directional && !light.HasRankingOrigin) + throw new ArgumentException( + "A nondirectional light requires an explicit authored ranking origin.", + nameof(light)); foreach (var existing in _all) if (ReferenceEquals(existing, light)) return; _all.Add(light); @@ -88,6 +92,9 @@ public sealed class LightManager Array.Clear(_active); _activeCount = 0; _viewerLight = null; // re-created + re-registered by the next UpdateViewerLight + _pointSnapshot.Clear(); + _dynamicPointSnapshot.Clear(); + _staticPointSnapshot.Clear(); } /// @@ -175,30 +182,20 @@ public sealed class LightManager /// is global, not part of an object's per-object set, so all 8 are point/spot. public const int MaxLightsPerObject = 8; - /// Hard cap on the per-frame global point-light snapshot the shader - /// indexes. #176 root-cause history (2026-07-06, corrected): retail's pool is - /// collected from ALL RESIDENT EnvCells (CEnvCell::add_dynamic_lights - /// 0x0052d410 walks the static CEnvCell::visible_cell_table — the - /// loaded-cell registry that add_visible_cell 0x0052de40 fills from each - /// activated cell + its dat visible-cell list; NOT the per-frame portal flood) - /// and capped nearest-THE-PLAYER (Render::insert_light 0x0054d1b0 sorts - /// by distance to Render::player_pos) with small caps (7 dynamic + 40 - /// static, 0x0081ec94/98). Two prior acdream models both flickered - /// because their pool was CAMERA-coupled: (1) nearest-CAMERA-128 over all - /// registered lights (chase-boom swing churned the eviction boundary), then - /// (2) frame-FLOOD scoping `c500912b` (gaze-dependent: the under-room portal - /// purples entered/left the pool as the camera turned — the seam-floor - /// blink). Current model: all registered - /// (=resident) lit lights, then dynamics-first nearest-player, capped here. - /// A later last-frame drawable-cell filter was removed after the Facility Hub - /// zoom trace proved it recreated the same camera-root coupling: the pool - /// collapsed from five lights to one without the player moving. 128 is wider - /// than retail's 40+7 — a documented backstop - /// that in a properly cell-scoped room only ever evicts far-out-of-range - /// statics; adopting retail's exact dual-pool caps + degrade levels is A7-arc - /// work. The 1024 uncap remains refuted (striped-floor artifact + the unported - /// static 1/d³ fixture curve, A7 fix #2). Register row AP-85. - public const int MaxGlobalLights = 128; + /// Retail's independent retained dynamic-light cap. + public const int MaxDynamicPointLights = 7; + + /// Retail's independent retained static-light cap. + public const int MaxStaticPointLights = 40; + + /// Combined GPU-index transport capacity: dynamics, then statics. + public const int MaxGlobalLights = MaxDynamicPointLights + MaxStaticPointLights; + + /// + /// EnvCell shells consume the complete retained dynamic and static products. + /// Ordinary objects retain . + /// + public const int MaxLightsPerEnvCell = MaxGlobalLights; private readonly List _pointSnapshot = new(); @@ -209,27 +206,23 @@ public sealed class LightManager /// . /// public IReadOnlyList PointSnapshot => _pointSnapshot; - internal bool LastPointSnapshotUsedBoundedSelection { get; private set; } - internal bool LastPointSnapshotUsedTieFallback { get; private set; } - - // Slice H-b: keep only the best MaxGlobalLights entries in a retained - // max-heap. Rank includes qualifying registration order because retail - // insert_light (0x0054D1B0) advances past equal-distance residents. - private readonly List _pointSelectionHeap = - new(MaxGlobalLights); - private Comparison? _rankComparison; - private Vector3 _legacyPoolAnchor; - private Comparison? _legacyPoolComparison; + private readonly List _dynamicPointSnapshot = + new(MaxDynamicPointLights); + private readonly List _staticPointSnapshot = + new(MaxStaticPointLights); /// - /// Rebuild from ALL registered lit point/spot - /// lights — retail's per-frame collection over the RESIDENT-cell registry. - /// The sun and unlit lights are excluded (the sun is global ambient-path; - /// unlit torches contribute nothing). + /// Rebuild from all registered lit point/spot + /// residents as independent seven-dynamic and forty-static products. + /// Each product is stable insertion-ranked from the player using the + /// authored root origin for points; spots have rank zero. The viewer is + /// inserted before ordinary dynamics. The combined snapshot is dynamics + /// followed by statics so binding-5 indices are deterministic. /// /// Retail anchors (#176 corrected reading, 2026-07-06): - /// CEnvCell::add_dynamic_lights (0x0052d410) walks the WHOLE static - /// CEnvCell::visible_cell_table — the resident-EnvCell registry that + /// CEnvCell::add_dynamic_lights (0x0052D410) walks the WHOLE static + /// CEnvCell::visible_cell_table — the resident-EnvCell registry — and calls + /// CObjCell::add_dynamic_to_global_lights (0x0052B390) for each entry. The table is the one /// CEnvCell::add_visible_cell (0x0052de40) populates from each activated /// cell plus its dat visible-cell list (it DBObj::Get-loads absent cells; /// entries leave only via the flush machinery). It is NOT the per-frame portal @@ -240,18 +233,10 @@ public sealed class LightManager /// them to every Hub cell) — the faceted purple wedge is faithful. /// /// - /// When more than qualify, DYNAMICS are kept - /// first (retail's dynamic lights live in their own 7-slot pool — - /// Render::add_dynamic_light 0x0054d420 — and never compete with - /// statics), then the nearest THE PLAYER (Render::insert_light - /// 0x0054d1b0 insertion-sorts by squared distance to Render::player_pos, - /// set from player->m_position, SmartBox 0x00453d3a, with the - /// viewer-cell fallback 0x00455ab6). The distance SORT is therefore a function - /// of PLAYER position and light registration ONLY — camera rotation/position - /// cannot change it. Both camera-anchored pools and last-frame drawable-cell - /// scoping produced gaze/zoom-dependent membership changes, so neither is an - /// input to this retail resident-cell collection. Call once per frame before - /// per-object selection. + /// Retail's insert_light (0x0054d1b0) uses a strict forward + /// newDistance < existingDistance insertion. Equal and unordered + /// comparisons therefore advance, which preserves registration order without + /// float.CompareTo or an unstable sort. /// /// /// The player's world position (render position; @@ -260,200 +245,58 @@ public sealed class LightManager public void BuildPointLightSnapshot(Vector3 playerWorldPos) { _pointSnapshot.Clear(); - _pointSelectionHeap.Clear(); - int qualifyingOrdinal = 0; - bool overflow = false; - LastPointSnapshotUsedBoundedSelection = false; - LastPointSnapshotUsedTieFallback = false; + _dynamicPointSnapshot.Clear(); + _staticPointSnapshot.Clear(); + + // SmartBox inserts the viewer light before collecting ordinary dynamic + // residents. Processing it first also preserves its equal-root tie even + // though the retained registry may contain earlier registrations. + if (_viewerLight is { IsLit: true } viewer) + InsertRetained(viewer, playerWorldPos, _dynamicPointSnapshot, MaxDynamicPointLights); + foreach (var light in _all) { - if (!light.IsLit || light.Kind == LightKind.Directional) continue; - var ranked = new RankedLight( + if (ReferenceEquals(light, _viewerLight) + || !light.IsLit + || light.Kind == LightKind.Directional) + continue; + + InsertRetained( light, - qualifyingOrdinal++, - Vector3.DistanceSquared( - light.WorldPosition, - playerWorldPos)); - _pointSnapshot.Add(light); - if (_pointSnapshot.Count <= MaxGlobalLights) - continue; - - if (!overflow) - { - for (int index = 0; - index < MaxGlobalLights; - index++) - { - LightSource existing = _pointSnapshot[index]; - _pointSelectionHeap.Add(new RankedLight( - existing, - index, - Vector3.DistanceSquared( - existing.WorldPosition, - playerWorldPos))); - } - HeapifyWorstFirst(_pointSelectionHeap); - overflow = true; - } - - // Root is the currently-worst selected rank. A later light at the - // same distance ranks after an earlier resident, matching retail. - if (CompareRankedLights(ranked, _pointSelectionHeap[0]) < 0) - { - _pointSelectionHeap[0] = ranked; - SiftWorstDown(_pointSelectionHeap, 0); - } - } - - if (overflow) - { - _rankComparison ??= CompareRankedLights; - _pointSelectionHeap.Sort(_rankComparison); - bool comparatorTie = - SelectedRanksContainObservableTie(playerWorldPos); - if (comparatorTie) - { - LastPointSnapshotUsedTieFallback = true; - // The previous List.Sort comparator intentionally returned zero - // for equal pool/distance ranks. List.Sort is unstable, so its - // exact tie permutation is observable in shader indices. Keep - // that legacy oracle for tied frames; H-b is performance-only. - // AP-85's eventual dual-pool port can adopt retail's stable tie - // insertion as a separately visual-gated behavior change. - _legacyPoolAnchor = playerWorldPos; - _legacyPoolComparison ??= CompareLegacyPoolLights; - _pointSnapshot.Sort(_legacyPoolComparison); - _pointSnapshot.RemoveRange( - MaxGlobalLights, - _pointSnapshot.Count - MaxGlobalLights); - } - else - { - LastPointSnapshotUsedBoundedSelection = true; - _pointSnapshot.Clear(); - for (int index = 0; - index < _pointSelectionHeap.Count; - index++) - { - _pointSnapshot.Add( - _pointSelectionHeap[index].Light); - } - } + playerWorldPos, + light.IsDynamic ? _dynamicPointSnapshot : _staticPointSnapshot, + light.IsDynamic ? MaxDynamicPointLights : MaxStaticPointLights); } + _pointSnapshot.AddRange(_dynamicPointSnapshot); + _pointSnapshot.AddRange(_staticPointSnapshot); } - private static int CompareRankedLights( - RankedLight left, - RankedLight right) + private static void InsertRetained( + LightSource light, + Vector3 playerWorldPos, + List product, + int capacity) { - if (left.Light.IsDynamic != right.Light.IsDynamic) - return left.Light.IsDynamic ? -1 : 1; + // Retail ranks only POINT. Non-point lights have rank zero. The strict + // forward comparison is intentional: equal and NaN comparisons advance, + // preserving insertion order and retail's unordered behavior. + float rank = light.Kind == LightKind.Point + ? Vector3.DistanceSquared(light.RankingOrigin, playerWorldPos) + : 0f; + light.DistSq = rank; - int distance = left.DistanceSq.CompareTo(right.DistanceSq); - return distance != 0 - ? distance - : left.QualifyingOrdinal.CompareTo(right.QualifyingOrdinal); + int index = 0; + while (index < product.Count && !(rank < product[index].DistSq)) + index++; + if (index >= capacity) + return; + + product.Insert(index, light); + if (product.Count > capacity) + product.RemoveAt(capacity); } - private bool SelectedRanksContainObservableTie(Vector3 playerWorldPos) - { - for (int index = 1; - index < _pointSelectionHeap.Count; - index++) - { - if (HaveSameLegacyRank( - _pointSelectionHeap[index - 1], - _pointSelectionHeap[index])) - { - return true; - } - } - - // A tie may straddle the cap with only one copy in the selected heap. - // Such a tie can change which light the old unstable List.Sort kept. - RankedLight cutoff = _pointSelectionHeap[^1]; - int cutoffMatches = 0; - for (int index = 0; index < _pointSnapshot.Count; index++) - { - LightSource light = _pointSnapshot[index]; - if (light.IsDynamic != cutoff.Light.IsDynamic) - continue; - - float distance = Vector3.DistanceSquared( - light.WorldPosition, - playerWorldPos); - if (distance.CompareTo(cutoff.DistanceSq) != 0) - continue; - - if (++cutoffMatches > 1) - return true; - } - - return false; - } - - private static bool HaveSameLegacyRank( - RankedLight left, - RankedLight right) => - left.Light.IsDynamic == right.Light.IsDynamic - && left.DistanceSq.CompareTo(right.DistanceSq) == 0; - - private int CompareLegacyPoolLights( - LightSource left, - LightSource right) - { - if (left.IsDynamic != right.IsDynamic) - return left.IsDynamic ? -1 : 1; - - float leftDistance = Vector3.DistanceSquared( - left.WorldPosition, - _legacyPoolAnchor); - float rightDistance = Vector3.DistanceSquared( - right.WorldPosition, - _legacyPoolAnchor); - return leftDistance.CompareTo(rightDistance); - } - - private static void HeapifyWorstFirst(List heap) - { - for (int index = heap.Count / 2 - 1; - index >= 0; - index--) - { - SiftWorstDown(heap, index); - } - } - - private static void SiftWorstDown( - List heap, - int index) - { - while (true) - { - int left = checked(index * 2 + 1); - if (left >= heap.Count) - return; - - int right = left + 1; - int worse = right < heap.Count - && CompareRankedLights(heap[right], heap[left]) > 0 - ? right - : left; - if (CompareRankedLights(heap[worse], heap[index]) <= 0) - return; - - (heap[index], heap[worse]) = - (heap[worse], heap[index]); - index = worse; - } - } - - private readonly record struct RankedLight( - LightSource Light, - int QualifyingOrdinal, - float DistanceSq); - // ── Viewer light — retail SmartBox::set_viewer (0x00452c40) ────────────── // Retail adds a white fill light pinned to the player EVERY frame via // Render::add_dynamic_light. It is the dominant INTERIOR fill: the outdoor @@ -495,6 +338,7 @@ public sealed class LightManager _viewerLight = new LightSource { Kind = LightKind.Point, + LocalPose = Matrix4x4.CreateTranslation(0f, 0f, 2f), ColorLinear = Vector3.One, // white (1,1,1) Intensity = ViewerLightIntensity, Range = ViewerLightFalloff * 1.5f, // dynamic rangeAdjust 1.5 @@ -504,6 +348,7 @@ public sealed class LightManager }; _all.Add(_viewerLight); } + _viewerLight.RankingOrigin = playerWorldPos; _viewerLight.WorldPosition = playerWorldPos + new Vector3(0f, 0f, 2f); } @@ -575,73 +420,22 @@ public sealed class LightManager } /// - /// Per-CELL light selection — retail minimize_envcell_lighting (0x0054c170). - /// Unlike (per-object sphere-overlap cull), retail enables - /// the ENTIRE dynamic subset for EVERY EnvCell it draws (verified by a live cdb trace of - /// config_hardware_light: the same 4 intensity-100 portal lights are applied to - /// every Facility Hub cell, every frame). So here: ALL dynamic lights are added - /// unconditionally (the shader's per-light range cutoff zeroes ones that don't reach — - /// same as D3D's hardware range), THEN remaining slots fill with the nearest STATIC lights - /// that reach the cell sphere. This is what makes a cell's floor lighting STABLE as the - /// portal flood shifts — a per-cell sphere-overlap cull of the dynamics is what made the - /// floor lighting FLAP (#176). Objects keep - /// (retail minimize_object_lighting). + /// Per-CELL light selection: every retained dynamic and static candidate. + /// Retail enables the complete dynamic product and separately bakes every + /// retained static into EnvCell vertices. Our documented GPU-evaluation path + /// supplies both products and leaves the unchanged shader range cutoff to + /// decide each vertex contribution. Ordinary objects keep + /// and its eight-light hardware cap. /// public static int SelectForCell( IReadOnlyList snapshot, - Vector3 center, - float radius, Span outIndices) { - int cap = Math.Min(outIndices.Length, MaxLightsPerObject); - if (cap <= 0) return 0; - - int count = 0; - - // 1) ALL dynamic lights, unconditionally (retail applies the whole dynamic subset to - // every cell — stable regardless of the cell's relation to each light). - for (int li = 0; li < snapshot.Count && count < cap; li++) - if (snapshot[li].IsDynamic) - outIndices[count++] = li; - - // 2) Fill remaining slots with the nearest STATIC lights that reach the cell sphere, - // insertion-sorted among the static slots only (dynamic slots [0..staticStart) are fixed). - int staticStart = count; - Span keptDistSq = stackalloc float[MaxLightsPerObject]; - for (int li = 0; li < snapshot.Count; li++) - { - var light = snapshot[li]; - if (light.IsDynamic) continue; // dynamics already added - float reach = light.Range + radius; - float dsq = (light.WorldPosition - center).LengthSquared(); - if (dsq >= reach * reach) continue; - - if (count < cap) - { - int j = count; - while (j > staticStart && keptDistSq[j - 1] > dsq) - { - keptDistSq[j] = keptDistSq[j - 1]; - outIndices[j] = outIndices[j - 1]; - j--; - } - keptDistSq[j] = dsq; - outIndices[j] = li; - count++; - } - else if (staticStart < cap && dsq < keptDistSq[cap - 1]) - { - int j = cap - 1; - while (j > staticStart && keptDistSq[j - 1] > dsq) - { - keptDistSq[j] = keptDistSq[j - 1]; - outIndices[j] = outIndices[j - 1]; - j--; - } - keptDistSq[j] = dsq; - outIndices[j] = li; - } - } + int count = Math.Min( + Math.Min(snapshot.Count, outIndices.Length), + MaxLightsPerEnvCell); + for (int index = 0; index < count; index++) + outIndices[index] = index; return count; } } diff --git a/src/AcDream.Core/Lighting/LightSource.cs b/src/AcDream.Core/Lighting/LightSource.cs index 5ce1fb2f..d8cf0836 100644 --- a/src/AcDream.Core/Lighting/LightSource.cs +++ b/src/AcDream.Core/Lighting/LightSource.cs @@ -15,7 +15,7 @@ public enum LightKind /// /// Per-frame light record. Used by and fed to -/// the shader UBO on every draw call. +/// the directional UBO or retained point-light storage buffer as appropriate. /// /// /// Retail semantics (r13 §10.2): @@ -40,6 +40,25 @@ public sealed class LightSource { public LightKind Kind; public Vector3 WorldPosition; + private Vector3 _rankingOrigin; + + /// + /// Authored root-frame origin used by retail's retained-light insertion + /// rank. This is deliberately independent of , + /// which includes the authored LIGHTINFO local offset. + /// + public Vector3 RankingOrigin + { + get => _rankingOrigin; + set + { + _rankingOrigin = value; + HasRankingOrigin = true; + } + } + + /// True only after the nondirectional ranking root was supplied. + public bool HasRankingOrigin { get; private set; } public Vector3 WorldForward; // for Spot/Directional // R,G,B in [0,1], pre-brightness. NOTE (Campaign VM VM3, 2026-08-22 // review item A4): despite the name, this is retail's authored diff --git a/src/AcDream.Core/Lighting/LightingHookSink.cs b/src/AcDream.Core/Lighting/LightingHookSink.cs index dec5ab9b..5d45d11f 100644 --- a/src/AcDream.Core/Lighting/LightingHookSink.cs +++ b/src/AcDream.Core/Lighting/LightingHookSink.cs @@ -150,6 +150,7 @@ public sealed class LightingHookSink : IAnimationHookSink { LightSource light = lights[i]; Matrix4x4 lightWorld = light.LocalPose * rootWorld; + light.RankingOrigin = rootWorld.Translation; light.WorldPosition = lightWorld.Translation; Vector3 forward = Vector3.TransformNormal(Vector3.UnitY, lightWorld); if (forward.LengthSquared() > 1e-8f) diff --git a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs index 7cf46b98..7e41a9d1 100644 --- a/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/WorldLifecycleAutomationControllerTests.cs @@ -437,6 +437,8 @@ public sealed class WorldLifecycleAutomationControllerTests IsDynamic: true, IsLit: true, TracksOwnerPose: true, + RankingOrigin: new EnvCellLightingVectorBits( + 0x3F000000u, 0x3F800000u, 0x40000000u), Position: new EnvCellLightingVectorBits( 0x3F800000u, 0x40000000u, 0x40400000u), Forward: new EnvCellLightingVectorBits( @@ -459,7 +461,7 @@ public sealed class WorldLifecycleAutomationControllerTests [ new EnvCellLightingCellSetSnapshot( 0x00070143u, - [0, -1, -1, -1, -1, -1, -1, -1]), + [0, .. Enumerable.Repeat(-1, 46)]), ], Ambient: new EnvCellLightingAmbientSnapshot( new EnvCellLightingVectorBits( diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.EnvCellLighting.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.EnvCellLighting.cs new file mode 100644 index 00000000..8791469a --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/MeshModernSharedIndexOffscreenTests.EnvCellLighting.cs @@ -0,0 +1,176 @@ +using System.Numerics; +using System.Runtime.InteropServices; +using AcDream.App.Rendering.Gpu; +using AcDream.App.Rendering.Gpu.Vk; +using AcDream.App.Rendering.Packs; +using AcDream.Core.Lighting; + +namespace AcDream.App.Tests.Rendering.Gpu.Vk; + +public sealed unsafe partial class MeshModernSharedIndexOffscreenTests +{ + [Trait("Lane", "Vulkan")] + [Theory] + [InlineData("mesh_modern", false)] + [InlineData("mesh_atmospheric", true)] + public void CommittedProductionWorldShaders_UseEnvCell47AndOrdinary8LightStrides( + string shaderName, + bool atmospheric) + { + lock (VulkanLock) + { + string shaderDirectory = Path.Combine( + RepositoryRoot(), "src", "AcDream.App", "Rendering", "Shaders", "spv"); + using var host = HeadlessVulkanHost.Create(shaderDirectory); + + (Pixel envFirst, Pixel envSecond) = RenderStridePair( + host, + shaderName, + atmospheric, + lightingMode: 1, + firstSlot: 8, + secondSlot: 46, + stride: 47); + (Pixel objectFirst, Pixel objectSecond) = RenderStridePair( + host, + shaderName, + atmospheric, + lightingMode: 0, + firstSlot: 7, + secondSlot: 7, + stride: 8); + + AssertRed(envFirst, $"{shaderName} EnvCell slot 8"); + AssertGreen(envSecond, $"{shaderName} EnvCell slot 46 / instance stride 47"); + AssertRed(objectFirst, $"{shaderName} ordinary slot 7"); + AssertGreen(objectSecond, $"{shaderName} ordinary instance stride 8"); + } + } + + private static (Pixel First, Pixel Second) RenderStridePair( + HeadlessVulkanHost host, + string shaderName, + bool atmospheric, + int lightingMode, + int firstSlot, + int secondSlot, + int stride) + { + VulkanGpuDevice device = host.Device; + using IGpuBuffer vertices = device.CreateBuffer(new GpuBufferDescription( + $"478-{shaderName}-vertices", + 4 * Marshal.SizeOf(), + GpuBufferUsage.Vertex | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + using IGpuBuffer indices = device.CreateBuffer(new GpuBufferDescription( + $"478-{shaderName}-indices", + 6 * sizeof(ushort), + GpuBufferUsage.Index | GpuBufferUsage.TransferDestination, + GpuMemoryResidency.DeviceLocal)); + Vertex[] vertexData = + [ + new(new Vector3(-0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.Zero), + new(new Vector3( 0.30f, -0.30f, 0f), Vector3.UnitZ, Vector2.UnitX), + new(new Vector3( 0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.One), + new(new Vector3(-0.30f, 0.30f, 0f), Vector3.UnitZ, Vector2.UnitY), + ]; + vertices.Upload(0, MemoryMarshal.AsBytes(vertexData)); + indices.Upload(0, MemoryMarshal.AsBytes([0, 1, 2, 2, 3, 0])); + + using IGpuRenderTarget target = CreateTarget(device, $"478-{shaderName}-{lightingMode}"); + using IGpuPipeline pipeline = device.CreatePipeline(new GpuPipelineDescription + { + Name = $"478-{shaderName}-{lightingMode}", + Shaders = new GpuShaderSet(shaderName), + VertexLayout = GpuVertexLayout.WorldMesh, + Topology = GpuPrimitiveTopology.TriangleList, + Blend = GpuBlendMode.None, + Depth = GpuDepthState.Disabled, + Cull = GpuCullMode.None, + UsesRenderPackShaderAbi = atmospheric, + SampleCount = 1, + }); + + // Pad both modes to 94 entries. The ordinary path still reads only + // offsets 7/15, while its stride-47 sabotage deterministically reads + // in-bounds wrong data rather than invoking driver-dependent OOB access. + var lightIndices = Enumerable.Repeat( + -1, + LightManager.MaxLightsPerEnvCell * 2).ToArray(); + lightIndices[firstSlot] = 0; + lightIndices[stride + secondSlot] = 1; + GlobalLight[] globalLights = + [ + DynamicLight(new Vector3(-0.48f, 0f, 1f), Vector3.UnitX), + DynamicLight(new Vector3( 0.48f, 0f, 1f), Vector3.UnitY), + ]; + + using (IGpuFrame frame = device.BeginFrame()) + { + using IGpuPassEncoder encoder = BeginPass(frame, target, $"478-{shaderName}-stride"); + encoder.BindPipeline(pipeline); + GpuPushConstants constants = GpuPushConstants.Default; + constants.LightingMode = lightingMode; + constants.LightDebug = 3; + encoder.SetPushConstants(constants); + + BindStorage(frame, encoder, GpuBindingModel.StorageInstances, + [ + Matrix4x4.CreateTranslation(-0.48f, 0f, 0f), + Matrix4x4.CreateTranslation( 0.48f, 0f, 0f), + ]); + BindStorage(frame, encoder, GpuBindingModel.StorageBatches, + [new BatchData(device.DefaultTextureSlot.Index, 1f, 0u, 0u)]); + BindStorage(frame, encoder, GpuBindingModel.StorageClipSlots, [0u, 0u]); + BindStorage(frame, encoder, GpuBindingModel.StorageGlobalLights, globalLights); + BindStorage(frame, encoder, GpuBindingModel.StorageInstanceLightSets, lightIndices); + BindStorage(frame, encoder, GpuBindingModel.StorageInstanceIndoor, [1u, 1u]); + BindStorage(frame, encoder, GpuBindingModel.StorageInstanceAlpha, [1f, 1f]); + BindStorage(frame, encoder, GpuBindingModel.StorageInstanceSelectionLighting, + [new Vector2(0f, 1f), new Vector2(0f, 1f)]); + BindStorage(frame, encoder, GpuBindingModel.StorageInstanceDetailCategory, [0u, 0u]); + BindUniform(frame, encoder, GpuBindingModel.UniformSceneLighting, default(SceneLightingUbo)); + if (atmospheric) + { + BindUniform(frame, encoder, GpuBindingModel.UniformAtmosphericFrame, + default(AtmosphericFrameUniforms)); + BindUniform(frame, encoder, GpuBindingModel.UniformDirectionalShadow, + ShadowUniforms(device.DefaultTextureSlot, enabled: false, -Vector3.UnitY)); + } + + encoder.BindVertexBuffer(0, vertices, 0); + encoder.BindIndexBuffer(indices, 0, GpuIndexType.UInt16); + encoder.DrawIndexed(6, 2, 0, 0, 0); + } + + device.WaitIdle(); + VulkanGpuRenderTarget vkTarget = Assert.IsType(target); + byte[] pixels = ReadBack( + host.Vk, + host.PhysicalDevice, + host.LogicalDevice, + host.Queue, + host.QueueFamily, + vkTarget.ColorResult.Image); + return (PixelAt(pixels, 16, 32), PixelAt(pixels, 48, 32)); + } + + private static GlobalLight DynamicLight(Vector3 position, Vector3 color) => new( + new Vector4(position, (float)LightKind.Point), + new Vector4(0f, 0f, 1f, 10f), + new Vector4(color, 1f), + new Vector4(0f, 1f, 0f, 0f)); + + private static void AssertRed(Pixel pixel, string label) + { + // Vulkan's selected offscreen format is copied in BGRA byte order. + Assert.True(pixel.R < 8 && pixel.G < 8 && pixel.B > 180, + $"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}"); + } + + private static void AssertGreen(Pixel pixel, string label) + { + Assert.True(pixel.R < 8 && pixel.G > 180 && pixel.B < 8, + $"{label}: rgba={pixel.R},{pixel.G},{pixel.B},{pixel.A}"); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs index 4e9dffe7..2ababbfd 100644 --- a/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Gpu/Vk/VulkanShaderManifestTests.cs @@ -51,7 +51,9 @@ public sealed class VulkanShaderManifestTests // S5-c4 replaces the padding word at batch offset 4 with authored // surface opacity and forwards it plus exact detail category/flags // flat to the fragment shader. The 16-byte ABI is unchanged. - ["mesh_modern.vert.spv"] = "6194193764616bc88bf967d1fdc2bea5f6f2d1ec2ef94fd492922137503f56e4", + // Re-pinned 2026-09-05 (#478): binding 5 keeps object stride 8 + // while EnvCell mode consumes the complete retained 47-light set. + ["mesh_modern.vert.spv"] = "9909ca4729dbe4fc7fbffb11c73481977d8f593d37f130fcdd44a7803b47944f", ["particle.frag.spv"] = "680da227704e0b3afa9b5226a7d73dd65aa9d8759d081cf4d5009d30e148726b", // Re-pinned 2026-08-27: portal-view clip slots now travel with // deferred billboard particles, matching retail PortalList draws. diff --git a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs index e7a38f04..89e56c1d 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/OrderPreservingSubmitterTests.cs @@ -934,6 +934,9 @@ public sealed class OrderPreservingSubmitterTests AssertLocalSection( GpuBindingModel.StorageInstanceLightSets, [0, 2, 4, 6, -1, -1, -1, -1, 1, 3, 5, 7, -1, -1, -1, -1]); + Assert.Equal( + (uint)(2 * AcDream.Core.Lighting.LightManager.MaxLightsPerObject * sizeof(int)), + LastBind(GpuBindingModel.StorageInstanceLightSets).SizeBytes); AssertLocalSection(GpuBindingModel.StorageInstanceIndoor, [0u, 1u]); AssertLocalSection(GpuBindingModel.StorageInstanceAlpha, [0.25f, 0.75f]); AssertLocalSection( diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs index 8e4a5e15..50905a7e 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellRendererTests.cs @@ -78,6 +78,7 @@ public class EnvCellRendererTests { Kind = LightKind.Spot, WorldPosition = new Vector3(50f, 60f, 70f), + RankingOrigin = new Vector3(5f, 6f, 7f), WorldForward = new Vector3(0.25f, -0.5f, 0.75f), ColorLinear = new Vector3(0.11f, 0.22f, 0.33f), Intensity = 4.5f, @@ -99,6 +100,7 @@ public class EnvCellRendererTests { Kind = LightKind.Point, WorldPosition = Vector3.Zero, + RankingOrigin = Vector3.Zero, WorldForward = Vector3.UnitX, ColorLinear = new Vector3(0.9f, 0.1f, 0.2f), Intensity = 2f, @@ -111,6 +113,7 @@ public class EnvCellRendererTests { Kind = LightKind.Point, WorldPosition = new Vector3(100f, 0f, 0f), + RankingOrigin = new Vector3(100f, 0f, 0f), WorldForward = Vector3.UnitY, ColorLinear = new Vector3(0.2f, 0.8f, 0.4f), Intensity = 3f, @@ -173,16 +176,17 @@ public class EnvCellRendererTests (int)setBind.OffsetBytes, (int)setBind.SizeBytes)).ToArray(); Assert.Equal( - actualSets.AsSpan(0, LightManager.MaxLightsPerObject).ToArray(), + actualSets.AsSpan(0, LightManager.MaxLightsPerEnvCell).ToArray(), Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices); Assert.Equal( actualSets.AsSpan( - LightManager.MaxLightsPerObject, - LightManager.MaxLightsPerObject).ToArray(), + LightManager.MaxLightsPerEnvCell, + LightManager.MaxLightsPerEnvCell).ToArray(), Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices); - Assert.Equal([0, 1, -1, -1, -1, -1, -1, -1], + int[] expectedSet = [0, 1, 2, .. Enumerable.Repeat(-1, 44)]; + Assert.Equal(expectedSet, Assert.Single(snapshot.CellSets, set => set.CellId == cellA).Indices); - Assert.Equal([0, 2, -1, -1, -1, -1, -1, -1], + Assert.Equal(expectedSet, Assert.Single(snapshot.CellSets, set => set.CellId == cellB).Indices); EnvCellLightingSourceSnapshot source = snapshot.Sources[0]; @@ -193,6 +197,7 @@ public class EnvCellRendererTests Assert.True(source.IsDynamic); Assert.True(source.IsLit); Assert.True(source.TracksOwnerPose); + Assert.Equal(BitConverter.SingleToUInt32Bits(5f), source.RankingOrigin.X); Assert.Equal(BitConverter.SingleToUInt32Bits(50f), source.Position.X); Assert.Equal(BitConverter.SingleToUInt32Bits(-0.5f), source.Forward.Y); Assert.Equal(BitConverter.SingleToUInt32Bits(0.33f), source.Color.Z); @@ -235,6 +240,98 @@ public class EnvCellRendererTests renderer.CaptureLightingConsumption(emptyLighting, currentWorldFrame: true)); } + [Fact] + public void SubmitRhi_BindsTwoCompleteFortySevenIndexCellSetsInCurrentGeneration() + { + const uint cellA = 0x8C040101u; + const uint cellB = 0x8C040102u; + using var device = new RecordingGpuDevice(); + using var meshManager = CreateMeshManager(device); + var frameLifetime = new GpuDeviceFrameLifetime(device); + var scope = new VulkanWorldPassScope(sampleCount: 1); + using var renderer = new EnvCellRenderer( + device, + frameLifetime, + scope, + meshManager, + new WbFrustum()); + LightSource[] lights = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell) + .Select(index => new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(index, 0f, 0f), + RankingOrigin = new Vector3(index, 0f, 0f), + Range = 10f, + IsDynamic = index < LightManager.MaxDynamicPointLights, + }) + .ToArray(); + renderer.SetPointSnapshot(lights); + + frameLifetime.BeginFrame(); + IGpuFrame frame = frameLifetime.CurrentFrame!; + IGpuPassEncoder pass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "envcell-47-index-binding", + Vector4.Zero, + sampleCount: 1)); + IDisposable publication = scope.Publish(pass); + renderer.BeginFrame(frame.SlotIndex); + SeedLightingSubmission(renderer, instanceCount: 2); + device.Clear(); + InvokeSubmitRhi(renderer, + [ + new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA }, + new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB }, + ], instanceCount: 2); + + GpuRecordedStorageBind bind = Assert.Single( + device.Calls.OfType(), + call => call.Binding == GpuBindingModel.StorageInstanceLightSets); + Assert.Equal((uint)(2 * LightManager.MaxLightsPerEnvCell * sizeof(int)), bind.SizeBytes); + int[] actual = MemoryMarshal.Cast(device.RingBytes.Slice( + (int)bind.OffsetBytes, + (int)bind.SizeBytes)).ToArray(); + int[] expected = Enumerable.Range(0, LightManager.MaxLightsPerEnvCell).ToArray(); + Assert.Equal(expected, actual.AsSpan(0, 47).ToArray()); + Assert.Equal(expected, actual.AsSpan(47, 47).ToArray()); + Assert.Equal(8, GpuBindingModel.MaxLightsPerObject); + Assert.Equal(47, GpuBindingModel.MaxLightsPerEnvCell); + + publication.Dispose(); + pass.Dispose(); + frameLifetime.EndFrame(); + + // A shorter next-generation snapshot must overwrite every binding-5 + // slot. No index from the prior 47-entry generation may survive. + renderer.SetPointSnapshot(lights.Take(2).ToArray()); + frameLifetime.BeginFrame(); + frame = frameLifetime.CurrentFrame!; + using IGpuPassEncoder nextPass = frame.BeginPass( + GpuPassDescription.BackbufferClear( + "envcell-47-index-binding-next-generation", + Vector4.Zero, + sampleCount: 1)); + using IDisposable nextPublication = scope.Publish(nextPass); + renderer.BeginFrame(frame.SlotIndex); + SeedLightingSubmission(renderer, instanceCount: 2); + device.Clear(); + InvokeSubmitRhi(renderer, + [ + new InstanceData { Transform = Matrix4x4.Identity, CellId = cellA }, + new InstanceData { Transform = Matrix4x4.Identity, CellId = cellB }, + ], instanceCount: 2); + + bind = Assert.Single( + device.Calls.OfType(), + call => call.Binding == GpuBindingModel.StorageInstanceLightSets); + actual = MemoryMarshal.Cast(device.RingBytes.Slice( + (int)bind.OffsetBytes, + (int)bind.SizeBytes)).ToArray(); + int[] expectedShort = [0, 1, .. Enumerable.Repeat(-1, 45)]; + Assert.Equal(expectedShort, actual.AsSpan(0, 47).ToArray()); + Assert.Equal(expectedShort, actual.AsSpan(47, 47).ToArray()); + } + [Theory] [InlineData(CullMode.Landblock)] [InlineData(CullMode.None)] diff --git a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs index b9da40c2..121a80fa 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldRenderFrameBuilderTests.cs @@ -263,11 +263,13 @@ public sealed class WorldRenderFrameBuilderTests var visibleLight = new LightSource { Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, CellId = visibleCell, }; var hiddenLight = new LightSource { Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, CellId = hiddenCell, }; lighting.Register(visibleLight); diff --git a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs index 03297459..1dd18f36 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockConcretePresentationPipelineTests.cs @@ -395,11 +395,13 @@ public sealed class LandblockConcretePresentationPipelineTests { OwnerId = staticEntity.Id, Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, }); fixture.Lighting.RegisterOwnedLight(new LightSource { OwnerId = liveEntity.Id, Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, }); fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); @@ -448,11 +450,13 @@ public sealed class LandblockConcretePresentationPipelineTests { OwnerId = staticEntity.Id, Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, }); fixture.Lighting.RegisterOwnedLight(new LightSource { OwnerId = liveEntity.Id, Kind = LightKind.Point, + RankingOrigin = Vector3.Zero, }); fixture.Translucency.StartPartFade(staticEntity.Id, 0u, 0f, 1f, 2f); fixture.Translucency.StartPartFade(liveEntity.Id, 0u, 0f, 1f, 2f); diff --git a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs index c76c8b86..789e3fe6 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightManagerTests.cs @@ -12,6 +12,7 @@ public sealed class LightManagerTests { Kind = LightKind.Point, WorldPosition = pos, + RankingOrigin = pos, Range = range, IsLit = lit, OwnerId = ownerId, @@ -23,6 +24,7 @@ public sealed class LightManagerTests { Kind = LightKind.Point, WorldPosition = pos, + RankingOrigin = pos, Range = range, IsLit = true, IsDynamic = true, @@ -53,6 +55,21 @@ public sealed class LightManagerTests Assert.Equal(1, mgr.RegisteredCount); } + [Fact] + public void Register_NondirectionalWithoutExplicitRankingOrigin_Throws() + { + var mgr = new LightManager(); + var light = new LightSource + { + Kind = LightKind.Point, + WorldPosition = new Vector3(3f, 4f, 5f), + }; + + ArgumentException error = Assert.Throws(() => mgr.Register(light)); + Assert.Contains("ranking origin", error.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, mgr.RegisteredCount); + } + [Fact] public void Tick_SelectsByDistance_Top8() { @@ -175,18 +192,148 @@ public sealed class LightManagerTests } [Fact] - public void BuildPointLightSnapshot_IndexStable_InBudget() + public void BuildPointLightSnapshot_UnderCap_SortsByRootDistance() { var mgr = new LightManager(); - // Registration order preserved when under MaxGlobalLights (no sort). mgr.Register(MakePoint(new Vector3(100, 0, 0), 5f)); // far mgr.Register(MakePoint(new Vector3(1, 0, 0), 5f)); // near mgr.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(2, mgr.PointSnapshot.Count); - Assert.Equal(100f, mgr.PointSnapshot[0].WorldPosition.X, 3); // index 0 = first registered - Assert.Equal(1f, mgr.PointSnapshot[1].WorldPosition.X, 3); + Assert.Equal(1f, mgr.PointSnapshot[0].WorldPosition.X, 3); + Assert.Equal(100f, mgr.PointSnapshot[1].WorldPosition.X, 3); + } + + [Fact] + public void BuildPointLightSnapshot_RanksRootBeforeAuthoredOffsetFinalPosition() + { + var manager = new LightManager(); + LightSource nearRootFarFinal = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 1); + nearRootFarFinal.RankingOrigin = new Vector3(1f, 0f, 0f); + LightSource farRootNearFinal = MakePoint(new Vector3(2f, 0f, 0f), 20f, ownerId: 2); + farRootNearFinal.RankingOrigin = new Vector3(50f, 0f, 0f); + manager.Register(farRootNearFinal); + manager.Register(nearRootFarFinal); + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(new[] { nearRootFarFinal, farRootNearFinal }, manager.PointSnapshot); + Assert.Equal(1f, manager.PointSnapshot[0].DistSq); + } + + [Fact] + public void BuildPointLightSnapshot_StrictForwardInsertion_PreservesEqualAndNaNOrder() + { + var manager = new LightManager(); + LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 1); + nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f); + LightSource equalA = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 2); + equalA.RankingOrigin = new Vector3(2f, 0f, 0f); + LightSource equalB = MakePoint(new Vector3(20f, 0f, 0f), 20f, ownerId: 3); + equalB.RankingOrigin = new Vector3(-2f, 0f, 0f); + manager.Register(nan); + manager.Register(equalA); + manager.Register(equalB); + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(new[] { nan, equalA, equalB }, manager.PointSnapshot); + } + + [Fact] + public void BuildPointLightSnapshot_StrictForwardInsertion_AdvancesPastNaN() + { + var manager = new LightManager(); + LightSource far = MakePoint(new Vector3(10f, 0f, 0f), 20f, ownerId: 1); + LightSource nan = MakePoint(Vector3.Zero, 20f, ownerId: 2); + nan.RankingOrigin = new Vector3(float.NaN, 0f, 0f); + LightSource near = MakePoint(Vector3.One, 20f, ownerId: 3); + manager.Register(far); + manager.Register(nan); + manager.Register(near); + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(new[] { near, far, nan }, manager.PointSnapshot); + Assert.Equal(new[] { 3f, 100f }, manager.PointSnapshot.Take(2).Select(light => light.DistSq)); + Assert.True(float.IsNaN(manager.PointSnapshot[2].DistSq)); + } + + [Fact] + public void BuildPointLightSnapshot_SpotUsesZeroRank_PointUsesRootDistance() + { + var manager = new LightManager(); + LightSource point = MakePoint(new Vector3(1f, 0f, 0f), 20f, ownerId: 1); + LightSource spot = MakePoint(new Vector3(100f, 0f, 0f), 20f, ownerId: 2); + spot.Kind = LightKind.Spot; + manager.Register(point); + manager.Register(spot); + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(new[] { spot, point }, manager.PointSnapshot); + Assert.Equal(0f, spot.DistSq); + } + + [Fact] + public void BuildPointLightSnapshot_IndependentSevenAndFortyProducts_DoNotCrossEvict() + { + var manager = new LightManager(); + var dynamics = new List(); + var statics = new List(); + for (int i = 0; i < 10; i++) + { + LightSource light = MakeDynamic(new Vector3(100f + i, 0f, 0f), 10f); + light.OwnerId = checked((uint)(100 + i)); + dynamics.Add(light); + manager.Register(light); + } + for (int i = 0; i < 50; i++) + { + LightSource light = MakePoint(new Vector3(i, 0f, 0f), 10f, checked((uint)(200 + i))); + statics.Add(light); + manager.Register(light); + } + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count); + Assert.Equal(dynamics.Take(7), manager.PointSnapshot.Take(7)); + Assert.Equal(statics.Take(40), manager.PointSnapshot.Skip(7)); + } + + [Fact] + public void BuildPointLightSnapshot_ViewerWinsEqualRootTieThoughRegisteredLast() + { + var manager = new LightManager(); + LightSource ordinary = MakeDynamic(new Vector3(0f, 0f, 2f), 15f); + ordinary.RankingOrigin = Vector3.Zero; + manager.Register(ordinary); + manager.UpdateViewerLight(Vector3.Zero); + + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Equal(2, manager.PointSnapshot.Count); + Assert.NotSame(ordinary, manager.PointSnapshot[0]); + Assert.Equal(new Vector3(0f, 0f, 2f), manager.PointSnapshot[0].WorldPosition); + Assert.Equal(Vector3.Zero, manager.PointSnapshot[0].RankingOrigin); + Assert.Same(ordinary, manager.PointSnapshot[1]); + } + + [Fact] + public void BuildPointLightSnapshot_ClearRemovesRetainedProducts() + { + var manager = new LightManager(); + manager.Register(MakePoint(Vector3.One, 5f)); + manager.BuildPointLightSnapshot(Vector3.Zero); + Assert.NotEmpty(manager.PointSnapshot); + + manager.Clear(); + Assert.Empty(manager.PointSnapshot); + manager.BuildPointLightSnapshot(Vector3.Zero); + + Assert.Empty(manager.PointSnapshot); } // ── Resident collection (#176 corrected reading, 2026-07-06) ─────────────── @@ -280,8 +427,6 @@ public sealed class LightManagerTests manager.BuildPointLightSnapshot(Vector3.Zero); Assert.Equal(expected, manager.PointSnapshot); - Assert.True(manager.LastPointSnapshotUsedTieFallback); - Assert.False(manager.LastPointSnapshotUsedBoundedSelection); manager.BuildPointLightSnapshot(Vector3.Zero); long before = GC.GetAllocatedBytesForCurrentThread(); @@ -369,8 +514,6 @@ public sealed class LightManagerTests Assert.Equal(LightManager.MaxGlobalLights, manager.PointSnapshot.Count); Assert.Equal(expected, manager.PointSnapshot); - Assert.True(manager.LastPointSnapshotUsedBoundedSelection); - Assert.False(manager.LastPointSnapshotUsedTieFallback); } [Fact] @@ -391,8 +534,6 @@ public sealed class LightManagerTests } manager.BuildPointLightSnapshot(Vector3.Zero); manager.BuildPointLightSnapshot(Vector3.Zero); - Assert.True(manager.LastPointSnapshotUsedBoundedSelection); - Assert.False(manager.LastPointSnapshotUsedTieFallback); long before = GC.GetAllocatedBytesForCurrentThread(); for (int iteration = 0; iteration < 100; iteration++) @@ -498,14 +639,14 @@ public sealed class LightManagerTests Assert.Equal(a[0], b[0]); } - // ── SelectForCell — retail minimize_envcell_lighting (all dynamics on every cell) ── + // ── SelectForCell — complete retained 7-dynamic + 40-static products ── [Fact] public void SelectForCell_AppliesAllDynamicLights_EvenOutOfReach() { - // Retail enables the WHOLE dynamic subset for every cell (cdb-verified: the same - // portal lights on every Facility Hub cell) — including ones that don't reach it, - // since the shader's range cutoff zeroes those. Static lights still cull by reach. + // Retail enables the whole dynamic subset and bakes the whole retained static + // product. The GPU path supplies both unchanged; per-vertex range cutoff decides + // which entries contribute. var snapshot = new[] { MakePoint(new Vector3(1, 0, 0), range: 5f), // 0: static, reaches @@ -513,8 +654,8 @@ public sealed class LightManagerTests MakeDynamic(new Vector3(2, 0, 0), range: 5f), // 2: dynamic, near MakePoint(new Vector3(50, 0, 0), range: 5f), // 3: static, far (out of reach) }; - Span sel = stackalloc int[LightManager.MaxLightsPerObject]; - int n = LightManager.SelectForCell(snapshot, Vector3.Zero, radius: 1f, sel); + Span sel = stackalloc int[LightManager.MaxLightsPerEnvCell]; + int n = LightManager.SelectForCell(snapshot, sel); bool d1 = false, d2 = false, s0 = false, s3 = false; for (int i = 0; i < n; i++) @@ -527,7 +668,7 @@ public sealed class LightManagerTests Assert.True(d1, "the FAR dynamic light must still be applied — retail enables all dynamics"); Assert.True(d2, "the near dynamic light is applied"); Assert.True(s0, "the near static light reaches the cell → selected"); - Assert.False(s3, "the far static light doesn't reach → not selected"); + Assert.True(s3, "the complete retained static product is supplied; the shader applies range"); } [Fact] @@ -541,15 +682,35 @@ public sealed class LightManagerTests MakeDynamic(new Vector3(0, 0, 0), range: 5f), MakeDynamic(new Vector3(100, 0, 0), range: 5f), }; - Span a = stackalloc int[8]; - Span b = stackalloc int[8]; - int na = LightManager.SelectForCell(snapshot, new Vector3(0, 0, 0), 1f, a); - int nb = LightManager.SelectForCell(snapshot, new Vector3(500, 0, 0), 1f, b); + Span a = stackalloc int[LightManager.MaxLightsPerEnvCell]; + Span b = stackalloc int[LightManager.MaxLightsPerEnvCell]; + int na = LightManager.SelectForCell(snapshot, a); + int nb = LightManager.SelectForCell(snapshot, b); Assert.Equal(2, na); // both dynamics on the near cell Assert.Equal(2, nb); // both dynamics on the far cell too — identical, no flap } + [Fact] + public void SelectForCell_CarriesAllSevenDynamicsAndFortyStatics_WhileObjectStaysEight() + { + var snapshot = new List(); + for (int i = 0; i < LightManager.MaxDynamicPointLights; i++) + snapshot.Add(MakeDynamic(new Vector3(i, 0f, 0f), 100f)); + for (int i = 0; i < LightManager.MaxStaticPointLights; i++) + snapshot.Add(MakePoint(new Vector3(i + 10f, 0f, 0f), 100f)); + + Span cell = stackalloc int[LightManager.MaxLightsPerEnvCell]; + int cellCount = LightManager.SelectForCell(snapshot, cell); + Span obj = stackalloc int[LightManager.MaxLightsPerEnvCell]; + int objectCount = LightManager.SelectForObject(snapshot, Vector3.Zero, 100f, obj); + + Assert.Equal(47, cellCount); + for (int index = 0; index < cellCount; index++) + Assert.Equal(index, cell[index]); + Assert.Equal(8, objectCount); + } + /// /// #176/#177 (2026-07-06, corrected same day) — the end-state pin. The pool is /// retail's RESIDENT collection anchored at the PLAYER: a light in range of an @@ -599,7 +760,7 @@ public sealed class LightManagerTests "an in-range light beside the player was evicted from the pool — " + "per-cell lighting would pop (the #176/#177 mechanism)"); Assert.True(torchSelected2, "consecutive same-player builds must select identically"); - Assert.Equal(LightManager.MaxGlobalLights, mgr.PointSnapshot.Count); // cap applied to the far cluster + Assert.Equal(LightManager.MaxStaticPointLights, mgr.PointSnapshot.Count); static bool SelectedContains( System.Collections.Generic.IReadOnlyList snapshot, @@ -623,22 +784,34 @@ public sealed class LightManagerTests continue; ranked.Add(new OracleRank( light, - Vector3.DistanceSquared(light.WorldPosition, player))); + light.Kind == LightKind.Point + ? Vector3.DistanceSquared(light.RankingOrigin, player) + : 0f)); } - if (ranked.Count <= LightManager.MaxGlobalLights) - return ranked.Select(static item => item.Light).ToArray(); + var dynamics = ranked.Where(static item => item.Light.IsDynamic).ToList(); + var statics = ranked.Where(static item => !item.Light.IsDynamic).ToList(); + StableRetailInsertion(dynamics, LightManager.MaxDynamicPointLights); + StableRetailInsertion(statics, LightManager.MaxStaticPointLights); + return dynamics.Concat(statics).Select(static item => item.Light).ToArray(); - ranked.Sort(static (left, right) => + static void StableRetailInsertion(List values, int cap) { - if (left.Light.IsDynamic != right.Light.IsDynamic) - return left.Light.IsDynamic ? -1 : 1; - return left.DistanceSq.CompareTo(right.DistanceSq); - }); - return ranked - .Take(LightManager.MaxGlobalLights) - .Select(static item => item.Light) - .ToArray(); + var selected = new List(cap); + foreach (OracleRank value in values) + { + int index = 0; + while (index < selected.Count && !(value.DistanceSq < selected[index].DistanceSq)) + index++; + if (index >= cap) + continue; + selected.Insert(index, value); + if (selected.Count > cap) + selected.RemoveAt(cap); + } + values.Clear(); + values.AddRange(selected); + } } private readonly record struct OracleRank( diff --git a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs index 9ca3d840..e9eecb25 100644 --- a/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/LightingHookSinkTests.cs @@ -14,9 +14,9 @@ public sealed class LightingHookSinkTests var mgr = new LightManager(); var sink = new LightingHookSink(mgr, new MutablePoseSource()); - var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true }; - var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true }; - var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true }; + var light1 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero }; + var light2 = new LightSource { Kind = LightKind.Point, OwnerId = 42, IsLit = true, RankingOrigin = Vector3.Zero }; + var other = new LightSource { Kind = LightKind.Point, OwnerId = 99, IsLit = true, RankingOrigin = Vector3.Zero }; sink.RegisterOwnedLight(light1); sink.RegisterOwnedLight(light2); sink.RegisterOwnedLight(other); @@ -35,8 +35,8 @@ public sealed class LightingHookSinkTests var mgr = new LightManager(); var sink = new LightingHookSink(mgr, new MutablePoseSource()); - sink.RegisterOwnedLight(new LightSource { OwnerId = 7 }); - sink.RegisterOwnedLight(new LightSource { OwnerId = 7 }); + sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero }); + sink.RegisterOwnedLight(new LightSource { OwnerId = 7, RankingOrigin = Vector3.Zero }); Assert.Equal(2, mgr.RegisteredCount); sink.UnregisterOwner(7); @@ -48,7 +48,7 @@ public sealed class LightingHookSinkTests { var mgr = new LightManager(); var sink = new LightingHookSink(mgr, new MutablePoseSource()); - var light = new LightSource { OwnerId = 1, IsLit = true }; + var light = new LightSource { OwnerId = 1, IsLit = true, RankingOrigin = Vector3.Zero }; sink.RegisterOwnedLight(light); // Should not crash or change state for non-SetLight hooks. @@ -71,6 +71,7 @@ public sealed class LightingHookSinkTests var light = new LightSource { OwnerId = 42u, + RankingOrigin = Vector3.Zero, LocalPose = Matrix4x4.CreateTranslation(1, 0, 2), TracksOwnerPose = true, }; @@ -81,6 +82,7 @@ public sealed class LightingHookSinkTests Assert.InRange(light.WorldPosition.X, 9.99f, 10.01f); Assert.InRange(light.WorldPosition.Y, 20.99f, 21.01f); Assert.InRange(light.WorldPosition.Z, 31.99f, 32.01f); + Assert.Equal(new Vector3(10f, 20f, 30f), light.RankingOrigin); Assert.Equal(0x01010002u, light.CellId); } @@ -95,6 +97,7 @@ public sealed class LightingHookSinkTests { OwnerId = 42u, WorldPosition = new Vector3(7, 8, 9), + RankingOrigin = new Vector3(7, 8, 9), LocalPose = Matrix4x4.CreateTranslation(1, 0, 0), TracksOwnerPose = false, }; @@ -114,7 +117,7 @@ public sealed class LightingHookSinkTests sink.InitializeOwnerLighting(7u, enabled: true); sink.SetOwnerLighting(7u, enabled: false); sink.UnregisterOwner(7u, forgetState: false); - var replacement = new LightSource { OwnerId = 7u }; + var replacement = new LightSource { OwnerId = 7u, RankingOrigin = Vector3.Zero }; sink.RegisterOwnedLight(replacement); @@ -160,6 +163,7 @@ public sealed class LightInfoLoaderTests Assert.Equal(10.4f, light.Range, 3); // Falloff 8 × static_light_factor 1.3 (calc_point_light 0x00820e24) Assert.Equal(0.8f, light.Intensity); Assert.Equal(new Vector3(101, 202, 303), light.WorldPosition); + Assert.Equal(new Vector3(100, 200, 300), light.RankingOrigin); Assert.Equal(new Vector3(1, 2, 3), light.LocalPose.Translation); Assert.InRange(light.ColorLinear.X, 0.99f, 1.01f); } diff --git a/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs index e79a2815..185e2c67 100644 --- a/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs +++ b/tests/AcDream.Core.Tests/Lighting/SceneLightingUboTests.cs @@ -30,6 +30,7 @@ public sealed class SceneLightingUboTests { Kind = LightKind.Point, WorldPosition = new Vector3(1, 2, 3), + RankingOrigin = new Vector3(1, 2, 3), ColorLinear = new Vector3(1f, 0.5f, 0.25f), Intensity = 0.8f, Range = 6f, @@ -84,6 +85,7 @@ public sealed class SceneLightingUboTests { Kind = LightKind.Point, WorldPosition = new Vector3(i, 0, 0), + RankingOrigin = new Vector3(i, 0, 0), Range = 200f, // all in range }); } diff --git a/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs index 0c2cd0ae..ccce7e58 100644 --- a/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Issue93TownNetworkFountainRoomLightInspectionTests.cs @@ -1,6 +1,9 @@ using System; using System.IO; +using System.Linq; +using System.Numerics; using AcDream.Content; +using AcDream.Core.Lighting; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Options; @@ -111,7 +114,7 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests _out.WriteLine($"=== Setup 0x{setupId:X8}: Parts={setup!.Parts.Count} PlacementFrames={setup.PlacementFrames.Count} Lights={setup.Lights.Count} ==="); foreach (var kvp in setup.Lights) - _out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle}"); + _out.WriteLine($" light[{kvp.Key}] Color=({kvp.Value.Color?.Red},{kvp.Value.Color?.Green},{kvp.Value.Color?.Blue}) Intensity={kvp.Value.Intensity} Falloff={kvp.Value.Falloff} ConeAngle={kvp.Value.ConeAngle} LocalOrigin=({kvp.Value.ViewSpaceLocation?.Origin.X:R},{kvp.Value.ViewSpaceLocation?.Origin.Y:R},{kvp.Value.ViewSpaceLocation?.Origin.Z:R})"); var flat = AcDream.Core.Meshing.SetupMesh.Flatten(setup); _out.WriteLine($" SetupMesh.Flatten -> {flat.Count} MeshRefs"); @@ -139,6 +142,68 @@ public class Issue93TownNetworkFountainRoomLightInspectionTests $"=> GameWindow.cs:7324 would {(survivors == 0 ? "DROP" : "KEEP")} this entity"); } + [Fact] + public void TownCeilingFixture_AuthoredTypeKeyAndRootRankOrigin_ArePinnedFromInstalledDat() + { + var datDir = ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint cellId = 0x00070144u; + const uint setupId = 0x02000365u; + EnvCell cell = Assert.IsType(dats.Get(cellId)); + var fixture = Assert.Single(cell.StaticObjects, entry => entry.Id == setupId); + Setup setup = Assert.IsType(dats.Get(setupId)); + var authored = Assert.Single(setup.Lights); + + // The dictionary key is native LIGHTINFO.type. The current loader's + // cone-derived Point/Spot projection deliberately remains unchanged. + Assert.Equal(0, authored.Key); + Assert.Equal(0xCDCDCDCDu, BitConverter.SingleToUInt32Bits(authored.Value.ConeAngle)); + Assert.Equal(0.000759337f, authored.Value.ViewSpaceLocation.Origin.X); + Assert.Equal(0.00675148f, authored.Value.ViewSpaceLocation.Origin.Y); + Assert.Equal(0.0277f, authored.Value.ViewSpaceLocation.Origin.Z); + + Vector3 root = new( + fixture.Frame.Origin.X, + fixture.Frame.Origin.Y, + fixture.Frame.Origin.Z); + Quaternion rotation = new( + fixture.Frame.Orientation.X, + fixture.Frame.Orientation.Y, + fixture.Frame.Orientation.Z, + fixture.Frame.Orientation.W); + Assert.Equal(new Vector3(69.875f, -69.916f, 5.005f), root); + Assert.Equal(new Quaternion(0f, 0f, -0.94372f, 0.330745f), rotation); + LightSource light = Assert.Single(LightInfoLoader.Load( + setup, + ownerId: 0x4000712Fu, + entityPosition: root, + entityRotation: rotation, + isDynamic: false, + cellId: cellId)); + + Assert.True(light.HasRankingOrigin); + Assert.Equal(root, light.RankingOrigin); + Assert.Equal(LightKind.Point, light.Kind); + Assert.NotEqual(root, light.WorldPosition); + var localFrame = authored.Value.ViewSpaceLocation; + var localOffset = new Vector3( + localFrame.Origin.X, + localFrame.Origin.Y, + localFrame.Origin.Z); + var localRotation = new Quaternion( + localFrame.Orientation.X, + localFrame.Orientation.Y, + localFrame.Orientation.Z, + localFrame.Orientation.W); + Matrix4x4 expectedWorld = (Matrix4x4.CreateFromQuaternion(localRotation) + * Matrix4x4.CreateTranslation(localOffset)) + * (Matrix4x4.CreateFromQuaternion(rotation) * Matrix4x4.CreateTranslation(root)); + Assert.Equal(expectedWorld.Translation, light.WorldPosition); + } + /// /// Follow-up (same session, 2026-07-09): user confirmed lighting improved but /// reported missing candle flames + fountain water particles. Hypothesis tested: