diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 00904958..70ac1377 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -190,7 +190,7 @@ readiness/requeue adaptation. See | AD-75 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** Urgent Assistance (`0x10000206`) and Report Abuse (`0x10000207`) never call `ShellExecuteA` against `http://support.turbine.com/ics/support/ticketnewwizard.asp?style=classic` — the endpoint is dead in 2026. Each button instead ALWAYS emits its own byte-verified retail failure body (the `ShellExecuteA`-failure `MessageBoxA` text, `(Error code %d)` dropped since no real Win32 error ever occurs, the URL kept verbatim) through the interface-text seam (`RetailLogTextType.ClientLocal`) instead of a native `MessageBoxA` popup. | `src/AcDream.Core/Chat/OptionsPanelText.cs` (`UrgentAssistanceUnavailable`/`ReportAbuseUnavailable`); `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring) | The URL genuinely does not resolve to a live Turbine support endpoint; attempting `ShellExecuteA` would open a browser to a dead page rather than usefully fail. The retained failure TEXT is retail's own (byte-verified), just always shown instead of conditionally on a real launch failure, and routed to acdream's existing interface-text channel rather than a modal OS dialog (retail's own EoR-era mechanism has no acdream analogue for a one-off native `MessageBoxA`). | If Turbine ever revives the endpoint, both buttons would still short-circuit instead of opening it — a silent staleness, not a crash. | `gmGameplayOptionsUI::ListenToElementMessage @0x0049E110`; `ShellExecuteA` call sites `0x0049E154`/`0x0049E1F0`; research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.1/§4.2 | | AD-76 | **Filed 2026-08-11 at Campaign OP slice OP3 (D5).** In-Game Help Files (`0x10000205`) is authored and clickable but has no handler — clicking it does nothing visible. | `src/AcDream.App/UI/Layout/OptionsPanelController.cs` (button wiring — no callback bound) | Retail's own `KeyStone::OpenHelp` loads a third-party embedded help viewer (`plugins\ACHelpPlugin.dll` via `keystone.dll`) that acdream does not have and cannot port (no DAT-resident help content, no source). Retail ITSELF fails silently with the plugin absent (`KeyStone::m_fnAC2HelpPluginExecute` unresolved) — mirroring that as an inert button is the faithful behavior for "the asset is missing", not an invented stub screen. | A user clicking In-Game Help Files gets no feedback at all, same as retail with the plugin missing — indistinguishable from a dead button unless they already expect the asset-missing case. | `KeyStone::OpenHelp @0x00557010`; `KeyStone::Init @0x00556CF0` (the unresolved plugin function pointer); research doc `2026-08-10-keyboard-config-and-gameplay-tab.md` §4.5 | | AD-77 | **Filed 2026-08-11 at the Campaign OP OP3 review-fix round (dual-review S4/MUST-FIX 2 — the plan's §5 "out of scope" list explicitly delegated this ruling to the OP3 review).** Retail exposes TWO `gmPanelUI` host variants for the same panel stack — a floating host (`0x2100006E`, `gmFloatyPanelUI`) and a docked host (`0x21000017`) — so a retail user can dock the Options panel (and every other `gmPanelUI` sibling) into a fixed screen position instead of leaving it freely floating. acdream mounts every main panel through `RetailWindowFrame.Mount` + `RetailPanelUiController.RegisterMainPanel` against the floating host ONLY; no code path resolves or mounts `0x21000017` at all. | `src/AcDream.App/UI/RetailUiRuntime.cs` (every `Mount*`/`RegisterMainPanel` call site for a `gmPanelUI` sibling — Character/Inventory/Spellbook/Effects/the four indicator-detail panels/Options); `src/AcDream.App/UI/Layout/RetailWindowFrame.cs` | This predates OP3 — every `gmPanelUI` sibling has shipped floating-only since its own slice landed; OP3 did not introduce the gap, it just added a tenth panel to an already-floating-only cohort. The plan explicitly scoped filing the row to "whichever slice's review deems it a divergence" rather than blocking any one panel's slice on building a docked-host variant no prior panel has either. | A user who expects to dock the Options panel (or any other main panel) the way retail allows cannot — every `gmPanelUI` sibling is floating-only in acdream, client-wide, not an Options-specific gap. | research doc `2026-08-10-options-panel-structure.md` §10.1 (docked/floating host pair); `docs/plans/2026-08-10-options-panel-campaign.md` §5 | -| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[#226 addendum, 2026-08-21: Building Detail Textures gained a live renderer consumer and is no longer dimmed; Config is now 20 of 27 dimmed.]** **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (20 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | +| AD-78 | **Filed 2026-08-11, user-directed (verbatim: "mark all options that are not implemented now, so I can clearly see what is not implemented"), gate 2 of Campaign OP's follow-up.** Retail dims nothing on any Options-panel row or Configure-Keyboard action row — every retail row drives its own real consumer by construction, so retail has no "does this actually do anything" ambiguity to signal. acdream, by contrast, ships a large honest store-only set (AP-198/AP-199/AP-200/AP-203, TS-73/TS-74/TS-75/TS-76/TS-77/TS-78/TS-79/TS-80, and the Character-tab Group A/D rows) that persist and, where auto-save, send the wire bit, but drive nothing observable client-side. Per explicit user direction, every such row's CAPTION now renders in a shared neutral grey (`UiRenderContext.StoreOnlyCaptionColor`, `(0.5,0.5,0.5,1)` — the SAME value the existing disabled/ghosted convention already used, `UiMenu.TextColorGhosted`) instead of its normal white/DAT-authored color, while the row itself stays fully interactive (click/drag/persist exactly as before — only the caption's paint color changes). No invented marker text is added anywhere (the project's "no user-visible strings outside the DAT" rule stands); the dim IS the marker. **[#226 addendum, 2026-08-21: Building Detail Textures gained a live renderer consumer and is no longer dimmed; Config is now 20 of 27 dimmed.]** **[Campaign OVERHAUL v2 S5-c3 addendum, 2026-09-04: Automatic Degrades, Graphics Performance, and Degrade Distance now drive the renderer-lifetime building degrade owner and are no longer dimmed; Config is now 16 of 27 dimmed.]** **[FA4 fix-round addendum, 2026-08-12 — blast SHOULD-FIX 1 + mechanism SF-8/SF-9: this row's own count had drifted stale THROUGH two campaigns (FA4's D7 un-dim landed 31, but this row still read the pre-FA4 "35"; the fix round then reverted three of FA4's four un-dims — see below — landing at 34). The Character-tab count is now 34 of 50 dimmed / 16 live.]** | `src/AcDream.App/UI/UiRenderContext.cs` (`StoreOnlyCaptionColor`, the one shared constant); `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (16 of 27 rows dimmed — `ApplyLabelAndTooltip`/`SetLabelText`'s `storeOnly` parameter, threaded from each `BindXxxSection` call site); `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs` (**34 of 50 rows dimmed** — `RowSpec.StoreOnly`, derived per-row in the class doc's table, cross-checked against actual shipped consumers rather than the research doc alone. FA4 D7 originally un-dimmed 4 rows — `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests`/`FellowshipShareXP`/`FellowshipShareLoot` — landing at 31. The FA4 FIX ROUND, 2026-08-12, reverted THREE of those four back to dimmed: `IgnoreFellowshipRequests`/`FellowshipAutoAcceptRequests` per the corrected plan D6 (retail's client reads neither option bit on the fellowship-invite path — both are pure server-side filters with no client consumer, exactly like the two allegiance bits that were always meant to parallel them; the client-side auto-respond interceptor that was their claimed consumer, `RetailUiRuntime.TryAutoRespondToFellowshipInvite`, is deleted outright), and `FellowshipShareLoot` per mechanism review SF-8 (its claimed "second checkbox surface" consumer never actually reads the stored value back — a second EDITOR of a value is not a CONSUMER of it). Only `FellowshipShareXP` survives as genuinely live (the fellowship Create flow reads it as the sent `shareXP` bit) — net ONE row un-dimmed from the pre-FA4 baseline, not four.); `src/AcDream.App/UI/Layout/KeyboardConfigController.cs` (`BuildActionRow` dims a row when `RetailActionIdentityTable.TryResolve` fails, i.e. `MappedAction` is null — AP-203's set); `src/AcDream.App/UI/Layout/ChatOptionsPageController.cs` (audited, zero dimmed rows — every row already has a live consumer). | Explicit, unambiguous user direction (this session, gate 2) overriding the earlier per-slice register rows' silence on presentation; the four controllers' own conformance tests (`ConfigOptionsPageControllerTests.CaptionDimming_MatchesTheStoreOnlySetExactly`, `CharacterOptionsPageControllerTests.StoreOnlyRows_MatchTheDerivationTableExactly` + `Bind_AppliesDimmedCaptionColor_ForStoreOnlyRows_AndWhiteForLiveRows`, `KeyboardConfigControllerTests.UnmappedRows_DimTheirCaption_MappedRowsStayWhite`) pin the exact dimmed set so a future consumer landing without also flipping its row's literal fails the build, not just the eye. | A reviewer comparing a byte-exact retail screenshot to acdream will see caption colors retail never has — this row exists precisely so that divergence is understood as intentional, not a bug. If a row's dim/live classification in the four cited tables ever drifts from its ACTUAL consumer state (a landed consumer whose row was never un-dimmed, or a regressed consumer whose row was never re-dimmed), the caption becomes misleading in the OPPOSITE direction it was built to prevent — treat any report of "this dimmed row visibly does something" or "this live-looking row does nothing" as a real defect, not a rendering nit (see the gate script's own note). **The FA4 fix round is itself an instance of this exact risk materializing** — the register row lagged two code-side count changes across one campaign before this addendum caught up. This row retires only when acdream reaches full retail parity (zero store-only rows remaining), at which point the convention itself — not just its content — should be deleted. | None (acdream-only divergence; retail has no store-only rows to compare against) — `docs/research/2026-08-10-character-options-map.md` §7.1 (Group A/B/C/D split); `docs/research/2026-08-11-campaign-op-test-script.md` (per-tab store-only enumerations this row's dimmed set matches) | | AD-79 | **MOSTLY RETIRED 2026-08-13 (user-ordered social completion batch):** Friends Add/Remove/Appear-Offline and Squelch add-character/add-account/remove are LIVE (the wire beneath had existed end-to-end since J4.1/FA1 — docs/research/2026-08-13-social-wire-completion.md §4; the panel now publishes the same Runtime commands). REMAINING scope: the Friends "Send Tell" button (`0x10000516`) only, which needs the chat-tell seam. **Original filing — 2026-08-12 at Campaign FA slice FA3, D1 (the plan's "Friends + Squelch pages bind READ-ONLY... their mutation actions are wired only if their wire is already served by ACE and trivially pinnable in-slice — otherwise the action buttons are honest INERT" decision).** The social panel's Friends page authors three buttons (Add/Remove Friend-shaped, `0x10000514`/`0x10000515`/`0x10000516`) plus an "Appear Offline"-shaped checkbox (`0x1000052C`); the Squelch page authors three buttons (`0x10000547`/`0x1000054B`/`0x1000054C`). All seven are built, laid out, and clickable exactly as authored, but carry no click handler — no Friends add/remove/appear-offline wire and no Squelch add/remove/clear wire is implemented this campaign. `gmFriendsUI`/`gmSquelchUI` were also outside lane A/B/C/D's own decompiled scope (only Fellowship/Allegiance were researched), so their real button semantics and wire opcodes are not yet established either — this row covers BOTH "not wired" and "not yet researched." | `src/AcDream.App/UI/Layout/SocialFriendsPageController.cs`; `src/AcDream.App/UI/Layout/SocialSquelchPageController.cs` (both classes' own doc comments cite this row) | FA3 is the panel SHELL slice; D1 sets the bar for which Friends/Squelch actions get wired in-slice at "trivially pinnable," which none of these seven meet without their own wire research. `SocialPanelControllerTests.FriendsAndSquelchActionButtons_AreClickable_ButHaveNoHandler` pins the INERT contract so a future consumer landing without also removing this row's citation fails nothing silently — the row is the only signal until a follow-up slice wires real handlers. | A user clicking Add/Remove Friend, Appear Offline, or any Squelch button in acdream sees no effect and no feedback — indistinguishable from a dead control unless they already expect the gap. The Friends/Squelch LISTS themselves are live (bound read-only to `RuntimeCommunicationState.Friends`/`.Squelch`) — only the mutation controls are inert. | None (no retail decomp anchor — `gmFriendsUI`/`gmSquelchUI` are outside this campaign's researched scope); `docs/research/2026-08-11-fa-panel-structure.md` §10 (coordinator addendum, the panel discovery that first surfaced these two pages); `docs/plans/2026-08-11-fellowship-allegiance-campaign.md` D1 | | AD-80 | **Filed 2026-08-12 at Campaign FA slice FA4, D5.** The fellowship page's per-fellow percentage text renders retail's own byte-decoded XP-share table verbatim (1.0/.75/.6/.55/.5/.45/.4/.35/.3111111/.28, default 0.0 — `docs/research/2026-08-11-fa-fellowship-wire.md` §7.2, byte-decoded from the PDB-paired binary because both available decompilers folded the function to a constant). The currently-targeted ACE server computes the ACTUAL distributed XP from a DIFFERENT table (`.3` at 9 fellows instead of `.3111111`, no explicit 10-fellow row, and a wrong out-of-range default of `1.0` instead of `0.0` — `Fellowship.cs:604-632`, lane B §4.3). So a full (9-member) or over-full-in-retail's-table (10-member) fellowship's displayed percentage will not exactly match the XP ACE actually grants. This is a divergence between ACE and RETAIL, not between acdream and retail — acdream's client-side display is retail-faithful — but it is filed here because it is directly user-visible through this panel and a tester comparing "panel says 31.1%" against "server granted 30%" is measuring ACE's bug, not acdream's port. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`EvenSplitPercentTable`, `FormatStatsText`) | The client-side table is byte-verified against the retail binary; re-deriving it to match ACE's (wrong) numbers would make acdream disagree with a REAL retail client observing the same fellowship, which is the opposite of this project's goal. | A tester with a 9- or 10-member fellowship on ACE sees a panel percentage that does not exactly match the XP bonus they actually receive; below 9 members the two agree exactly. The proportional (non-even-split) branch has a SEPARATE, narrower gap: acdream has not ported an `ExperienceToRaiseLevel`-equivalent table, so that branch omits the percentage entirely (level only) rather than computing a wrong number — see AD-81's citation of the same method. | `FellowshipSystem::GetEvenSplitXPPctg @0x005B9BA0` (lane B §7.2); ACE `Fellowship.cs:604-632`; `docs/research/2026-08-11-fa-fellowship-wire.md` §4.3 | | AD-81 | **Filed 2026-08-12 at Campaign FA slice FA4.** Two retail text-composition primitives the fellowship page's mechanism needs are not ported, so this controller renders their CONTENT as plain numeric composites instead of retail's exact resolved sentence, never invented English: (1) **`StringInfo` variable substitution** — every row field beyond the bare name is a retail `StringInfo` template with embedded variables (`ID_Fellowship_FellowStats` + `ID_Level`/`ID_Experience`; the three `…Status` fields + `ID_Cur`/`ID_Max` — `docs/research/2026-08-11-fa-panel-structure.md` §3.1/§4.1), resolved at runtime through `StringInfo::InqString` → `StringTableMetaLanguage::UnescapeString`, a cross-cutting UI-string engine acdream has never ported (the SAME gap the pre-Campaign-OP Character window recorded, `docs/research/2026-06-25-character-window-faithful-spec.md`: "NOT yet ported — current controller uses canonical AC labels"); this controller instead renders `"{level} {pct}%"` and `"{cur}/{max}"` — the retail-authored NUMBERS, without retail's surrounding words. **AMENDED 2026-08-13:** the no-metalanguage fragment/variable interleave of `StringTable::GetString @0x004300D0` IS now ported as `DatStringResolver.ResolveTemplate` (the AD-85 dialog narrowing), so VERIFIED-token-free templates can resolve exactly; this row's remaining scope is the meta-token engine (`StringTableMetaLanguage::RenderString @0x004302B1` + `StripMetaLetters`) the multi-variable stats templates may need, plus `FormatName`. (2) **`ACCharGenData::FormatName`** — retail's Create flow canonicalizes the typed fellowship name and writes the formatted text back into the entry box before sending (lane B §2.2/§6.2); acdream sends the raw typed text verbatim. Neither gap affects the WIRE — the `0x00A2` builder's `str16L` field is unaffected either way; only the client-side PRESENTATION differs. | `src/AcDream.App/UI/Layout/SocialFellowshipPageController.cs` (`UpdateRow`, `FormatStatsText`, `SetVitals`, the create-button `OnClick`) | Porting `StringTableMetaLanguage` is a cross-cutting UI-string-engine prerequisite, not a fellowship-specific task, and guessing its token syntax without decoding `StringInfo::InqString` would risk silently-wrong substitution rather than an honestly-numeric fallback — exactly the guessing CLAUDE.md's workflow forbids. `FormatName`'s capitalization/character rules are a separate chargen algorithm with no fellowship-specific anchor read yet. | A user sees "12 31%" / "140/140" instead of retail's full sentence, and a typed fellowship name keeps whatever casing/spacing the player typed instead of retail's canonicalized form. The underlying DATA (level, percentage, cur/max, the name itself) is correct in every case — only the surrounding words/formatting are absent. | `StringInfo::InqString @0x0042e490` → `StringTableMetaLanguage::UnescapeString` (unresolved — not yet decoded); `gmFellowshipUI::CreateFellowship @0x0048F730` (the `ACCharGenData::FormatName` call, lane B §2.2); `docs/research/2026-06-25-character-window-faithful-spec.md` (the identical prior finding for the Character window) | @@ -282,7 +282,7 @@ research and is no longer active. | AP-193 | Character option id `0x34` (`ListenToPKDeathMessages` / "Listen to PK death messages") is mapped to `CharacterOptions2` bit `0x02000000` and modeled as a batched (non-auto-save) option purely on ACE's own enum — the id does not exist in the 2013 EoR PDB (`PlayerOption` there terminates at `TotalNumberOfPlayerOptions_PlayerOption = 0x34`), so neither the mask nor its `IsAutoSaveOption`/`GetDefaultOptionValue` classification is byte-verifiable against our binary. | `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` (`HearPkDeathMessages` row) | The user's retail memory (and ACE's own `CharacterOption` enum) both carry this option; shipping wire+store coverage for it is strictly better than omitting the row the Character tab's screenshots show, and ACE never actually reads the bit server-side (`PlayerFactory.cs:659-660` — "possibly was added to Defaults post PDB we have"), so a wrong id/mask/auto-save guess here has zero server-observable consequence either way. | If the final EoR client's real id/mask/auto-save classification ever surfaces (a later PDB, or a byte-level trace against a 2015+ binary), this row's values may be wrong and need correcting — until then treat them as ACE-sourced, not retail-verified. | ACE `PlayerFactory.cs:659-660`, `CharacterOptions2.cs` (`ListenToPKDeathMessages = 0x02000000`); `named-retail/acclient.h:4162-4218` (2013 `PlayerOption` terminates at `0x34`); `docs/research/2026-08-10-set-character-options-wire.md` §8.1 | | ~~AP-196~~ | **RETIRED 2026-08-11 at Campaign OP slice OP9.** Filed at the OP4 review-fix round (MUST-FIX 3/blast M2) recording that OP4's Group-C re-point deleted only three of the eight re-pointed `GameplaySettings` fields (`AutoTarget`/`AutoRepeatAttack`/`ViewCombatTarget`), leaving `VividTargetingIndicator`/`CoordinatesOnRadar`/`LockUI`/`AcceptLootPermits`/`ToggleRun` behind as WRITE-BEHIND `settings.json` persistence/draft mirrors of the now-authoritative server bit (plus a "two writable copies" default-source change, ADDENDUM historical only). OP9 verified all remaining `GameplaySettings` members — those five plus `ShowTooltips`/`SideBySideVitals`/`SpellDuration`/`AllowGive`/`ShowHelm`/`ShowCloak`/`AdvancedCombatUI`/`UseMouseTurning`, 13 total — already had a live server-bit home in `RuntimeCharacterOptionsState` (11 as OP4 Character-tab rows through `CharacterOptionTable`/`CharacterOptionsPageController`; `LockUI` through `/lockui` + the PlayerDescription `SetUiLocked` convergence, deliberately not a Character-tab row; `UseMouseTurning` through the Gameplay-tab mouse-macro button + the Config tab's Use-Mouse-Turning row — OP9 review NIT 6's channel-attribution correction) and deleted the `GameplaySettings` record outright — the type, the `SettingsStore.LoadGameplay`/`SaveGameplay` plumbing, and `RuntimeSettingsController`'s `Gameplay` property/`SetAcceptLootPermits` write-behind method — closing the "two writable copies" gap for good: there is no longer a second store to diverge from server truth. | `src/AcDream.App/Settings/RuntimeSettingsController.cs`; `src/AcDream.App/UI/Layout/CharacterOptionsPageController.cs`; `src/AcDream.Runtime/Gameplay/CharacterOptionTable.cs` | — | — | `docs/research/2026-08-10-character-options-map.md` §7.1/§7.2 (Group C re-point directive); `CharacterOptionTable.cs` | | AP-197 | **Filed 2026-08-11 at the OP4 review-fix round (SF-1/S4).** "Display Timestamps" hardcodes retail's `PlayerModule` constructor-default format string `"%#H:%M:%S "` rather than reading the PER-CHARACTER override `GenericQualitiesData::InqString(m_pPlayerOptionsData, 1, &m_TimeStampFormat)` carries when the wire's `GenericQualitiesData` string-key `1` is populated — acdream's `PlayerDescription` parser reads and discards that field (wire research doc: "timestamp string (`0x80`) \| read, discarded \| ❌ \| never sent"). | `src/AcDream.Core/Chat/ChatLog.cs` (`FormatTimestampPrefix`); parser site cited at `docs/research/2026-08-10-set-character-options-wire.md:647` | The 2013 client's own constructor default is the only format any fresh/default character would ever show — retail ships no options-panel control that authors a custom one — so hardcoding the one value every real player sees is a safe, honest approximation until a consumer needs the per-character override. | A character whose account somehow carries a non-default persisted timestamp format (a modded/legacy server, or a hypothetical later retail patch exposing a UI for it) sees acdream render the DEFAULT format instead of their stored one — cosmetic only (still a valid H:MM:SS-shaped timestamp), never a wire or data-loss risk. | `PlayerModule::PlayerModule @0x005D51F0` (ctor default literal); `GenericQualitiesData::InqString` call site (wire doc §3.3); `docs/research/2026-08-10-set-character-options-wire.md` U6 | -| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author nine rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, `Render_DegradeDistance`, `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, `Render_LandscapeDrawDistance`, `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail building/EnvCell detail replay and is no longer caption-dimmed as store-only. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature texture-detail/degrade-distance knob for the nine residual rows to drive. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. **Sub-note, `Render_LandscapeDrawDistance` specifically:** its retail default (`gmConfigUI::InitOptions @0x0049E70D`, `SetDefaultValue(8)`) does not index its own 6-entry `UIPreferences::SetEnumChoices` array (`ID_Graphics_Value_VeryLow`..`Extreme`, `gmClient::InitUIPreferences @0x004041b7`) — reproduced faithfully as an opaque `int` (`DisplaySettings.LandscapeDrawDistance`), not guessed into a clamped index; the Config-tab menu simply shows no highlighted selection at the default. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`) | Building the nine residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these nine residual Config-tab controls sees no visual change and, for `LandscapeDrawDistance` specifically, may see no highlighted menu item even after Defaults — both are the CONTRACTED behaviour for this row, not a bug. Building Detail Textures is explicitly outside this residual and must visibly change eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md` | +| AP-198 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review N1/S2); NARROWED 2026-08-21 by #226; NARROWED 2026-09-04 at Campaign OVERHAUL v2 S5-c3; CORRECTED 2026-09-04 at S5-c3 fix round 1.** The Config tab's "Graphics Options" + "Rendering Quality Options" sections author five rows that still have no acdream renderer consumer: `Render_ScreenBrightness` (its OWN `DisplaySettings.ScreenBrightness` field, range [-1,1] default 0 — NOT the pre-existing `Gamma` multiplier, which has a different unit system and its own live legacy Settings-panel consumer; no gamma-correction pass exists for either), `Render_LandscapeTextureDetail`, `Render_EnvironmentTextureDetail`, `Render_TextureFiltering`, and `Render_MultiPassAlpha`. #226 removed `Render_BuildingDetailTextures` from this row: the existing checkbox now directly gates the retail building/EnvCell detail replay and is no longer caption-dimmed as store-only. S5-c3 removes `Render_AutomaticDegrades`, `Render_GraphicsPerformance`, and `Render_DegradeDistance`: they now feed the single renderer-lifetime retail building-degrade controller and visibly change selected building shells. `Render_LandscapeDrawDistance` was already live before S5-c3 through `RuntimeSettingsController.ApplyLandscapeDrawDistance`, which writes the aggregate quality setting's far streaming radius and reapplies the live streaming window. acdream's remaining world-quality controls are Vulkan driven by ONE aggregate `QualitySettings`/`QualityPreset` (near/far streaming radii, anisotropic level, alpha-to-coverage, completion budget) — there is no per-feature consumer for the five residual rows. Each residual round-trips faithfully through `DisplaySettings`/`SettingsStore` and shows retail's own row/label/range (where applicable), with zero observable render effect. | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindGraphicsSection`/`BindRenderingQualitySection`); `src/AcDream.App/Rendering/BuildingDegradeController.cs`; `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`ApplyLandscapeDrawDistance`) | Building the five residual dead per-feature render knobs into a Vulkan renderer that has no analogous controls would be pure UI theater with no correctness payoff; persisting them faithfully keeps the panel honest while the register makes the "no effect" fact auditable. | A user who changes any of these five residual Config-tab controls sees no visual change. Landscape Draw Distance, Building Detail Textures, and the three S5-c3 degrade controls are explicitly outside this residual and must visibly affect their respective streaming or eligible building/EnvCell surfaces. | `gmConfigUI::InitOptions @0x0049E400`; `gmClient::InitUIPreferences @0x004035b0` (`UIPreferences::AttachPreference`/`SetEnumChoices` calls); `src/AcDream.App/Settings/RuntimeSettingsController.cs` (`QualitySettings`/`ReapplyQualityPreset`/`ApplyLandscapeDrawDistance`); `docs/research/2026-08-21-retail-building-detail-texturing-pseudocode.md`; Campaign OVERHAUL v2 S5-c3 | | AP-199 | **Filed 2026-08-11 at Campaign OP slice OP6; CORRECTED at the OP6 rework round (2026-08-11, review M2) — the field names and the "gating to zero when disabled" wording were describing an INVERTED, muted-by-default bug, not the shipped behaviour.** The Config tab's "Sound Options" section authors three rows with no acdream consumer: `Sound_SoundFeatures` (Stereo/Mono menu — acdream's OpenAL backend has no channel-count toggle), the Interface Sound toggle+slider trio (`Sound_InterfaceSoundDisabled`/`Sound_InterfaceSoundVolume` — AP-174 already documents this as retail's OWN dead knob, "registered and then never read... interface sounds are scaled by the EFFECT knob"; acdream matches that exact behaviour rather than building a working Interface bus), and `Sound_PlaySoundOnlyWhenActive` (no window-focus-based audio mute subsystem exists). All three round-trip faithfully through the new `AudioSettings.SoundFeatures`/`InterfaceEnabled`/`InterfaceVolume`/`PlaySoundOnlyWhenActive` fields. The Sound and Ambient trios' own toggle+slider pairs are NOT covered by this row — `SfxEnabled`/`AmbientEnabled`/`Sfx`/`Ambient` are LIVE (`RuntimeSettingsController.SaveAudio` now pushes into `OpenAlAudioEngine` on every change; the effective volume is zero only when the corresponding `*Enabled` flag is false — retail's own `SoundManager::effect_sounds_enabled`/`ambient_sounds_enabled` statics default to enabled, so a fresh profile is audible, not muted). | `src/AcDream.UI.Abstractions/Panels/Settings/AudioSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindSoundSection`) | Matches the SAME reasoning AP-174 already established for the Interface knob specifically; Sound Features and Play-Only-When-Active are honest new store-only rows with no existing or planned acdream subsystem to bind (stereo/mono output selection and window-focus audio gating are both out of this campaign's scope). | A user who changes any of these three Config-tab controls sees/hears no change — the CONTRACTED behaviour, matching retail's own Interface-knob precedent for two of the three. | `gmClient::InitUIPreferences @0x004035b0` (`AttachPreference(&Sound_SoundFeatures, ...)`/`&Sound_InterfaceSoundDisabled`/`&Sound_InterfaceSoundVolume`/`&Sound_PlaySoundOnlyWhenActive`); AP-174 (Interface-knob precedent); `SoundManager::InitPrefs @0x005503F0` (`UserPreferences::RegisterPreference` binding the enabled-sense statics) | | AP-200 | **Filed 2026-08-11 at Campaign OP slice OP6.** The Config tab's "UI Options" section authors `UI_ChatFontFace`/`UI_ChatFontSize` menu rows (retail Windows TrueType face name / a Tiny-Small-Medium-Large-XLarge size-tier enum). These are DELIBERATELY separate NEW fields (`ChatSettings.ChatFontFace`/`ChatFontSizeIndex`) rather than reusing the existing LIVE `ChatSettings.FontSize` (a 10..20pt float acdream's chat panel already renders with) — there is no verified index-to-point mapping from retail's five-tier enum to that float range, and acdream's text rendering has no arbitrary system-font-face swap capability (DAT-baked/bitmap fonts only, not OS TrueType files). Store-only round-trip; `FontSize` is untouched by these two rows. | `src/AcDream.UI.Abstractions/Panels/Settings/ChatSettings.cs`; `src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs` (`BindUiSection`) | Inventing a size-index-to-point mapping without retail evidence would risk silently overwriting `FontSize`'s own already-live, user-visible behaviour with a guessed value; keeping the two concepts separate is the honest choice until a byte-verified mapping (or a font-face-swap capability) exists. | A user who changes either Config-tab font control sees no chat-panel rendering change; the SEPARATE, pre-existing font-size control (wherever acdream currently exposes `ChatSettings.FontSize`) remains the only live one. | `gmClient::InitUIPreferences @0x0040387b`/`@0x00403a1a` (`AttachPreference(&UI_ChatFontFace, ...)`/`&UI_ChatFontSize`, `SetEnumChoices` choice arrays "Arial"/"Tiny".."XLarge") | | AP-172 | **Filed 2026-08-08 (#354 fix — spell-bar drag reorder).** Retail removes a lifted favorite from `PlayerModule` (+ UI list + wire) the instant a drag starts and the remaining shortcuts visibly slide left to close the gap for the rest of the gesture (`RecvNotice_ItemListBeginDrag` → `RemoveSpellFromMenu`, live). acdream's controller performs the same PlayerModule/wire removal at drag-begin but DEFERS the whole favorite-list's visual rebuild until the drag concludes (drop or off-bar release) — the lifted cell's icon stays visible in its old slot and siblings do not slide until release, instead of reflowing continuously through the gesture. `DropFavorite` compensates by porting retail's own `AddFavorite`-side index adjustment (decrement the target index by one when the lifted item's original index was before it) against the now-intentionally-stale sibling numbering, so the FINAL landed position is byte-identical to retail's in every case exercised (`DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire`). **NARROWED + CORRECTED 2026-08-08 (drop-ring change).** Correction: this row originally claimed empty-tail-slot drops were "already-live-count-relative and are untouched" — false. The #354 `-1` adjustment sat inside `DropFavorite`, which the empty-cell path also calls, so its live-count-clamped (post-lift-numbered) index was double-corrected: lifting a non-last favorite onto the empty tail landed it second-to-last instead of last. `FavoriteDropIndex` is now THE one landing computation and applies retail's rule exactly — the `-1` is gated on the lifted spell's pre-lift-numbered removal site (retail's `RemoveSpellFromMenu`-return-gated decrement @0x004C7157), which for a live-numbered empty-tail target is retail's `RemoveSpellFromMenu == -1` no-adjustment case (test `SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd` fails against the double-correcting code). Narrowing: the mid-drag presentation now includes retail's authored drag-over Accept ring — `SpellCastSubMenu::OnItemListDragOver` @0x004C5990 setting the per-cell authored DragAccept child (element 0x1000045A, `UIElement_UIItem::PostInit` @0x004E1870) to `ItemSlot_DragOver_Accept` (UIStateId 0x10000040 → authored art 0x060011F9) on the hovered cell while a spell drag is live, cleared on leave/drop (`UiCatalogSlot.DragOverAcceptance` → `UiItemSlot.DrawDragAcceptOverlay`), with the ring and the drop sharing `FavoriteDropIndex` so the ring cannot promise a different landing. | `src/AcDream.App/UI/Layout/SpellcastingUiController.cs` (`BeginFavoriteDrag`, `EndFavoriteDrag`, `DropFavorite`, `Tick` — the `_favoriteDragActive` gate) | `UiRoot`'s subtree-removal safety net (`ClearSubtreeOwnership`, `UiRoot.cs:240-247`) cancels any in-flight drag whose source widget is destroyed, and `Rebuild()` tears down and recreates every cell in the list (`UiItemList.Flush` → `RemoveChild` per cell) rather than incrementally diffing. Left unguarded, the press-time removal's `SpellbookChanged` event would let the very next per-frame `Tick()` (production drives this unconditionally via `RetailUiRuntime.Tick`) destroy the cell driving the gesture and silently cancel the reorder before the user could complete the drop — this was the reported bug. Deferring the rebuild for the gesture's duration is the minimal fix that does not touch the shared `UiRoot` drag machinery every other panel (toolbar/inventory/vendor/paperdoll) also depends on. | A future rewrite that makes `Rebuild()` an incremental per-cell diff (add/remove/reflow one cell) instead of flush-and-recreate-all would make this deferral unnecessary and should retire this row along with it — until then, a player watching their OWN spell bar mid-drag sees the vacated slot's icon linger and siblings snap into place only on release, rather than reflowing live as retail does — and one ring consequence of that frozen bar: when dragging rightward past the source, the Accept ring's SCREEN slot sits one cell right of where the icon finally lands (retail's live-reflowed bar makes them coincide); the ring is on the correct CELL in both — the spell lands immediately before that cell's spell, retail's exact insert-before semantic. No effect on final position, the wire pair sent, or any other panel; cross-window spellbook→favorite drops are live-count-relative and untouched (the empty-tail claim this sentence used to carry was corrected 2026-08-08 — see the Divergence column). | `gmSpellcastingUI::RecvNotice_ItemListBeginDrag` @0x004C7360 (`SpellCastSubMenu::RemoveSpellFromMenu`, immediate live-list removal at lift); `SpellCastSubMenu::AddFavorite` @0x004C7060 (`RemoveSpellFromMenu`'s return value gating the `-1`-if-lifted-before-target `m_numSpells` adjustment before `ItemList_InsertSpellShortcut`); `PlayerModule::AddSpellFavorite` @0x005D43E0 (`InsertPos`); `PlayerModule::RemoveSpellFavorite` @0x005D4910 | @@ -380,7 +380,7 @@ research and is no longer active. | 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. | `src/AcDream.Core/Lighting/LightManager.cs` (`BuildPointLightSnapshot`, `MaxGlobalLights`); `src/AcDream.App/Rendering/WorldRenderFrameBuilder.cs` (`RuntimeWorldFrameEnvironmentPreparation.Prepare`); 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 128-entry list | 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 | `insert_light` 0x0054d1b0; `add_visible_cell` 0x0052de40; `add_dynamic_lights` 0x0052d410; caller 0x00452d30; `calc_point_light` 0x0059c8b0; Campaign OVERHAUL S5-c1 | +| 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-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` | @@ -422,7 +422,7 @@ research and is no longer active. | ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | AP-115 | **NARROWED 2026-08-08 (Campaign A slice A4) — the sound half is landed; only the notice's presentation remains.** The enter/exit cues now play: `LocalPlayerTeleportPresentation.EnterTunnel`/`ExitTunnel` fire `UI_EnterPortal`/`UI_ExitPortal` through the resolved interface sound bank, which is where retail plays them (`0x004D638E` / `0x004D7405`, inside the teleport-animation boundary rather than the tunnel renderer). The DAT-authored portal-space viewport, animation `SoundTweakedHook`, and centered repeating `"In Portal Space - Please Wait..."` display string are live. **Scope note (2026-08-06):** this row covers the cue's PRESENTATION only. Its five-second arming threshold is a separate, unregistered divergence now filed as AP-150 — retail emits the notice unconditionally per tunnel rotation segment (0.6-1.8 s) and has no such threshold. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; `src/AcDream.App/Streaming/LocalPlayerTeleportController.cs`; `src/AcDream.App/UI/PortalWaitNoticeController.cs` | acdream has no ClientUISystem sound-table-enum resolver yet; inventing direct wave IDs would be less faithful. The notice uses the retained fullscreen UI rather than chat and remains tied to the portal presentation lifetime. | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, animation-authored sound, centered wait notice, and (as of A4) retail's short UI enter/exit cue sounds. The residual is that the notice uses the retained fullscreen UI rather than chat, and its five-second arming is AP-150. | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | -| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | +| AP-116 | Default `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; the `Retail` option restores exact values. **[Campaign OVERHAUL v2 S5-c3 clarification, 2026-09-04: this explicit Retail/Extended particle-range choice is not driven by the automatic/manual building-degrade multiplier or Degrade Distance; retail's adaptive object/particle distance budget remains uncoupled.]** | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs`; `src/AcDream.App/Rendering/BuildingDegradeController.cs` | User explicitly requested doubled range as the normal non-dev-UI behavior; it changes no terrain, scenery, entity, fog, or streaming distance, and remains reversible through settings. Coupling it to building adaptive degradation would change that accepted choice. | The default roughly enlarges the active particle area and reduces the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff regardless of automatic building degradation. | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | ~~AP-117~~ | **RETIRED 2026-09-04 at Campaign OVERHAUL S5-c1.** `RetailPViewFrameResult.VisibleLandscapeCells` now publishes `WalkFrameDriver.VisitedLandscapeCellIds` directly. Outdoor particle update consumes that exact completed product through `CLandCell::IsInView` semantics; EnvCells use the constant-true `CEnvCell::IsInView` virtual. The complete `TerrainModernRenderer.CollectVisibleCells`/`TerrainVisibleCellIds` null-root frustum reconstruction is deleted, so a frame without a walk product publishes no outdoor visibility answer. | `src/AcDream.App/Rendering/RetailPViewRenderer.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs`; `src/AcDream.App/Rendering/TerrainModernRenderer.cs` | — | — | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; CEnvCell ICF virtual body `0x005269F0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` | | AP-118 | An AutoWield transaction begun in active combat preserves the ready mode implied by the requested weapon. After authoritative `WieldObject`, a mode that settled without a blocker transition clears immediately; local ACE's observed pre-wield transition plus `ready -> NonCombat`, or post-wield `NonCombat -> ready -> NonCombat`, causes one normal `ChangeCombatMode` request from the trailing notice. Explicit user combat input cancels settlement. Retail's client does not need this extra request against the retail server. | `src/AcDream.App/UI/AutoWieldController.cs`; production binding in `GameWindow.cs` | Local ACE queues a trailing NonCombat callback during primary-weapon replacement and rejects an earlier request while the shuffle is busy; responding to the authoritative notice that completes that exact sequence orders the ordinary request after it without suppressing any server state | A non-ACE server that emits a different intermediate sequence can retain the settlement until a later explicit combat request, replacement, or logout clears it; peace-mode equips send none | `CPlayerSystem::AutoWield @ 0x00560A60`; `ACCWeenieObject::ServerSaysMoveItem @ 0x0058DBB0`; ACE `Player_Inventory.TryShuffleStance` / `TryDequipObjectWithNetworking` | | AP-119 | Equal-generation CreateObject refresh applies the packet's complete `PhysicsDesc` to the existing `EntityEffectProfile`, including replacing its network sound/PES-table/default-script description. Retail's equal-`INSTANCE_TS` branch applies the individual ObjDesc/Parent-or-Position/Movement/State/Vector/WeenieDesc tail and does not call `CPhysicsObj::set_description` again. | `src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs` (`ILiveEntitySameGenerationUpdateSink.OnDescription`); `LiveEntitySameGenerationUpdateRouter.cs` | Existing spell/projectile/portal VFX tests and connected behavior were accepted with this refresh. Slice 4 records and isolates it rather than silently changing DAT-effect ownership during an architecture extraction. | A same-generation CreateObject whose PeTable/sound/default-script fields differ from the original can replace effect lookup state where retail would retain the original table, producing a different later typed effect. | `SmartBox::HandleCreateObject @ 0x00454C80`; `CPhysicsObj::set_description @ 0x00514F40`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | @@ -487,7 +487,7 @@ research and is no longer active. | ~~TS-8~~ | **RETIRED 2026-07-31 (#268 stat-chain closeout).** `EnchantmentWireReader` parses the complete 0x02C2 payload and `GameEventWiring` publishes its StatMod type/key/value and bucket through the same `ActiveEnchantmentRecord` used at login. An end-to-end dispatch test proves a mid-session skill modifier changes `LocalPlayerState.GetEffectiveSkill` immediately. | `src/AcDream.Core.Net/Messages/EnchantmentWireReader.cs`; `src/AcDream.Core.Net/GameEventWiring.cs`; `tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs` | — | — | `CEnchantmentRegistry::EnchantAttribute @ 0x00594570`; `CEnchantmentRegistry::EnchantSkill @ 0x005947B0`; holtburger `messages/magic/types.rs` | | TS-9 | **RE-SCOPED 2026-08-08 (Campaign A slice A6) — the blast radius is one wave, measured.** MP3 (0x55) and MS-ADPCM (0x02) waves still decode to null and play as silence where retail decoded both through the winmm ACM. What changed is the size of the problem: an independent walk of the shipped dats found exactly **1 MP3 among 786 waves** (`0x0A000393`, a ~2 s mono clip) and the row's original "any MP3 cue, common for music-ish clips" framing was wrong — it was written when we believed retail had a music system, and retail has none. A managed decoder for one two-second asset is not worth the dependency; the honest options are a ~50-line decode or an accepted-loss row, and this row is now the accepted-loss record. The ADPCM count has NOT been measured and is the one open question here. | `src/AcDream.Core/Audio/WaveDecoder.cs:33` | Measured rather than assumed. PCM covers 785 of 786 waves. | One ~2 s clip is silent, plus an unmeasured number of ADPCM clips. | winmm ACM path; dat census in `docs/research/2026-08-08-audio-retail-dat-layer.md` §4c | | TS-14 | Setup `Flatten` ignores ParentIndex part hierarchy (treats every placement as root-local); still in production use (GameWindow hydration, SkyRenderer) | `src/AcDream.Core/Meshing/SetupMesh.cs:15` | Most Setups are flat single-level rigs where root-local equals composed; hierarchical composition deferred ("Phase 3") | Any Setup with genuinely nested parts renders them at wrong offsets — mis-assembled multi-part objects in the Flatten paths | retail Setup ParentIndex chain composition | -| TS-15 | No distance-driven degrade (LOD): always close-detail slot 0; plus the **#47** static `Degrades[0]` swap for 34-part humanoids only (structural sentinel detector) | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs:57` (+ `src/AcDream.App/Rendering/GameWindow.cs:2608`) | LOD plumbing doesn't exist; slot 0 is correct for player + nearby NPCs; #47 closed the visible low-detail-arms bug without porting UpdateViewerDistance | Distant objects render max-detail (perf + wrong visuals where far meshes intentionally differ/hide parts); a future 34-part non-humanoid matching the sentinel gets the wrong mesh swap | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; ::Draw 0x0050D7A0; ::LoadGfxObjArray 0x0050DCF0 | +| TS-15 | **NARROWED 2026-09-04 at Campaign OVERHAUL v2 S5-c3 to non-building parts.** Building part-zero now uses retail distance-driven degrade selection and the selected GfxObj is threaded through shell rendering. Other entity/particle parts still use close-detail slot 0, plus the #47 static `Degrades[0]` swap for 34-part humanoids. | `src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`; `src/AcDream.App/Rendering/GameWindow.cs`; building closure in `src/AcDream.App/Rendering/Walk/WalkBuilding.cs` | General per-part LOD plumbing remains outside the bounded building repair. | Distant non-building objects render max-detail; building shells do not share this residual. | `CPhysicsPart::UpdateViewerDistance` 0x0050E030; `CPhysicsPart::Draw` 0x0050D7A0; `CPhysicsPart::LoadGfxObjArray` 0x0050DCF0 | | TS-17 | AttackConditions suffix always empty in combat chat — formatting ported, wire bitflag not plumbed (Phase I.7 follow-up) | `src/AcDream.Core/Chat/CombatChatTranslator.cs:233` | Only the wire plumbing is missing; the holtburger-ported formatter is ready | Combat log omits "[Sneak Attack]"-style suffixes retail displays — hidden combat-mechanic feedback | holtburger chat.rs:588-595 | | TS-18 | `LandCell.BuildingCellId` (CSortCell building bridge) declared but never populated — always null in Stage 1 | `src/AcDream.Core/World/Cells/LandCell.cs:19` | Cell graph shipped in stages; population is explicitly membership Stage 2 (the outdoor→indoor entry path the physics digest flags as unvalidated) | Cell-graph paths that should discover a building's EnvCells from the outdoor cell silently find nothing — the doorway-entry bug class | CSortCell (acclient.h:31880) | | TS-19 | Legacy non-retail ChaseCamera (invented pitch/distance, K-fix12 airborne Z-pin) retained behind `ACDREAM_RETAIL_CHASE=0` / DebugPanel toggle; both update every frame | `src/AcDream.App/Rendering/ChaseCamera.cs:49` | Diagnostic before/after comparison path, "pending the follow-up deletion commit" | When toggled on, the eye diverges from retail's spring-arm — and the render roots at the VIEWER cell, so a non-retail eye changes the render root near doorways, masking or manufacturing flap symptoms during debugging | `CameraManager::UpdateCamera` (retail path in RetailChaseCamera.cs) | diff --git a/src/AcDream.App/Composition/FrameRootComposition.cs b/src/AcDream.App/Composition/FrameRootComposition.cs index 37f1f4cd..8b544a76 100644 --- a/src/AcDream.App/Composition/FrameRootComposition.cs +++ b/src/AcDream.App/Composition/FrameRootComposition.cs @@ -40,6 +40,7 @@ internal sealed record FrameRootDependencies( EntityEffectPoseRegistry EffectPoses, WorldRenderRangeState RenderRange, RuntimeSettingsController Settings, + BuildingDegradeController BuildingDegrades, DisplayFramePacingController DisplayFramePacing, WorldSceneDebugState WorldSceneDebugState, RetailAlphaQueue RetailAlphaQueue, @@ -506,7 +507,8 @@ internal sealed class FrameRootCompositionPhase ?? throw new InvalidOperationException( "The retail frame walk requires the landscape registry."), d.CellVisibility, - d.PhysicsEngine.ShadowObjects), + d.PhysicsEngine.ShadowObjects, + d.BuildingDegrades), retailPViewPassExecutor), retailPViewCells, worldScenePasses, @@ -798,7 +800,8 @@ internal sealed class FrameRootCompositionPhase privatePresentation, live.FrameDiagnostics, postDiagnostics, - NullRenderFrameFailureRecovery.Instance); + NullRenderFrameFailureRecovery.Instance, + d.BuildingDegrades); Fault(FrameRootCompositionPoint.RenderRootCreated); var liveFrameCoordinator = new RetailLiveFrameCoordinator( diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs index 01763f22..2aca8a79 100644 --- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs +++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs @@ -66,6 +66,7 @@ internal sealed record InteractionRetainedUiDependencies( // Save button writes here, same file GameWindow's startup load reads. string KeyBindingsFilePath, RuntimeSettingsController Settings, + BuildingDegradeController BuildingDegrades, GameRuntime Runtime, IRuntimeCombatAttackOperations CombatAttackOperations, RuntimeCombatTargetOperationsSlot CombatTargetOperations, @@ -310,6 +311,18 @@ internal interface IInteractionRetainedUiCompositionFactory internal sealed class RetailInteractionRetainedUiCompositionFactory : IInteractionRetainedUiCompositionFactory { + internal static FpsRuntimeBindings CreateFpsBindings( + BuildingDegradeController buildingDegrades, + Func isVisible) + { + ArgumentNullException.ThrowIfNull(buildingDegrades); + ArgumentNullException.ThrowIfNull(isVisible); + return new FpsRuntimeBindings( + () => buildingDegrades.Fps, + () => buildingDegrades.ActiveMultiplier, + isVisible); + } + public IDisposable BindCombatTarget( InteractionRetainedUiDependencies d, DeferredSelectionUiAuthority selection) => @@ -787,9 +800,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory d.ClientTime), JumpPowerbar: new JumpPowerbarRuntimeBindings( () => d.PlayerController.Controller?.JumpCharge ?? default), - Fps: new FpsRuntimeBindings( - () => d.FrameDiagnostics.Snapshot.Fps, - () => 1.0, + Fps: CreateFpsBindings( + d.BuildingDegrades, () => d.Settings.DisplayPreview.ShowFps), VividTarget: new VividTargetRuntimeBindings( d.Actions.Selection, diff --git a/src/AcDream.App/Rendering/BuildingDegradeController.cs b/src/AcDream.App/Rendering/BuildingDegradeController.cs new file mode 100644 index 00000000..4afdd889 --- /dev/null +++ b/src/AcDream.App/Rendering/BuildingDegradeController.cs @@ -0,0 +1,173 @@ +using AcDream.UI.Abstractions.Panels.Settings; + +namespace AcDream.App.Rendering; + +/// +/// Renderer-lifetime owner of retail's FPS history and automatic degrade +/// multiplier. Scene/world replacement never recreates this owner. +/// +internal sealed class BuildingDegradeController : IBuildingDegradeFrameTick +{ + internal const int FpsHistoryLength = 20; + internal const int CandidateHistoryLength = 30; + internal const float MinimumFps = 8f; + internal const float IdealFps = 10f; + internal const float MaximumFps = 20f; + + private readonly Func _settings; + private readonly float[] _frameSeconds = new float[FpsHistoryLength]; + private readonly float[] _candidateHistory = new float[CandidateHistoryLength]; + private float _automaticMultiplier; + private float _fps; + + internal BuildingDegradeController(Func settings) + => _settings = settings ?? throw new ArgumentNullException(nameof(settings)); + + internal float Fps => _fps; + internal float AutomaticMultiplier => _automaticMultiplier; + internal float DegradeDistance => _settings().DegradeDistance; + internal float ActiveMultiplier + { + get + { + DisplaySettings settings = _settings(); + return settings.AutomaticDegrades + ? _automaticMultiplier + : settings.GraphicsPerformance; + } + } + + /// SceneTool::UpdateFPSCounter followed by CalcDegLevel. The + /// FPS sum observes the prior 20 samples; the just-finished frame is + /// shifted in only after that calculation. + internal void Tick(double elapsedSeconds) + { + double total = 0d; + for (int i = 0; i < _frameSeconds.Length; i++) + total += _frameSeconds[i]; + _fps = total > 0.000199999995f + ? (float)(FpsHistoryLength / total) + : 0f; + + AdvanceFrameHistory(_frameSeconds, (float)elapsedSeconds); + _automaticMultiplier = AdvanceAutomaticMultiplier( + _candidateHistory, + _settings().AutomaticDegrades, + _fps, + _automaticMultiplier); + } + + void IBuildingDegradeFrameTick.Tick(double elapsedSeconds) => Tick(elapsedSeconds); + + /// Retail's physical history direction: old slots 0..18 move + /// to 1..19 and the just-finished binary32 duration is stored at slot 0. + internal static void AdvanceFrameHistory(Span history, float frameSeconds) + { + if (history.Length != FpsHistoryLength) + throw new ArgumentException( + $"Retail FPS history must contain exactly {FpsHistoryLength} slots.", + nameof(history)); + + history[..^1].CopyTo(history[1..]); + history[0] = frameSeconds; + } + + /// Literal state transition of Render::CalcDegLevel: + /// shift first, compare the candidate against all thirty prior values, + /// conditionally commit, then store the resulting current multiplier. + internal static float AdvanceAutomaticMultiplier( + Span history, + bool automatic, + float fps, + float current) + { + if (history.Length != CandidateHistoryLength) + throw new ArgumentException( + $"Retail degrade history must contain exactly {CandidateHistoryLength} slots.", + nameof(history)); + + history[1..].CopyTo(history); + if (!automatic) + { + history[^1] = current; + return current; + } + + float candidate = CalculateCandidate(fps, current); + bool stable = IsCandidateStable(history, candidate); + + if (stable) + current = candidate; + history[^1] = current; + return current; + } + + /// The x87 comparison promotes both binary32 stores and tests + /// their wide difference against retail's qword 0.01 constant. + internal static bool IsCandidateStable(ReadOnlySpan history, float candidate) + { + if (history.Length != CandidateHistoryLength) + throw new ArgumentException( + $"Retail degrade history must contain exactly {CandidateHistoryLength} slots.", + nameof(history)); + + for (int i = 0; i < CandidateHistoryLength; i++) + { + if (!(Math.Abs((double)history[i] - candidate) < 0.01)) + return false; + } + + return true; + } + + /// The exact five-weight candidate calculation from + /// Render::CalcDegLevel @0x0054CAF0, separated so fixed-vector + /// tests do not need to infer the math through controller history. + internal static float CalculateCandidate(float fps, float current) + { + double fpsWide = fps; + double minimumFps = MinimumFps; + double idealFps = IdealFps; + double maximumFps = MaximumFps; + double w0 = LowShoulder(fpsWide, minimumFps * 0.75, + (minimumFps + idealFps) * 0.5); + double w1 = Triangle(fpsWide, minimumFps, + minimumFps * 0.25 + idealFps * 0.75); + double w2 = Triangle(fpsWide, + (minimumFps + idealFps) * 0.5, + (idealFps + maximumFps) * 0.5); + double w3 = Triangle(fpsWide, + idealFps * 0.75 + maximumFps * 0.25, + maximumFps); + double w4 = HighShoulder(fpsWide, + (idealFps + maximumFps) * 0.5, + maximumFps * 1.25); + double weight = w0 + w1 + w2 + w3 + w4; + float numeratorAfterW0 = (float)((double)-0.150000006f * w0); + float numeratorAfterW1 = (float)( + (double)numeratorAfterW0 - (double)0.02f * w1); + float numeratorAfterW2 = (float)( + (double)numeratorAfterW1 + (double)0f * w2); + float numeratorAfterW3 = (float)( + (double)numeratorAfterW2 + (double)0.01f * w3); + double numerator = (double)numeratorAfterW3 + (double)0.1f * w4; + double adjustment = weight > 0d + ? numerator / weight + : 0d; + double candidate = (double)current + adjustment; + if (candidate > 1d) + candidate = 1d; + else if (candidate < -1d) + candidate = -1d; + return (float)candidate; + } + + private static double Triangle(double value, double low, double high) + => Math.Max(0d, 1d - Math.Abs(2d * value - (high + low)) / (high - low)); + + private static double LowShoulder(double value, double peak, double zero) + => value < peak ? 1d : Math.Max(0d, 1d - Math.Abs(value - peak) / (zero - peak)); + + private static double HighShoulder(double value, double zero, double peak) + => value > peak ? 1d : Math.Max(0d, 1d - Math.Abs(value - peak) / (peak - zero)); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ad9e8a08..487ecad7 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -193,6 +193,7 @@ public sealed class GameWindow : private Exception? _runFailure; private readonly DisplayFramePacingController _displayFramePacing; private readonly RuntimeSettingsController _runtimeSettings; + private readonly BuildingDegradeController _buildingDegrades; // Phase A.1: streaming fields replacing the one-shot _entities list. private AcDream.App.Streaming.LandblockStreamer? _streamer; @@ -784,6 +785,8 @@ public sealed class GameWindow : _applicationPaths.SettingsFile), log: Console.WriteLine, characterOptionValue: _runtime.CharacterOwner.Options.GetOptionBit); + _buildingDegrades = new BuildingDegradeController( + () => _runtimeSettings.DisplayPreview); _animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment(); _uiRegistry = uiRegistry; _renderPackRegistry = renderPackRegistry; @@ -1552,6 +1555,7 @@ public sealed class GameWindow : _localPlayerTeleportSink, _applicationPaths.KeyBindingsFile, _runtimeSettings, + _buildingDegrades, _runtime, _combatAttackOperations, _combatTargetOperations, @@ -1746,6 +1750,7 @@ public sealed class GameWindow : _effectPoses, _renderRange, _runtimeSettings, + _buildingDegrades, _displayFramePacing, _worldSceneDebugState, _retailAlphaQueue, diff --git a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs index 67e97c04..2f12a682 100644 --- a/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs +++ b/src/AcDream.App/Rendering/RenderFrameOrchestrator.cs @@ -52,6 +52,11 @@ internal interface IRenderFrameLifetime void EndFrame(); } +internal interface IBuildingDegradeFrameTick +{ + void Tick(double elapsedSeconds); +} + internal interface IRenderFrameResourcePhase { void Prepare(RenderFrameInput input); @@ -180,6 +185,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot private readonly IRenderFrameDiagnosticsPhase _diagnostics; private readonly IRenderFramePostDiagnosticsPhase _postDiagnostics; private readonly IRenderFrameFailureRecovery _recovery; + private readonly IBuildingDegradeFrameTick? _buildingDegrades; public RenderFrameOrchestrator( IRenderFrameLifetime lifetime, @@ -189,7 +195,8 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot IPrivatePresentationFramePhase presentation, IRenderFrameDiagnosticsPhase diagnostics, IRenderFramePostDiagnosticsPhase postDiagnostics, - IRenderFrameFailureRecovery recovery) + IRenderFrameFailureRecovery recovery, + IBuildingDegradeFrameTick? buildingDegrades = null) { _lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime)); _gpuMeasurement = gpuMeasurement @@ -201,6 +208,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot _postDiagnostics = postDiagnostics ?? throw new ArgumentNullException(nameof(postDiagnostics)); _recovery = recovery ?? throw new ArgumentNullException(nameof(recovery)); + _buildingDegrades = buildingDegrades; } public RenderFrameOutcome Render(RenderFrameInput input) @@ -212,6 +220,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot if (input.ViewportWidth <= 0 || input.ViewportHeight <= 0) return RenderFrameOutcome.ZeroArea; + _buildingDegrades?.Tick(input.DeltaSeconds); _lifetime.BeginFrame(); Exception? renderFailure = null; try diff --git a/src/AcDream.App/Rendering/RetailPViewRenderer.cs b/src/AcDream.App/Rendering/RetailPViewRenderer.cs index bae4029f..00354879 100644 --- a/src/AcDream.App/Rendering/RetailPViewRenderer.cs +++ b/src/AcDream.App/Rendering/RetailPViewRenderer.cs @@ -72,7 +72,8 @@ internal sealed class RetailPViewRenderer Walk.WalkBuildingRegistry walkBuildings, Walk.WalkLandscapeAssembler walkLandscape, CellVisibility walkCellRegistry, - ShadowObjectRegistry shadows) + ShadowObjectRegistry shadows, + BuildingDegradeController? buildingDegrades = null) { _renderSceneShadow = renderSceneShadow ?? throw new ArgumentNullException(nameof(renderSceneShadow)); @@ -85,6 +86,9 @@ internal sealed class RetailPViewRenderer _walkWorldData = new Walk.WalkProductionWorldData( _walkBuildings, shadows ?? throw new ArgumentNullException(nameof(shadows))); + _frameWalk = buildingDegrades is null + ? new Walk.RetailFrameWalk() + : new Walk.RetailFrameWalk(buildingDegrades); _walkFlushLandscapeAction = FlushWalkLandscape; _walkClearInteriorDepthAction = ClearWalkInteriorDepth; _walkDrawExitSealsFunc = DrawWalkExitSeals; @@ -518,7 +522,7 @@ internal sealed class RetailPViewRenderer } } - private readonly Walk.RetailFrameWalk _frameWalk = new(); + private readonly Walk.RetailFrameWalk _frameWalk; /// S3 chunk 2 (§8.2 B3): the flush half of the former combined /// ClearWalkInteriorDepth — retail's D3DPolyRender::FlushAlphaList(0f) diff --git a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs index d880ac47..bc4ef30f 100644 --- a/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs +++ b/src/AcDream.App/Rendering/Walk/RetailFrameWalk.cs @@ -68,6 +68,9 @@ public interface IRetailFrameWalkContext : IWalkBuildingFrameContext /// public sealed class RetailFrameWalk { + private readonly BuildingDegradeController? _degradation; + private readonly float? _fixedDegradeDistance; + private readonly float? _fixedDegradeMultiplier; // RenderDeviceD3D::Init @0x0059efb0: indoor_pview = PView(…, 1) — the // interior pview DRAWS the landscape through its surviving exit views. private readonly WalkPView _interiorPView = new() { DrawLandscape = true }; @@ -86,14 +89,19 @@ public sealed class RetailFrameWalk /// regardless of per-cell visibility. public bool AlwaysDrawObjects = true; - /// Retail global Render::deg_mul — DYNAMIC: the - /// auto-tuner (auto_update_deg_mul) swings it with frame load - /// (positive ⇒ degrade thresholds slide toward each level's max; - /// negative ⇒ toward its min). The oracle captures pin ≈+0.99 for every - /// fixture except doorway-still, whose capture ran under cdb load with - /// the multiplier depressed (the recon session's live dump read −0.99 - /// under the same conditions). - public float DegradeMultiplier = WalkBuilding.DefaultDegradeMultiplier; + public RetailFrameWalk() { } + + internal RetailFrameWalk(BuildingDegradeController degradation) + => _degradation = degradation ?? throw new ArgumentNullException(nameof(degradation)); + + /// Fixture-only capture-state constructor. Production always + /// supplies the renderer-lifetime controller; historical retail captures + /// retain their recorded preference/multiplier without becoming defaults. + internal RetailFrameWalk(float degradeDistance, float degradeMultiplier) + { + _fixedDegradeDistance = degradeDistance; + _fixedDegradeMultiplier = degradeMultiplier; + } /// FW4 slice 1: the interior root's outside_view — the /// exit-view polygons THIS walk's own ConstructView installed @@ -272,8 +280,8 @@ public sealed class RetailFrameWalk /// portal pass, and the shell draw — sits inside retail's /// if (part->gfxobj[part->deg_level] != 0) @0x0059f2d3; a /// degraded-out slot draws NOTHING beyond the BLD event. - /// HasGeometry + a non-null SelectDrawingBsp together model - /// that one gate. Inside the gate, retail's own order + /// The selected GfxObj id alone models that gate; its drawing BSP is an + /// optional portal-walk input. Inside the gate, retail's own order /// (@0x0059f30b–0x0059f345) is D3DPolyRender::FlushAlphaList(0f) → /// CPhysicsPart::Draw(parts, 1) (the PORTAL flavor — the two-pass /// punch/look-in walk below) → CPhysicsPart::Draw(parts, 0) (the @@ -285,47 +293,47 @@ public sealed class RetailFrameWalk IRetailFrameWalkContext ctx, IWalkEventSink sink) { sink.Emit(WalkEvent.Building(building.PositionCellId)); - if (!building.HasGeometry) return; - // Retail walks the CURRENT degrade level's drawing BSP - // (part->gfxobj[deg_level]); a degraded-out slot skips everything - // after publishing the portal list. - WalkBspNode? bsp = building.SelectDrawingBsp( + WalkBuildingSelection selection = building.Select( ctx.ViewerDistanceTo(building), - degradeMultiplier: DegradeMultiplier); - if (bsp is null) return; + _degradation?.DegradeDistance ?? _fixedDegradeDistance ?? 50f, + _degradation?.ActiveMultiplier ?? _fixedDegradeMultiplier ?? 0f); + if (selection.GfxObjId == 0) + return; // Additive (Campaign FW3.2b-1): the alpha barrier // (D3DPolyRender::FlushAlphaList(0f) @0x0059f30b) — gated by the // SAME part->gfxobj[deg_level]!=0 check as everything below it, so - // this fires only now that both HasGeometry and the bsp lookup have - // passed. + // this fires after the selected-id gate, independently of whether + // that selected object's drawing BSP exists. sink.OnBuildingTurn(building); - int viewCount = Math.Max(activeViews.ViewCount, 0); - var passSink = new PortalPassSink(building, sink); - Vector3 viewpoint = ctx.ViewpointInBuilding(building); - for (int v = 0; v < viewCount; v++) + if (selection.DrawingBsp is WalkBspNode bsp) { - // Retail pins building_view = the CURRENT view for the whole - // two-pass walk (DrawMeshInternal @0x0059f3bf) — the punch fans - // it emits clip against THAT view, so the sink carries the index. - passSink.ActiveViewIndex = v; - ctx.SetActiveView(activeViews, v); - WalkBuildingPortals.BuildDrawPortalsOnly( - bsp, 1, viewpoint, - (portalRef, pass) => WalkBuildingPortals.DrawPortal( - _outdoorPView, building, portalRef, pass, ctx, passSink)); - WalkBuildingPortals.BuildDrawPortalsOnly( - bsp, 2, viewpoint, - (portalRef, pass) => WalkBuildingPortals.DrawPortal( - _outdoorPView, building, portalRef, pass, ctx, passSink)); + int viewCount = Math.Max(activeViews.ViewCount, 0); + var passSink = new PortalPassSink(building, sink); + Vector3 viewpoint = ctx.ViewpointInBuilding(building); + for (int v = 0; v < viewCount; v++) + { + // Retail pins building_view = the CURRENT view for the whole + // two-pass walk (DrawMeshInternal @0x0059f3bf). + passSink.ActiveViewIndex = v; + ctx.SetActiveView(activeViews, v); + WalkBuildingPortals.BuildDrawPortalsOnly( + bsp, 1, viewpoint, + (portalRef, pass) => WalkBuildingPortals.DrawPortal( + _outdoorPView, building, portalRef, pass, ctx, passSink)); + WalkBuildingPortals.BuildDrawPortalsOnly( + bsp, 2, viewpoint, + (portalRef, pass) => WalkBuildingPortals.DrawPortal( + _outdoorPView, building, portalRef, pass, ctx, passSink)); + } } // Additive (Campaign FW3.2b-1): CPhysicsPart::Draw(parts, 0) // @0x0059f331 — the building's own shell mesh — runs AFTER the // portal walk completes (CPhysicsPart::Draw(parts, 1) just above), // not before it. - sink.OnBuildingShellTurn(building); + sink.OnBuildingShellTurn(building, selection); } /// Returns the extracted cell-id array so diff --git a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs index 0f150262..21c45fd3 100644 --- a/src/AcDream.App/Rendering/Walk/WalkBuilding.cs +++ b/src/AcDream.App/Rendering/Walk/WalkBuilding.cs @@ -40,7 +40,21 @@ public sealed class WalkBspNode /// One degrade-ladder level: the drawing BSP of that level's /// GfxObj (portal-only view) and the level's authored distance bands. public readonly record struct WalkBuildingDegradeLevel( - float MinDist, float IdealDist, float MaxDist, WalkBspNode? DrawingBsp); + uint GfxObjId, + uint Mode, + float MinDist, + float IdealDist, + float MaxDist, + WalkBspNode? DrawingBsp); + +/// The complete result of retail's part-zero degrade selection. +/// A nonzero GfxObj id is drawable even when its drawing BSP is null; the +/// BSP is only the optional portal-walk input. +public readonly record struct WalkBuildingSelection( + uint GfxObjId, + WalkBspNode? DrawingBsp, + int Level, + uint Mode); /// The walk's building model (retail CBuildingObj + /// BuildInfo as the frame walk consumes them). @@ -56,6 +70,11 @@ public sealed class WalkBuilding /// Used directly when the model has no degrade ladder. public WalkBspNode? DrawingBsp; + /// Part zero's direct/base GfxObj. Zero is retail's authored + /// complete-body sentinel and suppresses the alpha, portal and shell + /// turns after the unconditional BLD publication. + public uint GfxObjId; + /// The degrade ladder (near→far). Retail walks the CURRENT /// level's drawing BSP (part->gfxobj[deg_level]) — and building /// degrade models beyond the first band carry NO portal nodes, which is @@ -63,27 +82,18 @@ public sealed class WalkBuilding /// every Holtburg building has ports at level 0 only). public WalkBuildingDegradeLevel[] DegradeLevels = []; - /// part->gfxobj[deg_level] != 0 — a degraded-out slot - /// skips the whole building AFTER publishing the portal list. - public bool HasGeometry = true; - /// The base GfxObj's sort center — retail measures the viewer /// distance to the part's SCALED sort center, not the position origin /// (CPhysicsPart::UpdateViewerDistance @0x0050e030). public Vector3 SortCenter; - /// Retail Render::s_rDegradeDistance — a subtractive - /// slack before the ladder applies (live-dumped 100 on the capture - /// client; registry-configurable). - public const float DefaultDegradeDistance = 100f; + /// Part-zero Setup resting/default-scale transform. Direct Gfx + /// buildings use identity. + public Matrix4x4 PartZeroTransform = Matrix4x4.Identity; - /// The live capture client's Render::deg_mul arm: - /// magnitude 0.99 pinned by the walkout-F2 fixture (building a9b4001e, - /// effective 30.3, level 0 ideal/max 24/48 — retail floods, so its - /// threshold ≈ max ⇒ the POSITIVE arm; the recon note's "−0.99" sign was - /// a misread — the negative arm's threshold ≈ min contradicts the - /// fixture from both directions). Re-dump at the next retail session. - public const float DefaultDegradeMultiplier = 0.99f; + /// The selected part scale's Z component used by retail's + /// UpdateViewerDistance division. + public float PartZeroScaleZ = 1f; /// /// GfxObjDegradeInfo::get_degrade @0x0051e4b0 (BN flag-mush on @@ -96,22 +106,41 @@ public sealed class WalkBuilding /// mul is Render::deg_mul when auto_update_deg_mul is on, /// else s_rUserSuppliedDegradeBias. /// - public WalkBspNode? SelectDrawingBsp( + public WalkBuildingSelection Select( float viewerDistance, - float degradeDistance = DefaultDegradeDistance, - float degradeMultiplier = DefaultDegradeMultiplier) + float degradeDistance, + float degradeMultiplier, + bool degradesDisabled = false, + int forcedLevel = -1) { - if (DegradeLevels.Length == 0) return DrawingBsp; + if (DegradeLevels.Length == 0) + return new WalkBuildingSelection(GfxObjId, DrawingBsp, 0, 1u); + if (degradesDisabled) + return SelectionAt(0); + if (forcedLevel >= 0) + return SelectionAt(Math.Min(forcedLevel, DegradeLevels.Length - 1)); + float effective = MathF.Max(0f, MathF.Abs(viewerDistance) - degradeDistance); - foreach (WalkBuildingDegradeLevel level in DegradeLevels) + for (int i = 0; i < DegradeLevels.Length; i++) { - float threshold = degradeMultiplier >= 0f - ? level.IdealDist - (level.IdealDist - level.MaxDist) * degradeMultiplier - : level.IdealDist + (level.IdealDist - level.MinDist) * degradeMultiplier; + WalkBuildingDegradeLevel level = DegradeLevels[i]; + double threshold = degradeMultiplier >= 0f + ? (double)level.IdealDist + - ((double)level.IdealDist - level.MaxDist) * degradeMultiplier + : (double)level.IdealDist + + ((double)level.IdealDist - level.MinDist) * degradeMultiplier; if (effective < threshold) - return level.DrawingBsp; + return new WalkBuildingSelection( + level.GfxObjId, level.DrawingBsp, i, level.Mode); + } + return SelectionAt(DegradeLevels.Length - 1); + + WalkBuildingSelection SelectionAt(int index) + { + WalkBuildingDegradeLevel level = DegradeLevels[index]; + return new WalkBuildingSelection( + level.GfxObjId, level.DrawingBsp, index, level.Mode); } - return DegradeLevels[^1].DrawingBsp; } } diff --git a/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs b/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs index 680d3124..54ec5480 100644 --- a/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs +++ b/src/AcDream.App/Rendering/Walk/WalkBuildingFactory.cs @@ -1,5 +1,7 @@ using System.Numerics; using AcDream.Content; +using AcDream.Core.Meshing; +using AcDream.Core.World; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; using DatReaderWriter.Types; @@ -37,7 +39,21 @@ public static class WalkBuildingFactory /// placement transform ( already has the /// caller's block offset baked in — see ). public sealed record Entry( - WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform); + WalkBuilding Building, Matrix4x4 WorldTransform, Matrix4x4 InverseWorldTransform) + { + public Matrix4x4 PartZeroWorldTransform { get; } = + Building.PartZeroTransform * WorldTransform; + + public Matrix4x4 InversePartZeroWorldTransform { get; } = + Invert(Building.PartZeroTransform * WorldTransform); + + private static Matrix4x4 Invert(Matrix4x4 value) + { + if (!Matrix4x4.Invert(value, out Matrix4x4 inverse)) + throw new InvalidOperationException("A building part-zero transform is not invertible."); + return inverse; + } + } /// Builds every building of one landblock. /// is the SAME landblock-local world offset every other production @@ -84,8 +100,32 @@ public static class WalkBuildingFactory WalkBspNode? bsp = null; Vector3 sortCenter = Vector3.Zero; + uint partZeroGfxObjId = 0; + Matrix4x4 partZeroTransform = Matrix4x4.Identity; + float partZeroScaleZ = 1f; var degradeLevels = new List(); - if (dats.Get(buildingInfo.ModelId) is GfxObj gfxObj) + if (dats.Get(buildingInfo.ModelId) is GfxObj directGfxObj) + { + partZeroGfxObjId = buildingInfo.ModelId; + PopulateGfx(directGfxObj); + } + else if (dats.Get(buildingInfo.ModelId) is Setup setup) + { + IReadOnlyList parts = SetupMesh.Flatten(setup); + if (parts.Count > 0) + { + MeshRef partZero = parts[0]; + partZeroGfxObjId = partZero.GfxObjId; + partZeroTransform = partZero.PartTransform; + partZeroScaleZ = setup.DefaultScale.Count > 0 + ? setup.DefaultScale[0].Z + : 1f; + if (dats.Get(partZero.GfxObjId) is GfxObj setupGfxObj) + PopulateGfx(setupGfxObj); + } + } + + void PopulateGfx(GfxObj gfxObj) { bsp = ConvertDrawingBsp(gfxObj, gfxObj.DrawingBSP?.Root); sortCenter = new Vector3(gfxObj.SortCenter.X, gfxObj.SortCenter.Y, gfxObj.SortCenter.Z); @@ -102,7 +142,13 @@ public static class WalkBuildingFactory levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root); } degradeLevels.Add( - new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp)); + new WalkBuildingDegradeLevel( + (uint)level.Id, + level.DegradeMode, + level.MinDist, + level.IdealDist, + level.MaxDist, + levelBsp)); } } } @@ -116,9 +162,12 @@ public static class WalkBuildingFactory { PositionCellId = positionCellId, Portals = portals, + GfxObjId = partZeroGfxObjId, DrawingBsp = bsp, DegradeLevels = degradeLevels.ToArray(), SortCenter = sortCenter, + PartZeroTransform = partZeroTransform, + PartZeroScaleZ = partZeroScaleZ, }, worldTransform, inverse)); diff --git a/src/AcDream.App/Rendering/Walk/WalkEvents.cs b/src/AcDream.App/Rendering/Walk/WalkEvents.cs index 7f1ae7a6..b88d6185 100644 --- a/src/AcDream.App/Rendering/Walk/WalkEvents.cs +++ b/src/AcDream.App/Rendering/Walk/WalkEvents.cs @@ -176,9 +176,8 @@ public interface IWalkEventSink /// draw) in if (part->gfxobj[part->deg_level] != 0) /// @0x0059f2d3 — a degraded-out slot draws NOTHING beyond the /// unconditional - /// call. and a non-null - /// result together model that - /// one gate, so this hook fires only after both have passed. This is the + /// call. The selected GfxObj id alone models that gate; a null drawing + /// BSP only suppresses the portal sub-walk. This is the /// ALPHA BARRIER turn (D3DPolyRender::FlushAlphaList(0f) /// @0x0059f30b) — retail's own order runs it BEFORE the portal pass /// (CPhysicsPart::Draw(parts, 1)), which in turn runs BEFORE the @@ -198,7 +197,9 @@ public interface IWalkEventSink /// fires when also fired (same gate; see its /// doc comment) — a degraded-out slot reaches neither. Default no-op. /// - void OnBuildingShellTurn(WalkBuilding building) { } + void OnBuildingShellTurn( + WalkBuilding building, + WalkBuildingSelection selection) { } /// /// Fires when the two-pass portal machinery diff --git a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs index 0ff79d10..2cb4e1a5 100644 --- a/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs +++ b/src/AcDream.App/Rendering/Walk/WalkFrameDriver.cs @@ -81,7 +81,8 @@ internal interface IWalkFrameWorldData /// Building-local → world, for transforming a punch polygon /// before — the /// production implementation is 's - /// WorldTransform (FW3.2b-2 wiring). + /// PartZeroWorldTransform (the Setup part-zero frame already composed + /// with the building root). Matrix4x4 GetBuildingWorldTransform(WalkBuilding building); } @@ -1590,7 +1591,9 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource _currentDcStage = WalkDrawStage.LookInStatic; } - void IWalkEventSink.OnBuildingShellTurn(WalkBuilding building) + void IWalkEventSink.OnBuildingShellTurn( + WalkBuilding building, + WalkBuildingSelection selection) { ArgumentNullException.ThrowIfNull(building); RequireOpenFrame(); @@ -1602,10 +1605,27 @@ internal sealed class WalkFrameDriver : IWalkEventSink, IWalkLookInViewSource // look-in flood appended (keeps every range single-stage). MarkIfGrown(); WalkFrameStaticRecords shell = _worldData.GetBuildingShellStatics(building); - _populator.PopulateCell( - _stream, WalkDrawStage.BuildingShell, building.PositionCellId, - shell.Records, shell.TupleLandblockId, _cameraWorldPosition, _viewProjection, - alphaSubmissions: _alphaSubmissions); + if (shell.Records.Count > 1) + { + throw new InvalidOperationException( + $"Building 0x{building.PositionCellId:X8} has " + + $"{shell.Records.Count} retained shell records; expected at most one."); + } + if (shell.Records.Count == 1) + { + ref readonly RenderProjectionRecord record = + ref shell.Records.AsSpan()[0]; + _populator.PopulateBuildingShell( + _stream, + building.PositionCellId, + in record, + shell.TupleLandblockId, + _cameraWorldPosition, + _viewProjection, + in selection, + building.PartZeroTransform, + _alphaSubmissions); + } if (_alphaSubmissions.Count != _alphaSubmitMark) { MarkIfGrown(); diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs index f8df55bb..84872857 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionFrameContext.cs @@ -173,21 +173,24 @@ public sealed class WalkProductionFrameContext : IWalkFrameContext, IRetailFrame } public Vector3 ViewpointInBuilding(WalkBuilding building) - => Vector3.Transform(WorldViewpoint, GetEntry(building).InverseWorldTransform); + => Vector3.Transform(WorldViewpoint, GetEntry(building).InversePartZeroWorldTransform); /// CPhysicsPart::UpdateViewerDistance @0x0050e030: the /// distance to the part's SCALED sort center, not the position origin. public float ViewerDistanceTo(WalkBuilding building) { WalkBuildingFactory.Entry entry = GetEntry(building); - return Vector3.Distance( - WorldViewpoint, Vector3.Transform(building.SortCenter, entry.WorldTransform)); + float distance = Vector3.Distance( + WorldViewpoint, + Vector3.Transform(building.SortCenter, entry.PartZeroWorldTransform)); + return distance / building.PartZeroScaleZ; } public int ClipBuildingPolygon( WalkBuilding building, WalkPolygon polygon, int side, Span output) { - Matrix4x4 objectToClip = GetEntry(building).WorldTransform * _viewProjection; + Matrix4x4 objectToClip = + GetEntry(building).PartZeroWorldTransform * _viewProjection; Span projected = stackalloc WalkScreenPoint[polygon.Vertices.Length]; for (int i = 0; i < polygon.Vertices.Length; i++) { diff --git a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs index da038bbb..497302f8 100644 --- a/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs +++ b/src/AcDream.App/Rendering/Walk/WalkProductionWorldData.cs @@ -543,6 +543,6 @@ internal sealed class WalkProductionWorldData : IWalkFrameWorldData throw new InvalidOperationException( $"walk building 0x{building.PositionCellId:X8} has no committed registry entry"); } - return entry.WorldTransform; + return entry.PartZeroWorldTransform; } } diff --git a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs index 67ac60db..039a5310 100644 --- a/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs +++ b/src/AcDream.App/Rendering/Walk/WalkStaticStreamPopulator.cs @@ -82,6 +82,34 @@ internal sealed class WalkStaticStreamPopulator } } + /// Classifies exactly the selected part-zero GfxObj against the + /// one retained building-shell record. Its retained identity/material/ + /// palette/effect/picking payload is borrowed unchanged; only the mesh + /// id and precomputed part-zero transform come from degrade selection. + internal void PopulateBuildingShell( + OrderedDrawStream stream, + uint cellId, + in RenderProjectionRecord record, + uint tupleLandblockId, + Vector3 cameraWorldPosition, + Matrix4x4 viewProjection, + in WalkBuildingSelection selection, + Matrix4x4 partZeroTransform, + List alphaSubmissions) + { + ClassifyAndAppend( + stream, + WalkDrawStage.BuildingShell, + cellId, + in record, + tupleLandblockId, + cameraWorldPosition, + viewProjection, + alphaSubmissions: alphaSubmissions, + buildingSelection: selection, + buildingPartTransform: partZeroTransform); + } + /// /// The landscape entry point: outdoor static content /// (RenderProjectionClass.OutdoorStatic — @@ -275,7 +303,9 @@ internal sealed class WalkStaticStreamPopulator bool liveDynamic = false, IWalkLookInViewSource? lookInViews = null, int lookInRouteIndex = -1, - List? alphaSubmissions = null) + List? alphaSubmissions = null, + WalkBuildingSelection? buildingSelection = null, + Matrix4x4 buildingPartTransform = default) { _batchScratch.Clear(); _selectionScratch.Clear(); @@ -288,7 +318,9 @@ internal sealed class WalkStaticStreamPopulator lookInViews, lookInRouteIndex, cellId, - diagnosticViewProjection: viewProjection); + diagnosticViewProjection: viewProjection, + buildingSelection: buildingSelection, + buildingPartTransform: buildingPartTransform); for (int i = 0; i < _batchScratch.Count; i++) { diff --git a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs index ed2b2c15..610e50fc 100644 --- a/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs +++ b/src/AcDream.App/Rendering/Wb/EnvCellLandblockBuild.cs @@ -44,6 +44,7 @@ public sealed class EnvCellLandblockBuild VisibilityCells = visibilityCells.ToImmutableArray(); Shells = shells.ToImmutableArray(); WalkBuildings = (walkBuildings ?? Enumerable.Empty()).ToImmutableArray(); + WalkBuildingMeshDependencies = CollectWalkBuildingMeshDependencies(WalkBuildings); WalkMaxZ = walkMaxZ; WalkMinZ = walkMinZ; @@ -67,6 +68,14 @@ public sealed class EnvCellLandblockBuild /// LandblockRenderPublisher.AdvanceCompleteOne. public ImmutableArray WalkBuildings { get; } + /// + /// Unique nonzero GfxObj ids referenced by the buildings' degrade ladders. + /// This worker-built immutable carrier is acquired as ordinary mesh + /// ownership with the containing near-tier landblock; EnvCell shell ids + /// remain on the separate prepared-geometry path. + /// + public ImmutableArray WalkBuildingMeshDependencies { get; } + /// Campaign FW3.1: this landblock's retail z-slab /// (heightTable[maxByte] + 200) — the walk landscape's per-block /// visibility bound (WalkLandscapeAssembler.PublishLandblock). @@ -77,6 +86,24 @@ public sealed class EnvCellLandblockBuild /// Campaign FW3.1: this landblock's retail z-slab /// (heightTable[minByte] − 1). See . public float WalkMinZ { get; } + + private static ImmutableArray CollectWalkBuildingMeshDependencies( + ImmutableArray buildings) + { + var seen = new HashSet(); + var dependencies = ImmutableArray.CreateBuilder(); + for (int buildingIndex = 0; buildingIndex < buildings.Length; buildingIndex++) + { + WalkBuildingDegradeLevel[] levels = buildings[buildingIndex].Building.DegradeLevels; + for (int levelIndex = 0; levelIndex < levels.Length; levelIndex++) + { + uint id = levels[levelIndex].GfxObjId; + if (id != 0 && seen.Add(id)) + dependencies.Add(id); + } + } + return dependencies.ToImmutable(); + } } /// diff --git a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs index 2e5949a4..d5b3d0ce 100644 --- a/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs @@ -76,7 +76,9 @@ public sealed class LandblockSpawnAdapter /// public void OnLandblockLoaded( LoadedLandblock landblock, - IEnumerable? additionalReadinessIds = null) + IEnumerable? additionalReadinessIds = null, + IEnumerable? additionalOrdinaryIds = null, + bool replaceExisting = false) { System.ArgumentNullException.ThrowIfNull(landblock); @@ -90,6 +92,8 @@ public sealed class LandblockSpawnAdapter foreach (var meshRef in entity.MeshRefs) unique.Add((ulong)meshRef.GfxObjId); } + if (additionalOrdinaryIds is not null) + unique.UnionWith(additionalOrdinaryIds.Where(static id => id != 0)); HashSet? preparedIds = additionalReadinessIds is null ? null @@ -110,6 +114,11 @@ public sealed class LandblockSpawnAdapter MarkAllUndesired(registration.Prepared); registration.WantsLoaded = true; } + else if (replaceExisting) + { + MarkAllUndesired(registration.Ordinary); + MarkAllUndesired(registration.Prepared); + } MarkDesired(registration.Ordinary, unique); if (preparedIds is not null) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs index 8062b8b0..af9cabe6 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.WalkClassify.cs @@ -47,11 +47,9 @@ namespace AcDream.App.Rendering.Wb; /// (TranslucencyFadeManager/EntityOpacity) is NOT threaded /// through: that mechanic is TransparentPartHook, a retail LIVE-entity /// behavior keyed by ServerGuid, not something world statics undergo — every -/// classified batch here carries Alpha = 1f. Likewise the async -/// mesh-miss self-heal request (_missRequested/EnsureLoaded, -/// frame-scoped state cleared by BeginEntityDispatch) is not fired -/// here — this seam has no production frame to be scoped to yet; a missing -/// mesh is simply skipped, matching every other unwired FW2/FW3.2a path. +/// classified batch here carries Alpha = 1f. A mesh miss uses the +/// existing frame-scoped _missRequested/EnsureLoaded seam and +/// skips this frame; the caller retries naturally on the following frame. /// public sealed partial class WbDrawDispatcher { @@ -93,6 +91,7 @@ public sealed partial class WbDrawDispatcher "A walk part frame was opened before the previous scope closed."); } + _missRequested.Clear(); _walkDrawnParts.Clear(); BeginFacilityStairSubmissionProbeFrame(); _walkPartFrameActive = true; @@ -274,7 +273,9 @@ public sealed partial class WbDrawDispatcher IWalkLookInViewSource? lookInViews = null, int lookInRouteIndex = -1, uint lookInCellId = 0, - Matrix4x4 diagnosticViewProjection = default) + Matrix4x4 diagnosticViewProjection = default, + WalkBuildingSelection? buildingSelection = null, + Matrix4x4 buildingPartTransform = default) { ArgumentNullException.ThrowIfNull(batches); ArgumentNullException.ThrowIfNull(selectionParts); @@ -316,12 +317,22 @@ public sealed partial class WbDrawDispatcher paletteIdentity = TextureCache.GetPaletteIdentity(entity.PaletteOverride); IReadOnlyList? meshRefs = projection.EntityPayload.MeshRefs; - if (meshRefs is null) + if (buildingSelection is null && meshRefs is null) return; - for (int partIndex = 0; partIndex < meshRefs.Count; partIndex++) + IReadOnlyDictionary? buildingSurfaceOverrides = + buildingSelection is not null && meshRefs is { Count: > 0 } + ? meshRefs[0].SurfaceOverrides + : null; + int meshRefCount = buildingSelection is null ? meshRefs!.Count : 1; + for (int partIndex = 0; partIndex < meshRefCount; partIndex++) { - MeshRef meshRef = meshRefs[partIndex]; + MeshRef meshRef = buildingSelection is WalkBuildingSelection selected + ? new MeshRef(selected.GfxObjId, buildingPartTransform) + { + SurfaceOverrides = buildingSurfaceOverrides, + } + : meshRefs![partIndex]; if (_meshAdapter.IsRuntimeHiddenMarker(meshRef.GfxObjId)) continue; @@ -333,7 +344,9 @@ public sealed partial class WbDrawDispatcher continue; } - if (renderData.IsSetup && renderData.SetupParts.Count > 0) + if (buildingSelection is null + && renderData.IsSetup + && renderData.SetupParts.Count > 0) { // Same entity-scoped OR the classic/packed classifiers compute // — a Setup composite's parts are separate GfxObjs with their diff --git a/src/AcDream.App/Streaming/GpuWorldState.cs b/src/AcDream.App/Streaming/GpuWorldState.cs index 5ad82a65..8f1e527b 100644 --- a/src/AcDream.App/Streaming/GpuWorldState.cs +++ b/src/AcDream.App/Streaming/GpuWorldState.cs @@ -22,7 +22,9 @@ internal sealed record GpuLandblockSpatialPublication( IReadOnlyList AdditionalRenderIds, IReadOnlyList StaticEntities, bool RequiresActivation = true, - uint RenderTraversalOrder = 0); + uint RenderTraversalOrder = 0, + IReadOnlyList? AdditionalOrdinaryRenderIds = null, + bool ReplacesMeshRegistration = false); /// /// Render-thread-owned registry of currently-loaded landblocks and their @@ -975,13 +977,19 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery internal GpuLandblockSpatialPublication CommitLandblockSpatial( LoadedLandblock landblock, IEnumerable? additionalRenderIds, - LandblockStreamTier tier) => - CommitLandblockSpatialCore(landblock, additionalRenderIds, tier); + LandblockStreamTier tier, + IEnumerable? additionalOrdinaryRenderIds = null) => + CommitLandblockSpatialCore( + landblock, + additionalRenderIds, + tier, + additionalOrdinaryRenderIds); private GpuLandblockSpatialPublication CommitLandblockSpatialCore( LoadedLandblock landblock, IEnumerable? additionalRenderIds, - LandblockStreamTier tier) + LandblockStreamTier tier, + IEnumerable? additionalOrdinaryRenderIds = null) { ArgumentNullException.ThrowIfNull(landblock); @@ -1036,8 +1044,10 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery tier = LandblockStreamTier.Near; } + bool replacesMeshRegistration = false; if (RemoveLoadedLandblock(landblock.LandblockId, out displaced)) { + replacesMeshRegistration = true; RemoveLoadedLandblockFromFlatView(displaced); _animatedIndexByLandblock.Remove(landblock.LandblockId); } @@ -1048,7 +1058,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery return CreateSpatialPublication( _loaded[landblock.LandblockId], (IEnumerable?)mergedRenderIds ?? Array.Empty(), - GetRenderTraversalOrder(landblock.LandblockId)); + GetRenderTraversalOrder(landblock.LandblockId), + additionalOrdinaryRenderIds, + replacesMeshRegistration); } /// @@ -1099,7 +1111,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _wbSpawnAdapter?.OnLandblockLoaded( publication.Landblock, - publication.AdditionalRenderIds); + publication.AdditionalRenderIds, + publication.AdditionalOrdinaryRenderIds, + publication.ReplacesMeshRegistration); // C.1.5b: fire DefaultScript for dat-hydrated entities only. // EntityScriptActivator owns a retryable per-static acquisition ledger, @@ -1115,10 +1129,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery private static GpuLandblockSpatialPublication CreateSpatialPublication( LoadedLandblock landblock, IEnumerable additionalRenderIds, - uint renderTraversalOrder) + uint renderTraversalOrder, + IEnumerable? additionalOrdinaryRenderIds = null, + bool replacesMeshRegistration = false) { ulong[] renderIds = additionalRenderIds as ulong[] ?? additionalRenderIds.ToArray(); + ulong[] ordinaryRenderIds = additionalOrdinaryRenderIds as ulong[] + ?? additionalOrdinaryRenderIds?.ToArray() + ?? Array.Empty(); WorldEntity[] staticEntities = landblock.Entities .Where(static entity => entity.ServerGuid == 0) .ToArray(); @@ -1127,7 +1146,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery landblock, renderIds, staticEntities, - RenderTraversalOrder: renderTraversalOrder); + RenderTraversalOrder: renderTraversalOrder, + AdditionalOrdinaryRenderIds: ordinaryRenderIds, + ReplacesMeshRegistration: replacesMeshRegistration); } /// @@ -2014,6 +2035,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery landblockId, entities, additionalRenderIds, + additionalOrdinaryRenderIds: null, parkIfMissing: true); if (publication is null) return false; @@ -2025,13 +2047,15 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery internal GpuLandblockSpatialPublication CommitEntitiesToExistingLandblockSpatial( uint landblockId, IReadOnlyList entities, - IEnumerable? additionalRenderIds) + IEnumerable? additionalRenderIds, + IEnumerable? additionalOrdinaryRenderIds = null) { GpuLandblockSpatialPublication? publication = CommitEntitiesToExistingLandblockSpatialCore( landblockId, entities, additionalRenderIds, + additionalOrdinaryRenderIds, parkIfMissing: false); return publication ?? throw new InvalidOperationException( @@ -2042,6 +2066,7 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery uint landblockId, IReadOnlyList entities, IEnumerable? additionalRenderIds, + IEnumerable? additionalOrdinaryRenderIds, bool parkIfMissing) { ArgumentNullException.ThrowIfNull(entities); @@ -2113,7 +2138,9 @@ public sealed class GpuWorldState : ILiveEntitySpatialQuery _loaded[canonical], effectiveRenderIds, staticEntities, - RenderTraversalOrder: GetRenderTraversalOrder(canonical)); + RenderTraversalOrder: GetRenderTraversalOrder(canonical), + AdditionalOrdinaryRenderIds: + additionalOrdinaryRenderIds?.ToArray() ?? Array.Empty()); } private void DrainVisibilityTransitions() diff --git a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs index b0e13f41..66808658 100644 --- a/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs +++ b/src/AcDream.App/Streaming/LandblockPresentationPipeline.cs @@ -957,6 +957,8 @@ public sealed class LandblockPresentationPipeline IEnumerable? renderIds = transaction.Build.EnvCells?.Shells.Select( static shell => shell.GeometryId); + IEnumerable? ordinaryRenderIds = + transaction.Build.EnvCells?.WalkBuildingMeshDependencies; transaction.SpatialPublication = transaction.Kind switch { PublicationKind.Loaded @@ -965,12 +967,14 @@ public sealed class LandblockPresentationPipeline _state.CommitLandblockSpatial( transaction.Build.Landblock, renderIds, - transaction.Tier), + transaction.Tier, + ordinaryRenderIds), PublicationKind.PromoteExisting => _state.CommitEntitiesToExistingLandblockSpatial( transaction.LandblockId, transaction.Build.Landblock.Entities, - renderIds), + renderIds, + ordinaryRenderIds), _ => throw new InvalidOperationException( $"Unknown landblock publication kind {transaction.Kind}."), }; diff --git a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs index b2998b35..8bc7c2fd 100644 --- a/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs +++ b/src/AcDream.App/UI/Layout/ConfigOptionsPageController.cs @@ -118,11 +118,14 @@ namespace AcDream.App.UI.Layout; /// Brightness (OP6 rework, review S2: its OWN DisplaySettings.ScreenBrightness /// field, range [-1,1] default 0 — NOT the pre-existing Gamma /// multiplier, a different unit system with its own live legacy-panel -/// consumer; no gamma-correction render pass exists for either); Automatic -/// Degrades/Graphics Performance/Degrade Distance/the four texture-detail- -/// family menus/Multi-Pass Alpha (the renderer is Vulkan + one aggregate -/// QualityPreset, no per-feature knobs). Building Detail Textures is -/// LIVE: #226 consumes it directly in the retail building/EnvCell detail pass; +/// consumer; no gamma-correction render pass exists for either); Landscape +/// Texture Detail/Environment Texture Detail/Texture Filtering/Multi-Pass +/// Alpha (the renderer is Vulkan + one aggregate QualityPreset, no +/// per-feature knobs). Automatic Degrades, Graphics Performance, and Degrade +/// Distance are LIVE through S5-c3's shared building-degrade owner. Landscape +/// Draw Distance is LIVE through ApplyLandscapeDrawDistance. Building +/// Detail Textures is LIVE: #226 consumes it directly in the retail +/// building/EnvCell detail pass; /// Camera Stiffness/Adjustment Speed/Align To Slope/Mouse Look /// Sensitivity/Invert Mouselook Y Axis/Use Mouse Turning (TS-74 — no /// persistent mouse-turning camera mode exists for ANY of these six to @@ -138,8 +141,8 @@ namespace AcDream.App.UI.Layout; /// /// /// Caption dimming (AD-78, user-directed, 2026-08-11, gate 2). Every -/// STORE-ONLY row above (the 20 rows named in the paragraph before this one -/// -- AP-198's nine, AP-199's three, and TS-74's six Camera/Input rows plus +/// STORE-ONLY row above (the 16 rows named in the paragraph before this one +/// -- AP-198's five, AP-199's three, and TS-74's six Camera/Input rows plus /// AP-200's two Chat-font rows) renders its caption in /// instead of the normal /// white/DAT-authored color. The row stays fully interactive -- it still @@ -1231,14 +1234,14 @@ public static class ConfigOptionsPageController listBox, "ID_Graphics_AdaptiveDegrade", defaultValue: false, page, resolveString, read: () => bindings.LoadDisplay().AutomaticDegrades, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { AutomaticDegrades = value }), - storeOnly: true); // AP-198 + storeOnly: false); BuildSliderRow( listBox, RangedSliderTemplateIndex, "ID_Graphics_AdaptiveDegradeBias", min: -1f, max: 1f, defaultValue: 0f, page, resolveString, read: () => bindings.LoadDisplay().GraphicsPerformance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { GraphicsPerformance = value }), - storeOnly: true, // AP-198 + storeOnly: false, rangeLowKey: "ID_Graphics_Value_Speed", rangeHighKey: "ID_Graphics_Value_Detail"); BuildSliderRow( @@ -1246,7 +1249,7 @@ public static class ConfigOptionsPageController min: 0f, max: 100f, defaultValue: 50.0f, page, resolveString, read: () => bindings.LoadDisplay().DegradeDistance, apply: value => bindings.SaveDisplay(bindings.LoadDisplay() with { DegradeDistance = value }), - storeOnly: true, // AP-198 + storeOnly: false, rangeLowKey: "ID_Graphics_Value_Close", rangeHighKey: "ID_Graphics_Value_Far"); display = bindings.LoadDisplay(); diff --git a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs index a2301c04..08bc7db0 100644 --- a/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs +++ b/src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs @@ -156,11 +156,10 @@ public sealed record DisplaySettings( bool ShowFps, QualityPreset Quality, ParticleRange ParticleRange, - // Campaign OP slice OP6: the Config tab's "Graphics Options" + - // "Rendering Quality Options" rows with no acdream renderer consumer — - // the world renderer is Vulkan + one aggregate QualityPreset, not - // per-feature knobs (register row, OP6). Persisted faithfully; every - // default below is retail's own byte-verified + // Campaign OP slice OP6: Config's graphics preferences. S5-c3 makes + // AutomaticDegrades, GraphicsPerformance and DegradeDistance live for + // building part-zero selection; the remaining per-feature knobs stay + // store-only under AP-198. Every default below is retail's byte-verified // gmClient::InitUIPreferences / gmConfigUI::InitOptions literal. // // OP6 rework (2026-08-11, review S2): Screen Brightness gets its OWN diff --git a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs index 5830751a..aa466a14 100644 --- a/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs +++ b/tests/AcDream.App.Tests/Composition/InteractionRetainedUiCompositionTests.cs @@ -17,6 +17,7 @@ using AcDream.Core.Spells; using AcDream.Runtime; using AcDream.Runtime.Gameplay; using AcDream.UI.Abstractions.Panels.Chat; +using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.Composition; @@ -36,6 +37,29 @@ public sealed class InteractionRetainedUiCompositionTests InteractionRetainedUiCompositionPoint.InventoryContainerBound, ]; + [Fact] + public void FpsPanelBorrowsTheExactRendererLifetimeDegradeOwner() + { + DisplaySettings settings = DisplaySettings.Default with + { + AutomaticDegrades = false, + GraphicsPerformance = -0.35f, + }; + var owner = new BuildingDegradeController(() => settings); + for (int i = 0; i < 21; i++) + owner.Tick(0.05); + + FpsRuntimeBindings bindings = + RetailInteractionRetainedUiCompositionFactory.CreateFpsBindings( + owner, () => true); + + Assert.Equal(owner.Fps, bindings.FramesPerSecond()); + Assert.Equal(-0.35, bindings.DegradeMultiplier(), 6); + settings = settings with { GraphicsPerformance = 0.65f }; + Assert.Equal(0.65, bindings.DegradeMultiplier(), 6); + Assert.True(bindings.IsVisible()); + } + [Fact] public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter() { @@ -278,6 +302,8 @@ public sealed class InteractionRetainedUiCompositionTests new AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink(), KeyBindingsFilePath: "keybinds.json", Settings: null!, + BuildingDegrades: new BuildingDegradeController( + () => DisplaySettings.Default), Runtime: runtime, CombatAttackOperations: new NoopCombatOperations(), CombatTargetOperations: new RuntimeCombatTargetOperationsSlot(), diff --git a/tests/AcDream.App.Tests/Rendering/BuildingDegradeControllerTests.cs b/tests/AcDream.App.Tests/Rendering/BuildingDegradeControllerTests.cs new file mode 100644 index 00000000..724a2e8e --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/BuildingDegradeControllerTests.cs @@ -0,0 +1,228 @@ +using AcDream.UI.Abstractions.Panels.Settings; +using AcDream.App.Rendering; + +namespace AcDream.App.Tests.Rendering; + +[Collection(ThreadSchedulingCollection.Name)] +public sealed class BuildingDegradeControllerTests +{ + [Fact] + public void FpsUsesPriorTwentyFramesThenInsertsJustFinishedFrame() + { + DisplaySettings settings = DisplaySettings.Default; + var controller = new BuildingDegradeController(() => settings); + + for (int i = 0; i < 20; i++) + controller.Tick(0.05); + + Assert.Equal(20f / 0.95f, controller.Fps, 4); + controller.Tick(0.05); + Assert.Equal(20f, controller.Fps, 4); + } + + [Fact] + public void FpsWideSumNarrowsOnceAtStoreAndMatchesRetailBits() + { + DisplaySettings settings = DisplaySettings.Default; + var controller = new BuildingDegradeController(() => settings); + for (int i = 0; i < 10; i++) + { + controller.Tick(0.001d); + controller.Tick(0.005d); + } + + controller.Tick(0d); + + Assert.Equal(0x43A6AAABu, BitConverter.SingleToUInt32Bits(controller.Fps)); + } + + [Fact] + public void FpsHistoryMovesOldSlotsUpAndStoresCurrentAtSlotZero() + { + float[] history = Enumerable.Range(1, 20).Select(static value => (float)value).ToArray(); + + BuildingDegradeController.AdvanceFrameHistory(history, 99f); + + Assert.Equal(99f, history[0]); + for (int i = 1; i < history.Length; i++) + Assert.Equal((float)i, history[i]); + } + + [Fact] + public void ManualAndAutomaticModesUseTheSamePersistedSettingsWithoutResettingAutoHistory() + { + DisplaySettings settings = DisplaySettings.Default with + { + AutomaticDegrades = true, + GraphicsPerformance = -0.4f, + DegradeDistance = 73f, + }; + var controller = new BuildingDegradeController(() => settings); + + for (int i = 0; i < 51; i++) + controller.Tick(1d / 14d); + float automatic = controller.AutomaticMultiplier; + Assert.True(automatic > 0f); + Assert.Equal(automatic, controller.ActiveMultiplier); + Assert.Equal(73f, controller.DegradeDistance); + + settings = settings with { AutomaticDegrades = false }; + controller.Tick(0.05); + Assert.Equal(-0.4f, controller.ActiveMultiplier); + Assert.Equal(automatic, controller.AutomaticMultiplier); + + settings = settings with { AutomaticDegrades = true }; + Assert.Equal(automatic, controller.ActiveMultiplier); + } + + [Fact] + public void WarmedTickIsAllocationFreeAndExceptionalDeltasStayDefined() + { + DisplaySettings settings = DisplaySettings.Default with + { + AutomaticDegrades = true, + }; + var controller = new BuildingDegradeController(() => settings); + for (int i = 0; i < 100; i++) + controller.Tick(1d / 60d); + + controller.Tick(0d); + controller.Tick(double.NaN); + controller.Tick(double.PositiveInfinity); + controller.Tick(double.NegativeInfinity); + + long allocated = ZeroAllocationProbe.MeasureWarmed( + () => controller.Tick(1d / 60d), + batchSize: 10_000, + warmupBatches: 2, + samples: 5); + + Assert.Equal(0, allocated); + Assert.InRange(controller.AutomaticMultiplier, -1f, 1f); + } + + [Theory] + [InlineData(0d, 0x41A86BCAu)] + [InlineData(0.00001d, 0x41A86B56u)] + [InlineData(double.NaN, 0u)] + [InlineData(double.PositiveInfinity, 0u)] + [InlineData(double.NegativeInfinity, 0u)] + public void ZeroTinyAndExceptionalDeltasKeepRetailFloatHistoryWithoutSanitizing( + double exceptionalDelta, + uint expectedFollowingFpsBits) + { + DisplaySettings settings = DisplaySettings.Default with + { + AutomaticDegrades = true, + }; + var controller = new BuildingDegradeController(() => settings); + for (int i = 0; i < 20; i++) + controller.Tick(0.05d); + + controller.Tick(exceptionalDelta); + Assert.Equal(0x41A00000u, BitConverter.SingleToUInt32Bits(controller.Fps)); + Assert.Equal(0u, BitConverter.SingleToUInt32Bits(controller.AutomaticMultiplier)); + + controller.Tick(0.05d); + Assert.Equal(expectedFollowingFpsBits, BitConverter.SingleToUInt32Bits(controller.Fps)); + Assert.Equal(0u, BitConverter.SingleToUInt32Bits(controller.AutomaticMultiplier)); + } + + [Fact] + public void NewControllerStartsAtRetailZeroWithoutSharingHistory() + { + DisplaySettings settings = DisplaySettings.Default with + { + AutomaticDegrades = true, + }; + var first = new BuildingDegradeController(() => settings); + for (int i = 0; i < 51; i++) + first.Tick(1d / 14d); + + var restarted = new BuildingDegradeController(() => settings); + Assert.NotEqual(0f, first.AutomaticMultiplier); + Assert.Equal(0f, restarted.AutomaticMultiplier); + Assert.Equal(0f, restarted.Fps); + } + + [Theory] + [InlineData(0f, -0.150000006f)] + [InlineData(10f, 0f)] + [InlineData(14f, 0.0054545454f)] + [InlineData(20f, 0.1f)] + [InlineData(25f, 0.1f)] + public void ExactFiveWeightFormulaMatchesFixedRetailVectors( + float fps, + float expected) + { + Assert.Equal( + expected, + BuildingDegradeController.CalculateCandidate(fps, 0f), + 7); + } + + [Fact] + public void FiveWeightFormulaKeepsX87IntermediatesUntilCandidateStore() + { + float candidate = BuildingDegradeController.CalculateCandidate(14f, 0f); + + Assert.Equal(0x3BB2BC0Au, BitConverter.SingleToUInt32Bits(candidate)); + } + + [Fact] + public void FiveWeightNumeratorPreservesAllFourRetailFloatStores() + { + float candidate = BuildingDegradeController.CalculateCandidate(16.25f, 0f); + + Assert.Equal(0x3CA3D70Au, BitConverter.SingleToUInt32Bits(candidate)); + } + + [Fact] + public void StabilityUsesQwordPointZeroOneRatherThanPromotedFloatConstant() + { + float candidate = BitConverter.UInt32BitsToSingle(0x3C13D70Au); + float prior = -BitConverter.UInt32BitsToSingle(0x3A800001u); + float[] history = Enumerable.Repeat(prior, 30).ToArray(); + + double difference = Math.Abs((double)prior - candidate); + Assert.True(difference > (double)0.01f); + Assert.True(difference < 0.01); + Assert.True(BuildingDegradeController.IsCandidateStable(history, candidate)); + } + + [Fact] + public void AutomaticCommitRequiresAllThirtyPriorSlotsWithinStrictBand() + { + float[] history = Enumerable.Repeat(0.1f, 30).ToArray(); + history[15] = 0f; + + float rejected = BuildingDegradeController.AdvanceAutomaticMultiplier( + history, automatic: true, fps: 20f, current: 0f); + + Assert.Equal(0f, rejected); + Assert.Equal(0f, history[^1]); + + Array.Fill(history, 0.1f); + float committed = BuildingDegradeController.AdvanceAutomaticMultiplier( + history, automatic: true, fps: 20f, current: 0f); + + Assert.Equal(0.1f, committed, 7); + Assert.All(history, value => Assert.Equal(0.1f, value, 7)); + } + + [Fact] + public void AutomaticClampAndManualArmMatchFixedExpectedTransitions() + { + float[] history = Enumerable.Repeat(1f, 30).ToArray(); + float clamped = BuildingDegradeController.AdvanceAutomaticMultiplier( + history, automatic: true, fps: 25f, current: 0.95f); + Assert.Equal(1f, clamped); + + history[10] = -1f; + float manual = BuildingDegradeController.AdvanceAutomaticMultiplier( + history, automatic: false, fps: 0f, current: 0.35f); + + Assert.Equal(0.35f, manual); + Assert.Equal(0.35f, history[^1]); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs index 30fe2144..df613a34 100644 --- a/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RenderFrameOrchestratorTests.cs @@ -1,5 +1,6 @@ using System.Reflection; using AcDream.App.Rendering; +using AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.App.Tests.Rendering; @@ -84,6 +85,43 @@ public sealed class RenderFrameOrchestratorTests Assert.Empty(phases.ObservedInputs); } + [Fact] + public void AcceptedRenderTicksSharedDegradeOwnerExactlyOnceAndZeroAreaDoesNot() + { + var calls = new List(); + var phases = new RecordingPhases(calls); + var degradation = new RecordingDegradeTick(calls); + var orchestrator = new RenderFrameOrchestrator( + phases, phases, phases, phases, phases, phases, phases, phases, + degradation); + + double[] durations = + [ + 1d / 1024d, 2d / 1024d, 3d / 1024d, 4d / 1024d, + 5d / 1024d, 6d / 1024d, 7d / 1024d, 8d / 1024d, + 9d / 1024d, 10d / 1024d, 11d / 1024d, 12d / 1024d, + 13d / 1024d, 14d / 1024d, 15d / 1024d, 16d / 1024d, + 17d / 1024d, 18d / 1024d, 19d / 1024d, 20d / 1024d, + 31d / 1024d, + ]; + for (int i = 0; i < durations.Length - 1; i++) + orchestrator.Render(Input with { DeltaSeconds = durations[i] }); + orchestrator.Render(Input with { ViewportWidth = 0 }); + orchestrator.Render(Input with { DeltaSeconds = durations[^1] }); + + Assert.Equal(durations, degradation.Durations); + Assert.Equal(0x42C30C31u, BitConverter.SingleToUInt32Bits(degradation.Fps)); + Assert.Equal(durations.Length, calls.Count(static call => call == "degrade-tick")); + for (int i = 0; i < calls.Count; i++) + { + if (calls[i] == "gpu-begin") + { + Assert.True(i > 0, "degrade tick must precede BeginFrame"); + Assert.Equal("degrade-tick", calls[i - 1]); + } + } + } + [Fact] public void BeginFailure_DoesNotAttemptAnyPhaseOrClose() { @@ -278,6 +316,7 @@ public sealed class RenderFrameOrchestratorTests typeof(IRenderFrameDiagnosticsPhase), typeof(IRenderFramePostDiagnosticsPhase), typeof(IRenderFrameFailureRecovery), + typeof(IBuildingDegradeFrameTick), ]; FieldInfo[] fields = typeof(RenderFrameOrchestrator).GetFields( BindingFlags.Instance | BindingFlags.NonPublic); @@ -293,7 +332,7 @@ public sealed class RenderFrameOrchestratorTests Assert.All( expectedFieldTypes, contract => Assert.False(contract.IsAssignableFrom(typeof(GameWindow)))); - foreach (Type contract in expectedFieldTypes) + foreach (Type contract in expectedFieldTypes.Where(type => type.IsInterface)) { foreach (MethodInfo method in contract.GetMethods()) { @@ -395,6 +434,25 @@ public sealed class RenderFrameOrchestratorTests private static RenderFrameOrchestrator Create(RecordingPhases phases) => new(phases, phases, phases, phases, phases, phases, phases, phases); + private sealed class RecordingDegradeTick : IBuildingDegradeFrameTick + { + private readonly List _calls; + private readonly BuildingDegradeController _inner = new( + () => DisplaySettings.Default); + + public RecordingDegradeTick(List calls) => _calls = calls; + + public List Durations { get; } = []; + public float Fps => _inner.Fps; + + public void Tick(double elapsedSeconds) + { + _calls.Add("degrade-tick"); + Durations.Add(elapsedSeconds); + _inner.Tick(elapsedSeconds); + } + } + private sealed class RecordingPhases : IRenderFrameLifetime, IRenderFrameGpuMeasurement, diff --git a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs index 33c52fb4..8a4916f1 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/RetailFrameWalkTests.cs @@ -15,6 +15,14 @@ public sealed class RetailFrameWalkTests /// them — proves the terrain/building/object-turn ORDER, not just /// each vocabulary in isolation. public readonly List Combined = new(); + public int BuildingAlphaTurns; + public readonly List ShellSelections = new(); + + public void OnBuildingTurn(WalkBuilding building) => BuildingAlphaTurns++; + + public void OnBuildingShellTurn( + WalkBuilding building, + WalkBuildingSelection selection) => ShellSelections.Add(selection); public void Emit(in WalkEvent walkEvent) { @@ -66,7 +74,7 @@ public sealed class RetailFrameWalkTests => new(screenX, screenY, 100f); } - private sealed class TestContext : IWalkFrameContext, IRetailFrameWalkContext + private class TestContext : IWalkFrameContext, IRetailFrameWalkContext { public readonly Dictionary Cells = new(); private readonly Matrix4x4 _viewProj; @@ -88,7 +96,7 @@ public sealed class RetailFrameWalkTests public float ViewportHeight => 480f; public Vector3 ViewpointInBuilding(WalkBuilding building) => Vector3.Zero; - public float ViewerDistanceTo(WalkBuilding building) => 0f; + public virtual float ViewerDistanceTo(WalkBuilding building) => 0f; public IWalkFrameContext CellContext => this; // Permissive near plane: every column wholly inside. // S3 chunk 3 (T2): settable, default unchanged — a permissive @@ -165,11 +173,154 @@ public sealed class RetailFrameWalkTests var ctx = new TestContext(); var walk = new RetailFrameWalk(); var recorder = new Recorder(); - var building = new WalkBuilding { PositionCellId = 0xF518002E, HasGeometry = false }; + var building = new WalkBuilding { PositionCellId = 0xF518002E, GfxObjId = 0 }; walk.DrawBuilding(building, new WalkPortalView(), ctx, recorder); Assert.Equal("BLD:f518002e", recorder.Signature()); + Assert.Equal(0, recorder.BuildingAlphaTurns); + Assert.Empty(recorder.ShellSelections); + } + + [Fact] + public void NullBspSelectedGfxStillRunsAlphaAndShellWithoutPortalWalk() + { + var building = new WalkBuilding + { + PositionCellId = 0xF518002Eu, + GfxObjId = 0x01000001u, + DrawingBsp = null, + }; + var recorder = new Recorder(); + + new RetailFrameWalk().DrawBuilding( + building, new WalkPortalView(), new TestContext(), recorder); + + Assert.Equal("BLD:f518002e", recorder.Signature()); + Assert.Equal(1, recorder.BuildingAlphaTurns); + WalkBuildingSelection selected = Assert.Single(recorder.ShellSelections); + Assert.Equal(0x01000001u, selected.GfxObjId); + Assert.Null(selected.DrawingBsp); + } + + [Fact] + public void SelectionUsesStrictThresholdAndFinalZeroIsCompleteBodyFailure() + { + var firstBsp = new WalkBspNode(); + var building = new WalkBuilding + { + GfxObjId = 0x0100FFFFu, + DegradeLevels = + [ + new(0x01000001u, 1u, 10f, 20f, 30f, firstBsp), + new(0u, 1u, 30f, 40f, 50f, null), + ], + }; + + Assert.Equal(0x01000001u, building.Select(29.999f, 0f, 1f).GfxObjId); + WalkBuildingSelection equality = building.Select(30f, 0f, 1f); + Assert.Equal(0u, equality.GfxObjId); + Assert.Equal(1, equality.Level); + + var recorder = new Recorder(); + var context = new DistanceContext(80f); + new RetailFrameWalk().DrawBuilding( + building, new WalkPortalView(), context, recorder); + Assert.Equal("BLD:00000000", recorder.Signature()); + Assert.Equal(0, recorder.BuildingAlphaTurns); + Assert.Empty(recorder.ShellSelections); + } + + [Fact] + public void SelectionComparesStoredFloatEffectiveDistanceToWideThreshold() + { + const float multiplier = 0.0020020019728690386f; + const float effective = 24.04804801940918f; + Assert.Equal(0x41C06267u, BitConverter.SingleToUInt32Bits(effective)); + var building = new WalkBuilding + { + DegradeLevels = + [ + new(0x01000001u, 1u, 0f, 24f, 48f, null), + new(0x01000002u, 1u, 48f, 64f, 80f, null), + ], + }; + + WalkBuildingSelection selected = building.Select( + effective, degradeDistance: 0f, degradeMultiplier: multiplier); + + Assert.Equal(0, selected.Level); + Assert.Equal(0x01000001u, selected.GfxObjId); + } + + [Fact] + public void DirectAndLadderSelectionsCarryExactIdBspLevelAndMode() + { + var directBsp = new WalkBspNode(); + var direct = new WalkBuilding + { + GfxObjId = 0x01000010u, + DrawingBsp = directBsp, + }; + Assert.Equal( + new WalkBuildingSelection(0x01000010u, directBsp, 0, 1u), + direct.Select(float.NaN, 50f, float.NaN)); + + var nearBsp = new WalkBspNode(); + var middleBsp = new WalkBspNode(); + var ladder = new WalkBuilding + { + GfxObjId = 0x0100FFFFu, + DegradeLevels = + [ + new(0x01000011u, 3u, 10f, 20f, 30f, nearBsp), + new(0x01000012u, 5u, 30f, 40f, 50f, middleBsp), + new(0u, 7u, 50f, 60f, 70f, null), + ], + }; + + Assert.Equal( + new WalkBuildingSelection(0x01000011u, nearBsp, 0, 3u), + ladder.Select(29f, 5f, 0.5f)); // effective 24 < positive threshold 25 + Assert.Equal( + new WalkBuildingSelection(0x01000011u, nearBsp, 0, 3u), + ladder.Select(-19f, 5f, -0.5f)); // effective 14 < negative threshold 15 + Assert.Equal( + new WalkBuildingSelection(0x01000012u, middleBsp, 1, 5u), + ladder.Select(30f, 5f, 0.5f)); // equality advances + Assert.Equal(2, ladder.Select(float.PositiveInfinity, 5f, 0f).Level); + Assert.Equal(2, ladder.Select(float.NegativeInfinity, 5f, 0f).Level); + Assert.Equal(2, ladder.Select(float.NaN, 5f, 0f).Level); + } + + [Fact] + public void DisableAndForcedLevelBranchesPreserveRetailPrecedenceAndClamp() + { + var firstBsp = new WalkBspNode(); + var building = new WalkBuilding + { + DegradeLevels = + [ + new(0x01000021u, 11u, 1f, 2f, 3f, firstBsp), + new(0x01000022u, 12u, 3f, 4f, 5f, null), + new(0u, 13u, 5f, 6f, 7f, null), + ], + }; + + Assert.Equal( + new WalkBuildingSelection(0x01000021u, firstBsp, 0, 11u), + building.Select(float.PositiveInfinity, 50f, 1f, + degradesDisabled: true, forcedLevel: 2)); + Assert.Equal(1, building.Select(0f, 50f, 0f, forcedLevel: 1).Level); + WalkBuildingSelection clamped = building.Select(0f, 50f, 0f, forcedLevel: 99); + Assert.Equal(2, clamped.Level); + Assert.Equal(13u, clamped.Mode); + Assert.Equal(0u, clamped.GfxObjId); + } + + private sealed class DistanceContext(float distance) : TestContext + { + public override float ViewerDistanceTo(WalkBuilding building) => distance; } // ── T2 (S3 chunk 3 §9.3): the per-cell interleave — DrawLandCell (LC) @@ -224,7 +375,7 @@ public sealed class RetailFrameWalkTests nearBlock.EnsureCellArrays(); // A building at an ordinary in-view cell (cx=3, cy=0), away from // both the excluded column and the closest/farthest cells. - var building = new WalkBuilding { PositionCellId = 0x11110019u, HasGeometry = false }; + var building = new WalkBuilding { PositionCellId = 0x11110019u, GfxObjId = 0 }; nearBlock.CellBuildings[3 * 8 + 0] = building; // The far block (grid slot 3 = (1,1), the diagonal corner — drawn diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs new file mode 100644 index 00000000..699b379f --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkBuildingInstalledDatCensusTests.cs @@ -0,0 +1,104 @@ +using AcDream.App.Tests.Rendering; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Options; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering.Walk; + +[Trait("Lane", "InstalledDat")] +public sealed class WalkBuildingInstalledDatCensusTests +{ + [Fact] + public void CompleteInstalledBuildingCorpusMatchesBoundedSelectionCensus() + { + string? datDir = CornerFloodReplayTests.ResolveDatDir(); + if (datDir is null) + Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory."); + using var dats = new DatCollection(datDir!, DatAccessType.Read); + + int landblocks = 0; + int instances = 0; + int ladderInstances = 0; + int ladderSlots = 0; + int nonzeroSlots = 0; + int zeroFinalSlots = 0; + var models = new HashSet(); + var ladderModels = new HashSet(); + var ladderHistogram = new Dictionary(); + + for (uint blockX = 0; blockX <= 0xFF; blockX++) + for (uint blockY = 0; blockY <= 0xFF; blockY++) + { + uint infoId = (blockX << 24) | (blockY << 16) | 0xFFFEu; + LandBlockInfo? info = dats.Get(infoId); + if (info?.Buildings is not { Count: > 0 } buildings) + continue; + landblocks++; + var anchors = new HashSet(); + uint landblock = infoId & 0xFFFF0000u; + + foreach (BuildingInfo building in buildings) + { + instances++; + models.Add(building.ModelId); + GfxObj? baseGfx = dats.Get(building.ModelId); + Assert.NotNull(baseGfx); + + BuildingPortal? firstInteriorPortal = building.Portals.FirstOrDefault( + static portal => portal.OtherCellId != 0xFFFF); + int cellX = (int)MathF.Floor(building.Frame.Origin.X / 24f); + int cellY = (int)MathF.Floor(building.Frame.Origin.Y / 24f); + uint placementCell = landblock | (uint)(cellX * 8 + cellY + 1); + uint anchor = firstInteriorPortal is null + ? placementCell + : landblock | firstInteriorPortal.OtherCellId; + Assert.True(anchors.Add(anchor), + $"duplicate building anchor 0x{anchor:X8}"); + + if (baseGfx!.DIDDegrade == 0) + continue; + GfxObjDegradeInfo? ladder = + dats.Get(baseGfx.DIDDegrade); + Assert.NotNull(ladder); + ladderInstances++; + ladderModels.Add(building.ModelId); + int count = ladder!.Degrades.Count; + ladderSlots += count; + ladderHistogram[count] = ladderHistogram.GetValueOrDefault(count) + 1; + Assert.NotEmpty(ladder.Degrades); + Assert.Equal(0u, (uint)ladder.Degrades[^1].Id); + zeroFinalSlots++; + + for (int i = 0; i < ladder.Degrades.Count - 1; i++) + { + GfxObjInfo slot = ladder.Degrades[i]; + Assert.NotEqual(0u, (uint)slot.Id); + GfxObj? selected = dats.Get((uint)slot.Id); + Assert.NotNull(selected); + Assert.NotNull(selected!.DrawingBSP?.Root); + nonzeroSlots++; + } + } + } + + Assert.Equal(1_639, landblocks); + Assert.Equal(6_979, instances); + Assert.Equal(398, models.Count); + Assert.Equal(6_760, ladderInstances); + Assert.Equal(27_859, ladderSlots); + Assert.Equal(350, ladderModels.Count); + Assert.Equal(6_760, zeroFinalSlots); + Assert.Equal(21_099, nonzeroSlots); + Assert.Equal( + new Dictionary + { + [2] = 196, + [3] = 216, + [4] = 4_964, + [5] = 1_341, + [6] = 43, + }, + ladderHistogram); + } +} diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs index 5024adb7..7e5ee8c3 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkFrameDriverTests.cs @@ -1036,6 +1036,7 @@ public sealed partial class WalkFrameDriverTests var building = new WalkBuilding { PositionCellId = 0xA9B4000Fu, + GfxObjId = (uint)shellGfxObj, Portals = [ new WalkBldPortal @@ -1139,6 +1140,36 @@ public sealed partial class WalkFrameDriverTests Assert.Equal(3, mdiCalls.Sum(c => (int)c.DrawCount)); } + [Fact] + public void BuildingShellTurnAllowsEmptyRecordButFailsLoudOnDuplicateAnchorRecords() + { + using var fx = new DispatcherFixture(); + var building = new WalkBuilding + { + PositionCellId = 0xA9B40001u, + GfxObjId = 0x01000001u, + }; + var selected = new WalkBuildingSelection( + building.GfxObjId, null, 0, 1u); + var world = new FakeWorldData(); + var driver = new WalkFrameDriver( + fx.Dispatcher, new RecordingLeafRenderer([]), world); + driver.BeginFrame(new TestContext(), Matrix4x4.Identity, Vector3.Zero); + + ((IWalkEventSink)driver).OnBuildingShellTurn(building, selected); + + RenderProjectionRecord record = MakeRecord( + 301, 0, Vector3.Zero, + [new MeshRef(building.GfxObjId, Matrix4x4.Identity)], + isBuildingShell: true); + world.ShellByBuilding[building] = new WalkFrameStaticRecords( + new[] { record, record with { Id = RenderProjectionId.FromRaw(302) } }, + 0xA9B4u); + InvalidOperationException error = Assert.Throws( + () => ((IWalkEventSink)driver).OnBuildingShellTurn(building, selected)); + Assert.Contains("expected at most one", error.Message, StringComparison.Ordinal); + } + // ── S4-c1 C1/T2 (quantifier corrected at fix round 1 F1): DrawPortalPoly // Internal's degenerate-input guard, ported at the punch-fan producer // (WalkFrameDriver.OnPunchGeometry — the handler that owns the LOCAL diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs index c9c1ab95..ce0ada67 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkPortalGateDumpTests.cs @@ -52,9 +52,9 @@ public sealed class WalkPortalGateDumpTests float dist = ctx.ViewerDistanceTo(building); Vector3 eyeInBuilding = ctx.ViewpointInBuilding(building); dump.AppendLine( - $"building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 100f):f1} " + $"building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 50f):f1} " + $"eyeLocal={eyeInBuilding} portals={building.Portals.Length}"); - WalkBspNode? bsp = building.SelectDrawingBsp(dist); + WalkBspNode? bsp = building.Select(dist, 50f, 0f).DrawingBsp; var refs = new List(); Collect(bsp, refs); dump.AppendLine($" level BSP portal refs: {refs.Count}"); @@ -161,9 +161,9 @@ public sealed class WalkPortalGateDumpTests world.Buildings.Keys.Single(b => b.PositionCellId == id); float dist = ctx.ViewerDistanceTo(building); Vector3 eyeInBuilding = ctx.ViewpointInBuilding(building); - WalkBspNode? bsp = building.SelectDrawingBsp(dist); + WalkBspNode? bsp = building.Select(dist, 50f, 0f).DrawingBsp; dump.AppendLine( - $" building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 100f):f1} " + $" building {id:x8}: dist={dist:f1} eff={MathF.Max(0f, dist - 50f):f1} " + $"bsp={(bsp is null ? "NULL" : "selected")} ports={CountPorts(bsp)}"); for (int li = 0; li < building.DegradeLevels.Length; li++) { @@ -249,7 +249,8 @@ public sealed class WalkPortalGateDumpTests WalkBuilding building = world.Buildings.Keys.Single(b => b.PositionCellId == 0xA9B40036u); Vector3 eye = ctx.ViewpointInBuilding(building); - WalkBspNode? bsp = building.SelectDrawingBsp(ctx.ViewerDistanceTo(building)); + WalkBspNode? bsp = building.Select( + ctx.ViewerDistanceTo(building), 50f, 0f).DrawingBsp; var dump = new StringBuilder(); dump.AppendLine($"camera cell={pose.CellId:x8} origin={pose.Origin}"); dump.AppendLine($"eyeInBuilding={eye} dist={ctx.ViewerDistanceTo(building):f2}"); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs index 66ceb2ce..6879c840 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionFrameContextTests.cs @@ -108,6 +108,32 @@ public sealed class WalkProductionFrameContextTests Assert.Equal(13f, ctx.ViewerDistanceTo(building)); } + [Fact] + public void SetupPartZeroTransformIsSharedByViewpointDistanceAndWorldPublication() + { + var registry = new WalkBuildingRegistry(); + var building = new WalkBuilding + { + PositionCellId = 1, + SortCenter = new Vector3(0f, 1f, 0f), + PartZeroTransform = Matrix4x4.CreateScale(2f) + * Matrix4x4.CreateTranslation(0f, 3f, 0f), + PartZeroScaleZ = 2f, + }; + Matrix4x4 root = Matrix4x4.CreateTranslation(0f, 10f, 0f); + Matrix4x4.Invert(root, out Matrix4x4 inverseRoot); + var entry = new WalkBuildingFactory.Entry(building, root, inverseRoot); + registry.Publish(0xA9B4FFFFu, [entry]); + var ctx = new WalkProductionFrameContext( + new CellVisibility(), registry, new Vector3(0f, 19f, 0f), Vector3.UnitY, + SimpleViewProjection(), 1024f, 768f); + + Assert.Equal(entry.PartZeroWorldTransform, + building.PartZeroTransform * entry.WorldTransform); + Assert.Equal(new Vector3(0f, 3f, 0f), ctx.ViewpointInBuilding(building)); + Assert.Equal(2f, ctx.ViewerDistanceTo(building)); + } + [Fact] public void CyPlane_MatchesTheRetailNearPlaneFormula() { diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs index ee941cd8..4da2cc09 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkProductionWorldConformanceTests.cs @@ -161,7 +161,7 @@ public sealed class WalkProductionWorldConformanceTests Dictionary buildings) = BuildProductionWorld(adapter, frame.Pose!.CellId, frame.Pose.Origin); var ctx = new WalkTraceReplayContext(frame.Pose, cells) { Buildings = buildings }; - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, null, assembler.Landscape, ctx, recorder); @@ -190,7 +190,7 @@ public sealed class WalkProductionWorldConformanceTests // Matches WalkTraceConformanceTests.Doorway_still_first_frame_diff: // this capture ran under cdb load with Render::deg_mul depressed to // the portless-arm threshold. Same environment pin, same reason. - var walk = new RetailFrameWalk { DegradeMultiplier = 0f }; + var walk = new RetailFrameWalk(100f, 0f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder); @@ -221,7 +221,7 @@ public sealed class WalkProductionWorldConformanceTests WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100 ? Assert.Contains(frame.Pose.CellId, cells) : null; - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, assembler.Landscape, ctx, recorder); @@ -302,7 +302,7 @@ public sealed class WalkProductionWorldConformanceTests }, }; - new RetailFrameWalk().WalkFrame( + new RetailFrameWalk(100f, 0.99f).WalkFrame( cameraCellId, camera, assembler.Landscape, @@ -348,7 +348,7 @@ public sealed class WalkProductionWorldConformanceTests (WalkLandscapeAssembler assembler, Dictionary cells, Dictionary buildings) = BuildProductionWorld(adapter, anchor.CellId, anchor.Origin); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); for (int n = 1; n < frames.Count - 1; n++) { @@ -402,7 +402,7 @@ public sealed class WalkProductionWorldConformanceTests Assert.NotNull(frame.Pose); WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells); var ctx = new WalkTraceReplayContext(frame.Pose, cells); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs index 127c7695..f60725ca 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkStaticStreamPopulatorTests.cs @@ -542,6 +542,260 @@ public sealed class WalkStaticStreamPopulatorTests Assert.Equal((uint)leavesGfxObj, selectionParts[1].GfxObjId); } + [Fact] + public void SelectedBuildingShellUsesExactlySelectedGfxAndPrecomputedPartZeroTransform() + { + using var fx = new DispatcherFixture(); + const uint baseGfx = 0x0100_0031u; + const uint selectedGfx = 0x0100_0032u; + InjectRenderData(fx.Manager, baseGfx, MakeFlatMesh( + MakeBatch(0x08000031u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + InjectRenderData(fx.Manager, selectedGfx, MakeFlatMesh( + MakeBatch(0x08000032u, TranslucencyKind.Opaque, 3, 4, 6, 2))); + Matrix4x4 root = Matrix4x4.CreateTranslation(8, 9, 10); + Matrix4x4 partZero = Matrix4x4.CreateScale(2f) + * Matrix4x4.CreateTranslation(1, 2, 3); + var surfaceOverrides = new Dictionary + { + [0x08000032u] = 0x05000032u, + }; + RenderProjectionRecord record = MakeRecord( + 231, 0x7000_0231u, new Vector3(8, 9, 10), + [new MeshRef(baseGfx, Matrix4x4.Identity) + { + SurfaceOverrides = surfaceOverrides, + }], + isBuildingShell: true); + var batches = new List(); + var selections = new List(); + var selected = new WalkBuildingSelection(selectedGfx, null, 2, 1u); + + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: partZero); + + WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches); + Assert.Equal(3u, batch.Key.FirstIndex); + Assert.True(batch.Key.TextureSlot.IsAssigned); + Assert.NotEqual(new GpuTextureSlot(2), batch.Key.TextureSlot); + Assert.Equal(partZero * root, batch.Transform); + var selection = Assert.Single(selections); + Assert.Equal(selectedGfx, selection.GfxObjId); + Assert.Equal(partZero * root, selection.LocalToWorld); + } + + [Fact] + public void SelectedBuildingShellMissDoesNotFallBackToResidentBaseGfx() + { + using var fx = new DispatcherFixture(); + const uint baseGfx = 0x0100_0041u; + const uint missingSelectedGfx = 0x0100_0042u; + InjectRenderData(fx.Manager, baseGfx, MakeFlatMesh( + MakeBatch(0x08000041u, TranslucencyKind.Opaque, 0, 0, 3, 1))); + RenderProjectionRecord record = MakeRecord( + 241, 0, Vector3.Zero, + [new MeshRef(baseGfx, Matrix4x4.Identity)], isBuildingShell: true); + var batches = new List(); + var selections = new List(); + var selected = new WalkBuildingSelection(missingSelectedGfx, null, 1, 1u); + + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + + Assert.Empty(batches); + Assert.Empty(selections); + + InjectRenderData(fx.Manager, missingSelectedGfx, MakeFlatMesh( + MakeBatch(0x08000042u, TranslucencyKind.Opaque, 3, 4, 6, 2))); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + Assert.Single(batches); + Assert.Equal(missingSelectedGfx, Assert.Single(selections).GfxObjId); + } + + [Fact] + public void WalkFrameBoundary_DeduplicatesMissWithinFrameAndRearmsNextAcceptedFrame() + { + const uint missingGfx = 0x01000049u; + var source = new RecordingPreparedAssetSource(); + using var fx = new DispatcherFixture(preparedAssets: source); + RenderProjectionRecord record = MakeRecord( + 249, 0, Vector3.Zero, + [new MeshRef(0x01000048u, Matrix4x4.Identity)], + isBuildingShell: true); + var selected = new WalkBuildingSelection(missingGfx, null, 1, 1u); + var batches = new List(); + var selections = new List(); + FieldInfo requestedField = typeof(WbDrawDispatcher).GetField( + "_missRequested", BindingFlags.Instance | BindingFlags.NonPublic)!; + var requested = (HashSet)requestedField.GetValue(fx.Dispatcher)!; + + fx.Dispatcher.BeginWalkPartFrame(); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + Assert.Equal([missingGfx], requested); + Assert.Throws(() => fx.Dispatcher.BeginWalkPartFrame()); + Assert.Equal([missingGfx], requested); + Assert.True(SpinWait.SpinUntil(() => source.ReadCount >= 1, TimeSpan.FromSeconds(5))); + Assert.Equal(1, source.ReadCount); + + fx.Manager.CancelStagedUploads([missingGfx]); + fx.Dispatcher.EndWalkPartFrame(); + fx.Dispatcher.BeginWalkPartFrame(); + Assert.Empty(requested); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + Assert.True(SpinWait.SpinUntil(() => source.ReadCount >= 2, TimeSpan.FromSeconds(5))); + Assert.Equal([missingGfx], requested); + fx.Dispatcher.EndWalkPartFrame(); + + for (int frame = 0; frame < 32; frame++) + { + fx.Manager.CancelStagedUploads([missingGfx]); + fx.Dispatcher.BeginWalkPartFrame(); + Assert.Empty(requested); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + Assert.Single(requested); + fx.Dispatcher.EndWalkPartFrame(); + } + Assert.Single(requested); + } + + [Fact] + public void OwnedWalkLadderDependency_PreparesPublishesAndClassifiesWithoutInjection() + { + const uint selectedGfx = 0x0100004Au; + var prepared = PreparedTriangle(selectedGfx, 0x0800004Au); + var source = new RecordingPreparedAssetSource(prepared); + using var fx = new DispatcherFixture(preparedAssets: source); + var ownership = new LandblockSpawnAdapter(fx.Adapter); + var building = new WalkBuilding + { + PositionCellId = 0x8C040001u, + DegradeLevels = + [ + new WalkBuildingDegradeLevel(selectedGfx, 1u, 0f, 10f, 20f, null), + ], + }; + var envCells = new EnvCellLandblockBuild( + 0x8C04FFFFu, + Array.Empty(), + Array.Empty(), + [new WalkBuildingFactory.Entry(building, Matrix4x4.Identity, Matrix4x4.Identity)]); + var landblock = new LoadedLandblock( + 0x8C04FFFFu, new LandBlock(), Array.Empty()); + + ownership.OnLandblockLoaded( + landblock, + additionalOrdinaryIds: envCells.WalkBuildingMeshDependencies); + + Assert.True(fx.Manager.IsOwned(selectedGfx)); + Assert.True(SpinWait.SpinUntil(() => + { + fx.Adapter.Tick(); + return fx.Adapter.TryGetRenderData(selectedGfx) is not null; + }, TimeSpan.FromSeconds(5))); + Assert.Equal(1, source.ReadCount); + + RenderProjectionRecord record = MakeRecord( + 250, 0, Vector3.Zero, + [new MeshRef(0x0100004Bu, Matrix4x4.Identity)], + isBuildingShell: true); + var batches = new List(); + var selections = new List(); + fx.Dispatcher.BeginWalkPartFrame(); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: building.Select(0f, 1f, 1f), + buildingPartTransform: Matrix4x4.Identity); + fx.Dispatcher.EndWalkPartFrame(); + + Assert.Single(batches); + Assert.Equal(selectedGfx, Assert.Single(selections).GfxObjId); + ownership.OnLandblockUnloaded(landblock.LandblockId); + Assert.False(fx.Manager.IsOwned(selectedGfx)); + } + + [Fact] + public void WarmedBuildingSelectionAndClassificationAllocateZeroAndDoNotMutateRetainedRecord() + { + using var fx = new DispatcherFixture(); + const uint baseGfx = 0x0100_0051u; + const uint selectedGfx = 0x0100_0052u; + InjectRenderData(fx.Manager, selectedGfx, MakeFlatMesh( + MakeBatch(0x08000052u, TranslucencyKind.Opaque, 3, 4, 6, 2))); + RenderProjectionRecord record = MakeRecord( + 251, 0x7000_0251u, new Vector3(8, 9, 10), + [new MeshRef(baseGfx, Matrix4x4.Identity)], + isBuildingShell: true, + parentCellId: 0x8C040112u, + casterIdentity: RenderCasterIdentityKind.Building) with + { + Source = new RenderSourceMetadata( + LocalEntityId: 251, + ServerGuid: 0x7000_0251u, + SourceId: 0x02000051u, + ParentCellId: 0x8C040112u, + EffectCellId: 0x8C040113u, + BuildingShellAnchorCellId: 0x8C040001u, + TransformFingerprint: new RenderSceneHash128(11, 21), + GeometryFingerprint: new RenderSceneHash128(12, 22), + AppearanceFingerprint: new RenderSceneHash128(13, 23)), + }; + RenderProjectionRecord original = record; + var building = new WalkBuilding + { + DegradeLevels = + [ + new(selectedGfx, 4u, 0f, 10f, 20f, null), + new(0u, 5u, 20f, 30f, 40f, null), + ], + }; + var batches = new List(1); + var selections = new List(1); + + void Classify() + { + batches.Clear(); + selections.Clear(); + WalkBuildingSelection selected = building.Select(0f, 50f, 0f); + fx.Dispatcher.ClassifyEntityForWalk( + in record, 0x8C04u, batches, selections, + buildingSelection: selected, + buildingPartTransform: Matrix4x4.Identity); + } + + Classify(); + long allocated = ZeroAllocationProbe.MeasureWarmed( + Classify, + batchSize: 10_000, + warmupBatches: 2, + samples: 5); + + Assert.Equal(0, allocated); + Assert.Equal(original, record); + Assert.Equal(selectedGfx, Assert.Single(selections).GfxObjId); + WbDrawDispatcher.WalkClassifiedBatch batch = Assert.Single(batches); + Assert.Equal(1u, batch.DetailCategory); + Assert.Equal(Matrix4x4.CreateTranslation(8, 9, 10), batch.Transform); + } + [Fact] public void ClassifyEntityForWalk_FrameScopeStampsEachAdmittedSetupPartOncePerRetailPass() { @@ -1005,6 +1259,31 @@ public sealed class WalkStaticStreamPopulatorTests return group; } + private static ObjectMeshData PreparedTriangle(uint objectId, uint surfaceId) => new() + { + ObjectId = objectId, + Vertices = + [ + new VertexPositionNormalTexture { Position = Vector3.Zero }, + new VertexPositionNormalTexture { Position = Vector3.UnitX }, + new VertexPositionNormalTexture { Position = Vector3.UnitY }, + ], + TextureBatches = + { + [(2, 2, Chorizite.Core.Render.Enums.TextureFormat.RGBA8)] = + [ + new TextureBatchData + { + Key = new TextureKey { SurfaceId = surfaceId }, + TextureData = Enumerable.Repeat((byte)0xFF, 16).ToArray(), + Indices = [0, 1, 2], + Translucency = TranslucencyKind.Opaque, + CullMode = CullMode.Clockwise, + }, + ], + }, + }; + [Fact] public void SubmitWalkAlphaInstance_RejectsAMismatchedViewProjectionInTheSameScope() { @@ -1098,7 +1377,8 @@ public sealed class WalkStaticStreamPopulatorTests bool withAlphaQueue = false, IRetailSelectionRenderSink? selectionSink = null, bool detailAvailable = false, - bool detailEnabled = false) + bool detailEnabled = false, + IPreparedAssetSource? preparedAssets = null) { Device = new RecordingGpuDevice(); FrameLifetime = new GpuDeviceFrameLifetime(Device); @@ -1107,7 +1387,7 @@ public sealed class WalkStaticStreamPopulatorTests _meshAdapter = new WbMeshAdapter( Device, new NoopDatReaderWriter(), - new NullPreparedAssetSource(), + preparedAssets ?? new NullPreparedAssetSource(), NullLogger.Instance, Device.Retirement); var entitySpawnAdapter = new EntitySpawnAdapter( @@ -1150,6 +1430,8 @@ public sealed class WalkStaticStreamPopulatorTests public ObjectMeshManager Manager => _meshAdapter.MeshManager!; + public WbMeshAdapter Adapter => _meshAdapter; + public DrawScope BeginDraw(bool beginAlpha = false) { if (beginAlpha) @@ -1201,6 +1483,31 @@ public sealed class WalkStaticStreamPopulatorTests } } + private sealed class RecordingPreparedAssetSource(ObjectMeshData? data = null) + : IPreparedAssetSource + { + private int _readCount; + + public int ReadCount => Volatile.Read(ref _readCount); + public PreparedAssetSourceStats Stats => default; + public CacheStats DecodedTextureCacheStats => default; + public PreparedAssetPresence Probe( + AcDream.Content.Pak.PakAssetType type, + uint sourceFileId) => PreparedAssetPresence.Available; + public PreparedAssetReadResult Read( + in PreparedAssetRequest request, + CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _readCount); + return data is not null && data.ObjectId == request.RuntimeObjectId + ? PreparedAssetReadResult.Loaded(data) + : PreparedAssetReadResult.Missing; + } + public void Dispose() + { + } + } + private sealed class NoopDatReaderWriter : IDatReaderWriter { private readonly StubDatabase _portal = new(); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs index c755c137..c3f9b89a 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.AlphaDepthTranscript.cs @@ -130,8 +130,10 @@ public sealed partial class WalkTraceConformanceTests public void OnBuildingTurn(WalkBuilding building) => _inner.OnBuildingTurn(building); - public void OnBuildingShellTurn(WalkBuilding building) => - _inner.OnBuildingShellTurn(building); + public void OnBuildingShellTurn( + WalkBuilding building, + WalkBuildingSelection selection) => + _inner.OnBuildingShellTurn(building, selection); public void OnPunchGeometry( WalkBuilding building, WalkPolygon polygon, int activeViewIndex) @@ -334,7 +336,7 @@ public sealed partial class WalkTraceConformanceTests var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData); leaf.Driver = driver; var sink = new AlphaDepthCollectSink(driver); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); // R2-2: seed the persistent counter from the fixture's own // pre-capture value instead of priming a throwaway first pass. @@ -416,7 +418,7 @@ public sealed partial class WalkTraceConformanceTests var driver = new WalkFrameDriver(fx.Dispatcher, leaf, worldData); leaf.Driver = driver; var sink = new AlphaDepthCollectSink(driver); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); driver.PortalsDrawnCount = initialCounter; queue.BeginFrame(); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs index 83a5dbde..6ad6926f 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkTraceConformanceTests.cs @@ -139,7 +139,7 @@ public sealed partial class WalkTraceConformanceTests { Buildings = world.Buildings, }; - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, null, world.Landscape, ctx, recorder); @@ -178,7 +178,7 @@ public sealed partial class WalkTraceConformanceTests // look-in floods. Every other fixture pins ≈ +0.99 (thresholds at // max). The recon session's live dump read −0.99 under the same cdb // load. Re-dump deg_mul per capture at the next retail session. - var walk = new RetailFrameWalk { DegradeMultiplier = 0f }; + var walk = new RetailFrameWalk(100f, 0f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); @@ -257,7 +257,7 @@ public sealed partial class WalkTraceConformanceTests Buildings = world.Buildings, }; WalkCell camera = Assert.Contains(frame.Pose.CellId, world.Cells); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); @@ -304,7 +304,7 @@ public sealed partial class WalkTraceConformanceTests WalkCell? camera = (frame.Pose.CellId & 0xFFFFu) >= 0x100 ? Assert.Contains(frame.Pose.CellId, world.Cells) : null; - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); @@ -363,7 +363,7 @@ public sealed partial class WalkTraceConformanceTests WalkOraclePose anchor = frames[1].Pose!; WalkLandscapeDatBuilder.BuiltWorld world = WalkLandscapeDatBuilder.Build(dats, anchor.CellId, anchor.Origin); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); for (int n = 1; n < frames.Count - 1; n++) { @@ -423,7 +423,7 @@ public sealed partial class WalkTraceConformanceTests Assert.NotNull(frame.Pose); WalkCell camera = Assert.Contains(frame.Pose!.CellId, cells); var ctx = new WalkTraceReplayContext(frame.Pose, cells); - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, landscape, ctx, recorder); @@ -467,7 +467,7 @@ public sealed partial class WalkTraceConformanceTests { Buildings = world.Buildings, }; - var walk = new RetailFrameWalk(); + var walk = new RetailFrameWalk(100f, 0.99f); var recorder = new Recorder(); walk.WalkFrame(frame.Pose.CellId, camera, world.Landscape, ctx, recorder); diff --git a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs index b0ee798a..d1fee47c 100644 --- a/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs +++ b/tests/AcDream.App.Tests/Rendering/Walk/WalkWorldDatAdapter.cs @@ -169,7 +169,9 @@ public static class WalkWorldDatAdapter { levelBsp = ConvertDrawingBsp(levelGfx, levelGfx.DrawingBSP?.Root); } - degradeLevels.Add(new WalkBuildingDegradeLevel(level.MinDist, level.IdealDist, level.MaxDist, levelBsp)); + degradeLevels.Add(new WalkBuildingDegradeLevel( + (uint)level.Id, level.DegradeMode, + level.MinDist, level.IdealDist, level.MaxDist, levelBsp)); } } } @@ -183,6 +185,7 @@ public static class WalkWorldDatAdapter { PositionCellId = positionCellId, Portals = portals, + GfxObjId = buildingInfo.ModelId, DrawingBsp = bsp, DegradeLevels = degradeLevels.ToArray(), SortCenter = sortCenter, diff --git a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs index b53a3db9..4355f9f0 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/EnvCellLandblockBuildTests.cs @@ -2,6 +2,7 @@ using System.Collections.Immutable; using System.Numerics; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Walk; namespace AcDream.App.Tests.Rendering.Wb; @@ -85,6 +86,41 @@ public class EnvCellLandblockBuildTests Assert.Throws(() => builder.Build()); } + [Fact] + public void Constructor_PrecomputesUniqueNonzeroWalkBuildingMeshDependencies() + { + WalkBuildingFactory.Entry[] buildings = + [ + Building(0x01000010u, 0u, 0x01000020u, 0x01000010u), + Building(0x01000020u, 0x01000030u), + ]; + + var build = new EnvCellLandblockBuild( + LandblockId, + Array.Empty(), + Array.Empty(), + buildings); + + Assert.Equal( + [0x01000010ul, 0x01000020ul, 0x01000030ul], + build.WalkBuildingMeshDependencies); + buildings[0].Building.DegradeLevels = []; + Assert.Equal( + [0x01000010ul, 0x01000020ul, 0x01000030ul], + build.WalkBuildingMeshDependencies); + } + + private static WalkBuildingFactory.Entry Building(params uint[] ids) => + new( + new WalkBuilding + { + PositionCellId = 0x8C040001u, + DegradeLevels = ids.Select(id => + new WalkBuildingDegradeLevel(id, 1u, 0f, 10f, 20f, null)).ToArray(), + }, + Matrix4x4.Identity, + Matrix4x4.Identity); + private static EnvCellShellPlacement Shell(uint cellId, ulong geometryId) { var min = new Vector3(cellId & 0xFF, 0, 0); diff --git a/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs index c9ddbfd2..f3c2a947 100644 --- a/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs +++ b/tests/AcDream.App.Tests/Rendering/Wb/LandblockSpawnAdapterReadinessTests.cs @@ -81,6 +81,85 @@ public sealed class LandblockSpawnAdapterReadinessTests Assert.DoesNotContain(sharedGeometryId, meshes.ReferenceCounts); } + [Fact] + public void WalkLadderDependencies_AreOrdinaryOwnedDeduplicatedAndBalancedAcrossRevisit() + { + const ulong baseAndLadder = 0x01000010ul; + const ulong ladderOnly = 0x01000020ul; + const ulong prepared = 0x2_0000_1234ul; + var meshes = new ReadinessMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu, (uint)baseAndLadder); + + adapter.OnLandblockLoaded( + landblock, + [prepared], + [0ul, baseAndLadder, ladderOnly, ladderOnly]); + + Assert.Equal(1, meshes.ReferenceCounts[baseAndLadder]); + Assert.Equal(1, meshes.ReferenceCounts[ladderOnly]); + Assert.Equal(1, meshes.ReferenceCounts[prepared]); + Assert.Equal([prepared], meshes.PreparedPinCalls); + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + + adapter.OnLandblockLoaded(landblock, [prepared], [baseAndLadder, ladderOnly]); + Assert.Equal(1, meshes.ReferenceCounts[baseAndLadder]); + Assert.Equal(1, meshes.ReferenceCounts[ladderOnly]); + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + } + + [Fact] + public void WalkLadderDependency_PartialAcquireFailureCanCancelThenReplaceWithoutLeak() + { + const ulong first = 0x01000010ul; + const ulong failed = 0x01000020ul; + const ulong replacement = 0x01000030ul; + var meshes = new FaultInjectingMeshAdapter(); + meshes.FailNext(ReferenceOperation.Increment, failed, committed: false); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu); + + Assert.Throws(() => + adapter.OnLandblockLoaded(landblock, null, [first, failed])); + Assert.Equal(1, meshes.ReferenceCounts[first]); + + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + adapter.OnLandblockLoaded(landblock, null, [replacement]); + Assert.Equal(1, meshes.ReferenceCounts[replacement]); + adapter.OnLandblockLoaded(landblock, null, [replacement]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, replacement)); + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + } + + [Fact] + public void WalkLadderDependency_ExactReplacementReleasesDisplacedDependencyOnce() + { + const ulong displaced = 0x01000010ul; + const ulong retained = 0x01000020ul; + const ulong added = 0x01000030ul; + var meshes = new FaultInjectingMeshAdapter(); + var adapter = new LandblockSpawnAdapter(meshes); + LoadedLandblock landblock = MakeLandblock(0x1234FFFFu); + adapter.OnLandblockLoaded(landblock, null, [displaced, retained]); + + adapter.OnLandblockLoaded( + landblock, + additionalOrdinaryIds: [retained, added], + replaceExisting: true); + + Assert.DoesNotContain(displaced, meshes.ReferenceCounts); + Assert.Equal(1, meshes.ReferenceCounts[retained]); + Assert.Equal(1, meshes.ReferenceCounts[added]); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Decrement, displaced)); + Assert.Equal(1, meshes.CallCount(ReferenceOperation.Increment, retained)); + adapter.OnLandblockUnloaded(landblock.LandblockId); + Assert.Empty(meshes.ReferenceCounts); + } + [Fact] public void Load_BeforeCommitFailure_RetryAcquiresOnlyUnfinishedReferences() { diff --git a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs index e2374d64..4e64cd33 100644 --- a/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs +++ b/tests/AcDream.App.Tests/Rendering/WorldSceneRendererTests.cs @@ -717,6 +717,7 @@ public sealed class WorldSceneRendererTests var building = new WalkBuilding { PositionCellId = landblockId | 1u, + GfxObjId = 0x0100_0001u, DrawingBsp = new WalkBspNode { InPortals = [] }, }; var entry = new WalkBuildingFactory.Entry( diff --git a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs index 402c05ef..51008596 100644 --- a/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs +++ b/tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs @@ -8,6 +8,8 @@ using AcDream.Core.World; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Types; +using DatReaderWriter.Enums; +using AcDream.App.Rendering.Walk; namespace AcDream.App.Tests.Streaming; @@ -140,6 +142,52 @@ public sealed class LandblockBuildFactoryTests Assert.Equal((LandblockId & 0xFFFF0000u) | 1u, buildingEntry.Building.PositionCellId); } + [Fact] + public void WalkBuildingFactory_SetupModelUsesExactlyRestingPartZeroAndDefaultScale() + { + const uint setupId = 0x0200_0100u; + const uint partZeroId = 0x0100_0101u; + var dat = CreateDat(out RecordingDatProxy proxy); + var resting = new AnimationFrame(1); + resting.Frames.Add(new Frame + { + Origin = new Vector3(1f, 2f, 3f), + Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.25f), + }); + var setup = new Setup(); + setup.Parts.Add(partZeroId); + setup.DefaultScale.Add(new Vector3(2f, 3f, 4f)); + setup.PlacementFrames[Placement.Resting] = resting; + proxy.Add(setupId, setup); + proxy.Add(partZeroId, new GfxObj + { + Id = partZeroId, + SortCenter = new Vector3(4f, 5f, 6f), + }); + var info = new BuildingInfo + { + ModelId = setupId, + Frame = new Frame + { + Origin = new Vector3(12f, 12f, 0f), + Orientation = Quaternion.Identity, + }, + Portals = [], + }; + + WalkBuildingFactory.Entry entry = Assert.Single( + WalkBuildingFactory.Build(dat, LandblockId, [info], Vector3.Zero)); + + Assert.Equal(partZeroId, entry.Building.GfxObjId); + Assert.Equal(new Vector3(4f, 5f, 6f), entry.Building.SortCenter); + Assert.Equal(4f, entry.Building.PartZeroScaleZ); + Matrix4x4 expected = Matrix4x4.CreateScale(2f, 3f, 4f) + * Matrix4x4.CreateFromQuaternion(resting.Frames[0].Orientation) + * Matrix4x4.CreateTranslation(1f, 2f, 3f); + Assert.Equal(expected, entry.Building.PartZeroTransform); + Assert.Equal(expected * entry.WorldTransform, entry.PartZeroWorldTransform); + } + [Fact] public void BuildFar_NeverPopulatesWalkDataBecauseEnvCellsIsNull() { diff --git a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs index 3ddef3d6..df29a10d 100644 --- a/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs +++ b/tests/AcDream.App.Tests/Streaming/StreamingControllerReadinessTests.cs @@ -1,14 +1,96 @@ using AcDream.App.Streaming; using AcDream.App.Rendering; using AcDream.App.Rendering.Wb; +using AcDream.App.Rendering.Walk; using AcDream.Core.World; using DatReaderWriter.DBObjs; using System.Collections.Immutable; +using System.Numerics; namespace AcDream.App.Tests.Streaming; public sealed class StreamingControllerReadinessTests { + [Fact] + public void NearPublication_OwnsWalkLadderAsOrdinaryReadinessAndReleasesOnRetirement() + { + const uint landblockId = 0x1236FFFFu; + const ulong ladderId = 0x01001234ul; + var meshes = new ReadinessMeshAdapter(); + var state = new GpuWorldState(new LandblockSpawnAdapter(meshes)); + var outbox = new Queue(); + var building = new WalkBuilding + { + PositionCellId = 0x12360001u, + DegradeLevels = + [ + new WalkBuildingDegradeLevel((uint)ladderId, 1u, 0f, 10f, 20f, null), + ], + }; + var envCells = new EnvCellLandblockBuild( + landblockId, + Array.Empty(), + Array.Empty(), + [new WalkBuildingFactory.Entry(building, Matrix4x4.Identity, Matrix4x4.Identity)]); + var build = new LandblockBuild( + new LoadedLandblock(landblockId, new LandBlock(), Array.Empty()), + envCells); + outbox.Enqueue(new LandblockStreamResult.Loaded( + landblockId, + LandblockStreamTier.Near, + build, + new AcDream.Core.Terrain.LandblockMeshData([], []))); + var controller = new StreamingController( + enqueueLoad: (_, _) => { }, + enqueueUnload: _ => { }, + drainCompletions: maximum => + { + var drained = new List(); + while (drained.Count < maximum && outbox.TryDequeue(out var result)) + drained.Add(result); + return drained; + }, + applyTerrain: (_, _) => { }, + state: state, + nearRadius: 0, + farRadius: 0); + + for (int frame = 0; frame < 32 && !meshes.ReferenceCounts.ContainsKey(ladderId); frame++) + controller.Tick(0x12, 0x36); + + Assert.Equal(1, meshes.ReferenceCounts[ladderId]); + Assert.False(controller.IsRenderNeighborhoodResident(landblockId, 0, 0)); + meshes.ReadyIds.Add(ladderId); + Assert.True(controller.IsRenderNeighborhoodResident(landblockId, 0, 0)); + + state.RemoveEntitiesFromLandblock(landblockId); + Assert.Empty(meshes.ReferenceCounts); + GpuLandblockSpatialPublication revisit = state.CommitEntitiesToExistingLandblockSpatial( + landblockId, + Array.Empty(), + additionalRenderIds: null, + additionalOrdinaryRenderIds: envCells.WalkBuildingMeshDependencies); + state.ActivateLandblockPresentation(revisit); + Assert.Equal(1, meshes.ReferenceCounts[ladderId]); + state.RemoveLandblock(landblockId); + Assert.Empty(meshes.ReferenceCounts); + + GpuLandblockSpatialPublication resetPublication = + state.CommitLandblockSpatial( + build.Landblock, + additionalRenderIds: null, + tier: LandblockStreamTier.Near, + additionalOrdinaryRenderIds: + envCells.WalkBuildingMeshDependencies); + state.ActivateLandblockPresentation(resetPublication); + Assert.Equal(1, meshes.ReferenceCounts[ladderId]); + + GpuWorldRecenterRetirement reset = state.DetachAllForOriginRecenter(); + Assert.Equal(landblockId, Assert.Single(reset.Landblocks).LandblockId); + state.ReleaseLandblockMeshReferences(landblockId); + Assert.Empty(meshes.ReferenceCounts); + } + [Fact] public void RenderNeighborhoodResident_RequiresEveryPublishedLandblockInRing() { diff --git a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs index 04225261..0a989143 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs @@ -1144,6 +1144,25 @@ public sealed class ConfigOptionsPageControllerTests } } + [Fact] + public void AdaptiveDegradeRowsApplyToTheSharedDisplaySettingsSnapshot() + { + (OptionsPanelController controller, FakeBindings bindings, bool bound) = BindReal(); + Assert.True(bound); + var automatic = Assert.IsType(controller.ConfigPage.Rows[16]); + var bias = Assert.IsType(controller.ConfigPage.Rows[17]); + var distance = Assert.IsType(controller.ConfigPage.Rows[18]); + + automatic.SetCurrentValue(true); + bias.SetCurrentValue(-0.35f); + distance.SetCurrentValue(77f); + controller.ConfigPage.Apply(); + + Assert.True(bindings.Display.AutomaticDegrades); + Assert.Equal(-0.35f, bindings.Display.GraphicsPerformance); + Assert.Equal(77f, bindings.Display.DegradeDistance); + } + [Fact] public void Bind_ResolvesOnlyTheAuthoredStringKeys_NoInventedOrDroppedKey() { @@ -1412,9 +1431,9 @@ public sealed class ConfigOptionsPageControllerTests (15, RowKind.Toggle, false, "Full Screen"), // LIVE (16, RowKind.Toggle, false, "Sync To Refresh"), // NEXT-LAUNCH (17, RowKind.Slider, true, "Screen Brightness"), // review S2 - (18, RowKind.Toggle, true, "Automatic Degrades"), // AP-198 - (19, RowKind.Slider, true, "Graphics Performance"), // AP-198 - (20, RowKind.Slider, true, "Degrade Distance"), // AP-198 + (18, RowKind.Toggle, false, "Automatic Degrades"), // LIVE + (19, RowKind.Slider, false, "Graphics Performance"), // LIVE + (20, RowKind.Slider, false, "Degrade Distance"), // LIVE (23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198 (24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198 (25, RowKind.Menu, true, "Texture Filtering"), // AP-198 @@ -1446,6 +1465,7 @@ public sealed class ConfigOptionsPageControllerTests // at its empty default and hide the very thing this test checks. (OptionsPanelController controller, _, bool bound) = BindReal(resolveString: (_, _) => "x"); Assert.True(bound); + Assert.Equal(16, DimmingExpectations.Count(expectation => expectation.StoreOnly)); var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!; var listBox = Assert.IsType( @@ -1506,6 +1526,22 @@ public sealed class ConfigOptionsPageControllerTests Assert.Equal(25, bindings.Display.LandscapeDrawDistance); } + [Fact] + public void Ap198TruthNamesFiveResidualsAndLandscapeDrawDistanceAsLive() + { + string registerPath = Path.Combine( + FindRepoRoot(), "docs", "architecture", "retail-divergence-register.md"); + string row = Assert.Single(File.ReadLines(registerPath), static line => + line.StartsWith("| AP-198 |", StringComparison.Ordinal)); + + Assert.Contains("author five rows", row, StringComparison.Ordinal); + Assert.Contains("Render_LandscapeDrawDistance` was already live", row, StringComparison.Ordinal); + Assert.Contains("ApplyLandscapeDrawDistance", row, StringComparison.Ordinal); + Assert.DoesNotContain("author six rows", row, StringComparison.Ordinal); + Assert.DoesNotContain("six residual", row, StringComparison.Ordinal); + Assert.DoesNotContain("no highlighted selection", row, StringComparison.Ordinal); + } + // ── #412-class regression: Config tab content escaping the window frame ── // // 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's @@ -1620,4 +1656,17 @@ public sealed class ConfigOptionsPageControllerTests ApplyAnchorRecursive(child); } } + + private static string FindRepoRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx"))) + return directory.FullName; + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); + } }