diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 4ee88dd8..8ad2d98b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -215,7 +215,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 162 active rows (AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-234 filed 2026-08-23 at the #426 solid-face extraction fix — cell-wall geometry keeps approximating retail's "skip untextured subsets in a cell" with the polygon's NoPos flag rather than the Surface's own Type; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) +## 3. Documented approximation (AP) — 161 active rows (AP-234 RETIRED 2026-09-02 at OH2/S1 chunk B — retail's exact CellStruct surface/subset construction (`CellStructSideCandidates`, source-surface-index subsets, and the built-EnvCell `(Surface.Type & 6) != 0` admission test) replaces the NoPos-based approximation, ported from `D3DPolyRender::ConstructMesh` @0x0059DFA0, `RenderDeviceD3D::DrawEnvCell` @0x0059F170, and `D3DPolyRender::DrawMesh` @0x0059D4A0 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), with chunk-A/B test evidence; AP-202 RETIRED 2026-08-26 by #446 — retail PFile `.keymap` Load/Save/startup/shutdown persistence is now live; AP-235 filed 2026-08-25 at the Campaign CT4 fix round — `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# switches instead of a live `EnumMapper` read; AP-233 filed 2026-08-23 at the Holtburg windmill fix — the render-side inter-frame animation blend, now holding the boundary frame at every seam; AP-232 filed 2026-08-22 at Campaign VM VM1 — the #226 two-draw detail blend weight on TRANSLUCENT subsets versus retail's single stage-1 output alpha; AP-185 RETIRED 2026-08-20 — `RetailWindowLockPresentationController` now swaps all eight imported locked/live chrome blocks, hides live-only floating-chat and SmartBox grips, suppresses only the nine-slice grip overlay, and applies the current lock before a late-mounted window's first `OnShown`; the radar's persistent B7/B8 semantic face is pinned against pointer-state clobber and covered by a real-fixture draw cycle; AP-231 filed 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the Skills page formula-connector-text approximation in `ComposeFormula`, see the row's own text for the full disclosure of what is byte-verified versus best-derived; AP-213 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 2 — the remaining flat-list-vs-four-bucket-sorted-model half is now ported: `ChargenSkillDetail`/`ChargenSkillFormula` (Core) thread `SkillBase.MinLevel`/`Description`/`Formula` from the global SkillTable through `ChargenOptions.TryGetSkillDetail` (`ChargenTableReader.Project` populates it, live-DAT-pinned at 38 entries — 23 MinLevel<=1/15 MinLevel==2, matching the Batch F investigation's own recorded finding exactly), and `CharacterCreationSkillsPage` now groups every costable skill into `SkillBucket` (Specialized/Trained/UseableUntrained/UnuseableUntrained, `UpdateSkillEntry`'s own `iMinlevel <= 1` test), sorts each bucket alphabetically (`InsertEntrySorted`'s `wcscmp`, ported as `string.CompareOrdinal`), and builds one `Templates[0]` header row per bucket ahead of that bucket's `Templates[1]` skill rows — `DoSkillRecords`'s own unconditional 4-header-then-populate build order. A level change re-buckets the row (detected per-refresh against each row's own cached bucket, then a full rebuild — the observable placement matches retail's incremental single-row `InsertEntrySorted` move without reproducing its internal mechanism, a documented and harmless substitution). 3 new fixture tests (`SkillsPage_BucketHeaders_AlwaysBuildAllFour_InRetailOrder`, `SkillsPage_UntrainedSkill_BucketsByMinLevel`, `SkillsPage_AdvancingASkill_MovesItsRowIntoTheNewBucket`) plus 1 new live-DAT test (`InstalledSkillTable_GlobalSkillDetails_MinLevelDistributionMatchesCostCoverage`); AP-216/AP-217 RETIRED 2026-08-16 at the Campaign CC gate round 1 closeout Group 1 — both rows' STOPPED items are now landed: `CharacterCreationUiController.AppearancePalSetSource`/`AppearanceClothingTableSource`/`AppearancePaletteColorSource` wire a DAT-backed `ChargenAppearanceCatalog` into the Appearance page from `LivePresentationComposition` (mirroring the existing `AppearancePreviewControl` seam), and `UiButton`/`UiDatElement` both gained a per-instance `Tint` property threaded into every existing `DrawSprite` call they make; `CharacterCreationAppearancePage` now sets `Tint` directly on each swatch button and the GradCircle element instead of layering a flat-fill `ChargenSwatchColorTile` overlay on top (that class is deleted) — a genuine multiplicative sprite tint on the widget's OWN authored art, matching retail's `SurfaceWindow::BlitAndColor(..., Blit_Multiply, color)` exactly rather than approximating it with an opaque rectangle. Both fixture test suites (`CharacterCreationAppearancePageSwatchColorTests`, 8 tests) and the live-DAT color pins (`ChargenAppearanceCatalogColorTests`) pass unchanged against the new mechanism; AP-218 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch C fix (GF-6) — `gmCGAppearancePage::Update`'s heritage-flavored static Hair/Eyes/Skin spin caption (`ID_CharGen_HairStyle`/`_Eyes`/`_Skin`, Gearknight `GearText_*`, Olthoi/OlthoiAcid `OlthoiText_*`) is now ported verbatim by `RefreshSpinCaptions`, replacing the prior ordinal substitution outright — see AP-215's own rewritten row for what remains open (the icon-thumbnail gap, restated); recount at this same edit: the row count this header carried before Batch B was already one LOW relative to the physical table (Batch A's own ending state: header said 164, the physical table already held 165 rows — verified by direct count against that commit) — a pre-existing drift this edit corrects to the counted total, not an artifact of Batch B's own net change (F12 correction, gate round 1 closeout, 2026-08-16: this note originally said "one high", the inverted direction — the header was UNDER-counting, not over-counting); AP-222 RETIRED 2026-08-16 at the Campaign CC gate round 1 Batch B fix (GF-11b) — the Appearance spins' current-part highlight and the Town buttons' Normal-to-white caption swap both port retail's actual mechanism (per-state label color/outline commit off the REQUESTED retail state id, independent of art-media availability — `UiButton.SetPerStateLabelStyle`/`ComputeRequestedStateId`), closing the row's own "not yet resolved which side is wrong" question: NEITHER client's spin ART changes (no Highlight media exists on either), but BOTH clients' spin TEXT does, matching retail's `SetState(1)`/`SetState(6)` property commit exactly (live-DAT-measured 218,167,85 -> 255,221,131, outline off -> on); AP-215 NARROWED the same batch (GF-9) — item 1 (the swatch-selection substitution) is RETIRED now that the real companion-overlay mechanism (`SetColor`'s `m_tColorWheel[...][0x10][iCurColor*7]->SetVisible`) is ported (`CharacterCreationAppearancePage`'s nine `SwatchOverlayIds`), leaving only item 2 (the icon-less style-spin ordinal label) open; AP-230 filed 2026-08-16 at the Campaign CC gate round 1 Batch A fix (GF-13) — the chargen-scoped-vs-general-importer-wide honor split for dat property 0x3B (Invisible: `UIElement::OnSetAttribute` case 8 hides an element), with the general client-wide honor deferred as its own visual gate (docs/ISSUES.md #408, 1,083 elements affected); AP-213 NARROWED the same gate round (GF-5) — the Skills page's click-to-advance/double-click-retreat single-button substitution is RETIRED now that the real per-row `pSkillUpButton`/`pSkillDownButton` arrows are wired to retail's own plain-click dispatch, leaving open only the flat-list-vs-four-bucket-sorted-model half; AP-229 filed 2026-08-16 at the Campaign CC CC7 review-fix round, F1 — the screen-layering divergence: retail's `UIFlow::UseNewMode` destroys/reconstructs the current UI framework on every mode switch where acdream's CC7 keeps both `CharacterManagementUiController` and `CharacterCreationUiController` mounted for the whole lifetime and only reveals/occludes them; AP-228 filed 2026-08-16 at the CC5 re-review residual round (R4) — the Summary listbox's skill-row KEY source, same divergence class as AP-226 filed the same round, a few retail lines away; AP-227 filed 2026-08-16 at the same review-fix round, F9 — an empty Summary name-field commit calls `SetName("")` (clearing the state), where retail's own NUL-inclusive length gate leaves `CharGenState.name` UNCHANGED for that specific case; AP-226 filed 2026-08-16 at the Campaign CC CC5 review-fix round, F11 — the Summary page's DAT-sourced labels versus retail's static `pcProfessions`/`pcGender`/`pcHeritage`/`pcTown` tables, including the non-human-heritage-renders-bare-"Heritage: " retail quirk; AP-225 RETIRED the same round, F6 — the reviewer re-derived `gmCGSummaryPage::ListenToElementMessage @0x0047bf40`'s length check and proved the 32-vs-33 threshold this row flagged as "not fully certain" does NOT exist: the compared length is NUL-inclusive (an empty field's length is 1, matching AP-226's own F11/F9 finding), so `length > 0x21` is EXACTLY `visibleChars > 32` — acdream's `MaxNameLength = 32` was always byte-correct, not merely internally-consistent; AP-223/AP-224 filed 2026-08-15 at Campaign CC slice CC5 — the acdream-only `HeritageOrGenderUnset` Finish refusal and the Summary listbox's two-bucket (Specialized/Trained only) skill-list narrowing (AP-224 corrected 2026-08-16 at the same review-fix round, F3 — its "template mechanism ported exactly" claim was FALSE as shipped, now fixed and true again, see its own row); AP-214 RETIRED the same slice — `RandomizeCharacter` is now ported and wired at the screen-open edge, closing the honest-blank-open gap it recorded; AP-212 NARROWED the same slice — the Appearance/Summary Random-button primitives are now real faithful ports, not uniform-pick approximations, leaving only Heritage/Profession/Town (still uniform-pick) and Skills (still unported) open; AP-222 filed 2026-08-15 at the re-review of Campaign CC CC6b-MOUNT fix commit `d2a71152` (N2) — the current-part spin highlight is a measured no-op for all nine spins, no Highlight media authored on any of them; AP-221 filed the same re-review (R2) — the chargen preview's one-shot-composition-vs-retryable-coordinator binding gap; AP-217 rewritten and AP-220 tightened the same re-review (R3 corrects the GradCircle from a dead click target to unported paint-art; N1 narrows the Gearknight-exit wording to non-Olthoi); AP-216..AP-220 filed 2026-08-15 at the Campaign CC CC6b-MOUNT review fix round, F2 — DoColorSpots swatch-art, the inert GradCircle, spin-caption/heritage-swap loss, the Skin-spin MoveTo reposition, and the Gearknight-boundary randomize calls; AP-215 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — the Appearance page's swatch-highlight (`UiButton.Selected` vs retail's separate overlay toggle) and icon-less style-spin ordinal-label substitutions; AP-214 filed 2026-08-15 at Campaign CC slice CC6b-MOUNT — retail's `gmCharGenMainUI` ctor rolls a full `RandomizeCharacter` BEFORE any page constructs, so retail's chargen screen is never actually blank on open (and the Appearance page's own gender-flip-on-init always fires against a real gender); acdream opens honestly blank instead, closing out the campaign plan's risk item 5; AP-212/AP-213 filed 2026-08-15 at Campaign CC slice CC4 — the Random button's uniform-pick approximation of retail's three unported randomize algorithms, and the Skills page's flat-listbox simplification of retail's four-bucket sorted skill model; AP-211 filed 2026-08-15 at the Campaign CC slice CC3 review-fix round — the client-side roster-vs-slotCount refusal in `RuntimeCharacterCreationState.TryBeginFinish` has no retail counterpart at that layer, retail enforces the cap in char-select UI instead; AP-207..AP-210 filed 2026-08-15 at Campaign CC slice CC3 — the FitTemplateToCharacter FPU-unrecoverable auto-detect skip, the shared-ClothingColors-list color-count approximation, the classID DAT-DID-lookup placeholder, and the ApplyTemplate atomic-replace-vs-per-attribute-guard simplification; AP-205 filed 2026-08-11 at Campaign OP gate 4 (#381) — the Apply/Reset/Defaults footer's opaque backing field is a genuine acdream synthesis with no authored retail counterpart; ~~AP-201~~ RETIRED 2026-08-11 at the Campaign OP gate-3 fix round — `UiScrollablePanel` now keeps a straddling row visible and CLIPS it to the viewport (`ClipsChildren` → `UiRenderContext.PushClip`, which existed by then), replacing the whole-row cull this row recorded; the user-observed symptom (the Chat tab's per-window filter blocks vanishing into a void at the DEFAULT scroll offset) closed issue #371; AP-203 RETIRED 2026-08-26 by #446 — all 306 installed-DAT rows now have distinct identities and concrete consumers; AP-204 corrected and RETIRED the same day — exact capture/conflict/button semantics now follow named retail; AP-202 RETIRED 2026-08-26 by #446 — retail `.keymap` file interchange and profile lifetime now ship; AP-200 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Chat Font Face/Size menu rows are store-only, distinct fields from the existing live `ChatSettings.FontSize`; AP-199 filed 2026-08-11 at Campaign OP slice OP6 — the Config tab's Sound Features menu, Interface Sound trio, and Play Sound Only When Active are store-only (the Interface trio cites AP-174's existing "retail's own dead knob" finding); AP-198 filed 2026-08-11 at Campaign OP slice OP6, row count reconciled at the OP6 rework round (2026-08-11, review N1) — the Config tab's TEN Graphics/Rendering-Quality-family rows (including Screen Brightness, its own field as of the S2 fix) are store-only, the Vulkan+one-aggregate-QualityPreset renderer having no per-feature knobs; AP-197 filed 2026-08-11 at the OP4 review-fix round (SF-1/S4) — Display Timestamps hardcodes retail's constructor-default format string instead of the per-character GenericQualitiesData key-1 override the parser reads and discards; ~~AP-196~~ RETIRED 2026-08-11 at Campaign OP slice OP9 — originally filed at the OP4 review-fix round (MUST-FIX 3 / blast M2) for the Group-C re-point's observable-default changes (ViewCombatTarget true→false) and the PARTIAL GameplaySettings retirement (AutoTarget/AutoRepeatAttack/ViewCombatTarget deleted, the other five kept as write-behind mirrors); OP9 deleted `GameplaySettings` outright (all 13 remaining members were already re-pointed to the server-bit seam at OP4), closing the write-behind-mirror gap for good — see its retirement note below; AP-195 RETIRED 2026-08-11 at Campaign OP slice OP5 — ported both halves left open at OP2 re-review closure: the ALL-set LED media swap (`UiButton.FaceFileOverride`, driven by the block-level `P0x10000082`/`P0x10000083` sprites now threaded through `ElementInfo`/`DatWidgetFactory`) and the `CreateChildren` self-sizing tail (`UiCheckboxBitfield64.Height` grows with `_contentHeight` per row; the ENCLOSING page ListBox reflows around the block's FINAL size via the new `UiTemplateListBox.AddPrebuiltRow`, reusing the ListBox's own stacking exactly as the row's own disposition menu allowed, rather than a third stacking path); AP-194 filed 2026-08-10 at Campaign OP slice OP1 — the GetDefaultOptionValue vs constructor-default disagreement for ConfirmVolatileRareUse/ShowHelm/ShowCloak (see the row below); AP-193 filed 2026-08-10 at Campaign OP slice OP1 — the 0x34 HearPKDeathMessages id/mask mapping is ACE-sourced (see the row below); AP-192 filed 2026-08-10 at the Campaign CH round-5 polish (S2) — authored outline `0x21`/`0x22` now reaches every text-bearing widget, but only at the element's effective-default state; per-STATE outline switching (dialog/character/combat buttons author `0x21` in state `0x3` only) is not ported; AP-191 filed 2026-08-10 at Campaign CH round 4 items 1+2 — the chat transcript's missing tag-colour (`0x1D`, green) and tag-font (`0x1C`) are deferred, needing a per-run tag concept `UiText.Line` does not have yet; AP-184 RETIRED 2026-08-10 at Campaign CH round 4 — the three PARTIAL `/help` group topics (channels/chatting/commands) are now COMPLETE verbatim listings, `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290` fully decoded (the "vftable slot" operands are the same pooled/mislabeled-data artifact as AP-186's own precedent, not real vtable dispatch — reading the function's own disassembly for the `push imm32` preceding each constructor call resolves them), closing ISSUES.md #364 (full retirement note later in this same list, at its own "AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09..." entry); AP-113 RETIRED 2026-08-10 at the consolidated-review round, SHOULD-FIX 3/1 byproduct — DoLifestone's own bad-args refusal text is now byte-recovered, see its retirement note below; AP-183 and AP-186 RETIRED 2026-08-10 by issue #363's interface-text seam — see their retirement notes below; AP-190 filed 2026-08-10 at Campaign CH slice CH6c — window opacity now fades every RetailWindowManager window on retail's focus-driven Default/Active mechanism, not just ChatInterface-derived ones, and ships gmMainChatUI's 1.0/1.0 default as the ONE shared default across every registered window (fixed from the original 0.5/1.0 base-ChatInterface value, per the row's own REWORDED (2)) instead of applying it only to ChatInterface-derived windows, retiring AP-40 (the prior "opacity is fixed at 0.75, no focus transition" row) in the same commit; AP-189 filed 2026-08-10 at the CH6a/b REJECT-review rework, SHOULD-FIX 5 — acdream's ONE shared 500-entry/200-line-display-tail chat log gives every window a shallower EFFECTIVE per-window scrollback depth than retail's own per-window 10,000-line log, though the accumulate-while-closed and independent-per-window-scroll BEHAVIORS are both correctly reproduced; AP-188 filed 2026-08-10 at Campaign CH slice CH6b — a floating chat window's chat entry always sends on the Say channel because the floaty LayoutDesc authors no talk-focus menu and acdream does not (yet) share the main window's currently-selected channel across all five chat-window instances; AP-187 filed 2026-08-10 at Campaign CH slice CH6b — the four floating chat windows' text-type filters persist in local `settings.json` only (`ChatSettings.ChatWindow1..4Filter`), with no analog to retail's server-side `0x1000008C` GameplayOptions blob, so a character's floaty filter customization does not travel between acdream installs or round-trip to/from a retail client sharing the same character; AP-186 RETIRED 2026-08-10, issue #363's interface-text seam — `ChatVM` now carries an `OnInterfaceText` hook (`Action?`) the App-layer composition wires to `RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal)`, exactly fix shape (a) this row's own filing proposed; `ChatCommandRouter`'s two local-presentation fallbacks (`RetailCommandHelpTable.UnknownCommand` and the degenerate-prefix "Unknown command: {verb}." refusal) now call `ShowInterfaceText` and reach the SpewBox, with a null-fallback into the chat log (still tagged `ClientLocal`) for hosts that never wire the hook (headless has no `ChatVM` at all). Closes ISSUES.md #367; AP-185 filed 2026-08-10 at Campaign CH slice CH6a — the chat window's UiLocked border-art cosmetic swap is unported, see the row for detail; AP-184 RETIRED 2026-08-10 at Campaign CH round 4, closing ISSUES.md #364 — filed 2026-08-09 at Campaign CH user-gate round 2, item 3, recording that three of the seven retail `/help` group-topic listings (channels/chatting/commands) remained PARTIAL because their detail text is built in full or in part by `ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290`, which the filing believed "not decodable with confidence from a static string sweep" because Binary Ninja renders its three internal string operands as dereferences of unrelated vtable slots (`&ClientCommunicationSystem::\`vftable'.RecvNotice_StartBarberNotice` etc.). That belief was WRONG — the same pooled/mislabeled-data artifact this register already documented elsewhere (AP-113's retirement note) applies here too: reading the function's own disassembly for the `push imm32` immediately preceding each `PStringBase::PStringBase` constructor call (rather than trusting BN's line-grouped rendering, which hides the true instruction order) resolves all three operands directly — `"@"` + a one-character tag sliced from a shared wide literal `U"fvpca"`/`U"mh,."` (a wide string read through a narrow `char*` truncates at the first zero high byte, the "hack" retail's own function name calls out) + `" - Sends a broadcast to your "` + `ChannelSystem::GetChannelName`'s own literal switch-table result + `".\n"`. `ChannelsGroupDetail` (entirely 6 such calls), `ChattingGroupDetail` (6 more, plus a `HelpReply@0x00577A50` Summary-branch quirk that unconditionally emits reply+pr+mr together — read directly, not assumed), and `CommandsGroupDetail` (`HelpAllGroup`, a straight-line concatenation of every other group's Detail branch plus a handful of its own short one-liners, including a CONFIRMED retail saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the four (death/status/text/allegiances) the original filing already had. See `RetailCommandHelpTable`'s class remarks and `RetailCommandHelpTableTests` for the full per-line address citations. Round 2 item 2 also deletes `PortalWaitNoticeController` (the dedicated centered-overlay presentation the user reported was the wrong retail surface) and reroutes the portal-space wait-cue notice through the same `AddText`/SpewBox chokepoint every other on-screen interface-text site uses — AP-178's open SpewBox position/extent/font/colour questions now cover this notice too, since its separate controller and consts are gone; no new row was needed for the surface mismatch itself, since it was never separately registered (`PortalWaitNoticeController`'s own doc comment asserted "not a chat message" as an accepted design, not a flagged divergence). AP-150 RETIRED 2026-08-09 at Campaign CH user-gate round 1, item D (#329) — `PortalTunnelPresentation.TickRotation` now emits `"In Portal Space - Please Wait..."` unconditionally on every rotation-segment expiry, exactly matching `gmSmartBoxUI::UseTime`'s `else`-arm at 0x004D6FCD, instead of gating on `_waitCueVisible`, which only ever went true after the invented 5-second `RuntimeWorldTransitState.RetailWaitCueDelay` hold; `RetailWaitCueDelay`/`ObserveWait`/`SetWaitCue` remain as `LocalPlayerTeleportController`'s own hold-delay telemetry (`RuntimePortalSnapshot.WaitCueShown`) but no longer gate the on-screen cue, so they are not a residual of this row — closes issue #329; AP-183 RETIRED 2026-08-10, issue #363 — every named site now routes through the `ChatVM.ShowInterfaceText`/`OnInterfaceText` seam (see AP-186's retirement note) at its correct retail type: `DoStupidChannelHack` ("You must specify the text you wish to say!", newly wired — the six legacy channel verbs previously fell through `ChatInputParser.Parse`'s pure `return null` with no message at all), `DoChannelList`/`On`/`Off` ("Please specify the channel name.", reclassified), `DoAllegiance` ("Please see @help Allegiance...", reclassified), `DoHouseAvailableList` (reclassified AND corrected to retail's own "Please see @help hslist for more information on how to use this command" string, replacing the acdream-synthesized "Usage: /hslist " fallback — verified `acclient_2013_pseudo_c.txt:381481`/`1029383`), and `DoReply` ("Someone must @tell you first!", newly wired for the message-but-no-last-teller branch only — bare `/r` with no message at all is a separate retail branch, deliberately still unported). `DoSpeaker`/`DoEndurance`/`DoTitle` are untouched, confirmed still correct at `0x00`. The generic bad-args fallback (`ChatCommandRouter.Submit`'s catalog dispatch) now resolves `WeenieErrorMessages.Resolve(0x026u, null)` ("That is not a valid command.", the exact port of `DoCommand @0x0057E46D`'s `HandleFailureEvent(0x26)`) instead of synthesizing a `"Usage: {Usage}"` line — cross-checked against five decompiled handlers (`DoDie` plus the four above), all `0x1A`, confirming the uniform routing decision; AP-182 filed 2026-08-09 at Campaign CH slice CH4, corrected at the CH4 REJECT-review (nit 11) — `@title` is wired to a pure no-op (the value is neither stored nor consumed anywhere) and also omits `DoTitle`'s three local failure messages; recount at the CH3 Opus review corrected a pre-existing off-by-one; AP-181 filed 2026-08-09, Campaign CH slice CH3 — the local chat spam throttle (`IsMessageSpam`) has no acdream port. AP-178 NARROWED 2026-08-09 at the CH2 REJECT-review rework NIT 3, wording corrected at the CH2 re-review nits pass (`docs/plans/2026-08-09-chat-parity-campaign.md`, nits 1/2/6) — the original `dats.Portal` pass used an id source that was not Portal's own (`dats.Portal.GetAllIdsOfType()` is empty for this type), so it established nothing about Portal either way; extending a correctly-paired sweep to `dats.Local` FOUND the SpewBox element there; extent (`450×72`) and `MaxConcurrentItems` (`4`, not the code-default `1`) are now AUTHORED, leaving absolute screen position, colour, AND vertical content flow (now TOP-aligned, acdream's own invention pending measurement) open. AP-180 filed 2026-08-09 at the CH2 REJECT-review rework — `RuntimeCommunicationState.AddText`'s `windowId` parameter is accepted but not consumed, so retail's dual-destination echo (a `0x1A` message with a non-zero `windowId` lands in both the SpewBox and its originating chat window) is unimplemented; latent today since every production caller passes `windowId = 0`. AP-177/AP-178/AP-179 filed 2026-08-09, Campaign CH slice CH2 (interface text / SpewBox) — AP-177 records the invented 5-second SpewBox line lifetime (retail's real timeout is keystone-owned and unmeasured); AP-178's original filing recorded the invented SpewBox screen position/extent/font/colour/MaxConcurrentItems after `SpewBoxLayoutDumpDiagnostic`'s Portal-only sweep found zero elements of class 0x10000016 — see the NARROWED note above for the corrected finding; AP-179 is the OnCombatLine half of the RETIRED AP-176 split out to its own row. AP-176 RETIRED the same day — the WeenieErrorMessages full 344-row `HandleFailureEvent` port (`WeenieErrorMessages.Resolve`) replaces the single-stand-in-`LogTextType` approximation that row recorded for `ChatLog.OnWeenieError`. AP-175 filed 2026-08-09, Campaign CH slice CH1 — PopUpString renders as a chat-log line instead of retail's modal dialog; AP-39 updated the same day — chat coloring is now retail's exact 34-value `LogTextType` table, not a synthetic per-`ChatKind` approximation of it. AP-173 and AP-174 filed 2026-08-08, Campaign A slice A2 — AP-173 expresses retail's ±15 dB DirectSound pan as an OpenAL azimuth by inverting the constant-power pan law, since AL exposes no per-channel gain for a mono source; AP-174 records acdream's extra master volume knob on top of retail's three, folded into retail's single master multiply so the −50 dB cutoff and dB quantisation move with it. AP-172 and AP-171 filed 2026-08-08, #354 spell-bar drag-reorder fix — the favorite-bar reorder gesture defers its own list rebuild for the drag's duration so `UiRoot`'s drag-cancel safety net cannot destroy the in-flight cell, compensating the drop-time target index for the resulting stale sibling numbering; final positions and the wire pair are retail-exact, only the mid-drag visual reflow timing differs. AP-170 filed 2026-08-08, grand-gate finding G3 — an out-of-range vendor Use now arms on arrival instead of sending immediately, because the user's local ACE server polls for the player to actually reach use range before opening the shop panel and a too-early Use is silently lost; AP-169 filed 2026-08-08, grand-gate finding G2 — the vendor toolbar split-slider resolver falls back to the packed shop-supply-count field when the item's own `PublicWeenieDesc._stackSize` is absent, because the user's local ACE server never populates the latter for a browse-list item; AP-167/AP-168 filed 2026-08-09 at the Opus review of `92ea3977` (findings F1/F6) — Buy All's container-vs-item slot classification approximates retail's bitfield/capacity test with `ItemType.Container` [AP-168], and SellSingleItem's non-empty-container refusal branch is not ported [AP-167]; AP-164 RETIRED the same review (finding F4) — BF_RETAINED is now checked end to end; AP-162 NARROWED the same review (finding F1) — Buy All's four client-side pre-send guards are now ported, leaving only the single-item TryBuy path without one; AP-161 gains a REVIEW CORRECTIONS paragraph the same review (findings F1-F13) summarizing the rest as bug fixes to already-claimed behavior, not new divergences. AP-164/AP-165/AP-166 filed 2026-08-09 at Slice 6b/6c (staging+sell arc) — InqAcceptability's non-sellable bitfield is unmodeled [AP-164], the Buy-side stackable-removal-amount test substitutes DescStackSize for retail's _maxStackSize [AP-165], and the Buying/Selling tabs' own purse/count text plus the cross-panel pending-sell inventory highlight are unwired [AP-166]; AP-161 NARROWED the same day — the row's last vendor-specific residual (Buying/Selling tabs render but carry no data binding) CLOSES now that both tabs are fully wired (staging, drag-to-sell, InqAcceptability gating, Sell 0x0060, the X-close confirmation), leaving only the two long-standing PRE-EXISTING residuals (dropdown arrow-cap glyph, alt-currency m_last_sale simplification) plus the three new AP-164/165/166 residuals just filed; AP-162 EXTENDED the same day — the same no-client-pre-check omission now also covers the batched "Buy All" path (TryBuyAll), not just the single-item TryBuy. AP-162/AP-163 filed 2026-08-09 at Slice 6.3 (buy arc) — no client-side Buy affordability/capacity pre-check [AP-162] and the shop-item guid-collision skip-not-clobber policy [AP-163]; AP-161 NARROWED the same day — the private-selection and unwired-examine residuals CLOSE at Slice 6.1/6.2, leaving only the dropdown arrow-cap glyph and the alt-currency `m_last_sale` simplification, plus a confirmed-absent-from-retail note on double-click-to-buy. AP-161 REWRITTEN 2026-08-09 at the Slice 5.4 review (findings F1-F8) — the popup-never-rendered, wrong-quantity-price, no-auto-select, dropped-icon-layer, stale-category-on-vendor-switch, and unguarded-Apply-fanout bugs the review found are fixed (`VendorUiController.cs`, `VendorState.cs`, `GameEventWiring.cs`, `RetailUiRuntime.cs`); the row now records only the four consciously-deferred residuals it still owns (private per-panel selection vs. retail's global `ACCWeenieObject::selectedID`, the unwired shop-item examine route, the dropdown button-face arrow-cap glyph, and the alt-currency held-amount's `m_last_sale`-free simplification). AP-110's "retail-correct per-unit prices" phrasing is corrected the same day to "quantity-correct pricing" — the OLD phrase mischaracterized what retail even shows (a `GetObjectSplitSize`-quantity price, not literally one unit) independent of whether the code was buggy. AP-161 filed 2026-08-09 at Slice 5.4 (vendor browse panel) — the authored "Buying"/"Selling" tabs render and switch pages but carry no data binding, per contract decision 8's required successor to AP-110's narrowing; AP-110 NARROWED the same day — "vendor" is retired from its absent-panels list now that the "Items" browse tab is user-reachable. AP-160 filed 2026-08-07 at Slice 5.3 — the client-local vendor-panel distance watcher closes on plain 3D center distance instead of retail/ACE's cylinder-gap distance, because Runtime has no per-entity collision radius/height source outside the App-layer's Setup-cylinder resolver. AP-158 RETIRED 2026-08-06 by the #333 fix, closing #337 — the `maxReach` distance pre-filter is DELETED rather than re-centred, because retail has none: `CObjCell::find_obj_collisions` @0x0052b750 walks the cell's shadow list and calls `CPhysicsObj::FindObjCollisions` unconditionally. The row's predicted symptom was observed live at Neftet before it was fixed — a tall prop AP-156 had just placed correctly still not blocking, plus jumps sinking into the mesh and corpses falling through. Perf measured, not assumed: at the live-maximum 38 in-cell candidates 10.61 µs → 16.68 µs per resolve. AP-159 filed 2026-08-06 at the #334 fix — the INDOOR half of AP-156’s traversal residual is all that remains of it; the outdoor half is CLOSED by the `find_bbox_cell_list` port, and AP-156’s RISK COLUMN IS CORRECTED at the same commit: it recorded the residual as “extra broadphase candidates, never a missed one”, which generalised the indoor direction to the whole row and is exactly why #334 — a MISSED one, and a user-observed loss of collision on landblock-spanning formations — sat inside it unnoticed. AP-158 filed 2026-08-06 at the AP-156 fix review — the shadow broadphase's `maxReach` distance pre-filter is acdream's own invention with NO retail counterpart, and it measures from the part origin, so it can discard a genuine contact for exactly the off-centre parts AP-156 just placed correctly; issue #333. AP-156 CORRECTED at the same review: its population was understated — 172 is AP-152's DISPATCH population, not AP-156's CONTAINMENT population. AP-155 NARROWED and AP-156/AP-157 filed 2026-08-06 at the AP-152 retail-conformance review. AP-155 bundled two divergences with different code paths, populations and gates under one id; its flood half is now AP-156, **with its direction corrected**. AP-155(b) recorded the BSP flood approximation as OVER-inclusive and used that direction as the reason the residual was safe to defer; measured over the installed DAT it was UNDER-inclusive for 428 of the 530 BSP-bearing Setups (the AP-156 fix review corrected the originally-recorded '170 of 172'), because `BuildFloodSpheres` carried each physics-BSP part's root bounding-sphere RADIUS while discarding that sphere's own ORIGIN and centring it on the part origin. That is the #98/#168 class, and for 43 Setups the post-AP-152 flood was strictly smaller than the pre-AP-152 one. AP-156 records the correction and the fix — `ShadowShape.BoundsCenter`, filled from the same resolver that supplies the radius, plus the retirement of the 10-sphere clamp on a branch where retail has none — and keeps open only the sphere-vs-portal TRAVERSAL approximation. AP-157 is the previously unregistered third-branch substitution: retail floods from one `CPartArray::GetSortingSphere` where acdream floods from every Sphere shape, and acdream's cylinder flood ignores `CylHeight`. AP-152 RETIRED 2026-08-06, one day after it was filed: `ShadowShapeBuilder.FromSetup` now dispatches BSP-first instead of unioning, and `ShadowObjectRegistry.BuildFloodSpheres` now applies `calc_cross_cells`' own BSP → cylsphere → sorting-sphere order. Four statements in the row were false and are corrected in its retirement text — most importantly its predicted symptom, "catching on a doorway sill", which could not have been occurring: `Transition.BspOnlyDispatch` had already made the extra primitive inert at collision-query time since 2026-05-25. The live half was CELL MEMBERSHIP, the #98/#168 symptom class, which had no such guard. AP-153/AP-154/AP-155 filed at that retirement — retail's dispatch flag is cached once at part-array construction where acdream's gate is live [AP-153]; acdream's query-time guard takes a CLIENT-DERIVED flag off the WIRE and never derives it, an undeclared dependency on ACE reading the same DAT bit [AP-154]; and the static publication paths emit a Setup Sphere as a height-capped Cylinder while `BuildFloodSpheres` approximates retail's bounding BOX with bounding SPHERES [AP-155, whose flood-priority half is closed by the same commit]. AP-152 filed 2026-08-06 at the AP-22 retirement — the LIVE collision path emits Setup primitives and per-part physics-BSP shapes additively where retail's `CPhysicsObj::FindObjCollisions` dispatches exclusively; 172 of 5,935 installed Setups are affected, including BSP doors, so it needs its own visual gate and was deliberately not folded into the AP-22 commit; the count is unchanged because AP-22 retired in the same commit. AP-22 RETIRED 2026-08-06 — retail synthesizes no shape for a shapeless object (`CPhysicsObj::FindObjCollisions` 0x0050f050 exits at `0x0050f22f je 0x50f31b` returning the seeded OK_TS, and `CPartArray::GetRadius`/`GetHeight` are absent from its whole call set), so the invented `setup.Radius` cylinder was deleted rather than re-derived; the row's site list named one file that never contained the fallback and omitted the two that did, one of them the headless-only copy, and its "rare decorative props" risk described an unreachable branch — 0 of 5,935 installed Setups can satisfy the guard. AP-150/AP-151 filed 2026-08-06 at the #280 dual review — the wait cue's five-second arming is acdream's own and not retail's trigger [AP-150], and the reveal gate is materially stricter than retail's DAT-residency prefetch predicate on the mesh-build/GPU-upload axis [AP-151], the opposite asymmetry from AP-149; AP-149 filed 2026-08-05 at the #280 portal-prefetch fix — the reveal gate's outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell; the fix closes the reveal-window/visible-window ratio, not this residual; AP-148 filed 2026-08-05 at the C5b closeout — acdream's local-player Gate A requires the wire TELEPORT_TS to be EQUAL where retail requires only that it not be OLDER, verified by disassembly against the PDB-paired binary after two review rounds read the Binary Ninja tautology and missed it; AP-147 filed 2026-08-05 at the C5b architecture review, finding D3 — the accepted-Position delta stream's cardinality change and its torn intermediate; AP-138 amended at the same review — C5b staled its route-2 first-submit `CurrentCellId` measurement; AP-131 RETIRED 2026-08-05, C5b, closing #275 — the steady-state merge's `installPlacementFrame: true, clearParent: true` literals no longer exist; `InboundPhysicsStateController.TryApplyPosition` now computes both flags PRE-MERGE from `(disposition, hasAnimations(old))`, which is exactly `RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition`'s own `ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting` rows (false/false on the Gate A force row, `!HasAnimations`/true on every accepted non-force route). Retail decides both writes BEFORE `MoveOrTeleport` is consulted — Gate A @0x0045400C returns @0x0045409D ahead of `unset_parent` @0x00454129 and the `HasAnims` `SetPlacementFrame` gate @0x00454137 — so the flags need no route, no player distance and no signature change. The row's predicted symptoms are gone: an animated entity's ordinary Position no longer installs a placement frame retail skips, and a ForcePosition no longer unparents. Evidence: `InboundPhysicsStateControllerTests` — `ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame`, `ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame`, `ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment`, and the 12-row `MergedPrePlacementFieldsMatchTheClassifiedRouteFlags` matrix which uses the production classifier as its oracle rather than re-encoding the table; all four sabotage-verified in both directions. The row's "the legacy caller is deleted at the production cutover" framing was overtaken: the caller was CORRECTED, not deleted, and remains the only production Position wire caller; AP-145 RETIRED 2026-08-05, C5a commit 1, closing #318 — `TryPublishPlace` now publishes the local player's Place through `LocalPlayerShadowSynchronizer.SyncPose`, the same publisher ordinary per-tick movement uses, instead of a direct `LocalPlayerShadowState.Set` that never touched `PhysicsEngine.ShadowObjects`; AP-1 RETIRED 2026-08-05, C5a deletion sweep — `PhysicsEngine.Resolve`/`ResolvePlacement`/`HasCellSurface` deleted outright, zero production callers, so "production zero-delta routes remain on the legacy resolver" is now structurally false; AP-146 filed 2026-08-05, #319 fix — the local player's canonical cell is written only at login/inbound-Position/teleport, not per ordinary-movement tick as retail's SetPositionInternal does; #319's fix makes a player-parented child inherit exactly this coarseness, stale-but-equal to the parent, not a new staleness class; follow-up filed as issue #320; AP-144 filed 2026-08-05, C4 route 3 round 3 (R7) — the portal-arrival movement-event send reuses `UsePositionFromServer` (`autonomy_level != 2`) where retail's actual gate, `SendMovementEvent`, is `autonomy_level != 0`; the two agree everywhere except level 1, which no production caller can reach today; AP-142/AP-143 filed 2026-08-04, C4 route 7 — the parented-child single-field cell model (id/pointer collapse, zero-not-stale removal propagation, same-cell tick-loop subsumption) and the headless parent-realize drive's skipped holding-location validation; AP-141 filed 2026-08-04, C4 route 5, NARROWED 2026-08-04 at the round-2 delta review — the far-branch StopInterpolating clause was wrong for the adopted-body case (it is now ported there) and the row's language now distinguishes "never armed" from "never re-anchored"; CORRECTED 2026-08-04 at the round-3 delta review — the risk column's "would drag the body toward a stale anchor" claim was itself wrong (the leash anchor is write-only; `ConstraintManager::adjust_offset` only brakes, never pulls) and is retracted; every half remains test-gated only, since ACE never sends a missile UpdatePosition; AP-140 filed AND RETIRED 2026-08-04 — filed at the Bug B Opus review because the two accepted-Position routing gates read the client `Airborne` flag, i.e. walkability, where retail's free-flight predicate is CONTACT, and Bug B had just turned "in contact, not on walkable ground" from unreachable into ordinary; retired the same day by pointing both gates at `PhysicsBody.InContact`, retail's literal `transient_state & 1` test at `InterpolationManager::adjust_offset` @0x00555D52 (bit 0 = `CONTACT_TS`, acclient.h:3690), while leaving `Airborne` and all five of its `!Body.OnWalkable` writers untouched — the narrow shape the row itself pinned. A remote sliding on a steep face now interpolates as retail does instead of snapping at UpdatePosition cadence; AP-139 filed 2026-08-04, Bug B remote steep-contact slide — the interpolation-queue clear on the landing edge, carried over from the deleted hand-rolled remote landing block; AP-81 narrowed the same day by that fix, which retired its whole GRAVITY half; AP-87 annotated the same day — its predicted symptom was observed live and then fixed at the source, with the row's own thresholds and conditions deliberately unchanged; AP-138 filed 2026-08-04, C4 route 4b-2 dual Opus review, parts (1) and (2) rewritten the same day at the DELTA review — the far snap's refusable-placement residual: store_position only on the outcomes that never reached the engine, the two quiescence parks made restorable at the source, with the rollback gated on the cell it actually restores into, rather than refused by a pre-flight that structurally cannot see them, and the leash not armed through a superseded incarnation; AP-137 filed 2026-08-04, C4 route 4b-2 and rewritten the same day at that review, `teleport_hook`'s call list completed at the delta review — the acdream-only null/rejected/cell-less leftover arm, what the deleted duplicated 96 m/4 m constant pairs actually computed, and the vacuous headless satisfaction; AP-136 filed 2026-08-04, C4 route 4b-1 review, NARROWED 2026-08-04 at the C4 route 4b-2 delta review and AMENDED 2026-08-04 by the cancelled-park presentation rollback (the row's "restored visible" claim covered only the CANONICAL half; the presentation half was never rolled back, which left a parked-then-cancelled remote that stops moving invisible in the world AND absent from the radar for the rest of the session — a defect, now fixed by the `WithdrawalRestored` receipt, with the selection residual filed as AD-63) — a cancelled lost-cell park re-shows the entity where retail keeps it hidden until cell load, and the rollback's scope now covers the two placement-side quiescence parks whenever the cell it restores into is not itself quiescing — round 4 (2026-08-04) applies that same test a second time at RESTORE time, because a retained park's rollback lands a packet later; AP-135 filed 2026-08-03, C4 route 4a — the airborne no-op's retained acdream bookkeeping; the stated total was 2 rows stale before that filing and is now a literal count of this section; AP-130/AP-131/AP-132 filed 2026-08-02, continuation-executor slice; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55) **2026-08-28 count correction:** #408 retired AP-230 by moving property `0x3B` visibility into the shared importer and every retained stateful widget; @@ -257,7 +257,6 @@ research and is no longer active. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| | AP-235 | **Filed 2026-08-25 at the Campaign CT4 fix round.** Retail resolves gender display text via `AppraisalSystem::InqGenderDisplayName @0x005b47c0` and heritage via `InqHeritageGroupDisplayName @0x005b4710`, both through the static `EnumMapper::GetString(uint32_t enumValue, uint32_t queryId, PStringBase*) @0x0041ac40` overload — `DBObj::GetDIDByEnum(&did, enumValue, 1)` (master map `0x25000000` → category-1 sub-map `0x25000001` → `ClientEnumToID[0x10000001]`/`[0x10000002]` → EnumMapper DIDs `0x2200000A`/`0x2200000B`) — reading each id's `IdToStringMap` entry live, with heritage ids 2/5/0xd hardcoded to `"Gharu'ndim"`/`"Umbraen"`/`"Olthoi"` in place of the raw internal names `"Gharundim"`/`"Shadowbound"`/`"OlthoiAcid"`. `CharacterIdentityText.GenderDisplayName`/`HeritageGroupDisplayName` are hardcoded C# `switch` tables instead — a mechanism divergence (compile-time constant vs. live DAT read), not a content one: `CharacterPanelLiveDatTests.GenderHeritageDisplayNameTables_MatchTheRetailEnumMapperChain` (filed the same round) walks the live EnumMapper chain and asserts every table entry byte-exact, including the two entries (10 "Penumbraen", 12 "Olthoi") the CT4 review had flagged as unverified guesses — both are correct. | `src/AcDream.App/UI/Layout/CharacterIdentityText.cs` (`GenderDisplayName`, `HeritageGroupDisplayName`); `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs` (`ResolveHeritage` — CT5 fix round 2026-08-25 deleted its independent re-implementation of the same 2/5/13 overrides; it now delegates straight to `CharacterIdentityText.HeritageGroupDisplayName`, so this row's divergence has exactly ONE owner, not two) | `RetailDataIdResolver.Resolve` (`src/AcDream.Content/RetailDataIdResolver.cs`) already ports the generic two-level `GetDIDByEnum` chain (used today for layout/material DIDs); unifying gender/heritage onto it needs only `Resolve(dats, enumValue: 0x10000001u/0x10000002u, enumCategory: 1u)` plus an `EnumMapper.IdToStringMap` read — a live-DAT-only path with no bespoke traversal code to write, which is why the tables stayed hardcoded this round rather than porting live-read on the spot; CT5 is the natural landing slot since it already owns this same DAT-lookup family for the Titles page | A future DAT/game update that renames or reorders a heritage/gender enum entry would silently desync acdream's hardcoded tables from retail's live text with no build-time or runtime signal — the CT5 fix round retired the second-copy drift risk (`ResolveHeritage` now reads the same single table), but the core hardcoded-vs-live-DAT divergence itself remains open | `AppraisalSystem::InqGenderDisplayName @ 0x005B47C0`; `InqHeritageGroupDisplayName @ 0x005B4710`; `EnumMapper::GetString @ 0x0041AC40`; `DBObj::GetDIDByEnum @ 0x004153A0` | -| AP-234 | **Filed 2026-08-23 at the #426 solid-face extraction fix.** Cell-wall (EnvCell/CellStruct) geometry approximates retail's "skip untextured subsets inside a cell interior" with the polygon's own `Stippling.NoPos` flag rather than resolving the Surface's own `Type` (`Base1Image`/`Base1ClipMap`) before the per-polygon draw decision — the same NoPos-vs-surface-type conflation #426 fixed for ordinary GfxObj extraction (`PrepareGfxObjMeshData`/`GfxObjMesh.Build`), deliberately LEFT in place here | `src/AcDream.Core/Meshing/CellMesh.cs:45`; `src/AcDream.Content/MeshExtractor.cs`'s `PrepareCellStructMeshData` `hasPos` gate carries the identical rule | Cells are the one retail context that genuinely skips untextured subsets (`DrawEnvCell`), so approximating "untextured" with NoPos is directionally correct for the common case — a solid-colour polygon always carries NoPos since it has no UVs to carry; resolving Surface.Type first would need a per-polygon dat lookup this code doesn't currently perform before the emit/skip decision | A textured polygon whose author left NoPos set (no positive UVs authored despite a real texture) would be wrongly skipped as if untextured, or an untextured polygon whose author left NoPos unset would wrongly draw — either edge case shows as a cell wall gaining or losing a face relative to retail | `RenderDeviceD3D::DrawEnvCell` @0x0059f170 → `D3DPolyRender::DrawMesh(..., arg4=1)`; `RetailUntexturedSurfacePolicy`/`RetailUntexturedSubsetPolicy` (`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) | | AP-233 | **Filed 2026-08-23 at the Holtburg windmill fix (row owed since the R1-P5 sequencer cutover).** `AnimationSequencer.BuildBlendedFrame` blends each part between `floor(FrameNumber)` and the next frame in the playback direction using the retail slerp (`SlerpRetailClient`). Retail never blends animation frames: `CPartArray::UpdateParts` applies `CSequence::get_curr_animframe` = `get_part_frame(floor(frame_number))`, holding every authored 30 fps frame for its whole interval. Since 2026-08-23 the blend holds the boundary frame at BOTH ends of a node's window — including the cyclic seam — so a cycle's last→first transition is retail's hard cut, not a blend. | `src/AcDream.Core/Physics/AnimationSequencer.cs` (`BuildBlendedFrame`); tests `AnimationSequencerTests.Advance_LinkTailDoesNotBlendIntoLinkFrame0` (#61), `Advance_CyclicSeamHoldsLastFrameInsteadOfBlendingIntoFrame0` (windmill) | The blend only smooths between authored interior frames of one node; at every seam the pose is exactly retail's held frame. Authored cycles that loop by symmetry (the Holtburg windmill's 60-frame quarter turn, `0x0300061B`) or by design read identically at the seam; link tails hold their end pose (#61). The owner chose this over dropping the blend (retail's 30 fps stepping) on 2026-08-23. | Any two adjacent authored frames that are NOT meant to be traversed smoothly (a deliberate authored pop inside a node) would be smoothed where retail pops; none known. A per-frame hitch of one held 33 ms interval at each cycle seam is the price of the cut (1.5° on the windmill). | `CPartArray::UpdateParts @0x005190F0`; `CSequence::get_curr_animframe @0x00524970`; `CSequence::get_curr_frame_number @0x005249D0` | | AP-232 | **Filed 2026-08-22 at Campaign VM slice VM1 (the #226 single-pass re-port; deviation introduced at `05970306`, row owed since then).** Retail's single-pass detail combine produces ONE pixel per subset whose OUTPUT alpha is stage 1's `MODULATE(TEXTURE, CURRENT)` (`D3DPolyRender::SetSurface @0x0059c4d0`, op at `0x0059c549`) — for a delayed-alpha (translucent) subset that product is the framebuffer blend weight. acdream draws the base subset with its own alpha, then a second `mesh_detail` draw weighted by `detail.a * instanceOpacity` under `SRCALPHA + INVSRCALPHA`. For OPAQUE subsets (base alpha 1) the two compose to exactly `lerp(base, detail, detail.a*opacity)` and, with both draws fogged, to retail's fog-after-combine pixel (identity pinned by `RetailDetailTextureContractTests`). For TRANSLUCENT building/EnvCell subsets the destination after the base draw is `mix(behind, foggedBase, baseAlpha)`, not `foggedBase`, so the detail weight differs from retail's single product. | `src/AcDream.App/Rendering/Shaders/mesh_detail.frag`; `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.Rhi.cs` (transparent interleave); `src/AcDream.App/Rendering/Wb/EnvCellRenderer.Rhi.cs` (transparent interleave) | Opaque subsets are the overwhelming majority of building shells and interior walls and are exact; translucent detail-bearing subsets (ClipMap/alpha/additive/inverse-alpha glass and grates) get a bounded weight difference that never exceeds the detail texture's own alpha (mean 0.132 on the live Dereth category texture). Collapsing to one draw would require the base pipelines to sample the detail texture, i.e. a second `mesh_modern` variant on the retail path. | A translucent building/EnvCell surface with the detail preference on reads visibly different from retail against a bright background. Separate from AP-34 (queue ORDER); this row is about the blend WEIGHT. | `D3DPolyRender::SetSurface @0x0059c4d0` (stage table), `RenderMeshSubset @0x0059ca10`; VM2 cdb note `docs/research/2026-08-22-vm2-retail-detail-path-cdb.md` | | AP-231 | **Filed 2026-08-16 at the Campaign CC gate round 1 closeout, Group 2 (Skills page info-box completion).** `CharacterCreationSkillsPage.ComposeFormula` ports `gmCGSkillsPage::MakeSkillFormula @0x00480e10` with HIGH CONFIDENCE for the `"Formula : "` prefix, the per-attribute `"(%u x %s)"`-vs-bare-name choice (a term's own multiplier > 1 gets the parenthesized form, else just the attribute name), the `" / %u"` divisor suffix (gated on `Divisor != 1`), and the `" +%u"` additive-bonus suffix (gated on `AdditiveBonus != 0`) — every one of those is a directly-read compiled string literal or a field the DatReaderWriter binding already exposes by name (`SkillFormula`'s six fields map 1:1 onto the decompiled struct's own `_w/_x/_y/_z/_attr1/_attr2` offsets, confirmed by their exact 0x28/0x2c/0x30/0x34/0x38/0x3c stride). LOWER CONFIDENCE: the CONNECTOR text between a two-attribute formula's two terms. This port renders `" + "` — the well-known "(Attr1 + Attr2) / N" shape most published AC skill formulas use — but the decompiled function's own two candidate connector literals (`data_7a01a4`, appended between the terms; `data_797584`, appended again immediately after BOTH terms are present, an apparently redundant second literal whose exact role this session could not resolve) sit behind reference-counted `PStringBase` appends whose actual wide-character content Binary Ninja's HLIL does not surface as a literal — this session had no live cdb attach and no running Ghidra MCP instance to recover the raw bytes. A two-attribute skill's formula therefore renders as `"Formula : (2 x Strength) + Endurance / 4 +2"`-shaped text that is very likely retail-correct in STRUCTURE but not byte-verified. | `src/AcDream.App/UI/Layout/CharacterCreationSkillsPage.cs` (`ComposeFormula`, `AppendAttributeTerm`) | The single-attribute majority of skills render byte-correct today; only the minority of two-attribute formulas carry the unverified connector, and the gap is disclosed in the method's own doc rather than silently guessed. | A live retail capture of a two-attribute skill's formula text (e.g. via the cdb toolchain) could reveal `" + "` is wrong — the actual connector might be `" and "`, `" / "` (an OR-style formula, common for some AC skills that use whichever attribute is higher), or something else the two unresolved literals encode; `data_797584`'s role (appended after both terms) is also unexplained and could indicate a THIRD text segment this port omits entirely. | `gmCGSkillsPage::MakeSkillFormula @0x00480e10`; `SkillFormula` struct (`acclient.h`) | diff --git a/docs/architecture/worldbuilder-inventory.md b/docs/architecture/worldbuilder-inventory.md index ce46942b..93c77ef3 100644 --- a/docs/architecture/worldbuilder-inventory.md +++ b/docs/architecture/worldbuilder-inventory.md @@ -416,6 +416,32 @@ heap. Deterministic pressure tests exceed every configured ceiling and prove zero-charge teardown. Connected evidence: `docs/research/2026-07-24-slice-d-unified-residency-report.md`. +**OH2/S1 exact CellStruct surface-index construction (2026-09-02).** Bake +recipe 8 replaces `MeshExtractor.PrepareCellStructMeshData`'s NoPos-based +approximation with retail's exact `D3DPolyRender::ConstructMesh` (`0x0059DFA0`) +side/candidate table: side candidates come only from `CPolygon::sides_type` +(`AcDream.Core.Meshing.CellStructSideCandidates`); `NoPos`/`NoNeg` mean "this +side's UV-index array is absent" only (`CPolygon::UnPack` `0x00538650`), never +face suppression. The subset/material owner is the source surface-array +index, not the resolved Surface DID or texture format: +`TextureBatchData.SourceSurfaceIndex`/`RetailSurfaceMask`/`RawSurfaceType`/ +`IsCellShell` carry that fact through the prepared package, and +`CellSurfaceSubsets.InAscendingSurfaceOrder` recovers the retail subset walk +order independent of the (Width,Height,Format) storage grouping the package +still uses for atlas dedup. Built-EnvCell draw admission is +`(Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0` +(`RenderDeviceD3D::DrawEnvCell` `0x0059F170` → +`D3DPolyRender::DrawMesh(..., arg4=1)` `0x0059D4A0`), applied after surface +resolution via `RetailUntexturedSurfacePolicy.IsUntextured`, not approximated +before it. `CellMesh.Build`'s render-production role is retired; the +streaming build job now calls `CellMesh.HasDrawableGeometry` — the identical +exact admission rule, evaluated as a predicate only, with no mesh retained — +to decide whether a CellStruct contributes drawable geometry. Fixed retail +`D3DCULL_CW` (`RenderMeshSubset` `0x0059CA10`) remains the raster cull for +every constructed cell-shell subset after fan expansion; the authored +`sides_type` is not read as GPU cull state. Retires AP-234. Contract: +`docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md`. + **Workflow:** Before re-implementing any AC-specific rendering or dat-handling algorithm, **check this inventory first**. If we already extracted it (🟢 sections), it's in `src/AcDream.App/Rendering/Wb/` — use our copy. If WB has diff --git a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md index 066ca939..d4762275 100644 --- a/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md +++ b/docs/plans/2026-09-01-campaign-overhaul-world-solidity.md @@ -620,8 +620,8 @@ Update immediately when a slice changes state. Chat is not the ledger. |---|---|---|---|---|---|---|---| | v1 OH0 | CLOSED | `oh0-baseline.md` | `b3b7d922` | read-only classification | Release build green; 319 focused / 1 skip | n/a | historical; candidates it committed are S1/S2 seeds | | v1 OH1 | SUPERSEDED | research contracts committed at `5cd4fd2c`/`5d907ae9` (kept, binding) | T0–T3 evidence grammar parked on `quarantine/oh1-evidence-grammar-2026-09-02` | reviews found the grammar a false oracle three times | n/a | n/a | see §15 | -| S1 Geometry | IN PROGRESS | `oh2-cellstruct-surface-contract.md` | — | — | — | G1 | recipe 7 → 8 | -| Capture | PENDING | §7 + `tools/walk-oracle/oh/README.md` | scripts drafted; recon owed | — | offsets verified by recon | owner session ~1 h | before S3 | +| S1 Geometry | IN PROGRESS | `oh2-cellstruct-surface-contract.md` (§3.5 arbitrated on the binary 2026-09-02) | chunk A landed `acf17246` (descriptor, extraction, record, serializer, recipe 8); chunk B (CellMesh unification, installed-DAT scan, canonical pins, AP-234, docs) in progress | lead verified chunk A against the named pseudo-C and the paired binary; one inexact absent-UV rule found and corrected before landing; retail review of the whole slice pending | chunk A: Core 32 descriptor tests, Content 170/170, Bake 18/18, Launcher.Core 365/365 (Lane!=Linux), Release solution build 0/0 | G1 pending | recipe 7 → 8; dev pak rebake queued | +| Capture | READY | §7 + `tools/walk-oracle/oh/README.md` | scripts committed `ebaa41df` + `14d8fe64`; lead resolved DBObj::m_DID=+0x28 and the cdecl alpha-function conventions; recon attach re-confirms | — | recon dt/x/uf dumps at session start | owner session ~1 h | before S3 | | S2 World graph | PLANNED | `oh1-construction-landscape-contract.md` | — | — | — | G2 | seeds in `b3b7d922` | | S3 Walk | PLANNED | built-mesh/world contracts + captures | — | — | — | folded into G3 | | | S4 Depth + alpha | PLANNED | depth/alpha contracts + captures | — | — | — | G3 | retires AP-34 | diff --git a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index f8030062..2d4b3edb 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -2110,6 +2110,18 @@ namespace AcDream.App.Rendering.Wb indexSegments); } + // OH2/S1 (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md + // §3.6 point 5): for a cell-shell mesh (batch.IsCellShell true), + // this dictionary walk groups by (Width,Height,Format) for + // atlas/storage packing only — it is NOT retail's ascending + // source-surface-index subset draw order. The resulting + // `renderBatches` list order is what EnvCellRenderer later + // replays as draw order. Recovering the exact retail order + // requires walking AcDream.Content.CellSurfaceSubsets. + // InAscendingSurfaceOrder(meshData) instead; no consumer does + // that yet (OH7's ordered draw stream is the intended owner) so + // this upload path still does not preserve retail's subset + // order for cell shells. foreach (var (format, batches) in meshData.TextureBatches) { foreach (var batch in batches) diff --git a/src/AcDream.App/Streaming/LandblockBuildFactory.cs b/src/AcDream.App/Streaming/LandblockBuildFactory.cs index 9712df86..6333be3e 100644 --- a/src/AcDream.App/Streaming/LandblockBuildFactory.cs +++ b/src/AcDream.App/Streaming/LandblockBuildFactory.cs @@ -526,9 +526,11 @@ public sealed class LandblockBuildFactory && environment.Cells.TryGetValue(envCell.CellStructure, out cellStruct)) { // Phase A8 (2026-05-28): cells render through EnvCellRenderer, NOT as - // WorldEntities with fake MeshRefs. CellMesh.Build remains the existing - // drawable-geometry predicate; the actual shell placement is now owned - // by this streaming job's EnvCellLandblockBuild transaction. + // WorldEntities with fake MeshRefs. CellMesh.HasDrawableGeometry (OH2/S1 + // chunk B, retired CellMesh.Build's render-production role and AP-234's + // NoPos approximation with it) remains the drawable-geometry predicate; + // the actual shell placement is now owned by this streaming job's + // EnvCellLandblockBuild transaction. // Static objects inside the cell continue to flow through the dispatcher // as WorldEntity records below — they have real GfxObj MeshRefs that work // fine; EnvCellRenderer receives only the completed shell transaction. @@ -548,7 +550,7 @@ public sealed class LandblockBuildFactory var physicsCellTransform = cellTransform; // PORTAL VISIBILITY: register EVERY cell with a valid cellStruct, regardless - // of whether CellMesh.Build produced drawable sub-meshes. A portals-only + // of whether CellMesh.HasDrawableGeometry reports drawable geometry. A portals-only // pass-through connector (a ramp / stair / cellar mouth) yields 0 render // sub-meshes but MUST be in the visibility graph so the flood can traverse it // to the cells beyond — otherwise the flood lookup-misses the unregistered @@ -560,7 +562,8 @@ public sealed class LandblockBuildFactory // the render sub-meshes. The +0.02 m render lift is a DRAW concern only and // is intentionally NOT fed into the visibility transform (#119-residual: the // lift shifted horizontal portal planes 2 cm, side-culling deck/stair cells). - var cellSubMeshes = AcDream.Core.Meshing.CellMesh.Build(envCell, cellStruct, _dats); + bool hasDrawableGeometry = + AcDream.Core.Meshing.CellMesh.HasDrawableGeometry(envCell, cellStruct, _dats); envCellBuild.AddCell( envCellId, envCell, @@ -569,7 +572,7 @@ public sealed class LandblockBuildFactory physicsCellTransform, cellOrigin, cellTransform, - hasDrawableGeometry: cellSubMeshes.Count > 0); + hasDrawableGeometry: hasDrawableGeometry); } } diff --git a/src/AcDream.Core/Meshing/CellMesh.cs b/src/AcDream.Core/Meshing/CellMesh.cs index 373bb979..2c5d78f5 100644 --- a/src/AcDream.Core/Meshing/CellMesh.cs +++ b/src/AcDream.Core/Meshing/CellMesh.cs @@ -1,141 +1,104 @@ -using System.Numerics; -using AcDream.Core.Terrain; -using DatReaderWriter; -using DatReaderWriter.DBObjs; +using System; +using System.Collections.Generic; using AcDream.Core.Content; +using DatReaderWriter.DBObjs; using DatReaderWriter.Types; namespace AcDream.Core.Meshing; /// -/// Builds renderable sub-meshes from an EnvCell's room geometry (walls, -/// floors, ceilings). The geometry lives in the linked Environment dat: -/// EnvCell.EnvironmentId → Environment → Cells[CellStructure] → CellStruct. -/// This mirrors GfxObjMesh.Build but reads surfaces from EnvCell.Surfaces -/// (not from the CellStruct itself) and uses the same fan-triangulation -/// and per-surface deduplication pattern. +/// OH2/S1 chunk B (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md +/// §9 item 5): retires this type's former render-production role. The old +/// Build method carried its own NoPos-based "skip untextured cell +/// geometry" approximation (AP-234, retired) — a second, divergent +/// interpretation of the retail CellStruct surface/subset construction +/// algorithm alongside MeshExtractor.PrepareCellStructMeshData, which +/// is now the ONE production interpretation (contract §9 item 5: "Core and +/// Content do not retain divergent CellStruct interpretations"). This class +/// now exposes only : the exact retail +/// predicate for whether a CellStruct contributes at least one drawable +/// subset, needed by the streaming build job +/// (AcDream.App.Streaming.LandblockBuildFactory) to decide whether a +/// cell's shell has drawable geometry before it registers the cell — a +/// question answerable without building or retaining any geometry. /// public static class CellMesh { /// - /// Walk a CellStruct's polygons and produce one - /// per referenced Surface. Surfaces are resolved from .Surfaces - /// (OR'd with 0x08000000 to form the full dat id). Polygons are triangulated as fans. + /// True iff at least one of this CellStruct's polygon construction + /// candidates (, + /// ported from D3DPolyRender::ConstructMesh @0x0059DFA0, contract + /// §3.4) targets a surface slot whose resolved Surface.Type is + /// TEXTURED ( + /// is false) — exactly the condition under which + /// MeshExtractor.PrepareCellStructMeshData would emit at least + /// one subset into the prepared package: retail's built-EnvCell draw + /// admission is (Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP)) != 0 + /// (RenderDeviceD3D::DrawEnvCell @0x0059F170 → + /// D3DPolyRender::DrawMesh(..., arg4=1) @0x0059D4A0, contract §4). /// - /// The EnvCell that owns the surface list. - /// The CellStruct containing the polygon + vertex geometry. - /// - /// Optional dat collection used to read Surface.Type flags and set - /// . When null (e.g. offline tests) - /// all sub-meshes default to . + /// + /// Side candidates come only from CPolygon::sides_type + /// (contract §3.4); NoPos/NoNeg play no role in this + /// predicate — they mean "this side's UV-index array is absent" + /// (contract §2.3/§3.5), which cannot change whether a subset draws, + /// only what its texture coordinates are. This predicate therefore + /// never reads a polygon's UV-index arrays. + /// + /// + /// The EnvCell whose ordered surface-override array + /// () resolves each candidate's source + /// surface-array slot to a qualified Surface DAT id, exactly like + /// MeshExtractor.PrepareCellStructMeshData's + /// surfaceOverrides parameter. /// - public static IReadOnlyList Build(EnvCell envCell, CellStruct cellStruct, IDatObjectSource? dats = null) + /// The CellStruct containing the polygon geometry. + /// DAT object source used to resolve each candidate's surface Surface.Type. + public static bool HasDrawableGeometry(EnvCell envCell, CellStruct cellStruct, IDatObjectSource dats) { - // Group output vertices and indices per surface dat id. - var perSurface = new Dictionary Vertices, List Indices, Dictionary<(int pos, int uv), uint> Dedupe)>(); + // One resolve per distinct surface slot, not per polygon: a slot's + // textured-ness is a per-slot fact (contract §3.2's per-surface + // mask/type), and re-resolving the same Surface DAT record for + // every polygon that references it would be wasted DAT I/O on the + // streaming worker thread that calls this predicate. + var slotIsTextured = new Dictionary(); - foreach (var kvp in cellStruct.Polygons) + bool SlotIsTextured(int slot) { - var poly = kvp.Value; + if (slotIsTextured.TryGetValue(slot, out bool cached)) + return cached; - if (poly.VertexIds.Count < 3) - continue; // degenerate polygon - - // Retail's RenderDeviceD3D::DrawEnvCell (@0x0059f170) calls - // D3DPolyRender::DrawMesh with arg4=1, which skips every - // UNTEXTURED subset inside an EnvCell interior — unlike ordinary - // objects, which draw them (see RetailUntexturedSurfacePolicy / - // RetailUntexturedSubsetPolicy, #426). We approximate - // "untextured" here with the polygon's own NoPos stippling flag - // rather than resolving the Surface's own Type - // (Base1Image/Base1ClipMap) before this per-polygon decision — - // see docs/architecture/retail-divergence-register.md AP-234. Do - // NOT remove this gate the way #426 removed the matching gate in - // GfxObjMesh.Build/MeshExtractor.PrepareGfxObjMeshData — retail - // genuinely skips untextured cell geometry, unlike ordinary - // objects. - if (poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos)) - continue; - - int surfaceIdx = poly.PosSurface; - if (surfaceIdx < 0 || surfaceIdx >= envCell.Surfaces.Count) - continue; // out-of-range surface index - - // Surfaces on EnvCell are unqualified ids; OR with 0x08000000 for the full dat id. - uint surfaceId = (uint)envCell.Surfaces[surfaceIdx] | 0x08000000u; - - if (!perSurface.TryGetValue(surfaceId, out var bucket)) + bool textured = false; + if (slot >= 0 && slot < envCell.Surfaces.Count) { - bucket = (new List(), new List(), new Dictionary<(int, int), uint>()); - perSurface[surfaceId] = bucket; + uint surfaceId = 0x08000000u | envCell.Surfaces[slot]; + if (dats.Get(surfaceId) is { } surface) + textured = !RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); } + slotIsTextured[slot] = textured; + return textured; + } - // Collect output vertex indices for this polygon. - var polyOut = new List(poly.VertexIds.Count); - bool skipPoly = false; + foreach (var poly in cellStruct.Polygons.Values) + { + // Same degenerate-fan gate as MeshExtractor.PrepareCellStructMeshData. + if (poly.VertexIds.Count < 3) continue; - for (int i = 0; i < poly.VertexIds.Count; i++) + ReadOnlySpan candidates = + CellStructSideCandidates.GetCandidates((int)poly.SidesType); + + foreach (var candidate in candidates) { - int posIdx = poly.VertexIds[i]; - int uvIdx = i < poly.PosUVIndices.Count ? poly.PosUVIndices[i] : 0; + short surfaceIdxRaw = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive + ? poly.PosSurface + : poly.NegSurface; + if (surfaceIdxRaw < 0) continue; - if (!cellStruct.VertexArray.Vertices.TryGetValue((ushort)posIdx, out var sw)) - { - skipPoly = true; - break; - } - - var texcoord = uvIdx >= 0 && uvIdx < sw.UVs.Count - ? new Vector2(sw.UVs[uvIdx].U, sw.UVs[uvIdx].V) - : Vector2.Zero; - - // Use normal from vertex data; fall back to up-vector if missing. - var normal = sw.Normal != Vector3.Zero ? sw.Normal : Vector3.UnitZ; - - var key = (posIdx, uvIdx); - if (!bucket.Dedupe.TryGetValue(key, out var outIdx)) - { - outIdx = (uint)bucket.Vertices.Count; - bucket.Vertices.Add(new Vertex(sw.Origin, normal, texcoord, TerrainLayer: 0)); - bucket.Dedupe[key] = outIdx; - } - polyOut.Add(outIdx); - } - - if (skipPoly || polyOut.Count < 3) - continue; - - // Fan triangulation: (v0, v1, v2), (v0, v2, v3), ... - for (int i = 1; i < polyOut.Count - 1; i++) - { - bucket.Indices.Add(polyOut[0]); - bucket.Indices.Add(polyOut[i]); - bucket.Indices.Add(polyOut[i + 1]); + if (SlotIsTextured(surfaceIdxRaw)) + return true; } } - // Emit one sub-mesh per surface. - var result = new List(perSurface.Count); - foreach (var kvp in perSurface) - { - // Resolve Surface.Type flags when a DatCollection is available so the - // renderer can split the draw into opaque and translucent passes. - var translucency = TranslucencyKind.Opaque; - if (dats is not null) - { - var surface = dats.Get(kvp.Key); - if (surface is not null) - translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); - } - - result.Add(new GfxObjSubMesh( - SurfaceId: kvp.Key, - Vertices: kvp.Value.Vertices.ToArray(), - Indices: kvp.Value.Indices.ToArray()) - { - Translucency = translucency, - }); - } - return result; + return false; } } diff --git a/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs b/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs index 29327c73..cbe42f56 100644 --- a/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs +++ b/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs @@ -55,12 +55,19 @@ public static class RetailUntexturedSurfacePolicy /// /// EnvCell interiors are retail's OTHER "skip untextured" case /// (RenderDeviceD3D::DrawEnvCell @0x0059f170 calls -/// DrawMesh(..., arg4: 1)), but EnvCell/CellStruct geometry never -/// reaches this predicate — it draws through EnvCellRenderer / -/// MeshExtractor.PrepareCellStructMeshData, which keeps its own -/// NoPos-based approximation of the same rule (see -/// docs/architecture/retail-divergence-register.md AP-234 and -/// CellMesh.cs's matching gate). +/// DrawMesh(..., arg4: 1)). As of OH2/S1 (2026-09-02, +/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md), +/// EnvCell/CellStruct geometry reaches this SAME predicate directly, with no +/// proxy: MeshExtractor.PrepareCellStructMeshData resolves each +/// candidate's source surface slot FIRST, then calls +/// on the resolved Surface.Type before +/// deciding whether to emit that slot's subset, and +/// CellMesh.HasDrawableGeometry (the streaming build job's +/// drawable-geometry predicate, formerly CellMesh.Build) does the +/// same. The old build-time Stippling.NoPos approximation +/// (docs/architecture/retail-divergence-register.md AP-234) is +/// retired — NoPos/NoNeg now mean only "this side's UV-index array is +/// absent" (contract §2.3/§3.5), never face or subset suppression. /// /// ONE shared predicate for WbDrawDispatcher's classic classifier /// (ClassifyBatches), packed classifier (ClassifyPackedBatches), diff --git a/tests/AcDream.Content.Tests/CellStructSurfaceConstructionInstalledDatTests.cs b/tests/AcDream.Content.Tests/CellStructSurfaceConstructionInstalledDatTests.cs new file mode 100644 index 00000000..db675eb5 --- /dev/null +++ b/tests/AcDream.Content.Tests/CellStructSurfaceConstructionInstalledDatTests.cs @@ -0,0 +1,420 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Security.Cryptography; +using AcDream.Core.Meshing; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Options; +using Xunit.Abstractions; +using RetailCullMode = DatReaderWriter.Enums.CullMode; + +namespace AcDream.Content.Tests; + +/// +/// OH2/S1 chunk B (docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md +/// §10.3/§10.4): the installed-DAT proof that retail's exact CellStruct +/// surface/subset construction (, +/// MeshExtractor.PrepareCellStructMeshData, +/// ) replaces the retired +/// build-time Stippling.NoPos approximation (AP-234) without +/// changing what the COMPLETE installed corpus admits. +/// +/// DAT identity this evidence was captured against (contract §1): +/// client_portal.dat DC6E500BA22E6B186DB7171E3F3345238B6444C85D798ADC85E550973B8D12E4 +/// client_cell_1.dat 6DB0ABF00FBCEED62C3F1EE842EE7C1F423D732BED77A5B7C102EE89A52AB99E +/// client_highres.dat 503E0828D14F2F9CCBC31431E1055AC188464BF4B499DE37F4C3D5B2D9F3E727 +/// client_local_English.dat E85C820280C88FAC7DF6C8043F5E24596E9C8774193AF4123D756546F78FB2BB +/// A hash change on any of these DATs is expected to move the pinned counts +/// below; that is the point of pinning them (a silent regression in the +/// extraction algorithm itself would move them too, on the SAME dat set). +/// +[Trait("Lane", "InstalledDat")] +public sealed class CellStructSurfaceConstructionInstalledDatTests { + private readonly ITestOutputHelper _out; + public CellStructSurfaceConstructionInstalledDatTests(ITestOutputHelper output) => _out = output; + + // Contract §10.3's ten canonical evidence cells: eight cathedral-area + // cells sharing three Environments, plus the Facility Hub's two-cell + // vertical stair pair. + private static readonly uint[] CanonicalCellIds = { + 0xF4180100u, 0xF4180101u, 0xF4180104u, 0xF4180106u, 0xF4180107u, + 0xF4180112u, 0xF4180113u, 0xF4180114u, + 0x8A02015Eu, 0x8A02015Fu, + }; + + private static MeshExtractor NewExtractor(DatCollection dats) { + var reader = new DatCollectionAdapter(dats); + return new MeshExtractor(reader, new TestConsoleLogger(), sideStagedSink: null); + } + + // ------------------------------------------------------------------ + // §10.4 — complete installed-DAT admission-delta scan. + // ------------------------------------------------------------------ + + /// + /// Walks every EnvCell actually present in client_cell_1.dat (not the + /// contract's exploratory 89th-cell stride sample) and, for every + /// polygon construction candidate + /// (), classifies + /// admission under the OLD rule (!NoPos on the positive side, + /// !NoNeg on the ST_BOTH negative side — MeshExtractor's + /// pre-OH2 hasPos/hasNeg gate, contract §5.1) against the + /// exact NEW rule (resolved Surface.Type & (BASE1_IMAGE|BASE1_CLIPMAP) + /// ) != 0). Every delta must be explained by the surface-type fact + /// that produced it: this is the complete-corpus successor to the + /// contract §6 exploratory stride sample (which found 55 concrete + /// "not-NoPos + untextured" false admissions under the OLD rule). + /// + [Fact] + public void InstalledDatScan_EveryOldNewAdmissionDeltaIsExplainedBySurfaceType() { + var datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; } + + var stopwatch = Stopwatch.StartNew(); + using var dats = new DatCollection(datDir, DatAccessType.Read); + + long landblocksVisited = 0; + long landblocksWithCells = 0; + long cellsVisited = 0; + long cellsWithGeometry = 0; + long missingEnvironmentOrStruct = 0; + long polygons = 0; + long candidates = 0; + long unknownSidesType = 0; + long unresolvedSurfaceSlots = 0; + long oldOnly = 0; // old=emit, new=skip + long newOnly = 0; // old=skip, new=emit + var unexplained = new List(); + + // Per-EnvCell surface cache: one wall/floor texture is referenced by + // many polygons within a single cell; re-resolving the same Surface + // dat record per polygon would be wasted I/O across ~10^5 EnvCells. + var slotCache = new Dictionary(); + + // Neither EnvCell nor LandBlockInfo carries a usable + // Chorizite.DatReaderWriter DBObjType id-range/mask (both are + // landblock-RELATIVE types — every landblock reuses the same low + // 16-bit id space 0x0100..0xFFFD for its own cells — so + // DatCollection.GetAllIdsOfType()/() return + // EMPTY: verified empirically 2026-09-02, then confirmed by + // decompiling DatReaderWriter.Lib.DBObjAttributeCache via ilspycmd + // (EnvCell's own [DBObjType] attribute carries FirstId=LastId= + // MaskId=0; DatDatabase.GetAllIdsOfType only consults + // Tree.GetFilesInRange(FirstId,LastId), never the per-landblock + // catch-all TypeFromId path DBObjTypeFromId falls back to for the + // Cell dat). The complete corpus therefore requires the same + // landblock-prefix walk LandblockBuildFactory/StipplingSurface + // EquivalenceTests already use per-fixture, run over the WHOLE + // 0x0000-0xFFFF landblock-prefix space instead of a hardcoded + // fixture list — exactly the shape of the contract §6 exploratory + // sample's own "734,976 candidate EnvCell ids", but complete + // (every candidate, not every 89th). + for (uint landblockPrefix = 0; landblockPrefix <= 0xFFFFu; landblockPrefix++) { + uint lbId = landblockPrefix << 16; + var lbInfo = dats.Get(lbId | 0xFFFEu); + if (lbInfo is null) continue; + landblocksVisited++; + if (lbInfo.NumCells == 0) continue; + landblocksWithCells++; + + for (uint low = 0x0100u; low < 0x0100u + lbInfo.NumCells; low++) { + uint envCellId = lbId | low; + cellsVisited++; + var envCell = dats.Get(envCellId); + if (envCell is null || envCell.EnvironmentId == 0) continue; + + uint envId = 0x0D000000u | envCell.EnvironmentId; + var environment = dats.Get(envId); + if (environment is null || !environment.Cells.TryGetValue(envCell.CellStructure, out var cellStruct)) { + missingEnvironmentOrStruct++; + continue; + } + cellsWithGeometry++; + slotCache.Clear(); + + Surface? ResolveSlot(int slot) { + if (slotCache.TryGetValue(slot, out var cached)) return cached; + Surface? resolved = null; + if (slot >= 0 && slot < envCell.Surfaces.Count) { + uint surfaceId = 0x08000000u | envCell.Surfaces[slot]; + resolved = dats.Get(surfaceId); + } + slotCache[slot] = resolved; + return resolved; + } + + foreach (var (polyId, poly) in cellStruct.Polygons) { + if (poly.VertexIds.Count < 3) continue; + polygons++; + + ReadOnlySpan polyCandidates = + CellStructSideCandidates.GetCandidates((int)poly.SidesType); + if (polyCandidates.Length == 0) { + unknownSidesType++; + continue; + } + + foreach (var candidate in polyCandidates) { + candidates++; + bool isPositive = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive; + short slotRaw = isPositive ? poly.PosSurface : poly.NegSurface; + + bool oldAdmitted = isPositive + ? !poly.Stippling.HasFlag(StipplingType.NoPos) + : !poly.Stippling.HasFlag(StipplingType.NoNeg); + + Surface? surface = ResolveSlot(slotRaw); + if (surface is null) { + unresolvedSurfaceSlots++; + continue; + } + + bool newAdmitted = (surface.Type & (SurfaceType.Base1Image | SurfaceType.Base1ClipMap)) != 0; + + if (oldAdmitted && !newAdmitted) { + oldOnly++; + bool untextured = (surface.Type & (SurfaceType.Base1Image | SurfaceType.Base1ClipMap)) == 0; + if (!untextured) + unexplained.Add($"OLD-ONLY unexplained: envCell=0x{envCellId:X8} env=0x{envId:X8} struct={envCell.CellStructure} poly={polyId} side={candidate.SurfaceSlot} slot={slotRaw} type=0x{(uint)surface.Type:X8}"); + } + else if (!oldAdmitted && newAdmitted) { + newOnly++; + bool uvAbsenceBitSet = isPositive + ? poly.Stippling.HasFlag(StipplingType.NoPos) + : poly.Stippling.HasFlag(StipplingType.NoNeg); + if (!uvAbsenceBitSet) + unexplained.Add($"NEW-ONLY unexplained: envCell=0x{envCellId:X8} env=0x{envId:X8} struct={envCell.CellStructure} poly={polyId} side={candidate.SurfaceSlot} slot={slotRaw} type=0x{(uint)surface.Type:X8} stippling={poly.Stippling}"); + } + } + } + } + } + + stopwatch.Stop(); + _out.WriteLine($"duration: {stopwatch.Elapsed}"); + _out.WriteLine($"landblocksVisited={landblocksVisited} landblocksWithCells={landblocksWithCells}"); + _out.WriteLine($"cellsVisited={cellsVisited} cellsWithGeometry={cellsWithGeometry} missingEnvironmentOrStruct={missingEnvironmentOrStruct}"); + _out.WriteLine($"polygons={polygons} candidates={candidates} unknownSidesType={unknownSidesType} unresolvedSurfaceSlots={unresolvedSurfaceSlots}"); + _out.WriteLine($"oldOnly={oldOnly} newOnly={newOnly} unexplained={unexplained.Count}"); + foreach (var line in unexplained.Take(50)) _out.WriteLine(" " + line); + + Assert.Empty(unexplained); + + // Golden counts pinned against the DAT identity in the class + // docstring (contract §1). A DAT change or an extraction-algorithm + // regression moves these; that visibility is the point of pinning + // them (contract §10.4's "run the complete installed Environment/ + // EnvCell corpus... every old/new admission delta must have the + // exact Surface.Type & 6 explanation"). + Assert.Equal(CellStructInstalledDatGolden.CellsVisited, cellsVisited); + Assert.Equal(CellStructInstalledDatGolden.CellsWithGeometry, cellsWithGeometry); + Assert.Equal(CellStructInstalledDatGolden.MissingEnvironmentOrStruct, missingEnvironmentOrStruct); + Assert.Equal(CellStructInstalledDatGolden.Polygons, polygons); + Assert.Equal(CellStructInstalledDatGolden.Candidates, candidates); + Assert.Equal(CellStructInstalledDatGolden.UnknownSidesType, unknownSidesType); + Assert.Equal(CellStructInstalledDatGolden.UnresolvedSurfaceSlots, unresolvedSurfaceSlots); + Assert.Equal(CellStructInstalledDatGolden.OldOnly, oldOnly); + Assert.Equal(CellStructInstalledDatGolden.NewOnly, newOnly); + } + + // ------------------------------------------------------------------ + // §10.3 — canonical evidence pins. + // ------------------------------------------------------------------ + + /// + /// Pin 1: 0xF4180104's eight ST_DOUBLE clip-map polygons and 44 total + /// drawable side calls across the WHOLE cell — retail's 36 ordinary + /// (forward, positive-normal) drawable candidates plus the 8 additional + /// reversed negative-normal copies the ST_DOUBLE polygons contribute + /// (contract §6): the old NoPos-proxy audit's "36-positive-only" + /// headline never counted the second copy at all. + /// + [Fact] + public void Cell0xF4180104_HasEightStDoubleClipMapPolygonsAndFortyFourDrawableSideCalls() { + var datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + const uint envCellId = 0xF4180104u; + var (envCell, _, cellStruct) = ResolveCanonicalCell(dats, envCellId); + + int stDoubleCount = 0; + int drawableSideCalls = 0; + var slotCache = new Dictionary(); + + Surface? ResolveSlot(int slot) { + if (slotCache.TryGetValue(slot, out var cached)) return cached; + Surface? resolved = null; + if (slot >= 0 && slot < envCell.Surfaces.Count) { + uint surfaceId = 0x08000000u | envCell.Surfaces[slot]; + resolved = dats.Get(surfaceId); + } + slotCache[slot] = resolved; + return resolved; + } + + foreach (var poly in cellStruct.Polygons.Values) { + if (poly.VertexIds.Count < 3) continue; + if (poly.SidesType == RetailCullMode.None) stDoubleCount++; // raw sides_type 1 = ST_DOUBLE + + foreach (var candidate in CellStructSideCandidates.GetCandidates((int)poly.SidesType)) { + bool isPositive = candidate.SurfaceSlot == CellStructPolygonSurfaceSide.Positive; + short slotRaw = isPositive ? poly.PosSurface : poly.NegSurface; + var surface = ResolveSlot(slotRaw); + if (surface is null) continue; + if (!RetailUntexturedSurfacePolicy.IsUntextured(surface.Type)) + drawableSideCalls++; + } + } + + _out.WriteLine($"0xF4180104: stDoubleCount={stDoubleCount} drawableSideCalls={drawableSideCalls}"); + Assert.Equal(8, stDoubleCount); + Assert.Equal(44, drawableSideCalls); + } + + /// + /// Pin 2: across all ten canonical cells, every polygon whose positive + /// side is NoPos-stippled resolves to a Surface whose raw Type is + /// exactly 0x11 (BASE1_SOLID | TRANSLUCENT — untextured), AND that + /// surface slot is absent from the emitted prepared mesh (constructed — + /// the slot has at least one admission candidate by + /// construction — + /// but skipped at built-EnvCell admission, contract §4/§6). + /// + [Fact] + public void CanonicalCells_NoPosSurfacesAreType0x11AndConstructedButSkipped() { + var datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + var extractor = NewExtractor(dats); + + int noPosSurfacesChecked = 0; + foreach (uint envCellId in CanonicalCellIds) { + var (envCell, _, cellStruct) = ResolveCanonicalCell(dats, envCellId); + + var noPosSlots = new HashSet(); + foreach (var poly in cellStruct.Polygons.Values) { + if (poly.VertexIds.Count < 3) continue; + if (!poly.Stippling.HasFlag(StipplingType.NoPos)) continue; + if (poly.PosSurface < 0 || poly.PosSurface >= envCell.Surfaces.Count) continue; + + uint surfaceId = 0x08000000u | envCell.Surfaces[poly.PosSurface]; + var surface = dats.Get(surfaceId); + Assert.NotNull(surface); + Assert.Equal(0x11u, (uint)surface!.Type); + noPosSlots.Add(poly.PosSurface); + noPosSurfacesChecked++; + } + + if (noPosSlots.Count == 0) continue; + + ObjectMeshData? mesh = extractor.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false); + Assert.NotNull(mesh); + var emittedSlots = CellSurfaceSubsets.InAscendingSurfaceOrder(mesh!) + .Select(b => b.SourceSurfaceIndex) + .ToHashSet(); + foreach (int slot in noPosSlots) + Assert.DoesNotContain(slot, emittedSlots); + } + + _out.WriteLine($"noPosSurfacesChecked={noPosSurfacesChecked} across {CanonicalCellIds.Length} canonical cells"); + Assert.True(noPosSurfacesChecked > 0, "expected at least one NoPos polygon across the canonical cells (contract §6)"); + } + + /// + /// Pin 3: two independent extraction runs of every canonical cell + /// produce a bit-identical SHA-256 over the emitted subset records + /// (SourceSurfaceIndex, RawSurfaceType, RetailSurfaceMask, index count, + /// total mesh vertex count), walked in + /// — proving + /// the ascending-source-surface-index order is independent of + /// dictionary/hash iteration order (contract §3.6, §10.3). + /// + [Fact] + public void CanonicalCells_EmittedSubsetHash_IsDeterministicAcrossTwoRuns() { + var datDir = ContentConformanceDats.ResolveDatDir(); + if (datDir is null) { Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md."); return; } + using var dats = new DatCollection(datDir, DatAccessType.Read); + + foreach (uint envCellId in CanonicalCellIds) { + // Fresh MeshExtractor per run: proves determinism is a property + // of the extraction algorithm itself, not of any state an + // extractor instance happens to retain (decoded-texture cache, + // solid-color cache) across calls. + var extractor1 = NewExtractor(dats); + var extractor2 = NewExtractor(dats); + + ObjectMeshData? mesh1 = extractor1.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false); + ObjectMeshData? mesh2 = extractor2.PrepareMeshData(envCellId | 0x1_0000_0000UL, isSetup: false); + Assert.NotNull(mesh1); + Assert.NotNull(mesh2); + + string hash1 = HashCellSubsets(mesh1!); + string hash2 = HashCellSubsets(mesh2!); + _out.WriteLine($"0x{envCellId:X8} hash={hash1}"); + Assert.Equal(hash1, hash2); + } + } + + private static string HashCellSubsets(ObjectMeshData mesh) { + using var stream = new System.IO.MemoryStream(); + using (var writer = new System.IO.BinaryWriter(stream, System.Text.Encoding.UTF8, leaveOpen: true)) { + writer.Write(mesh.Vertices.Length); + foreach (var batch in CellSurfaceSubsets.InAscendingSurfaceOrder(mesh)) { + writer.Write(batch.SourceSurfaceIndex); + writer.Write(batch.RawSurfaceType); + writer.Write(batch.RetailSurfaceMask); + writer.Write(batch.Indices.Count); + } + } + return Convert.ToHexString(SHA256.HashData(stream.ToArray())); + } + + private static (EnvCell envCell, uint envId, DatReaderWriter.Types.CellStruct cellStruct) ResolveCanonicalCell( + DatCollection dats, uint envCellId) { + var envCell = dats.Get(envCellId); + Assert.NotNull(envCell); + uint envId = 0x0D000000u | envCell!.EnvironmentId; + var environment = dats.Get(envId); + Assert.NotNull(environment); + Assert.True(environment!.Cells.TryGetValue(envCell.CellStructure, out var cellStruct), + $"EnvCell 0x{envCellId:X8}: Environment 0x{envId:X8} has no CellStruct {envCell.CellStructure}"); + return (envCell, envId, cellStruct!); + } +} + +/// +/// Golden counts for +/// , +/// pinned against the DAT identity in that class's docstring. Regenerate by +/// reading the test's own diagnostic output after a deliberate DAT/ +/// extraction-algorithm change and updating every field together — a +/// partial update would silently weaken the pin. +/// +internal static class CellStructInstalledDatGolden { + // Captured 2026-09-02 against the DAT identity in the class docstring + // (contract §1); landblocksVisited=5346, landblocksWithCells=3405 are + // printed diagnostics only (not pinned — they describe the world's + // overall building/dungeon density, orthogonal to this contract). + public const long CellsVisited = 729888; + public const long CellsWithGeometry = 729888; + public const long MissingEnvironmentOrStruct = 0; + public const long Polygons = 8601560; + public const long Candidates = 8608746; + public const long UnknownSidesType = 0; + public const long UnresolvedSurfaceSlots = 0; + + // oldOnly (old=emit/new=skip, i.e. "not-NoPos + untextured" — the exact + // false-admission class the contract's own exploratory 1-in-89 stride + // sample estimated at 55 hits in 97,020 polygons): the complete corpus + // finds 3,197 in 8,601,560 polygons, same order of magnitude and same + // direction. newOnly (old=skip/new=emit, i.e. "NoPos + textured") is + // exactly 0 across the WHOLE installed corpus — the complete-corpus + // confirmation of the stride sample's own "NoPos + textured surface = 0" + // finding (contract §6). + public const long OldOnly = 3197; + public const long NewOnly = 0; +} diff --git a/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs index fdf94e28..310b3b3d 100644 --- a/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/StipplingSurfaceEquivalenceTests.cs @@ -14,19 +14,53 @@ namespace AcDream.Core.Tests.Conformance; /// Retail suppresses portal-fill drawing at DRAW time via the skipNoTexture /// rule: building/cell surface batches whose CSurface.type lacks BASE1_IMAGE /// (0x2) and BASE1_CLIPMAP (0x4) are skipped (D3DPolyRender inner draw, -/// Ghidra 0x0059d4a0; default on @0x00820e30). acdream suppresses them at -/// BUILD time via Stippling.NoPos in all four extraction paths -/// (MeshExtractor.PrepareGfxObjMeshData + PrepareCellStructMeshData -/// [moved from ObjectMeshManager in MP1a], CellMesh.Build:44, GfxObjMesh.Build:71). +/// Ghidra 0x0059d4a0; default on @0x00820e30). /// -/// These criteria are equivalent ONLY if NoPos ⇔ untextured-surface holds on -/// the content. This sweep pins both directions across the populated -/// Holtburg-area landblocks (building shell models + every Environment -/// CellStruct their cells reference + the door setup parts): -/// (a) every NoPos poly's positive surface is untextured (else our skip -/// drops something retail draws), and -/// (b) every untextured-surface poly is NoPos (else we draw something -/// retail skips on building/cell passes — the would-be phantom class). +/// STALE PREMISE, CORRECTED 2026-09-02 (OH2/S1 chunk B). This test's +/// original premise was that acdream suppresses the same geometry at BUILD +/// time via Stippling.NoPos, in all four extraction paths, and that +/// the NoPos/untextured correlation below is why that build-time shortcut is +/// safe. That premise is no longer true for ANY of the four paths: +/// - MeshExtractor.PrepareGfxObjMeshData and GfxObjMesh.Build +/// stopped using NoPos as an emission gate at #426 (2026-08-23) — they +/// always emit a polygon's positive side regardless of NoPos, using +/// RetailUntexturedSurfacePolicy.IsUntextured(Surface.Type) only +/// to pick solid-color vs. textured fill, never to skip a face. +/// - MeshExtractor.PrepareCellStructMeshData and the former +/// CellMesh.Build (now CellMesh.HasDrawableGeometry, a +/// predicate with no render-production role) stopped using NoPos at +/// OH2/S1: side candidates come only from CPolygon::sides_type +/// (contract §3.4), and built-EnvCell subset admission is the exact +/// (Surface.Type & 6) != 0 test applied AFTER surface +/// resolution (contract §4) — see +/// docs/research/2026-09-01-overhaul/oh2-cellstruct-surface-contract.md +/// and the retired AP-234 register row. +/// +/// The NoPos ⇔ untextured-surface correlation this sweep measures is +/// therefore a fact ABOUT THE INSTALLED CONTENT, not a description of how +/// acdream currently decides what to draw. It remains worth pinning as a +/// content-shape sanity signal (a violation would mean an author left NoPos +/// set on a real texture, or textured a "NoPos" aperture fill — either is +/// surprising authored content worth knowing about), so the building-model +/// half below stays a LOAD-BEARING assertion: it is still an exact, +/// evaluable fact about the DAT content (poly.PosSurface resolves directly +/// off the GfxObj's own surface array, no EnvCell instance needed). The +/// CellStruct half is downgraded to a REPORT ONLY (see +/// below) — it was already a narrower +/// proxy check (NoPos-poly-is-a-portal-poly, not a real per-EnvCell surface +/// resolution — a CellStruct alone cannot resolve a surface without a +/// specific EnvCell's override array), and its original justification (are +/// we skipping only aperture fills, never wall geometry?) no longer applies +/// now that CellStruct extraction does not use NoPos to decide any of that. +/// +/// This sweep pins both directions across the populated Holtburg-area +/// landblocks (building shell models + every Environment CellStruct their +/// cells reference + the door setup parts): +/// (a) every NoPos poly's positive surface is untextured (else the old +/// build-time-suppression approximation, when it existed, would have +/// dropped something retail draws), and +/// (b) every untextured-surface poly is NoPos (else the old approximation +/// would have drawn something retail's build/cell passes skip). /// Violations of (b) on PLAIN OBJECT GfxObjs are allowed — retail's bypass /// draws solid batches for non-building/non-cell meshes, and so do we. /// @@ -53,8 +87,13 @@ public sealed class StipplingSurfaceEquivalenceTests using var dats = new DatCollection(datDir, DatAccessType.Read); int polysChecked = 0; - var aViolations = new List(); // NoPos but TEXTURED (our skip would drop a retail-drawn poly) - var bViolations = new List(); // untextured but NOT NoPos (we'd draw what retail skips) + // Building-model (GfxObj) violations only — LOAD-BEARING (Assert.Empty + // below). See the class docstring: this direction remains a real, + // per-polygon-resolvable fact about the DAT content even though + // neither GfxObj extraction path (#426) nor either CellStruct path + // (OH2/S1) still uses NoPos as a build-time emission gate. + var aViolations = new List(); // NoPos but TEXTURED + var bViolations = new List(); // untextured but NOT NoPos bool IsTextured(DatReaderWriter.DBObjs.Surface? s) => s is not null && @@ -98,6 +137,19 @@ public sealed class StipplingSurfaceEquivalenceTests } // ---- cell structs referenced by those landblocks' interior cells ---- + // REPORT ONLY (not asserted — see the class docstring). Direction (a) + // can't be evaluated without a specific EnvCell (a CellStruct alone + // has no surface array to resolve against), so this was always a + // narrower proxy: every NoPos poly must be a portal polygon + // (referenced by the struct's Portals list). Its original purpose — + // justifying a build-time NoPos-based skip that removes only + // aperture fills, never wall geometry — no longer applies, because + // neither MeshExtractor.PrepareCellStructMeshData nor + // CellMesh.HasDrawableGeometry uses NoPos to decide that any more + // (OH2/S1). Kept as a content-shape signal: a mismatch here would + // still be surprising authored content worth knowing about, but it + // no longer describes a bug in acdream's extraction. + var cellPortalPolyMismatches = new List(); foreach (var envId in environments) { var env = dats.Get(envId); @@ -109,28 +161,23 @@ public sealed class StipplingSurfaceEquivalenceTests var poly = kv.Value; polysChecked++; bool noPos = poly.Stippling.HasFlag(DatReaderWriter.Enums.StipplingType.NoPos); - // CellStruct polys resolve surfaces through the EnvCell's - // surface list at runtime; the struct itself stores only the - // index. Direction (a) can't be evaluated without a specific - // EnvCell, so for structs we pin only the NoPos→portal-poly - // correspondence: every NoPos poly must be a portal polygon - // (referenced by the struct's Portals list), i.e. our skip - // removes only aperture fills, never wall geometry. if (noPos) { bool isPortalPoly = cs.Portals.Any(p => p == kv.Key); if (!isPortalPoly) - aViolations.Add($"env 0x{envId:X8} struct {csId} poly {kv.Key}: NoPos but not a portal poly"); + cellPortalPolyMismatches.Add($"env 0x{envId:X8} struct {csId} poly {kv.Key}: NoPos but not a portal poly"); } } } } _out.WriteLine($"checked {polysChecked} polys across {buildingModels.Count} building models + {environments.Count} environments"); - _out.WriteLine($"(a) NoPos-but-textured (skip would drop retail-drawn): {aViolations.Count}"); + _out.WriteLine($"(a) building NoPos-but-textured (load-bearing): {aViolations.Count}"); foreach (var v in aViolations.Take(20)) _out.WriteLine($" {v}"); - _out.WriteLine($"(b) untextured-but-not-NoPos on buildings (we'd draw what retail skips): {bViolations.Count}"); + _out.WriteLine($"(b) building untextured-but-not-NoPos (load-bearing): {bViolations.Count}"); foreach (var v in bViolations.Take(20)) _out.WriteLine($" {v}"); + _out.WriteLine($"(c) cell NoPos-but-not-portal-poly (REPORT ONLY, not asserted): {cellPortalPolyMismatches.Count}"); + foreach (var v in cellPortalPolyMismatches.Take(20)) _out.WriteLine($" {v}"); Assert.Empty(aViolations); Assert.Empty(bViolations);