From 517d17b4b32f1fbe8d0dbb108f431a0bd064573f Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 23 Aug 2026 11:20:24 +0200 Subject: [PATCH] fix #426: extract solid-colour (NO_POS_UVS) faces; skip untextured subsets only on building shells and cells like retail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Holtburg windmill axle (GfxObj 0x010010CE, 8 polygons, all Stippling.NoPos + SurfaceType.Base1Solid) extracted to a 0-vertex mesh. NoPos ("NO_POS_UVS", acclient.h:7380-7388) means "this side has no texture coordinates" — true of every solid-colour polygon, since nothing samples them — not "there is no positive face". Extraction read it as the latter and dropped the polygon entirely, client-wide, for every untextured polygon on every object. Retail's D3DPolyRender::DrawMesh (@0x0059d4a0, named-retail decomp ~line 426048) draws an untextured subset on an ordinary object exactly like a textured one; the only retail cases that skip an untextured subset are a building shell (RenderDeviceD3D::DrawBuilding @0x0059f2a0 sets ObjBuildingOrBuildingPart=1) or an EnvCell interior (RenderDeviceD3D::DrawEnvCell @0x0059f170, arg4=1). The #119 investigation's "retail's skipNoTexture never draws them either" conclusion was itself wrong as a general rule. - MeshExtractor.PrepareGfxObjMeshData / GfxObjMesh.Build: emit the positive side whenever PosSurface is a valid index, regardless of NoPos; the existing UV-index-0 fallback already produces zero texcoords for a NoPos polygon with no UVs on the wire. - RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType): the one place that answers "is this surface textured" ((type & (Base1Image|Base1ClipMap)) == 0), replacing the old `isSolid = NoPos || Base1Solid` (which also mis-classified a NEG-side batch by the POS-side's NoPos flag). - RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured): the shared draw-time gate wired into WbDrawDispatcher.ClassifyBatches, .PackedOracle.ClassifyPackedBatches, and .DirectionalShadows.AddDirectionalShadowBatches — one predicate so the three walks cannot drift (Campaign VM VM6 lesson). - CellMesh.cs / MeshExtractor.PrepareCellStructMeshData deliberately KEEP their NoPos-gated skip for cell-wall geometry — retail's DrawEnvCell really does skip untextured subsets there; register row AP-234 documents the NoPos-vs-Surface.Type approximation. - PakFormat.CurrentBakeToolVersion 4->5 (LauncherInstallRecordStore in lockstep): a pak baked by an older tool is missing every untextured face. No bake was run as part of this commit. Also fixed: WorldBuilder's own upstream ObjectMeshManager.cs has the identical NoPos bug (ObjectMeshManager.cs:959,984) — our port had faithfully carried it over, and our own conformance test (Build_NoPosFlag_OnlyEmitsNegSide) asserted the bug as correct WB conformance. Renamed/reworded to Build_NoPosFlag_EmitsBothPosAndNegSide with a citation for why retail decomp overrides WB here. Issue119UpNullGfxObjDumpTests re-run against the installed DAT: #119's own two objects (0x010002B4 9/9 polys, 0x010008A8 1/1 poly) now gate DRAWS on every polygon instead of extracting to nothing. Co-Authored-By: Claude Sonnet 5 --- docs/ISSUES.md | 80 +++++ .../retail-divergence-register.md | 3 +- .../Rendering/Wb/ObjectMeshManager.cs | 40 ++- .../Wb/WbDrawDispatcher.DirectionalShadows.cs | 9 + .../Wb/WbDrawDispatcher.PackedOracle.cs | 8 + .../Rendering/Wb/WbDrawDispatcher.cs | 9 + src/AcDream.Content/MeshExtractor.cs | 47 ++- src/AcDream.Content/Pak/PakFormat.cs | 10 +- src/AcDream.Core/Meshing/CellMesh.cs | 14 +- src/AcDream.Core/Meshing/GfxObjMesh.cs | 19 +- .../Meshing/RetailUntexturedSurfacePolicy.cs | 76 +++++ .../LauncherInstallRecordStore.cs | 5 +- .../MeshExtractorSolidFaceExtractionTests.cs | 294 ++++++++++++++++++ .../Issue119UpNullGfxObjDumpTests.cs | 42 ++- .../Meshing/GfxObjMeshTests.cs | 52 ++++ .../RetailUntexturedSurfacePolicyTests.cs | 42 +++ .../Wb/MeshExtractionConformanceTests.cs | 31 +- .../Installation/LauncherInstallerTests.cs | 14 +- 18 files changed, 755 insertions(+), 40 deletions(-) create mode 100644 src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs create mode 100644 tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs create mode 100644 tests/AcDream.Core.Tests/Meshing/RetailUntexturedSurfacePolicyTests.cs diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 652e962b..434f0831 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -24,6 +24,74 @@ What does NOT go here: - Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending. - Promoting to a Phase: mark as `DONE (promoted to Phase X)` + commit SHA where the Phase entry landed. +## #426 — Every solid-colour (untextured) polygon on every object client-wide was invisible: mesh extraction misread NO_POS_UVS as "no positive face" + +**Status:** ✅ FIXED 2026-08-23 (found on the Holtburg windmill axle, GfxObj +0x010010CE, 30.4N 28.2E). +**Component:** content extraction (`MeshExtractor`/`GfxObjMesh`) + draw-time +classification (`WbDrawDispatcher`) + +**Symptom:** the windmill axle's 8 polygons (all `Stippling.NoPos` + +`SurfaceType.Base1Solid`) extracted to a 0-vertex mesh — +`[up-null] 0x010010CE produced a 0-vertex mesh`. Not an isolated case: EVERY +flat-coloured (untextured) polygon on EVERY GfxObj client-wide extracted to +nothing, because `PrepareGfxObjMeshData`/`GfxObjMesh.Build` gated emission of +a polygon's positive side on `!Stippling.HasFlag(StipplingType.NoPos)`. + +**Root cause:** `StipplingType.NoPos` (`NO_POS_UVS = 0x4`, +`docs/research/named-retail/acclient.h:7380-7388`) means "this side has no +texture coordinates" — true of every solid-colour polygon, since nothing +samples them — NOT "there is no positive face". The extraction code read it +as the latter and silently dropped the polygon entirely. Retail's +`D3DPolyRender::DrawMesh` (@0x0059d4a0, +`docs/research/named-retail/acclient_2013_pseudo_c.txt` ~line 426048) draws +an untextured subset (`(surface->type & 6) == 0`, i.e. neither +`BASE1_IMAGE` nor `BASE1_CLIPMAP`) on an ORDINARY object exactly like a +textured one; the ONLY retail cases that skip an untextured subset are a +BUILDING SHELL (`RenderDeviceD3D::DrawBuilding` @0x0059f2a0 sets +`ObjBuildingOrBuildingPart = 1`) and an EnvCell interior +(`RenderDeviceD3D::DrawEnvCell` @0x0059f170, `arg4 = 1`). The earlier #119 +investigation's "retail's skipNoTexture never draws them either" conclusion +was itself wrong — that only happened to hold for #119's two specific +GfxObjs because retail's per-model draw call passes `arg4` from the caller's +own context, not because untextured subsets are universally skipped (see the +#119 amendment below). + +**Fix:** `MeshExtractor.PrepareGfxObjMeshData` and `GfxObjMesh.Build` now +emit a polygon's positive side whenever `PosSurface` is a valid index, +regardless of NoPos; a NoPos polygon with no UVs on the wire falls back to +UV index 0 / zero texcoords (the pre-existing fallback path, unchanged). +`RetailUntexturedSurfacePolicy.IsUntextured(SurfaceType)` +(`src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs`) is the ONE +place that now answers "is this surface textured", built from the surface's +own `Type` flags (`Base1Image`/`Base1ClipMap`) instead of the polygon's +Stippling — `MeshExtractor`'s `isSolid`/`TextureKey.IsSolid` now uses it +(previously `isSolid = NoPos || Base1Solid`, which also mis-classified a +NEG-side batch by the POS-side's NoPos flag). `RetailUntexturedSubsetPolicy +.Draws(isBuildingShell, isUntextured)` in the same file is the shared +draw-time predicate wired into `WbDrawDispatcher.ClassifyBatches`, +`.PackedOracle.ClassifyPackedBatches`, and +`.DirectionalShadows.AddDirectionalShadowBatches` — the ONE thing that +still skips an untextured subset is a building-shell entity, matching +retail's `DrawBuilding` gate; the shadow caster and receiver agree by +construction. `CellMesh.cs` and `MeshExtractor.PrepareCellStructMeshData` +(EnvCell/cell-wall geometry) deliberately KEEP their existing NoPos-gated +skip — retail's `DrawEnvCell` really does skip untextured cell subsets, and +the NoPos flag remains an approximation of that rule rather than a bug (see +register row AP-234). + +**Verification:** `Issue119UpNullGfxObjDumpTests` (Lane=InstalledDat) reran +against the installed DAT post-fix: #119's own two objects (0x010002B4, 9/9 +polygons; 0x010008A8, 1/1 polygon — both all-NoPos+Base1Solid) now gate +`DRAWS` on every polygon instead of producing a 0-vertex mesh. + +**Pak version:** `PakFormat.CurrentBakeToolVersion` 4→5 (also +`LauncherInstallRecordStore.CurrentBakeToolVersion`, kept in lockstep) — a +pak baked by an older tool is missing every untextured face and MUST be +regenerated; no bake was run as part of this fix (out of scope for a code +commit — the next scheduled bake picks it up via the version bump forcing a +rebuild). + ## #425 — Options Apply "Atmospheric rendering" fell back to the default path and stayed locked out: Low's 64 MiB resident budget did not scale with resolution **Status:** ✅ FIXED 2026-08-23 (found at the owner's VM3/VM6 gate launch). @@ -16763,6 +16831,18 @@ failing step pins which candidate fires. **Filed:** 2026-06-11 (T5 comprehensive gate, user items 9+13) **Component:** render — mesh upload / content inclusion +**AMENDED 2026-08-23 (#426):** the "Retail's skipNoTexture never draws them +either" conclusion below is WRONG as a general rule — retail only skips an +untextured (solid-colour) subset on a BUILDING SHELL or inside an EnvCell; +an ORDINARY object's untextured polygons DO draw, and the extraction was +dropping every one of them client-wide via a NoPos misread (fixed by #426). +Post-fix, `Issue119UpNullGfxObjDumpTests` shows BOTH of this entry's +GfxObjs now gate DRAWS on every polygon instead of extracting to nothing — +whether that geometry is actually visible on screen (i.e. whether either +object is a building-shell part, which would still skip it at draw time) +is unverified and NOT the same question as the extraction-level "no draw" +claim this entry made in 2026-06-12. + **RESOLUTION (2026-06-12) — three root causes, fixed in sequence, each pinned by the ACDREAM_DUMP_ENTITY decisive probe (`3cf6bcc`):** 1. **`2163308` — Tier-1 cross-entity batch serving** (the broken stairs + diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index dc5cdf98..11daed32 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -210,7 +210,7 @@ readiness/requeue adaptation. See --- -## 3. Documented approximation (AP) — 162 active rows (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-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; 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) — 163 active rows (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-204~~ RETIRED 2026-08-11 at the OP8 rework — the silent-auto-reassign narrowing it recorded is fixed by a real `RetailDialogFactory` confirm-before-reassign dialog; see its retirement note below. AP-203/AP-202 filed 2026-08-11 at Campaign OP slice OP8 (Configure Keyboard) remain active — AP-202 records D4's `.keymap`-file-interchange narrowing (`keybinds.json` only), AP-203 records that roughly half of the DAT ActionMap's 306 user-bindable rows (82 of 87 Emotes, all 48 CharacterSettings hotkeys, all 10 CameraAlternateControls rows per the M2 de-alias fix, and assorted UI/Combat odds) render/bind/persist on the Configure Keyboard screen with no live acdream gameplay consumer yet; 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) Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered @@ -218,6 +218,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle | |---|---|---|---|---|---| +| 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/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs index 90874a57..25a6bc42 100644 --- a/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs +++ b/src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs @@ -1661,16 +1661,36 @@ namespace AcDream.App.Rendering.Wb var renderData = UploadGfxObjMeshData(meshData); if (renderData == null) { - // 0-vertex mesh: every polygon was gated out at extraction. #119 - // (2026-06-11) dat-verified this is LEGITIMATE for all-no-draw - // models (all polys NoPos + Base1Solid surfaces — retail's - // skipNoTexture never draws them either; 0x010002B4/0x010008A8 - // are this class, Issue119UpNullGfxObjDumpTests). The empty - // cache is the correct terminal state for those. The line stays - // as a tripwire for the OTHER way to get here (extraction - // dropped textured polys — a real defect; dat-verify with the - // dump test before treating as one). - Console.WriteLine($"[up-null] 0x{meshData.ObjectId:X10} produced a 0-vertex mesh — caching empty render data (legitimate for all-no-draw models; dat-verify via Issue119UpNullGfxObjDumpTests)"); + // 0-vertex mesh: every polygon was gated out at extraction. + // #119 (2026-06-11) ORIGINALLY reasoned this is LEGITIMATE + // for every all-NoPos+Base1Solid ("all-no-draw") model, + // claiming retail's skipNoTexture never draws untextured + // subsets at all. #426 (2026-08-23, the Holtburg windmill + // axle 0x010010CE) corrected that: retail's skipNoTexture + // only suppresses untextured subsets on a BUILDING SHELL + // (RenderDeviceD3D::DrawBuilding @0x0059f2a0 sets + // ObjBuildingOrBuildingPart=1) or inside an EnvCell + // interior (DrawEnvCell @0x0059f170, arg4=1) — an + // ORDINARY object's untextured (solid-colour) polygons DO + // draw (D3DPolyRender::DrawMesh(..., arg4=0)). Extraction + // now emits the positive side for every polygon with a + // valid PosSurface regardless of NoPos, so a 0-vertex + // mesh here is legitimate ONLY for a model whose every + // polygon is degenerate (fewer than 3 vertices) or + // references no valid Surface index at all. + // Issue119UpNullGfxObjDumpTests' own dump against the + // installed DAT confirms #119's original two objects + // (0x010002B4, 9 polys; 0x010008A8, 1 poly — both + // all-NoPos+Base1Solid) now gate DRAWS on every polygon + // and are NOT examples of the legitimate 0-vertex case + // any more; they were never actually all-degenerate or + // all-invalid-surface, they were all-solid, which #426 + // now extracts. The line stays as a tripwire for the + // OTHER way to get here (extraction dropped textured + // polys — a real defect; + // dat-verify with the dump test before treating a hit as + // legitimate). + Console.WriteLine($"[up-null] 0x{meshData.ObjectId:X10} produced a 0-vertex mesh — caching empty render data (legitimate only for degenerate/no-valid-surface models post-#426; dat-verify via Issue119UpNullGfxObjDumpTests)"); renderData = new ObjectRenderData(); } diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs index cdee27ef..350c73ec 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.DirectionalShadows.cs @@ -1183,6 +1183,15 @@ public sealed partial class WbDrawDispatcher batchIndex++) { ObjectRenderBatch batch = renderData.Batches[batchIndex]; + + // #426: a batch retail never draws for this entity casts no + // shadow either — same gate as ClassifyBatches/ + // ClassifyPackedBatches (RetailUntexturedSubsetPolicy), so the + // caster and receiver agree by construction (mirrors the + // FoliageWindClassification comment below). + if (!RetailUntexturedSubsetPolicy.Draws(candidate.IsBuildingShell, batch.Key.IsSolid)) + continue; + sourceBatches++; if (!DirectionalShadowPreparedDraws.TryClassifyMaterial( batch.Translucency, diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs index 06ea7b44..e701d3fc 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.PackedOracle.cs @@ -631,6 +631,14 @@ public sealed unsafe partial class WbDrawDispatcher { ObjectRenderBatch batch = renderData.Batches[batchIndex]; + + // #426: mirrors the classic ClassifyBatches gate exactly — see + // RetailUntexturedSubsetPolicy for the retail citation. ONE + // shared predicate so the classic and packed classifiers cannot + // drift (Campaign VM VM6). + if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid)) + continue; + TranslucencyKind translucency = batch.Translucency; if (opacity < 1f && IsOpaque(translucency)) translucency = TranslucencyKind.AlphaBlend; diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 9568f34b..f9fdbdd2 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -3421,6 +3421,15 @@ public sealed partial class WbDrawDispatcher : IDisposable { var batch = renderData.Batches[batchIdx]; + // #426: retail's D3DPolyRender::DrawMesh skips an UNTEXTURED + // (solid-colour) subset only on a BUILDING SHELL + // (RenderDeviceD3D::DrawBuilding sets ObjBuildingOrBuildingPart); + // ordinary statics/scenery/creatures/items draw it same as any + // textured subset. ONE shared predicate with ClassifyPackedBatches + // and AddDirectionalShadowBatches — see RetailUntexturedSubsetPolicy. + if (!RetailUntexturedSubsetPolicy.Draws(entity.IsBuildingShell, batch.Key.IsSolid)) + continue; + TranslucencyKind translucency = batch.Translucency; // #188: a mid-fade instance whose surface is otherwise Opaque/ClipMap diff --git a/src/AcDream.Content/MeshExtractor.cs b/src/AcDream.Content/MeshExtractor.cs index 0213d139..f3cba889 100644 --- a/src/AcDream.Content/MeshExtractor.cs +++ b/src/AcDream.Content/MeshExtractor.cs @@ -347,9 +347,20 @@ public sealed class MeshExtractor { if (poly.VertexIds.Count < 3) continue; // Handle Positive Surface - if (!poly.Stippling.HasFlag(StipplingType.NoPos)) { - AddSurfaceToBatch(poly, poly.PosSurface, false); - } + // #426 (2026-08-23, Holtburg windmill axle 0x010010CE): NoPos + // ("NO_POS_UVS", acclient.h:7386) means "this side has no texture + // coordinates" — that's true of every SOLID-COLOUR polygon, not + // "there is no positive face". Retail's D3DPolyRender::DrawMesh + // draws untextured (solid) subsets on ordinary objects same as + // textured ones (see RetailUntexturedSurfacePolicy); only a + // building shell or an EnvCell interior skips them, and that is + // a DRAW-time decision (RetailUntexturedSubsetPolicy, applied in + // WbDrawDispatcher), not an extraction-time one. So the positive + // side is always emitted when PosSurface is a valid index; + // AddSurfaceToBatch already falls back to UV index 0 / zero + // texcoords (via BuildPolygonIndices) when NoPos leaves no UVs to + // read. + AddSurfaceToBatch(poly, poly.PosSurface, false); // Handle Negative Surface // Some objects use Clockwise CullMode to indicate negative surface data is present @@ -376,7 +387,13 @@ public sealed class MeshExtractor { TextureFormat textureFormat; UploadPixelFormat? uploadPixelFormat = null; UploadPixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + // #426: "solid" (untextured) is a SURFACE fact, not a + // polygon-stippling fact — see RetailUntexturedSurfacePolicy. + // The old `NoPos ||` term conflated "this polygon's positive + // side has no UVs" with "this surface is untextured"; it also + // wrongly classified a NEG-side batch by the POS-side's NoPos + // flag, since this method is shared by both sides. + bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); uint paletteId = 0; bool isDxt3or5 = false; @@ -710,6 +727,20 @@ public sealed class MeshExtractor { // GL cull enum: 0 = pos, 1 = pos twice with reversed winding, // 2 = pos + neg surface. The DAT-side NoPos/NoNeg flags still // suppress hidden portal/cap faces before they reach our mesh. + // + // #426: unlike PrepareGfxObjMeshData (ordinary objects — fixed to + // emit every NoPos-flagged positive side, since NoPos means "no + // UVs", not "no face"), this NoPos gate is INTENTIONALLY kept. + // Cell-wall geometry draws through retail's + // RenderDeviceD3D::DrawEnvCell (@0x0059f170), which calls + // D3DPolyRender::DrawMesh with arg4=1 and skips every UNTEXTURED + // subset — approximated here by the polygon's own NoPos flag + // rather than by resolving Surface.Type + // (Base1Image/Base1ClipMap, see RetailUntexturedSurfacePolicy) + // before this decision is made. See + // docs/architecture/retail-divergence-register.md AP-234. Do NOT + // remove this gate to mirror the GfxObj fix — that would draw + // solid-colour cell-wall faces retail never shows. bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); bool hasNeg = !poly.Stippling.HasFlag(StipplingType.NoNeg); @@ -745,7 +776,13 @@ public sealed class MeshExtractor { TextureFormat textureFormat; UploadPixelFormat? uploadPixelFormat = null; UploadPixelType? uploadPixelType = null; - bool isSolid = poly.Stippling.HasFlag(StipplingType.NoPos) || surface.Type.HasFlag(SurfaceType.Base1Solid); + // #426: "solid" (untextured) is a SURFACE fact, not a + // polygon-stippling fact — see RetailUntexturedSurfacePolicy. + // The old `NoPos ||` term conflated "this polygon's positive + // side has no UVs" with "this surface is untextured"; it also + // wrongly classified a NEG-side batch by the POS-side's NoPos + // flag, since this method is shared by both sides. + bool isSolid = RetailUntexturedSurfacePolicy.IsUntextured(surface.Type); bool isClipMap = surface.Type.HasFlag(SurfaceType.Base1ClipMap); uint paletteId = 0; bool isDxt3or5 = false; diff --git a/src/AcDream.Content/Pak/PakFormat.cs b/src/AcDream.Content/Pak/PakFormat.cs index 4ccb2d6f..590b7e6a 100644 --- a/src/AcDream.Content/Pak/PakFormat.cs +++ b/src/AcDream.Content/Pak/PakFormat.cs @@ -22,9 +22,15 @@ public static class PakFormat { /// version 3 embeds exact render-pass translucency in each texture batch /// so production never rebuilds surface metadata from live DAT. Version 4 /// adds complete immutable flat collision and EnvCell-topology payloads. - /// The binary format remains version 1. + /// Version 5 (#426, 2026-08-23) extracts untextured (solid-colour) + /// positive faces that versions <=4 dropped — every GfxObj polygon + /// whose Stippling carries NoPos (NO_POS_UVS) previously extracted to + /// zero vertices on its positive side, so any pak baked by an older tool + /// is missing those faces (e.g. the Holtburg windmill axle 0x010010CE, + /// 8 polygons all NoPos + Base1Solid, extracted to a 0-vertex mesh). The + /// binary format remains version 1. /// - public const uint CurrentBakeToolVersion = 4; + public const uint CurrentBakeToolVersion = 5; } /// diff --git a/src/AcDream.Core/Meshing/CellMesh.cs b/src/AcDream.Core/Meshing/CellMesh.cs index 810863a9..373bb979 100644 --- a/src/AcDream.Core/Meshing/CellMesh.cs +++ b/src/AcDream.Core/Meshing/CellMesh.cs @@ -41,7 +41,19 @@ public static class CellMesh if (poly.VertexIds.Count < 3) continue; // degenerate polygon - // Skip if NoPos stippling is set (polygon has no positive surface geometry). + // 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; diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 3b5d2f0d..74415033 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -28,8 +28,16 @@ public static class GfxObjMesh /// The rule for emitting a polygon side: /// /// - /// Pos side: emit whenever !Stippling.NoPos and - /// PosSurface is a valid index. + /// Pos side: emit whenever PosSurface is a valid + /// index, REGARDLESS of Stippling.NoPos. #426 + /// (2026-08-23): NoPos ("NO_POS_UVS", acclient.h:7386) means + /// "this side has no texture coordinates" — every solid-colour + /// polygon carries it, since it has no UVs to carry — not "there + /// is no positive face". Retail's D3DPolyRender::DrawMesh + /// draws untextured (solid) subsets on ordinary objects the same + /// as textured ones (see ); + /// a NoPos polygon with no UVs to read falls back to UV index 0 / + /// zero texcoords below. /// Neg side: emit when /// Stippling.Negative, Stippling.Both, or /// (!Stippling.NoNeg && SidesType == CullMode.Clockwise). @@ -69,9 +77,10 @@ public static class GfxObjMesh continue; // degenerate — can't form a triangle // --- Positive side --- - bool hasPos = !poly.Stippling.HasFlag(StipplingType.NoPos); - if (hasPos) - EmitSide(poly, poly.PosSurface, isNeg: false); + // #426: always emit — NoPos only means "no positive UVs", not + // "no positive face" (see the class doc). EmitSide's own + // surfaceIdx-validity guard is the only real gate. + EmitSide(poly, poly.PosSurface, isNeg: false); // --- Negative side --- // Three ways AC flags a polygon as double-sided: diff --git a/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs b/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs new file mode 100644 index 00000000..29327c73 --- /dev/null +++ b/src/AcDream.Core/Meshing/RetailUntexturedSurfacePolicy.cs @@ -0,0 +1,76 @@ +using DatReaderWriter.Enums; + +namespace AcDream.Core.Meshing; + +/// +/// Retail's textured-vs-untextured surface classification, ported from +/// D3DPolyRender::DrawMesh @0x0059d4a0 (named-retail decomp, +/// docs/research/named-retail/acclient_2013_pseudo_c.txt ~line 426048): a +/// surface subset is TEXTURED — and therefore never subject to either of +/// retail's "skip untextured" gates (see +/// ) — when +/// (surface->type & 6) != 0, i.e. when +/// (BASE1_IMAGE, 0x2) or +/// (BASE1_CLIPMAP, 0x4) is set +/// (docs/research/named-retail/acclient.h:5822-5824). Every other surface — +/// including and any surface whose type +/// carries neither bit — is UNTEXTURED: a flat-colour ("solid") subset filled +/// from Surface.ColorValue rather than a decoded texture. +/// +/// +/// #426 (2026-08-23, the Holtburg windmill axle 0x010010CE): the polygon-side +/// StipplingType.NoPos flag ("this side has no texture coordinates", +/// acclient.h:7386) is NOT the same fact as "this surface is untextured" — +/// every solid-colour polygon carries NoPos (it has no UVs to carry), but +/// NoPos says nothing about whether the surface itself is textured. The old +/// extraction conflated the two (isSolid = NoPos || Base1Solid) and, +/// worse, used NoPos to decide whether to emit the polygon's positive side AT +/// ALL — dropping every solid-colour polygon on every object. This type is +/// the ONE place that answers "is this surface textured", built from the +/// Surface's own Type flags so extraction (isSolid / TextureKey.IsSolid) and +/// draw-time skip policy agree by construction. +/// +public static class RetailUntexturedSurfacePolicy +{ + public static bool IsUntextured(SurfaceType type) => + (type & (SurfaceType.Base1Image | SurfaceType.Base1ClipMap)) == 0; +} + +/// +/// Retail's draw-time policy for an UNTEXTURED (solid-colour) mesh subset on +/// an ordinary , ported from the +/// same D3DPolyRender::DrawMesh untextured branch: the subset draws +/// unless skipNoTexture != 0 && +/// RenderDeviceD3D::ObjBuildingOrBuildingPart != 0. skipNoTexture +/// @0x00820e30 is a global initialised to 1 and never cleared, so in +/// practice the gate reduces to +/// RenderDeviceD3D::ObjBuildingOrBuildingPart == 0. +/// RenderDeviceD3D::DrawBuilding (@0x0059f2a0) sets that flag around +/// the building-shell draw, so a building shell's own untextured subsets are +/// the ONE case where an ordinary WorldEntity skips them — statics, scenery, +/// creatures, and items (DrawMeshInternal @0x0059f360 → +/// DrawMesh(gfxobj, mesh, arg4: 0)) always draw their untextured +/// subsets. +/// +/// +/// 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). +/// +/// ONE shared predicate for WbDrawDispatcher's classic classifier +/// (ClassifyBatches), packed classifier (ClassifyPackedBatches), +/// and the directional-shadow caster walk (AddDirectionalShadowBatches) +/// — Campaign VM VM6 showed that two hand-maintained classifiers computing +/// the "same" fact independently drift apart. +/// +/// +public static class RetailUntexturedSubsetPolicy +{ + public static bool Draws(bool isBuildingShell, bool isUntextured) => + !isUntextured || !isBuildingShell; +} diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index d2843964..1b52cf84 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -29,7 +29,10 @@ public sealed record InstallRecordVerification( /// public sealed class LauncherInstallRecordStore { - public const uint CurrentBakeToolVersion = 4; + // Kept in lockstep with AcDream.Content.Pak.PakFormat.CurrentBakeToolVersion + // (#426, 2026-08-23: version 5 extracts untextured/solid-colour positive + // faces that versions <=4 dropped). + public const uint CurrentBakeToolVersion = 5; private static readonly JsonSerializerOptions SerializerOptions = new() { diff --git a/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs new file mode 100644 index 00000000..7763e7c6 --- /dev/null +++ b/tests/AcDream.Content.Tests/MeshExtractorSolidFaceExtractionTests.cs @@ -0,0 +1,294 @@ +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; +using AcDream.Content; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; +using DatReaderWriter.Lib.IO; +using DatReaderWriter.Types; +using Microsoft.Extensions.Logging.Abstractions; + +namespace AcDream.Content.Tests; + +/// +/// #426 (2026-08-23, the Holtburg windmill axle 0x010010CE, 8 polygons all +/// NoPos + Base1Solid): MeshExtractor.PrepareGfxObjMeshData used to gate the +/// positive side on !Stippling.NoPos, dropping every solid-colour +/// polygon on every object client-wide (NoPos means "no positive UVs" — +/// acclient.h:7386 — not "no positive face"). These tests exercise the fix +/// through the PUBLIC PrepareMeshData entry point against a synthetic, +/// entirely in-memory dat graph (no installed DAT directory required — this +/// lane stays hermetic), using a hand-rolled / +/// pair in the same shape as +/// DatResolutionPrecedenceTests' ResolutionSource/StubDatabase. +/// +public sealed class MeshExtractorSolidFaceExtractionTests +{ + private const uint GfxObjId = 0x01000001u; + private const uint SolidSurfaceId = 0x08000001u; + private const uint TexturedSurfaceId = 0x08000002u; + private const uint SurfaceTextureId = 0x05000001u; + private const uint RenderSurfaceId = 0x06000001u; + + /// + /// One quad polygon, NoPos + Base1Solid: the exact shape of the windmill + /// axle's own polygons. Must now extract to 4 vertices / 6 indices in a + /// single batch flagged solid, instead of the pre-#426 0-vertex mesh. + /// + [Fact] + public void PrepareMeshData_NoPosSolidQuad_EmitsSolidBatchWithFourVerticesAndSixIndices() + { + var dats = new FakeMeshExtractorDats(); + dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(SolidSurfaceId, noPos: true)); + var color = new ColorARGB { Alpha = 255, Red = 12, Green = 34, Blue = 56 }; + dats.Register(SolidSurfaceId, new Surface + { + Type = SurfaceType.Base1Solid, + ColorValue = color, + }); + + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData? mesh = extractor.PrepareMeshData(GfxObjId, isSetup: false); + + Assert.NotNull(mesh); + Assert.Equal(4, mesh!.Vertices.Length); + List batches = mesh.TextureBatches.Values.Single(); + TextureBatchData batch = Assert.Single(batches); + Assert.Equal(6, batch.Indices.Count); + Assert.True(batch.Key.IsSolid); + + // GetOrCreateSolidColorTexture bakes the surface's own ColorValue. + Assert.Equal((byte)color.Red, batch.TextureData[0]); + Assert.Equal((byte)color.Green, batch.TextureData[1]); + Assert.Equal((byte)color.Blue, batch.TextureData[2]); + Assert.Equal((byte)color.Alpha, batch.TextureData[3]); + } + + /// + /// Same NoPos quad, but the positive surface is TEXTURED + /// (Base1Image) rather than solid. NoPos still means "no UVs on this + /// polygon's wire data" regardless of what the surface is — the emitted + /// vertices fall back to UV (0,0), and the batch must be classified + /// non-solid (IsSolid == false) so it decodes the real texture instead + /// of baking a flat colour fill. + /// + [Fact] + public void PrepareMeshData_NoPosTexturedQuad_EmitsWithZeroUVsAndIsSolidFalse() + { + var dats = new FakeMeshExtractorDats(); + dats.RegisterRootGfxObj(GfxObjId, BuildQuadGfxObj(TexturedSurfaceId, noPos: true)); + dats.Register(TexturedSurfaceId, new Surface + { + Type = SurfaceType.Base1Image, + OrigTextureId = SurfaceTextureId, + }); + dats.Register(SurfaceTextureId, new SurfaceTexture + { + Textures = new List> { RenderSurfaceId }, + }); + dats.Register(RenderSurfaceId, new RenderSurface + { + Width = 1, + Height = 1, + Format = PixelFormat.PFID_A8R8G8B8, + SourceData = new byte[] { 10, 20, 30, 255 }, + }); + + var extractor = new MeshExtractor(dats, NullLogger.Instance, sideStagedSink: null); + + ObjectMeshData? mesh = extractor.PrepareMeshData(GfxObjId, isSetup: false); + + Assert.NotNull(mesh); + Assert.Equal(4, mesh!.Vertices.Length); + Assert.All(mesh.Vertices, v => Assert.Equal(Vector2.Zero, v.UV)); + + List batches = mesh.TextureBatches.Values.Single(); + TextureBatchData batch = Assert.Single(batches); + Assert.Equal(6, batch.Indices.Count); + Assert.False(batch.Key.IsSolid); + } + + /// One quad (4 verts), single polygon, PosSurface referencing . + private static GfxObj BuildQuadGfxObj(uint surfaceId, bool noPos) + { + return new GfxObj + { + Surfaces = { surfaceId }, + VertexArray = new VertexArray + { + Vertices = + { + // No UVs at all — matches a real NoPos polygon's vertices, + // which carry no UV entries because nothing samples them. + [0] = new SWVertex { Origin = new Vector3(0, 0, 0), Normal = Vector3.UnitZ }, + [1] = new SWVertex { Origin = new Vector3(1, 0, 0), Normal = Vector3.UnitZ }, + [2] = new SWVertex { Origin = new Vector3(1, 1, 0), Normal = Vector3.UnitZ }, + [3] = new SWVertex { Origin = new Vector3(0, 1, 0), Normal = Vector3.UnitZ }, + }, + }, + Polygons = + { + [0] = new Polygon + { + Stippling = noPos ? StipplingType.NoPos : default, + PosSurface = 0, + NegSurface = -1, + VertexIds = { 0, 1, 2, 3 }, + }, + }, + }; + } + + /// + /// Minimal in-memory for MeshExtractor: + /// resolves exactly one "root" GfxObj through Portal (the only path + /// PrepareMeshData needs for TryResolvePreferred's default + /// implementation), plus arbitrary typed lookups (Surface, + /// SurfaceTexture, RenderSurface) also through Portal. + /// + private sealed class FakeMeshExtractorDats : IDatReaderWriter + { + private readonly Dictionary _portalObjects = new(); + private uint _rootId; + + public FakeMeshExtractorDats() => Portal = new FakeDatDatabase(_portalObjects); + + public void RegisterRootGfxObj(uint id, GfxObj gfxObj) + { + _rootId = id; + _portalObjects[id] = gfxObj; + } + + public void Register(uint id, T obj) where T : IDBObj => _portalObjects[id] = obj; + + public string SourceDirectory => string.Empty; + public IDatDatabase Portal { get; } + public IDatDatabase Cell => EmptyDatDatabase.Instance; + public ReadOnlyDictionary CellRegions { get; } = + new(new Dictionary()); + public IDatDatabase HighRes => EmptyDatDatabase.Instance; + public IDatDatabase Language => EmptyDatDatabase.Instance; + public IDatDatabase Local => EmptyDatDatabase.Instance; + public ReadOnlyDictionary RegionFileMap { get; } = + new(new Dictionary()); + public int PortalIteration => 0; + public int CellIteration => 0; + public int HighResIteration => 0; + public int LanguageIteration => 0; + + public bool TryGetFileBytes(uint regionId, uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + + public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); + + public IEnumerable ResolveId(uint id) => + id == _rootId + ? new[] { new IDatReaderWriter.IdResolution(Portal, DBObjType.GfxObj) } + : Array.Empty(); + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public bool TrySave(uint regionId, T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + [return: MaybeNull] + public T Get(uint fileId) where T : IDBObj => + Portal.TryGet(fileId, out var value) ? value : default; + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj => + Portal.TryGet(fileId, out value); + + public void Dispose() + { + } + } + + private sealed class FakeDatDatabase : IDatDatabase + { + private readonly Dictionary _objects; + + public FakeDatDatabase(Dictionary objects) => _objects = objects; + + // Never dereferenced by MeshExtractor's own code paths (only + // RetailPhysicsScriptLoader's ctor reads it, into a NULLABLE field + // it never touches unless a physics-script emitter is loaded, which + // these tests never trigger). + public DatDatabase Db => null!; + public int Iteration => 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + if (_objects.TryGetValue(fileId, out IDBObj? obj) && obj is T typed) + { + value = typed; + return true; + } + value = default; + return false; + } + + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) + { + value = null; + return false; + } + + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public void Dispose() + { + } + } + + private sealed class EmptyDatDatabase : IDatDatabase + { + public static readonly EmptyDatDatabase Instance = new(); + + public DatDatabase Db => null!; + public int Iteration => 0; + + public IEnumerable GetAllIdsOfType() where T : IDBObj => Array.Empty(); + + public bool TryGet(uint fileId, [MaybeNullWhen(false)] out T value) where T : IDBObj + { + value = default; + return false; + } + + public bool TryGetFileBytes(uint fileId, [MaybeNullWhen(false)] out byte[] value) + { + value = null; + return false; + } + + public bool TryGetFileBytes(uint fileId, ref byte[] bytes, out int bytesRead) + { + bytesRead = 0; + return false; + } + + public bool TrySave(T obj, int iteration = 0) where T : IDBObj => + throw new NotSupportedException(); + + public void Dispose() + { + } + } +} diff --git a/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs index b94ab3df..d5a7b99e 100644 --- a/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs +++ b/tests/AcDream.Core.Tests/Conformance/Issue119UpNullGfxObjDumpTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using AcDream.Core.Meshing; using DatReaderWriter; using DatReaderWriter.DBObjs; using DatReaderWriter.Enums; @@ -22,6 +23,22 @@ namespace AcDream.Core.Tests.Conformance; /// and replicates MeshExtractor.PrepareGfxObjMeshData's gates (moved from /// ObjectMeshManager in MP1a) /// so the zeroing gate reads directly off the output. +/// +/// #426 (2026-08-23) UPDATE: the gate replica below now mirrors the FIXED +/// extraction rule — the positive side is added whenever PosSurface is a +/// valid index, regardless of Stippling.NoPos (NoPos means "no positive +/// UVs", not "no positive face"; see RetailUntexturedSurfacePolicy). Run +/// against the installed DAT, both of #119's original objects now gate +/// DRAWS on every single polygon (0x010002B4: 9/9 polys; 0x010008A8: 1/1 +/// poly — both all-NoPos+Base1Solid, wouldAddPos == polygon count) — they +/// are NEITHER all-degenerate NOR all-invalid-surface-index; they were +/// dropped for being all-solid, and #426 now extracts that geometry. The +/// #119 filing's "retail never draws untextured subsets" conclusion was +/// simply wrong (see #426's ISSUES.md entry) — it never held for these two +/// objects specifically. Whether their solid faces end up VISIBLE on +/// screen (vs. skipped again at draw time by RetailUntexturedSubsetPolicy, +/// if either object turns out to be a building-shell part) is a separate, +/// unverified question this dump does not answer. /// [Trait("Lane", "InstalledDat")] public sealed class Issue119UpNullGfxObjDumpTests @@ -57,7 +74,10 @@ public sealed class Issue119UpNullGfxObjDumpTests } // Replicate the extraction gates (PrepareGfxObjMeshData): - // pos added when !NoPos + // pos added whenever PosSurface is a valid index — #426 + // (2026-08-23): NoPos means "no positive UVs", not "no positive + // face"; ordinary objects draw untextured (solid) subsets the same + // as textured ones (RetailUntexturedSurfacePolicy). // neg added when Negative || Both || (!NoNeg && SidesType==Clockwise) // surface index must be in [0, Surfaces.Count) int wouldAddPos = 0, wouldAddNeg = 0, degenerate = 0; @@ -68,8 +88,7 @@ public sealed class Issue119UpNullGfxObjDumpTests if (poly.VertexIds.Count < 3) { degenerate++; gate = "degenerate(<3 verts)"; } else { - bool pos = !poly.Stippling.HasFlag(StipplingType.NoPos) - && poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count; + bool pos = poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count; bool neg = (poly.Stippling.HasFlag(StipplingType.Negative) || poly.Stippling.HasFlag(StipplingType.Both) || (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise)) @@ -96,6 +115,18 @@ public sealed class Issue119UpNullGfxObjDumpTests /// are "regular shell polys" — render digest user axiom). A non-zero /// "DROPPED but textured" count names the extraction as the stairs-miss /// mechanism; zero exonerates the per-poly gates. + /// + /// #426 (2026-08-23) UPDATE: post-fix, the POS side can only be dropped + /// when PosSurface itself is out of range — a textured pos-side surface + /// with a valid index is now ALWAYS emitted (that's the whole point of + /// #426). So this test's remaining bite is almost entirely the NEG-side + /// gate (unchanged by #426 — a textured NEG surface can still legitimately + /// be dropped when none of the three double-sided conditions hold). Kept + /// under its original name/assertion (zero textured drops) because that + /// invariant is still exactly what we want to hold; `SurfaceIsTextured` + /// now calls the shared RetailUntexturedSurfacePolicy predicate instead of + /// a bare Base1Solid check, so this test and the extraction it's checking + /// literally cannot drift on what "textured" means. /// [Theory] [InlineData(0x010014C3u)] @@ -112,7 +143,7 @@ public sealed class Issue119UpNullGfxObjDumpTests { if (idx < 0 || idx >= gfx!.Surfaces.Count) return false; if (!dats.Portal.TryGet(gfx.Surfaces[idx], out var surf) || surf is null) return false; - return !surf.Type.HasFlag(SurfaceType.Base1Solid); + return !RetailUntexturedSurfacePolicy.IsUntextured(surf.Type); } int draws = 0; @@ -120,8 +151,7 @@ public sealed class Issue119UpNullGfxObjDumpTests foreach (var (pid, poly) in gfx!.Polygons.OrderBy(kv => kv.Key)) { if (poly.VertexIds.Count < 3) continue; - bool pos = !poly.Stippling.HasFlag(StipplingType.NoPos) - && poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count; + bool pos = poly.PosSurface >= 0 && poly.PosSurface < gfx.Surfaces.Count; bool neg = (poly.Stippling.HasFlag(StipplingType.Negative) || poly.Stippling.HasFlag(StipplingType.Both) || (!poly.Stippling.HasFlag(StipplingType.NoNeg) && poly.SidesType == CullMode.Clockwise)) diff --git a/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs b/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs index c80bbad8..af5b0ee1 100644 --- a/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/GfxObjMeshTests.cs @@ -247,4 +247,56 @@ public class GfxObjMeshTests Assert.Empty(subs); // no valid polygons → no sub-meshes } + + /// + /// #426 (2026-08-23, Holtburg windmill axle 0x010010CE): a NoPos-flagged + /// polygon ("this side has no texture coordinates", acclient.h:7386) is + /// NOT "no positive face" — every solid-colour polygon carries NoPos. + /// Before the fix, hasPos = !Stippling.NoPos dropped this quad + /// entirely, producing zero vertices. GfxObjMesh.Build doesn't branch on + /// Surface.Type at all (that classification — "is this untextured" — + /// lives in RetailUntexturedSurfacePolicy and is consumed by + /// MeshExtractor, which is what actually decides solid-vs-textured + /// rendering), so a NoPos polygon over a "solid" surface and a NoPos + /// polygon over a "textured" surface are IDENTICAL from this method's + /// point of view — both are proven by this one case. + /// + [Fact] + public void Build_NoPosQuad_StillEmitsPositiveSideVerticesAndIndices() + { + var gfx = new GfxObj + { + Surfaces = { 0x08000000u }, + VertexArray = new VertexArray + { + Vertices = + { + // No UVs at all — matches a real solid-colour polygon's + // vertices, which carry no UV entries because nothing + // ever samples them. + [0] = new SWVertex { Origin = new(0, 0, 0) }, + [1] = new SWVertex { Origin = new(1, 0, 0) }, + [2] = new SWVertex { Origin = new(1, 1, 0) }, + [3] = new SWVertex { Origin = new(0, 1, 0) }, + }, + }, + Polygons = + { + [0] = new Polygon + { + Stippling = StipplingType.NoPos, + PosSurface = 0, + NegSurface = -1, + VertexIds = { 0, 1, 2, 3 }, + // No PosUVIndices — NoPos means there ARE none on the wire. + }, + }, + }; + + var sub = GfxObjMesh.Build(gfx).Single(); + + Assert.Equal(4, sub.Vertices.Length); + Assert.Equal(6, sub.Indices.Length); // fan-triangulated quad, 2 triangles + Assert.All(sub.Vertices, v => Assert.Equal(Vector2.Zero, v.TexCoord)); + } } diff --git a/tests/AcDream.Core.Tests/Meshing/RetailUntexturedSurfacePolicyTests.cs b/tests/AcDream.Core.Tests/Meshing/RetailUntexturedSurfacePolicyTests.cs new file mode 100644 index 00000000..067fbc12 --- /dev/null +++ b/tests/AcDream.Core.Tests/Meshing/RetailUntexturedSurfacePolicyTests.cs @@ -0,0 +1,42 @@ +using AcDream.Core.Meshing; +using DatReaderWriter.Enums; + +namespace AcDream.Core.Tests.Meshing; + +/// +/// #426 (2026-08-23, the Holtburg windmill axle 0x010010CE): pins the two +/// predicates that replaced the buggy isSolid = NoPos || Base1Solid +/// extraction rule and the "retail never draws untextured subsets" #119 +/// misconception. See RetailUntexturedSurfacePolicy.cs for the full retail +/// citations (D3DPolyRender::DrawMesh / DrawBuilding / DrawEnvCell). +/// +public sealed class RetailUntexturedSurfacePolicyTests +{ + [Theory] + [InlineData(SurfaceType.Base1Solid, true)] // solid-colour — untextured + [InlineData((SurfaceType)0, true)] // neither bit set — untextured + [InlineData(SurfaceType.Base1Image, false)] // BASE1_IMAGE (0x2) — textured + [InlineData(SurfaceType.Base1ClipMap, false)] // BASE1_CLIPMAP (0x4) — textured + [InlineData(SurfaceType.Base1Image | SurfaceType.Base1Solid, false)] // both bits — retail's literal (type & 6) != 0 still calls this textured + [InlineData(SurfaceType.Base1Image | SurfaceType.Additive, false)] // unrelated flags alongside a textured bit stay textured + public void IsUntextured_MatchesRetailBitmask(SurfaceType type, bool expected) + { + Assert.Equal(expected, RetailUntexturedSurfacePolicy.IsUntextured(type)); + } + + [Theory] + // (isBuildingShell, isUntextured) -> draws + [InlineData(false, true, true)] // ordinary object, solid subset — retail draws it (#426's own bug) + [InlineData(true, true, false)] // building shell, solid subset — retail's DrawBuilding skips it + [InlineData(false, false, true)] // ordinary object, textured subset — always drawn + [InlineData(true, false, true)] // building shell, textured subset — the shell gate only touches untextured subsets + public void Draws_MatchesRetailBuildingShellGate( + bool isBuildingShell, + bool isUntextured, + bool expectedDraws) + { + Assert.Equal( + expectedDraws, + RetailUntexturedSubsetPolicy.Draws(isBuildingShell, isUntextured)); + } +} diff --git a/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs index 726f789f..8ff1ef69 100644 --- a/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/Wb/MeshExtractionConformanceTests.cs @@ -18,6 +18,23 @@ namespace AcDream.Core.Tests.Rendering.Wb; /// If this test fails, either our port has drifted or the WB code has /// changed upstream — investigate which, do not "fix" the test. /// +/// +/// +/// ONE DOCUMENTED EXCEPTION (#426, 2026-08-23): +/// below intentionally +/// diverges from WorldBuilder's own upstream +/// ObjectMeshManager.cs:959 (if +/// (!poly.Stippling.HasFlag(StipplingType.NoPos))), which has the exact +/// same bug our port faithfully carried over: gating the polygon's positive +/// side on !NoPos, silently dropping every solid-colour polygon. +/// Named-retail decomp (D3DPolyRender::DrawMesh @0x0059d4a0) proves +/// NoPos ("this side has no texture coordinates", +/// acclient.h:7380-7388) does not gate whether retail draws the positive +/// side at all — see #426 in docs/ISSUES.md for the full citation. This is +/// the one case in this file where the retail decomp — not WB — is the +/// oracle; see the acdream-wide rule in CLAUDE.md ("the decompiled code is +/// ground truth ... if they disagree, the decompiled code wins"). +/// /// public sealed class MeshExtractionConformanceTests { @@ -66,8 +83,18 @@ public sealed class MeshExtractionConformanceTests Assert.Equal(2, ours.Count); } + /// + /// #426 (2026-08-23): renamed from Build_NoPosFlag_OnlyEmitsNegSide, + /// which asserted Assert.Single(ours) — the OLD bug's own + /// behavior (NoPos silently dropped the positive side). NoPos means "no + /// positive UVs" (acclient.h:7386), not "no positive face"; retail draws + /// an ordinary object's untextured positive side same as a textured one + /// (D3DPolyRender::DrawMesh @0x0059d4a0). See the class doc's "ONE + /// DOCUMENTED EXCEPTION" note for why this test intentionally diverges + /// from WorldBuilder's own (equally buggy) upstream algorithm. + /// [Fact] - public void Build_NoPosFlag_OnlyEmitsNegSide() + public void Build_NoPosFlag_EmitsBothPosAndNegSide() { var gfxObj = MakeUnitQuadGfxObj(); var poly = gfxObj.Polygons[0]; @@ -77,7 +104,7 @@ public sealed class MeshExtractionConformanceTests var ours = GfxObjMesh.Build(gfxObj, dats: null); - Assert.Single(ours); + Assert.Equal(2, ours.Count); } /// diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs index 2fd3d81e..9d8b1e74 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/LauncherInstallerTests.cs @@ -51,12 +51,12 @@ public sealed class LauncherInstallerTests : IDisposable await File.WriteAllTextAsync(request.OutputPath, "complete prepared package"); long bytes = new FileInfo(request.OutputPath).Length; output("acdream-bake human header\n{\"v\":1,\"e\":\"star"); - output("ted\",\"bakeToolVersion\":4,\"outputPath\":\"pak\"}\n"); + output($"ted\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion},\"outputPath\":\"pak\"}}\n"); output("{\"v\":1,\"e\":\"progress\",\"phase\":\"mesh\"," + "\"completed\":25,\"total\":100,\"failures\":0," + "\"elapsedSeconds\":5,\"etaSeconds\":15}\n"); output("{\"v\":1,\"e\":\"newMetric\",\"value\":1}\n"); - output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4," + output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}," + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); return new BakeProcessResult(0, string.Empty); }); @@ -153,7 +153,7 @@ public sealed class LauncherInstallerTests : IDisposable async (request, output, _) => { await File.WriteAllTextAsync(request.OutputPath, "partial replacement"); - output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); + output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n"); output("{\"v\":1,\"e\":\"error\",\"message\":\"fixture failed\"}\n"); return new BakeProcessResult(9, "human failure detail"); }, @@ -186,9 +186,9 @@ public sealed class LauncherInstallerTests : IDisposable { await File.WriteAllTextAsync(request.OutputPath, "contradictory output"); long bytes = new FileInfo(request.OutputPath).Length; - output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); + output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n"); output("{\"v\":1,\"e\":\"error\",\"message\":\"first failure\"}\n"); - output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4," + output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}," + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); return new BakeProcessResult(0, string.Empty); }); @@ -275,8 +275,8 @@ public sealed class LauncherInstallerTests : IDisposable Directory.CreateDirectory(Path.GetDirectoryName(request.OutputPath)!); await File.WriteAllTextAsync(request.OutputPath, "complete but unverified"); long bytes = new FileInfo(request.OutputPath).Length; - output("{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":4}\n"); - output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":4," + output($"{{\"v\":1,\"e\":\"started\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}}}\n"); + output($"{{\"v\":1,\"e\":\"completed\",\"bakeToolVersion\":{LauncherInstallRecordStore.CurrentBakeToolVersion}," + $"\"outputBytes\":{bytes},\"failures\":0}}\n"); return new BakeProcessResult(0, string.Empty); });