Commit graph

772 commits

Author SHA1 Message Date
Erik
55bfd9ca82 feat(chargen): Campaign CC slice CC6a — index→ObjDesc factory + preview renderer foundation
Delivers the CC6a foundation half of the chargen 3D preview: the missing
index->ObjDesc appearance factory the campaign plan's acdream-seams
section named, plus a static-pose offscreen renderer following
PrivateEntityViewportRenderer's proven paperdoll/appraisal architecture.
Page mount, spin/color-wheel controls, and rotate/zoom behavior stay out
of scope per the CC4-parallel worktree contract (CC6b, after CC4 merges).

Core (src/AcDream.Core/CharGen/, pure, no Chorizite on public surfaces):
ChargenAppearanceFactory.TryCompose ports gmCG3DView::Update @0x004EE9D0's
ObjDesc rebuild in its exact decompiled order - base body, hair style,
clothing in retail's own Headgear/Trousers/Shirt/Footwear order (not the
UI tab order or the wire's field order, both of which differ), eyes
(bald-aware), nose, mouth, then the unconditional skin subpalette, hair
color, eye color. ChargenPalSetMath ports PalSet::GetPaletteID's
shade-to-index formula, cross-checked three ways (decomp control flow,
ACE's PaletteSet.GetPaletteID "Taken from acclient.c" citation, ACViewer's
identical slider math). ChargenPalSet/ChargenClothingTable are pure
projections behind IChargenPalSetSource/IChargenClothingTableSource so the
factory itself never touches a dat.

Content (src/AcDream.Content/CharGen/): ChargenAppearanceCatalog is the
cached dat-backed implementation of those two source interfaces, mirroring
ChargenTableReader's no-leak discipline.

App (src/AcDream.App/Rendering/): ChargenPreviewRenderer is a third facade
over PrivateEntityViewportRenderer beside PaperdollViewportRenderer and
CreatureAppraisalViewportRenderer - no existing rendering file touched.
ChargenPreviewCamera carries the four retail-verbatim per-heritage eye
profiles from gmCGAppearancePage::Update @0x0047E8F0 (cross-checked
against ZoomIn/ZoomOut's identical literals) plus the recovered rotation
(3.0 s/revolution) and zoom-tween (0.6 s, reconstructed from the
decompiler's garbled float literals - the plan's own "measure if it
matters" note is resolved, not garbled beyond recovery). Rotation applies
to the character model, not the camera, per gmCGAppearancePage::DoRotation.
ChargenPreviewEntityBuilder resolves Setup/GfxObj/Surface/Animation itself
(there is no live entity yet), reusing DatLiveEntityProjectionMaterializer's
surface-override algorithm and RetailPaperdollPoseApplicator's held-pose
technique, generalized to chargen's per-heritage rest-pose DID.

Two register rows filed: TS-83 (the plan-named CC6a static-pose-vs-retail-
idle-loop staging, CC6b to retire) and TS-82 (measured, not assumed - the
un-ported clothing Setup-substitution fallback chain costs nothing for the
9 standard heritages with clothing UI, but Undead's default gear choices
genuinely lack ClothingBaseEffects coverage for Undead's own body Setup).

Tests: ChargenPalSetMathTests, ChargenAppearanceFactoryTests (hand-built
fixtures), ChargenAppearanceCatalogInstalledDatTests (installed-DAT sweep,
all 26 heritage/gender combinations, zero missing PalSet/ClothingTable
ids), ChargenPreviewCameraTests, ChargenPreviewEntityBuilderTests
(installed-DAT-gated, proves a real 34-part Aluvian mesh resolves).
Core.Tests 4767/1 skip, Content.Tests 146/0, App.Tests 5121/6 skips - all
pre-existing skips, zero failures, full solution Release build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 17:22:06 +02:00
Erik
f3ef7baae2 docs+fix(chargen): CC1/CC2 review closeout — R2/R3 residuals closed, ledger final
Both narrow re-reviews returned CLOSED. This closeout takes the two cheap
re-review residuals before CC3 takes references to the shared model:

R2: every array handed into the typed chargen model is now wrapped in
Array.AsReadOnly at the projection seam — a T[] behind IReadOnlyList<T>
was still downcast-mutable, and ChargenOptions is a process-shared
singleton graph.

R3: the no-Chorizite-leak guard now also walks public fields; every
current type uses properties, but a public field would have slipped
through the property-only walk.

Ledger: CC1 fix-round sha corrected to cb4703e8 (the cell previously
cited the pre-amend 459a87f2), CC1/CC2 rows flipped to REVIEW-CLOSED
with the re-review outcomes, R1 (retail refunds +1 credit on a
both-tier cost miss; port charges 0 — unreachable via retail's own
listbox, noted for CC3) and the Olthoi-locked-to-template-0 decomp fact
recorded for CC3/CC4.

Core.Tests 4736/1 skip, Content.Tests 145/0, Release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:41:37 +02:00
Erik
cb4703e8d5 fix(chargen): CC1 review fix round — Custom is template 0, SkillTable cost fallback, frozen model
Implements all six Opus review findings against 04450041 (Campaign CC
CC1 chargen data layer):

- F1 (HIGH, blocking): ChargenTemplate's doc claimed "Custom" has no
  ChargenTemplate entry and cited two nonexistent addresses. Verified
  against the named retail decomp: gmCGProfessionPage::UpdateProfession
  @ 0x004821b0 resolves BOTH the highlighted button and the description
  string from CharGenState.template_ 0..6, and case 0 is button
  0x100003d9 / ID_CharGen_CustomText. Custom IS template index 0 (the
  "Adventurer" row CC1 already found sitting at the attribute floor).
  CharGenState::SetTemplate @ 0x005C5A60 confirms every button (including
  Custom) calls CharGenState::ApplyTemplate @ 0x005C5080 when committing,
  so selecting Custom resets the sliders/skills to that row rather than
  leaving them untouched.

- F2 (MEDIUM): retail's skill-cost lookup is two-tiered
  (ACCharGenData::GetSkillTrainedCost/GetSkillSpecializedCost @
  0x005C26D0/0x005C27D0 fall through to the global SkillTable,
  portal.dat 0x0E000004, on a heritage-list miss — confirmed against
  ACE's identical PlayerFactory.cs precedence). ChargenTableReader now
  also projects the global SkillTable into
  ChargenOptions.GlobalSkillCostsBySkillId, and
  ChargenSkillCreditMath.ComputeSpent/RemainingCredits check the
  heritage list first and the global list on a miss. Added an
  installed-DAT completeness assertion recording reality: the global
  table prices 38/54 advancement skill ids, every one of the 13
  installed heritages ships exactly one heritage-specific override
  (always also priced globally), and 16 ids are genuinely uncostable in
  both tiers. Also filed a CC7 risk-item note: ACE's own heritage-
  override branch over-deducts on Specialize (PlayerFactory.cs:184-211)
  — a retail-legal build may be rejected by local ACE at the CC7
  connected gate; that is an ACE bug, not an acdream defect.

- F3 (MEDIUM): every collection ChargenTableReader hands into the
  record model is now frozen at projection (ToFrozenDictionary/ToArray,
  matching MagicCatalog's house pattern), including both
  ChargenOptions.Empty dictionaries.

- F4 (LOW): added a reflection guard test
  (ChargenNoChoriziteLeakTests) that walks every public
  AcDream.Core.CharGen member (property/indexer/constructor/method
  types, recursively through generic arguments) and fails if any
  resolves to the DatReaderWriter or a Chorizite* assembly.

- F5 (LOW): ChargenGenderOptions.HasAnyAppearanceOptions's doc now
  states precisely what the installed-DAT gate proves (an OR across
  eight lists, for at least one gender per heritage) rather than the
  stronger claim it previously made, and explicitly calls out the three
  omitted color lists. Added a second installed-DAT gate that records
  per-list reality across every gender of every heritage — found
  complete, no empty lists anywhere in the installed DAT today.

- F6 (LOW): ChargenOptions.TryGetHeritage/TryGetStarterArea now use
  [MaybeNullWhen(false)] instead of null! suppression, matching the
  house pattern already used elsewhere in the test suite. Fixed every
  call site this surfaced (more than the five originally estimated,
  since Content.Tests has TreatWarningsAsErrors).

Core.Tests: 4737 passed / 1 skip (pre-existing, unrelated).
Content.Tests: 145 passed / 0 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 13:33:37 +02:00
Erik
0445004164 feat(content): Campaign CC CC1 — chargen table reader and typed options model
Adds the CC1 data layer for Campaign CC (retail character creation):
a reader for portal.dat's CharGen table (0x0E000002) plus a
presentation-free, Chorizite-free typed options model, and the pure
attribute/skill credit math the later CC3 Runtime owner needs.

Retail oracle (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- ACCharGenData::Serialize @ 0x005C36D0 (table shape: StartingAreas +
  HeritageGroups)
- HeritageGroup_CG::Serialize @ 0x005C2100
- Sex_CG::Serialize @ 0x005C1600
- Template_CG::Serialize @ 0x005C0450
- CharGenState::SetHeritageGroup @ 0x005C67A0 and the six attribute-slider
  setters (~0x005C46CE..0x005C494E): remainingAtrbCredits = totalAtrbCredits
  - (str+end+coord+quick+focus+self) — a heritage's AttributeCredits is the
  budget the six RAW attribute values must fit, not points above the floor.
- CharGenState::Reset @ 0x005C68A0: atrbMin=10, atrbMax=100.
- gmCharGenMainUI::DoFinish @ 0x004E9170: Finish refuses only when
  remainingAtrbCredits > 0 (attributes only — skill credits are never
  gated to zero, confirmed by reading the function body).
- CharGenState::UpdateRemainingSkillCredits @ 0x005C37C0: exactly one of
  NormalCost/PrimaryCost is charged per Trained/Specialized skill.
- gmCGAppearancePage::Update @ 0x0047E8F0: the mHeritageGroup==0xc/0xd
  (Olthoi/OlthoiAcid) camera-offset branch CC6 will need.

Cross-checked against ACE's ACE.DatLoader.FileTypes.CharGen and
ACE.DatLoader.Entity.HeritageGroupCG/SexCG/TemplateCG/SkillCG loaders
(same field order, different byte format) and ACE.Entity.Enum.HeritageGroup
/ SkillAdvancementClass for the two small stable enums the model exposes.

src/AcDream.Core/CharGen/: ChargenOptions (root: StarterAreas +
HeritagesById), ChargenHeritageOptions, ChargenGenderOptions (BaseObjDesc
+ every appearance-option list: hair styles/colors, eye colors, eye/nose/
mouth strips, headgear/shirt/pants/footwear, clothing colors),
ChargenTemplate, ChargenObjDesc (palette/subpalette/texture/anim-part-swap
shape, mirrors PaletteOverride's presentation-free pattern), and the pure
math: ChargenAttributeMath (RemainingCredits/IsFullySpent/range checks) and
ChargenSkillCreditMath (retail's Trained-xor-Specialized cost sum) plus
ChargenSkillAdvancementSet, a structurally-fixed 55-slot type (reserved
slot 0 + SkillId 1..54) so CC2's future wire builder cannot send anything
but exactly 55 entries.

src/AcDream.Content/CharGen/ChargenTableReader.cs projects the Chorizite
DBObj graph into the Core model (MagicCatalog.Load's shape) — no Chorizite
type crosses into ChargenOptions.

Tests: hand-built-fixture unit tests for the pure math (Core.Tests) and the
Content projector (Content.Tests), plus six installed-DAT gate tests
(ContentConformanceDats pattern) against the real portal.dat: 13 heritage
groups (11 standard + 2 Olthoi), the four named heritages with retail
display names incl. "Gharu'ndim", every heritage has a gender with
non-empty appearance option lists, every template's attributes stay in
10..100 and never exceed its heritage's budget (discovered live: NOT every
template fully spends it — each human heritage's "Adventurer" template
sits at the floor as retail's real-DAT-backed "Custom" starting point),
start-area indices resolve into the shared list, and skill costs key to
valid 1..54 wire ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 12:53:50 +02:00
Erik
9ce7292570 fix(ui): Campaign LA gate round 2 — character-select screen media resolution
Root cause: the LA8 character-select screen's root background RenderSurface
(0x06007576, LayoutDesc 0x21000004 element 0x1000039A) is PFID_CUSTOM_RAW_JPEG
— a complete JFIF byte stream (confirmed live: 414,230 bytes, FFD8...FFD9,
Width=0/Height=0 on disk) that SurfaceDecoder.DecodeRenderSurface had no case
for, so it fell through the switch's `_ => DecodedTexture.Magenta` default arm
with nothing logged. Retail's RenderSurface::CreateFromSourceData
(named-retail decomp @0x004440a0) hands this exact byte stream to the Intel
JPEG Library (`_ijlInit`/`_ijlRead`/`_ijlFree`) at runtime and reads the real
pixel dimensions from the JPEG's own SOF header rather than this
RenderSurface's Width/Height fields, which are legitimately 0 for this
format — the same reason the decoder's generic non-positive-Width/Height
guard was also wrong to apply here.

A per-id media sweep of the installed DAT (new EveryDeclaredMediaId_
ResolvesToADecodableTexture test) showed this was the ONLY unresolved id
among the screen's 25 distinct media ids — the listbox (0x1000039D) and every
button face resolve fine. The listbox interior and the ENTER button's
circular fill are both transparent regions layered on top of the root, so
the one broken root background bled through everywhere nothing opaque
covered it, producing all three symptoms (full-screen background, listbox
interior, ENTER circle) from one cause.

Fix: SurfaceDecoder now special-cases PFID_CUSTOM_RAW_JPEG before the
Width/Height guard and decodes it with StbImageSharp (dual Unlicense/MIT,
pure managed, no native dependency — works on the Linux headless/graphical
targets Slice K/L commit to). JPEG is ITU T.81-standardized, so any
conforming decoder reproduces the pixels IJL would; round-tripped a
synthetic fixture through the real decode path to confirm. Verified against
the live DAT: 0x06007576 now decodes to 800x600, exactly the screen's
LayoutDesc-authored size.

Guard: per claude-memory/feedback_ui_resolve_zero_magenta.md, an unresolved
id reaching the draw path should be loud. That memory's existing guard
("guard on the id, not the handle") only covers a DIFFERENT trap — a
zero/absent id — and could not have caught this one, which has a real,
non-zero, DAT-resolved id. No guard existed for "id resolves but can't
decode" or "id doesn't exist in either dat" before this change, so both were
silent. SurfaceDecoder now logs once per surface id on every magenta-return
path (null data, JPEG decode failure, unsupported format, no-palette
paletted format, decode exception); TextureCache.GetOrUploadRenderSurface
logs once per id when a RenderSurface isn't found in Portal or HighRes at
all.

Tests: CharacterManagementLiveDatTests.EveryDeclaredMediaId_
ResolvesToADecodableTexture (installed-DAT gate, ACDREAM_PROBE_LIVE_MOUNT=1)
sweeps every StateMedia id in the char-select root + listbox row template
and asserts none decode to the magenta placeholder — this class of gap now
fails the gate instead of shipping silently. SurfaceDecoderTests adds
PFID_CUSTOM_RAW_JPEG coverage (real decode via a synthetic from-scratch
JPEG fixture — not retail art, generated with StbImageWriteSharp and
round-tripped before being pasted in as a literal; corrupt-data and
null-SourceData magenta paths) plus PFID_P8/PFID_INDEX16 no-palette cases
that now flow through the same logged path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:12:10 +02:00
Erik
f820eb258d fix(plugins): close LA5 ownership races 2026-08-14 19:28:14 +02:00
Erik
95f4be94db feat(plugins): complete Campaign LA5 cross-host hosting 2026-08-14 18:12:59 +02:00
Erik
67fe754dd6 fix: social gate round 2, part 2 - confirmation-dialog sentences + the
refused-drop yellow notice

Item 4 (confirmation dialogs missing text + names): the missing retail
mechanism was StringTable template substitution - an entry is N+1 literal
fragments interleaved with N named variables, composed by
StringTable::GetString @0x004300D0 (no-metalanguage branch @0x004303B7).
ACE sends the bare player name for types 1/4; retail's OWN CLIENT wraps
it. Ported as DatStringResolver.ResolveTemplate (PLAYER hash 0x05506DA2,
the exact compute_str_hash space; Chorizite stores the variable hashes
directly):

- Server-driven type 4 -> ID_Fellowship_FellowshipRequest, type 1 ->
  ID_Allegiance_AcceptSwearConfirmation, injected into
  GameplayConfirmationController; null resolve falls back to the bare
  wire message, never invented English. The 2/3/5/6 " Continue?" family
  never consults the composer.
- Local Swear/Break/Kick: the bind-time fragment-0 latch (which showed
  the dangling "Do you wish to swear to ") is replaced by click-time
  ResolveTemplate with the target's name.

All five templates verified token-free in the installed DAT - this is
NOT a StringTableMetaLanguage port (AD-81's engine caveat stands).

Item 5 (refused drop shows nothing; retail shows yellow top-center
text): the prevRequest latch was ALREADY ported (InventoryTransactionState);
what was missing was the consumer. InventoryTransactionState now raises
RequestFailed(request, weenieError) when a 0x00A0 clears the latch;
ItemInteractionController composes ServerSaysAttemptFailed @0x0058EAE0's
"The <item> can't be <verb>" (verb table + suffix map ported verbatim in
Core's InventoryFailureMessages, NAME_PLURAL for merge/split) and routes
it as LogTextType 0x1A ClientLocal -> the SpewBox, retail's yellow
top-center line. The dispatcher's second leg (@0x0055B342) also runs:
outside the 7-code exclusion set, WeenieErrorMessages resolves per-code
text/destination; 0x426 AttunedItem has no row in either place beyond
the verb line - faithful single-line output.

Register: AD-85 narrowed to its numeric-field item, AD-81 amended (the
token-free interleave is now ported; meta-token engine + FormatName
remain), AD-93 filed (wire-guid-match vs retail's latched-guid
preference; no Move/Wield latch kinds).

Tests: +2 InventoryTransactionState failure-latch, +5 ResolveTemplate
(constructed StringTable fixtures), +1 composer injection, +1 end-to-end
refused-drop line. Core 4,697/1 skip, App 4,983/3 skips.

Research: docs/research/2026-08-13-confirm-and-weenie-error-display.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 21:10:42 +02:00
Erik
4281750b4d fix(core): FA1 -- delete dead AllegianceTree scaffold
Campaign FA slice FA1, lane C §0/§7.3 DELETE verdict. Verified zero
production references (a repo-wide grep for AllegianceTree/
AllegianceNode/AllegianceMath outside the class's own file and its own
test file returns nothing but docs and an unrelated test-name string
coincidence in ChatChannelInfoTests.cs).

Two defects made this safer to delete than fix:

- AllegianceMath.ComputePassup transcribed retail's passup formula wrong
  by roughly 1000x: it computed (50 + 22.5*loyalty) / 291 instead of
  50 + 22.5*(loyalty/291) AS A PERCENTAGE (missing the trailing / 100),
  and its own unit test locked the wrong value in as correct.
- AllegianceTree's UpsertNode(guid, name, patronGuid, rank) modeled a
  patron edge the wire does not carry -- the wire names each record's
  TREE PARENT (§4.4), which for ACE's own writer is not always the real
  patron (ACE hangs a non-monarch patron directly off the monarch). The
  parsed record list plus its treeParent tags already IS the tree
  (see ClientCommandResponses.AllegianceProfileLookups, landed in the
  companion feat(net) commit this session) -- no separate tree class is
  needed. The client also never needs the passup number at all:
  _cp_tithed arrives pre-computed from the server.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:13:39 +02:00
Erik
5f9aa16f43 fix(chat): FA1 -- 8 fellowship WeenieError ids confirmed absent from retail, not missing
The FA1 contract asked to "add the 8 missing fellowship entries lane B
lists" (0x0417-0x041C, 0x04DB, 0x04DC). Re-ran this table's own binary-
sweep methodology specifically for these 8 ids rather than inventing
text for them: a full-text grep of the 1.4M-line
acclient_2013_pseudo_c.txt found ZERO comparisons/case-labels against any
of the 8 anywhere in the retail client, and a manual walk of
HandleFailureEvent's own case-label sequence confirmed the switch goes
straight from case 0x416/0x41d (skipping 0x417-0x41c) and from case
0x4da/0x4dd (skipping 0x4db/0x4dc).

Conclusion: retail's Sept-2013 client has no display text for any of
these 8 ids -- they are intentionally absent from this table, not
overlooked. This contradicts the FA1 contract's premise but not lane B's
own text, which only claimed the ids were "missing" from the table (true)
and that two of them (0x0417, 0x04DB) are on ACE's live send paths (also
true) -- it never claimed retail has text for them. Two of the ids are
therefore live-but-silent gaps against a real ACE server, and acdream's
current no-display behavior for them is ALREADY retail-faithful. Adding
invented English would be exactly the class of mistake SHOULD-FIX 4
(the no-default-case rule this table's Resolve() already implements)
exists to prevent.

Documents the finding at both table gaps and adds a conformance test
(Resolve_FellowshipIdsAbsentFromHandleFailureEvent_ReturnsNoText) proving
all 8 resolve to null text, matching the existing
Format_0x051D_ReturnsNull_NoRetailCaseExists precedent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 00:13:25 +02:00
Erik
b1968ce980 fix(ui): OP8 rework — activation/scope preservation, camera-row de-alias, conflict-confirm dialog
Fixes the three MUST-FIX findings from the 2026-08-11 combined dual-lens
review of commit b4edee97 (docs/research/2026-08-11-op8-review.md).

M1 — SetForAction destroyed ActivationType/InputScope on every write,
collapsing walk-mode's Hold, the three combat-scoped bindings, and
CameraInstantMouseLook's mouse chord the instant a row (including
Defaults, which touches all ~140 mapped rows at once) wrote back.
Widened the Bindings seam to carry the full Binding (chord + activation
+ scope), not a bare chord: KeyboardConfigController captures each
row's live Activation/Scope ONCE at build time (every multi-chord
action in KeyBindings.RetailDefaults() shares one pair across all its
bindings) and reapplies it on every write — rebind, Cancel/Revert, and
Defaults (which restores DAT-sourced KEYS only, never touches the
pair). New tests pin this across both Defaults and Cancel for a
Hold+MeleeCombat-scoped action.

M2 — InputMap 0x5 (CameraControls) and 0x6 (CameraAlternateControls)
aliased one InputAction each: both rows read/wrote the same live target,
so they showed identical stale chords, a rebind of one silently wiped
the other, and a row could conflict with its own twin. Building real
per-scheme dual-binding storage (or new InputAction members plus the
camera-dispatch code to consume them) is a feature, not a one-line fix.
Chose the third option: only ctx 0x5 — the scheme RetailDefaults()
actually has live support for — maps to InputAction; ctx 0x6 falls
through to the existing unmapped/store-only path (AP-203), fully
rendered, bindable, and persisted, honestly carrying no live effect.
This also retired 10 stale allowlist entries in the DAT-vs-
RetailDefaults() round-trip test: with the alias gone, ctx 0x5 alone
matches RetailDefaults() exactly for all twelve Camera actions.

M3 — the auto-reassign-on-conflict path was wired silent in production
(NotifyReassigned: _ => "") though the contract asked for a prompt and
retail confirms before overwriting (OpenOverwriteBindingDialog). Wired
a real confirm dialog through RetailDialogFactory.MakeConfirmation —
the same seam GameplayConfirmationController already uses — read
lazily since DialogFactory mounts after MountKeyboardConfig in
Initialize()'s order. Only reassigns on accept; decline leaves every
row untouched. AP-204 (which recorded the narrowing) is RETIRED; the
still-true OK/Cancel left-click-vs-right-click-release note moves to a
code comment (zero observable difference, doesn't warrant a register
row). Reverted the gate script's step 9 from documenting the silent
shape back to the real confirm-prompt behavior.

SHOULD-FIX addressed as one-liners in files already touched:
- S1: non-user-bindable conflicts are now checked BEFORE any row
  conflict (retail's own order), and ALL conflicting rows are collected
  (N-way), not just the first match.
- S3: Save wraps the file-write pair in the same try/catch
  RuntimeKeyBindingTarget.Apply already uses for keybinds.json.
- S4: assigning "Mapping 3" on a row with no existing bindings now
  lands on display index 2, not index 0 — ReplaceSlotValue trims only
  TRAILING empty slots instead of stripping every default(KeyChord).
  Right-click on an already-empty slot is now a no-op instead of
  shifting later bindings.
- S6: UiButton.OnRightClick returns false (unhandled, bubbles to
  parent) when no handler is set, disabled or not — matching the
  pre-existing behavior the class doc already claimed.

Left for a future pass (not one-liners): S2 (ActionMap.ConflictingMaps
is still unread — the conflict scan treats all 306 rows as one flat
universe instead of respecting the DAT's own legitimately-shared-key
table) and S5 (the ~330 DAT layout imports still run eagerly at mount
instead of lazily on first open).

19 KeyboardConfigControllerTests (was 12): +2 activation/scope
preservation (Defaults, Cancel), +1 camera de-alias, +2 confirm-dialog
accept/decline, +1 non-bindable-takes-priority-over-row-conflict, +1
sparse-row third-slot placement. Full solution suite 13,154 passed / 4
skipped / 0 failed (this round's baseline 13,147/4/0, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:53:10 +02:00
Erik
b4edee970f feat(ui): Campaign OP slice OP8 — Configure Keyboard
Ports retail's Configure Keyboard screen (gmKeyboardUI, LayoutDesc
0x21000009) — its own separate full-screen window, not a fifth Options-
panel tab. Retires OP3's INERT contract for the Gameplay tab's Configure
Keyboard button (0x10000204).

DAT reader (src/AcDream.Core/Input/RetailActionMap.cs): reads the
ActionMap singleton (DID 0x26000000, empirically the only one — not
0x27000000 as GetDBOType's Turbine-internal tag would suggest) and both
MasterInputMap defaults (0x14000000 "gmDefaultMap"/0x14000002
"DefaultMap"), union-merged per (InputMapId, ActionId) — proven order-
independent since the two maps' one shared context (0x5) has disjoint
action-id sets. Empirically resolved three lane-D unknowns against the
live DAT: the six ActionClass values (1=Movement, 2=Camera, 3=UI,
4=Combat, 5=Emote, 7=CharacterSettings — 6 is genuinely absent), that
the six unnamed InputMaps are 100% non-bindable (render nothing, not an
unlabeled group), and that the enum-to-DID pairing for the two master
maps is inconsequential to the merge result.

Identity table (src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs):
maps DAT (InputMapId, ActionId) pairs to acdream's InputAction where a
live consumer exists (~140 of 306 user-bindable rows — Movement/Camera/
Combat map almost completely; UI/Quickslot/Chat partially; only 5 of 87
Emotes and none of 48 CharacterSettings hotkeys, since acdream has no
general emote player or hotkey-to-option-toggle dispatcher yet). Every
entry cross-verified by label match AND a DAT-default-vs-
KeyBindings.RetailDefaults() byte comparison (RetailActionIdentityRoundTripTests),
which caught a real off-by-one in the Quickslot 13-18 block before it
shipped and found three genuine pre-existing RetailDefaults() gaps
(walk-mode's Shift-echoed chord, ten CameraAlternateControls arrow-key
alternates, and the Quickslot Ctrl+N use-vs-select ambiguity) — none
introduced by this slice, all documented rather than silently patched.

KeyboardConfigController: six ActionClass list boxes built from the
DAT, merged with live KeyBindings for mapped rows (rebind applies
immediately through the same InputDispatcher every other input path
uses) and a new sibling RetailUnmappedKeyBindings store for rows with
no InputAction yet. Left-click a key button opens real InputDispatcher
modal capture; right-click erases that slot. N-way conflict detection
scans every other row plus the live KeyBindings table for acdream-only
actions (Ctrl+M mute, debug F-keys) as the non-user-bindable refusal
analogue, using retail's own byte-verified "Could not overwrite "
string (table 0x23000004). OK/Cancel/Defaults/Revert reuse the
OptionPage/IOptionRow verb model via a new ActionKeyMapOptionRow.
Persistence is keybinds.json only (D4 — no .keymap file interchange).

Five register rows: AP-202 (.keymap interchange narrowing), AP-203
(store-only rows with no live consumer), AP-204 (silent auto-reassign
instead of retail's confirm dialog; OK/Cancel ported as left-click not
right-click-release).

Small supporting additions: UiButton.OnRightClick (additive, no
existing behavior changed), InputDispatcher.Bindings getter (the
screen's single live-truth read seam), RetailScanCodeMap (DIK scan
code <-> Silk.NET Key, keyboard + the one mouse-device row).

19 new tests (6 ActionMap reader conformance incl. live-DAT row-count/
label pins, 1 DAT-vs-RetailDefaults round-trip, 12 controller
behavior tests against the committed keyboard_config_21000009.json
fixture) — full solution suite 13,147 passed / 4 skipped / 0 failed
(baseline 13,128/4/0, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:19:54 +02:00
Erik
ac0304dcf0 fix(ui,runtime): OP4 re-review residuals R1-R4 (coordinator pass) — OP4 CLOSED
R1: the timestamp prefix moves from ChatLog.Append (which stamped the
stored BODY, rendering 'Alice says, "13:05:09 hi"') to ChatVM's display
composition — FormatTimestampPrefix(entry.Received) prepends the COMPOSED
line, matching retail's separate-leading-string model (fprintf("%ls%ls",
ts, text) @0x00563e5b; AddTextToScroll receives composed lines). The
prefix renders entry.Received in LOCAL time (retail strftime), invariant
literal colons. The ten defect-pinning test cases across
ChatLogTests/RuntimeCommunicationStateTests are rewritten to pin the
corrected contract (stored bodies stay clean; the composed line carries
the stamp outside the quotes — ChatVMTests).

R2: open option-bearing panels converge on every PlayerDescription seed:
OptionPage.ReloadFromLive (per-row live re-read + gating re-eval, NO
AfterApply flush — the seed just cleared the dirty module),
OptionsPanelController.OnServerOptionsSeeded (active page),
CombatUiController.OnServerOptionsSeeded (SyncControls), wired through
RuntimeSettingsController.ServerOptionsSeeded from the same factory hook
LockUI already uses. Retail cannot reach this state (its panels close
across login); the adaptation exists because retained panels survive the
session boundary — documented at the seam.

R3: tests drive the refresh widget push (model AND checkbox converge) and
ReloadFromLive's no-flush contract. R4: AP-196 addendum names the
headless AutoRepeatAttack false->true effective-default flip and the
characterOptions escape hatch.

Full Release suite: 13,083 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:36:14 +02:00
Erik
e71e5a9614 feat(ui): Campaign OP slice OP5 — the Chat tab
Binds LayoutDesc 0x2100005C through OP2's template-list mechanism and
OP3's per-page OptionPage model: the General Options header + two
DualHash-linked opacity sliders (Option_DefaultOpacity_Property
0x10000080 / Option_ActiveOpacity_Property 0x10000081, live-apply on
drag through RetailWindowOpacityController, defaults read from the
installed DAT's DBProperties collection at DID 0x78000001 via
ChatOptionsDatDefaults), and the five per-window text-filter blocks
(main window 12 rows minus Gameplay, four floaties 13 rows each — the
byte-verified authored order cross-checked against the raw
gmChatOptionsUI::InitOptions/AddCheckboxBitfield64Option pseudo-C, not
just the research doc's own table) writing AcDream.Core.Chat.
ChatWindowState directly, the same state CH6's chat windows already
read.

AP-195 retired: ported both halves left open at the OP2 re-review —
the ALL-set LED media swap (new 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 its stacked row content; the
enclosing ListBox reflows around the block's FINAL height via the new
UiTemplateListBox.AddPrebuiltRow, reusing the ListBox's own stacking
rather than a third stacking path). AP-187 broadened to cover the main
window's own filter (previously only the four floaties) and the new
live-editing write path.

The main chat window's filter (retail window id 8, ChatWindowState id
0) gains its own settings.json persistence (ChatSettings.
ChatWindowMainFilter) alongside the pre-existing floaty 1-4 fields;
opacity persistence is now wired on every live slider change, not only
through the old dev-scaffold Settings panel.

Fixture regeneration (ACDREAM_REGENERATE_UI_FIXTURES=1) picked up the
new ElementInfo.LedCheckedSprite/LedUncheckedSprite fields across all
19 committed layout fixtures — purely additive, confirmed against the
live installed DAT (0x10000520's own 0x82/0x83 properties resolve to
0x06004D17/0x06004D19 exactly as AP-195 documented).

Conformance: FilterRows/FilterBlocks pinned against the byte-verified
authored order and ChatWindowState's own default constants; the AP-195
LED swap and self-sizing behavior; the DAT opacity-default extraction
against the live installed DAT; live filter/opacity writes reaching
ChatWindowState/RetailWindowOpacityController; OnShown re-seed and
Reset/Defaults ghosting per the OP4 binding-pattern discipline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 06:25:59 +02:00
Erik
bc43fb1d1d fix(ui,runtime): OP4 review fixes — live re-seed, enable-gating, Combat panel re-point, universal timestamps
Both OP4 reviews converged on one headline bug (Character-tab rows never
re-read live server truth after their pre-login constructor-word seed) plus
overlapping MUST-FIXes. All ten converged/consolidated findings land here:

MUST-FIX:
- BoolOptionRow.SaveCurrentValue now re-reads its live binding (retail's
  GetValue()-into-SaveCurrentValue) on every OnShown — panel open, tab
  switch in, initial activation — instead of trusting the pre-login
  constructor word it was built with. Reset/tab-switch can now only
  restore values that were actually live at the last show. LockUI's
  host.Root.UiLocked one-shot mount seed now also converges on every
  PlayerDescription via the existing OnCharacterOptionsChanged hook.
- Apply/Reset are wired to OptionPage.OnOptionChanged in production
  (Ghosted when nothing changed, Normal when dirty, run once at bind so
  both start disabled per retail's PostInit); Defaults stays ungated.
- The Combat panel's three LEDs (Repeat Attacks/Auto Target/Keep in View)
  now read/write the same RuntimeCharacterOptionsState seam the Character
  tab uses instead of a disconnected client-local GameplaySettings copy —
  closes the "two writable copies" divergence. The three now-orphaned
  GameplaySettings fields and RuntimeSettingsController's mirror
  properties/SetCombatGameplay are deleted outright; the headless host's
  hardcoded AutoRepeatAttack/AutoTarget now read the live option bit.
- RuntimeSettingsController.SetUiLocked's convergence guard now compares
  against the last value actually applied to the runtime target instead
  of the persisted GameplaySettings.LockUI snapshot, which could already
  match a server-derived request without ever having been pushed.

SHOULD-FIX:
- DisplayTimeStamps now prefixes every chat producer (ChatLog.Append is
  the one seam all of them funnel through), not just AddText's own
  callers — heard speech, emotes, Turbine channels, and combat text were
  previously missed. The prefix format escapes its colons and forces
  InvariantCulture instead of the culture-dependent TimeSeparator
  placeholder.
- sky.frag now honors uFogParams.w (fog mode) like the mesh/terrain
  shaders, so Disable Distance Fog stops the sky dome's horizon band from
  blending toward fog color too.
- Corrected the "byte-verified" overclaim on the timestamp format string
  doc comment (BN-sourced, wire doc U6) and the AP-194 anchor-column
  class-name typo; the RunAsDefaultMovement doc comments now cite retail's
  actual acclient.h enumerator name.
- Added: DispatcherMovementInputSource's option x modifier truth table
  (incl. || AutoRunActive with the option off), the per-page Apply/Reset
  enable-gate tests, a real checkbox.OnClick/ToggleBehavior-driven click
  test, and hash-pins for the six header string keys.
- Gate script step 8 corrected for the logout-flush false-failure
  (closing the panel before relogging is load-bearing); a new step
  documents the enable-gate sequence and the Combat-panel/Character-tab
  cross-check.

Register: AP-196 (the Group-C default-source change + GameplaySettings
retirement) and AP-197 (the ignored per-character timestamp format
override) filed in this commit.

Full Release suite: 13,044 passed / 4 skipped / 0 failed (was 13,008/4/0;
net +36 tests from new coverage and legitimate assertion updates from the
GameplaySettings retirement).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 05:30:26 +02:00
Erik
22b86b9ff4 feat(ui): Campaign OP slice OP4 — the Character tab
Binds LayoutDesc 0x21000028 (gmCharacterSettingsUI) through OP2's
template-list mechanism and OP3's OptionPage model: 6 authored group
headers + 50 toggle rows (49 from the 2013 build + D3's "Listen to PK
death messages", AP-193) in research doc §2's authored order, each row
resolved by PlayerOption id through CharacterOptionTable, seeded from
live RuntimeCharacterOptionsState, defaulted from CharacterOptionTable.
ClientDefault (byte-verified against UIOption_Checkbox::SetPlayerOption
@0x00486e80's own GetDefaultOptionValue call — AP-194 updated to confirm
the directive was followed), labels/tooltips resolved by name from
string table 0x23000003 (never hard-coded English), and registered with
OptionsPanelController.CharacterPage. Apply/Reset/Defaults
(0x100001FC/FD/FE) are now wired per-page via a scoped subtree search
(UiElement.FindDescendant, promoted from UiTabPanel) since Character/
Chat/Config each author their own physical instance under the SAME
element ids.

Consumers: Group A (29 ids) wire+store only via the existing
SetSingleCharacterOptionRuntimeCmd/TrySetOption seam. Group B: Display
Timestamps prefixes new transcript lines (RuntimeCommunicationState.
DisplayTimestampsSource); Disable Distance Fog forces FogMode.Off
(WeatherSystem.DisableDistanceFogSource, retiring half of TS-73); Run as
Default Movement inverts the walk-mode modifier's default
(RuntimeLocalPlayerMovementState.RunAsDefaultMovementSource). Group C
re-points AutoTarget/AutoRepeatAttack/ViewCombatTarget
(CharacterOptionCombatSettingsSource), VividTargetingIndicator/
CoordinatesOnRadar/LockUI/AcceptLootPermits from the client-local
GameplaySettings record to the canonical server bit — closing two
previously-unfiled divergences where AutoRepeatAttack and
AcceptCorpseLootingPermissions never reached the wire despite being
retail auto-save ids. TS-73 narrowed to its two still-open cases;
TS-75..TS-80 file the genuine gaps (no day/night force, no weather-
particle/profanity-filter/salvage/housing/pickup-preference subsystem,
fellowship-create's unaudited client-sourced field) rather than
inventing stand-ins.

Conformance: CharacterOptionsPageControllerTests pins all 50 rows
against CharacterOptionTable in both directions (an invented or dropped
row fails the build), the authored group/order row-by-row, and the
build/seed/Apply/Reset/Defaults/wire-publish behavior end-to-end against
the committed fixture. 52 new tests; full solution suite 13,008 passed /
4 skipped / 0 failed (was 12,956/4/0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00
Erik
1aa7709988 fix(chat): CH6a/b rework — grip media, retail window-id model, floaty fixture
Applies docs/research/2026-08-10-ch6ab-review-findings.md in full:

- BLOCKER 1: UiResizeGrip now carries its ElementInfo/resolve pair and
  draws its own authored DirectState media (a synthetic parameterless
  grip still draws nothing, preserving existing resize-drag tests).
  DatWidgetFactory.BuildResizeGrip threads resolve through. All seven
  live grips on the main chat window now resolve a non-zero sprite,
  restoring the visible borders/corners CH6a silently dropped.

- SHOULD-FIX 2: ChatWindowState gains BroadcastTargetWindow, a sentinel
  distinct from every real window id (0-4), fixing the bug where the
  main window's explicit-addressing branch coincided with the broadcast
  check (both were literal 0). SetFilter's main-window no-op is dropped
  — the main window's filter is now genuinely settable. ChatWindowController
  .Bind takes a ChatWindowState (the same canonical instance the floating
  windows already share) and GetTranscriptLines builds a real accept
  predicate instead of accept:null. Verified safe: ClientLocal (0x1A)
  never reaches ChatLog (AddText routes it to the SpewBox and returns),
  so nothing observable regresses.

- SHOULD-FIX 3: UiButton.SuppressSelfToggle stops the four chat-window
  indicator buttons (DAT property 0x0B=true, no retail click handler)
  from flipping their own Selected mirror on a stray click.

- SHOULD-FIX 4: generated and committed chat_floaty_2100005b.json from
  the real installed dats; added the permanent RetailLayoutFixtureGenerator
  entry. All three flagged FloatingChatWindowController assumptions
  (input field, title bar, close button) are confirmed correct against
  real data — no controller code changes needed. New finding: unlike the
  main window, ALL EIGHT floaty border/corner elements are live Type-9
  grips (the floaty's own title bar is its move handle), so a floaty
  window resizes from every edge and corner.

- SHOULD-FIX 5: register row AP-189 documents the shared-500-entry/
  200-line-tail vs retail's per-window 10,000-line scrollback depth gap.

- NITs 1-5: documented the filter-persistence-only-on-/saveautoui
  asymmetry and the reconnect-preserves-filters intent; corrected the
  research doc's modifier-mask mislabel and the "ONLY function" false
  superlative; moved WrapText off ChatWindowController onto
  ChatTranscriptRenderer, closing the circular dependency.

Full Release suite: 12,420 passed / 4 skipped / 0 failed (baseline
12,392/4/0 at 22020ef2; net +28 tests, zero regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:09:32 +02:00
Erik
22020ef2c4 feat(chat): Campaign CH slice CH6b — floating chat windows 1-4
Mounts retail's four floating chat windows as always-resident, born-hidden
children per gmGamePlayUI::SetupChildren @0x004E9EC0, all sharing LayoutDesc
0x2100005B (window ids 0x10000505/0x1000050E/0x1000050F/0x10000510). New
FloatingChatWindowController (AcDream.App/UI/Layout) binds each window's own
widget tree — built fresh per instance from one shared imported ElementInfo
— reusing ChatWindowController's word-wrap + retail color-carry algorithm via
the extracted ChatTranscriptRenderer instead of duplicating it. A floaty
window has no talk-focus menu (research doc §2.2), so its entry field always
sends on Say; the mismatch against retail's possible shared-channel behavior
is UNVERIFIED and filed as #369/AP-188.

Runtime owns the per-window filter/open state: ChatWindowState (new,
AcDream.Core.Chat) seeds retail's exact PostInit defaults per window
(window 1 0x0000101C Speech/Tell/DirectSend/Emote, window 2 0x00040C00
Social/SocialSend/Allegiance, window 3 0x00080000 Fellowship, window 4
0x78000000 Turbine General/Trade/LFG/Roleplay) and implements the full
ShouldDisplay(windowId, targetWindowId, logTextType) display predicate from
ChatInterface::RecvNotice_DisplayFinalStringInfo @0x004F4640. It lives on
RuntimeCommunicationState.ChatWindows so every host borrows the same
instance. The main window's filter (0xFBFFFFFF, "no user filter") never
actually gates anything because its own explicit-address branch already
covers every broadcast line — that's why UpdateFromPlayerModule early-returns
for window 0 in retail, ported here by construction rather than a special
case.

Keybind wiring: InputAction.ToggleFloatingChatWindow1..4 and their
KeyBindings.RetailDefaults() chords already existed since Phase K.1c
(unwired until now). The MetaKeys table confirms retail's default is Alt+1
through Alt+4 (index 3 = bit 0x00000004, cross-checked against the same
file's Alt+A/D strafe and Alt+Enter/Tab/F4 rows). Routes through
GameplayInputCommandController -> RetainedGameplayWindowCommands ->
RetailUiRuntime.ToggleFloatingChatWindow -> the generic UiHost.ToggleWindow,
whose visibility-change event is the single chokepoint that syncs
ChatWindowState.SetOpen and mirrors the main window's 1-4 indicator button
regardless of what changed a window's visibility (keybind, close button, or
a restored layout).

A direct decomp read of gmMainChatUI::ListenToElementMessage @0x004CDA80 —
the only function in the whole binary that branches on a click message —
settles what the research doc had left as a hedge: it handles exactly
0x1000046f (max/min) and the talk-focus menu's selection message, with NO
case for 0x10000522-0x10000525. The four indicator buttons are PURE
one-directional mirrors in retail; clicking them does nothing.
ChatWindowController.SetIndicatorOpen ports this with no OnClick at all.
Corrected research doc §1.4 accordingly.

Persistence is local-only (register row AP-187; the retail 0x1000008C
GameplayOptions wire remains deferred to CH6f): window geometry and
open/visible state ride the existing generic RetailWindowLayoutPersistence
path for free once each window registers under its own WindowNames entry;
the four filter masks get a dedicated ChatSettings round-trip
(ChatWindow1Filter..ChatWindow4Filter, defaulting to the retail PostInit
constants) loaded at mount and saved alongside SaveLayout().

Tests: ChatWindowStateTests (defaults, TypeIsActive, the full display-rule
matrix, toggle/reset, revision counter), FloatingChatWindowControllerTests
(bind smoke tests against a synthetic 0x2100005B tree, per-window filter
routing, filter-change cache invalidation, fixed-Say submit), new
ChatWindowController.SetIndicatorOpen tests (Highlight/Normal state,
cross-window isolation, range validation), GameplayInputCommandController
routing for the four toggle actions, and a SettingsStore filter round-trip.
Full Release suite: 12,392 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:10:20 +02:00
Erik
e07fba5731 fix(chat): CH3 review fixes — phantom UN-9, allegiance-broadcast echo, /a legacy fallback
Applies the Opus review of Campaign CH slice CH3 (614a1e05):

- B1: UN-9 was a phantom divergence — ACE's CharacterOptions1.cs:47
  OR-sum is 0x50C4A54A (its own comment confirms 1355064650), identical
  to acdream's literal. The wrong 0x50C48D4A existed only in the research
  doc. Row deleted, register §5 reverted to 4 rows, research doc corrected
  with dated notes.
- S1/S4: AllegianceBroadcast (0x02000000) is a server-echoing channel —
  ACE's GameActionChatChannel handler includes the sender in its real-name
  Allegiance.Members broadcast (retail's DoAllegianceBroadcast has no
  AddTextToScroll), so the client must skip its local optimistic echo, not
  keep it. ChatChannelInfo.Legacy.IsSelfEchoChannel() now returns true for
  it; RouteLegacyChannel's comment corrected; Turbine.IsSelfEchoChannel()'s
  backwards comment rewritten truthfully.
- S3: retail's /a stays on the legacy AllegianceBroadcast bitflag until
  StartupTurbineChatSystem successfully starts Turbine chat — "never
  started" (TurbineChatState.Enabled == false) now falls back to legacy in
  both LiveSessionCommandRouter.RouteChat and
  DirectGameRuntimeCommandAdapter.TrySendChannel, while "enabled but no
  allegiance room" still correctly refuses locally.
- S5: added a LiveSessionEventRouter test proving the Options.Replace ->
  OnCharacterOptionsChanged seeding order, and RuntimeSettingsTargets /
  GameWindowLiveSessionOwnershipTests tests proving the concrete
  ICommandBus.Publish wiring and the single LiveSessionCommandSurface
  construction site.
- S6: AP-181 rewritten to name both of retail's omitted pre-send checks
  (IsMessageSafe silent-drop, then IsMessageSpam) and stop misattributing
  either to RouteLegacyChannel, which has no such gates.
- N1-N7: CharacterOptionId moved below SocialActions so its doc comment
  re-attaches; TurbineChatMembershipGate reuses TurbineChatDisplayNames
  instead of a duplicate table; the gate-to-refusal-text mapping is now
  shared via TurbineChatMembershipGate.ResolveRefusalText instead of
  duplicated in both hosts; ChatSettings.Default now matches ACE's real
  CharacterOptions2.Default (Roleplay/Society start off); a doc-comment
  clarifies only the five Hear toggles are server-backed; the register's
  §3 header recounted 129 -> 128.

Suite: 11,964 passed / 4 skipped / 0 failed (baseline 11,957/4/0 + 7 new
tests). Campaign ledger CH3 review column updated to APPROVE-WITH-FIXES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:24:29 +02:00
Erik
e0e7888308 fix(chat): CH2 rework — SpewBox tick-driven visibility + binary-derived error table
Reworks Campaign CH slice CH2 per the REJECT-review findings doc
(docs/research/2026-08-09-ch2-review-findings.md).

BLOCKER 1 — SpewBoxController never rendered a line and leaked its
pending queue. LinesProvider only ran through UiText.OnDraw, which
gates on Visible — and the box started invisible, so the provider (the
sole caller of SpewBoxState.Tick) never ran. Gave the controller an
explicit per-frame Tick(now) driven by UiRoot's global-message-3
broadcast (a zero-size GlobalTimeSink child, the same pattern
VendorUiController.DragOverGlobalTimeSink already uses), matching
retail's gmSpewBoxUI::Update. LinesProvider now only returns the
cache. Tests rewritten to drive root.Tick(...) instead of calling the
provider directly, plus new coverage for visibility-without-a-draw,
queue-drain-without-a-draw, and bounded-queue-across-many-ticks.

BLOCKER 2 — re-derived the HandleFailureEvent routing table from the
PDB-paired binary instead of the pseudo-C's ~33-char string previews.
tools/pdb-extract/sweep_weenie_strings.py sweeps every push imm32 in
VA 0x571990-0x575480, dereferences into .rdata/.data, and decodes the
full UTF-16LE literal. Added the 5 ids dispatched via else-if (missed
by case-label enumeration), resolved 0x4F8 (previously excluded),
fixed 18 wrong strings (16 the review flagged + 2 more — 0x4E9 and
0x518 — an automated diff between every swept literal and the landed
table found). Every changed row cross-checked against ACE's
WeenieError/WeenieErrorWithString enum doc comments; both oracles
agreed on every row, including a case where the review's own proposed
text for the new 0x4E8 row was itself wrong (it was 0x4E9's text) —
corrected via the else-if block's own instruction address plus the ACE
cross-check. Pinned table count: 344 (338 + 5 + 0x4F8).

SHOULD-FIX 1 — RuntimeCommunicationState.ResetSpewBox was dead code;
folded into the ChatIdentity generation-reset stage (same lifetime
boundary), with a reset assertion added to the existing populated-reset
test.

SHOULD-FIX 2 — AddText trimmed only the trailing end and invented an
empty-string early return; retail's AddTextToScroll trims both ends
(trim(&str, 1, 1, ws)) and has no empty guard. Both retired.

SHOULD-FIX 3 — ShowWeenieError bypassed the AddText chokepoint via
ChatLog.OnWeenieError (hardcoded LogTextType 0x00); routed through
Communication.AddText(Resolve(code, param)) instead, and
ChatLog.OnWeenieError is deleted — GameEventWiring's legacy no-router
fallback now resolves + calls OnSystemMessage directly.

SHOULD-FIX 4 — retail's HandleFailureEvent switch has no default case;
an unmapped id now resolves to a null Text (silence toward the
player) instead of the invented "WeenieError 0xNNNN" hex fallback,
with a diagnostics-only console log line for the id.

NITs — AP-TBD placeholders corrected to their real register rows
(AP-178, not the unrelated AP-177 lifetime row); filed AP-180 for the
windowId dual-destination gap and corrected three stale "lands with
CH2" comments; extended SpewBoxLayoutDumpDiagnostic from dats.Portal
to dats.Local and found the SpewBox element for real — LayoutDesc
0x21000011, element 0x10000048, size 450x72, MaxConcurrentItems
(ListBox property 0x10000028) = 4, not retail's code default of 1.
AP-178 narrowed accordingly; SpewBoxState.MaxConcurrentItems and
SpewBoxController's extent/anchor/OneLine are now authored rather than
placeholder (absolute screen position and colour remain open); fixed
the "19 ids... lists 18" miscount by retiring the stale paragraph in
the class doc rewrite; aligned the UseDone handler's silent-status
check with the other two WeenieError handlers.

Full Release suite: 11,914 passed / 4 skipped / 0 failed (build 0
errors).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:14:26 +02:00
Erik
77c8296e3f feat(chat): Campaign CH slice CH2 — retail SpewBox interface text
Retail routes on-screen refusals ("You can't jump while in the air",
"You are too encumbered to carry that!") through a SEPARATE transient
screen surface (gmSpewBoxUI, ClientSystem::AddTextToScroll @0x00563C50)
that never touches the chat scroll — type 0x1A is exactly the bit every
ChatInterface window's default filter excludes
(ChatInterface::ChatInterface @0x004F4550). acdream had no such split:
every WeenieError rendered in chat at a single stand-in LogTextType
0x00 (CH1-era approximation, register AP-176), and locally-detected
jump refusals were silently discarded.

This slice ports the full mechanism per
docs/research/2026-08-09-chat-retail-interface-text.md:

CORE (AcDream.Core/Chat):
- WeenieErrorMessages.Resolve now returns (text, RetailLogTextType) from
  a 338-row transcription of ClientCommunicationSystem::HandleFailureEvent
  @0x00571990 (Appendix A's 339 cases minus one, 0x4F8, deliberately
  excluded — its case body is a tangled decompiler artifact, not
  resolvable with confidence). Spot-checked ~20 rows directly against
  the raw decomp (case 0x2b/0x36/0x3a/0x4e/0x4ec/0x4f3/0x4f4 and the
  jump family), beyond the ~10 the brief asked for, because the first
  pass surfaced two transcription classes the research doc's markdown
  silently ate: (1) 7 ids marked "shared string global" resolved by
  reading the case bodies directly (0x24/0x48/0x49 reuse the jump-
  refusal globals; 0x4DE/0x4DF/0x55A/0x55E are pure param passthrough);
  (2) 19 "arg3 + literal" CONCATENATION ids whose leading space (and
  therefore their %s marker) the markdown table's cell-trimming ate —
  fixed by re-reading each case body, several requiring a SECOND
  non-truncated data_XXXXXXXX dump elsewhere in the same oracle file to
  recover text the ~33-char inline preview cut off. One retail typo is
  preserved verbatim: 0x4F4's second placeholder is literal "$s", not
  "%s" — only the first substitutes.
- ClientTextRefusals: the 11 process-lifetime string globals, all
  byte-recovered from the PDB-paired C:\Users\erikn\Downloads\acclient.exe
  (MATCH verified via check_exe_pdb.py) via raw UTF-16LE prefix search —
  5 were truncated in the research doc's own transcription and all 5
  turned out to end "...combat mode"/"...this position", not the
  shorter "...combat" a truncated read would suggest.
- SpewBoxState: the gmSpewBoxUI pending/visible queue port (insert-at-0,
  dedupe-against-index-0-only, MaxConcurrentItems overflow, per-entry
  expiry, one-frame enqueue/drain decoupling). Placed in Core (not
  Runtime as the brief's default) because AcDream.UI.Abstractions
  references Core but not Runtime, and SpewBoxVM needs to wrap it
  directly — the same constraint ChatVM already satisfies against
  ChatLog.
- Folded the 4-entry WeenieErrorText.cs into the full table; deleted it.

RUNTIME (AcDream.Runtime):
- RuntimeCommunicationState.AddText(text, type, windowId): the
  AddTextToScroll chokepoint. type == ClientLocal -> SpewBox only, never
  chat; everything else -> the existing transcript, tagged with type.
- GameEventWiring gains an `onInterfaceText` delegate hole (Core.Net
  cannot reference Runtime, so this follows the file's own established
  pattern for every other Runtime-owned sink). Rewires 0x028A/0x028B/
  UseDone through the full table + router; fixes 0x02EB
  CommunicationTransientString's routing type from a CH1-era 0x00
  guess to retail's hardcoded ClientLocal (Handle_Communication__
  TransientString @0x0057D460).
- LiveSessionEventRouter's 0xF7E0 ServerMessage handler now routes
  through AddText with the wire chatType verbatim instead of always
  writing ChatLog directly.
- PlayerMovementController gains OnInterfaceText, applied by
  RuntimeLocalPlayerMovementState to every controller it installs.
  Reports ChargeJump/jump refusals exactly as ClientCombatSystem::
  CommenceJump @0x0056AF90 / DoJump @0x0056B110 do — confirmed via
  their compiled dispatch that ONLY 0x24/0x48/0x49 produce text;
  0x47 (GeneralMovementFailure, fully-constrained/no-stamina) and any
  other code are retail-SILENT (DoJump's jump table has exactly 4 real
  targets), which contradicts this task's brief ("0x47 -> the
  constrained/stamina row per §4.2") — the brief's reading of §4.2
  described what jump_is_allowed COMPUTES, not what CommenceJump/DoJump
  DISPLAY for it. Implemented the decomp-verified silent behavior.

APP (AcDream.App / AcDream.UI.Abstractions):
- The 5 composition sites that already used RetailLogTextType.ClientLocal
  now call Communication.AddText instead of Chat.OnSystemMessage
  directly, so they reach the SpewBox instead of the transcript.
- SpewBoxVM (UI.Abstractions) + SpewBoxController (App), modeled
  directly on PortalWaitNoticeController. Position/font/colour/
  MaxConcurrentItems are placeholders: SpewBoxLayoutDumpDiagnostic
  exhaustively swept the installed client_portal.dat's entire LayoutDesc
  id range (0x21000000-0x21000075, 101/118 ids populated, sanity-checked
  against 3 known ids) and found ZERO elements of class 0x10000016 —
  gmSpewBoxUI is mounted from C++ code, not any authored LayoutDesc, so
  the dump cannot recover these values.

REGISTER: AP-176 retired (its WeenieError half is now the full table
port); its OnCombatLine half was never in this slice's scope and is
split out to AP-179 so that divergence keeps a row. AP-177 (invented
line lifetime) and AP-178 (invented position/font/colour/max-items)
filed for the presentation placeholders above. AP-175 (PopUpString ->
chat instead of modal) is untouched, not duplicated.

Suite: 11,890 passed / 4 skipped / 0 failed (was 11,835/4/0; +55 net
new tests, 0 regressions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:02 +02:00
Erik
34d8a3c0e7 fix(chat): CH1 review fixes — sbb-idiom channel catch-all, command-output typing
Applies the Opus review findings on CH1 (172c6f9a), the exact retail chat
color table. Two blockers plus should-fixes/nits, one commit:

BLOCKER 1 — LegacyChannelChatType.Resolve's channel-bit table was wrong.
Binary Ninja renders retail's `neg esi; sbb esi, esi` idiom (a branchless
select between Channel 0x08 and Channel_Send 0x09) as the trivial pseudo-C
`esi - esi` (always 0), hiding the real values. Corrected by decoding the
raw bytes at the PDB-paired binary: HEAR sbb site VA 0x00570F0A (mask -6 ->
0x08), SEND sbb site VA 0x00570D4F (mask -5 -> 0x09). The generic
admin/audit/sentinel catch-all is Channel/Channel_Send, NOT Abuse (0x0E) —
Abuse is retail's ONLY 0x0E producer (bit 0x0001). The unnamed
FellowBroadcast bit (0x4000000) is hear=Channel(0x08)/send=Fellowship(0x13),
not a flat 0x13. ACE's PDB-sourced Channel enum corroborates. Introduces
`RetailLogTextType`, the 34-value named enum for the wire LogTextType space
(values only, no color — Core stays presentation-free).

BLOCKER 2 — three ChatLog.OnSystemMessage sinks (ChatVM.ShowSystemMessage,
LiveSessionRuntimeFactory's ShowSystemMessage delegate,
HeadlessGameplayOperations.DisplayMessage) were typing ALL
ClientCommandController output 0x1A (bright red), including informational
command output (@version, /loc, friends list, usage lines). Retail types
the great majority of that output 0x00 Default (green) and reserves 0x1A
for genuine refusals/errors. Reverted to 0x00 with a comment noting the
refusal-vs-info split lands with CH2's SpewBox producer rewiring. The five
App composition sites that pass 0x1A for actual refusal text
(InteractionRetainedUiComposition, SessionPlayerComposition) were already
correct and are untouched (aside from converting the literal to the new
enum).

Also: AP-176 divergence-register row for OnWeenieError/OnCombatLine's
single-type approximation of retail's per-code/per-message dispatch; a
carry-forward test for the out-of-range LogTextType color fallback in
ChatWindowController; decomp-confirmed anchors replacing ACE-inferred
citations in CombatChatTranslator and ChatLog.OnPlayerKilled; required
(non-optional) logTextType parameters on OnLocalSpeech/OnTellReceived/
OnCombatLine/OnSelfSent since no production caller relied on a default;
LegacyChannelChatType.Resolve's parameter renamed channelBit -> channelId
with a doc note on multi-bit ids; corrections to the color-table research
doc's §3.3 wire tables; and issue #359 for the pre-existing (not
CH1-introduced) 0x019E PlayerKilled participant-suppression gap retail has
and acdream lacks.

dotnet build clean; full Release suite 11,835 passed / 4 skipped / 0 failed
(11,839 total), up from the CH1 baseline of 11,833/4/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:03:13 +02:00
Erik
172c6f9aa3 feat(chat): Campaign CH slice CH1 — retail LogTextType color table
Retail colors chat lines by the 34-value wire LogTextType (ACE's
ChatMessageType), NOT by acdream's synthetic 9-value ChatKind. The old
ChatWindowController.RetailChatColor(ChatKind) collapsed distinct retail
colors onto one bucket per ChatKind — e.g. every Channel line rendered
colorLightBlue (Magic's slot) when retail's actual palette spans five
different colors across the Turbine rooms and legacy allegiance family.

Ports ChatInterface::BuildChatColorLookupTable @0x004F31C0 verbatim
(RetailChatColorTable, all 34 RGBA floats read from the PDB-paired
binary's .data section) and threads a new ChatEntry.LogTextType field
through every ingestion site to the correct retail wire value:
HearSpeech/Tell pass the wire chatType through verbatim; Emote/SoulEmote
hard-code 0x0C; the Tell self-echo hard-codes 0x04; legacy ChatChannel
broadcasts derive their type from the channel bit via the new
LegacyChannelChatType helper (ported from the decompiled
Handle_Communication__ChannelBroadcast dispatch, hear vs. own-send);
TurbineChat rooms map through TurbineChatDisplayNames.LogTextType;
CombatChatTranslator's hit/miss/evade lines map to ACE's CombatSelf/
CombatEnemy per Player_Combat.cs; kill/death lines use retail's
decompiled 0x00 Default (not a combat color). ChatWindowController's
transcript now folds LogTextType through RetailChatColorTable with
retail's exact "out-of-range keeps the previous line's color" carry
rule; ChatPanel's combat highlighting sources the same table.

Corrects HearSpeech.cs's doc-comment ChatType legend (4 of 6 entries
were wrong). Adds register row AP-175 for the pre-existing (unchanged)
Popup-renders-in-chat divergence and updates AP-39's stale per-ChatKind
description. Narrows ISSUES #139 — its chat-colors half is done.

Retail renders no chat timestamp prefix path exists in acdream today,
so the "timestamp is always colorGrey 0x0C" rule has nothing to attach
to; noted here per the research doc rather than left silent.

Research: docs/research/2026-08-09-chat-retail-color-table.md
Full Release suite: 11,833 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 15:24:09 +02:00
Erik
2cf94dbcd2 feat(audio): Ctrl+M instant mute + inn-chatter investigation closed as server content
Two items from listening-gate round 2.

Mute: AcdreamToggleAudioMute (default Ctrl+M; bare M is selection) flips
OpenAlAudioEngine.Muted, implemented as the AL LISTENER gain - unused
since A2 moved all mixing to the CPU, so it is a free master switch that
silences already-playing voices instantly and restores them exactly,
without touching the retail mixing math, the -50 dB allocation cutoff, or
any persisted volume setting. Rebindable like every other action; console
line confirms each flip.

Inn chatter: the user hears talk-and-laughter ambience in retail inns and
not in acdream. Three installed-dat scans (pinned as conformance tests in
EnvCellSoundEmitterInventoryTests) prove the mechanism is NOT client
data: no interior static in the town landblock carries an ambient-slot
sound table, no Setup among all 5,935 in the portal dat references one,
and yet 23 sound tables carrying ONLY Ambient1..8 slots exist - pure
soundscape banks with nothing client-side pointing at them. They are
wire-bound: the server attaches one to an emitter object via
CreateObject's sound-table field and fires the slots over 0xF750 - ACE
implements exactly this (EmoteType.Sound heartbeat emotes ->
GameMessageSound broadcast). Our 0xF750 receiver (slice A3) is live and
now instrumented (ACDREAM_PROBE_SOUND_WIRE=1, via the new
AudioDiagnostics owner per Code Structure Rule 5, with per-event drop
reasons in AudioHookSink.PlayServerSound). A probed session against the
local ACE received ZERO 0xF750 events across a town walkabout: the
silence is server world-content (no emitters configured/firing), not a
client drop. The first scan's assertion originally encoded the
emitter-object hypothesis; the data refuted it, and the test now pins the
negative so the conclusion cannot silently rot.

Full Release suite green (the one failure during development was the
hypothesis-pinning assertion, corrected to pin the finding).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 14:09:24 +02:00
Erik
7c4dd1ade7 feat(audio): Campaign A slice A5 — retail's region ambient soundscape
acdream had no ambient system: StartAmbient minted a handle and played
nothing. Retail's is a weighted-accumulation + timer-queue engine, not
looping voices. On every objcell change (24 m) CellManager::ChangePosition
rebuilds per-sound weights over the 3x3 landblock ring x 64 land cells
each, decoding each cell's terrain word through the region file's
terrain -> scene -> AmbientSTBDesc chain; playback is a min-heap of
absolute deadlines drained from the frame tick, where each pop fires a
one-shot and re-arms.

A continuous bed (base_chance == 0) is non-positional, crossfaded by its
share of the TOTAL weight, and re-fired every min_rate seconds — that
rate is the author's intended loop period, and re-firing is how retail
fakes a sustained bed with no looping voice, re-rolling the variant and
the crossfade each time. An intermittent one keeps its authored volume,
plays at a random accumulated compass bearing at min + (max-min)*t^2,
and is dice-gated. Indoors is silent by design: CEnvCell's contributor is
a folded ret and EnvCell carries no sound data.

The Opus review caught four bugs before this landed, one fatal:

- Cell offsets were built in ABSOLUTE world coordinates and differenced
  against the listener's STREAMED-frame position, so every one of 576
  offsets came out ~32 km, every contribution was culled, and the whole
  feature was silent with nothing logged. Offsets are now landblock-local
  the way Position::get_offset builds them, and the streamed-frame
  position is carried separately for playback, where it belongs.
- The cell's weight was added to the shared denominator once per
  DESCRIPTOR instead of once per CELL, dividing every bed's crossfade by
  the table's entry count — enough to push a typical authored volume
  under the 0.03 audibility floor.
- The drain used  where retail's UseTime is strictly
  below, so a descriptor authored with a zero rate re-armed at the same
  instant and spun the frame forever.
- Arming only enqueued; retail's UpdatePlayQueue PLAYS and then re-arms,
  so a newly audible ambient was silent for a full period after the
  crossing that made it audible.

Also: beds now go through retail's single 16-voice priority pool rather
than acdream's UI pool (retail has one pool; parking beds in the UI pool
let an A4 portal cue chop one mid-wave and discarded the authored
priority), and CalcDir's in-block test is XY-only, since CalcWeight
includes Z on purpose and CalcDir excludes it on purpose.

Two behaviours are knowingly incomplete and registered rather than
guessed at slice end: TS-66 (sky-lit interiors should keep the outdoor
set) and TS-67 (contribution weight is computed in-plane). Retires TS-29.

The frame-loop hook is a typed IAmbientFramePhase, not a callback — the
first attempt used an Action<float> and the architecture guard
ExtractedUpdateOwners_DoNotRetainAnonymousCallbacks correctly rejected it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:53:41 +02:00
Erik
6eaa490bb3 feat(audio): Campaign A slice A4 — the interface sound bus
Retail's UI sound bank was absent, so three families of cue were silent:
the portal enter/exit stingers, the AdminEnvirons dungeon atmosphere
(chanting, drums, whispers, thunder — what players remember as dungeon
'music'), and every other interface slot.

The bank's DID is not a literal anywhere in retail: GetUISoundTable
@0x00563FB0 asks GetByEnum for enum slot 7, and DBCache::GetDIDFromEnum
@0x00413940 resolves it through two EnumIDMap hops off the portal dat
header's master map. UiSoundTableResolver walks that chain the way
RetailCursorResolver already walks it for cursors. Against the shipped
dats it resolves to 0x2000004B, and that table holds exactly the 32 UI_*
slots (UI_EnterPortal 0x6A .. UI_Thunder6 0x8A) — content that confirms
the walk independently of the decode. UiSoundTableResolutionTests pins
the walk, the DID, and the content, and skips when dats are absent.

Two corrections to the research along the way. The lane-5 note recorded
GetByEnum's arguments transposed: the 0x22 it called a fileType is the
CACHE type (CLOCache(cache, CSoundTable::Allocator, 0x22)) and the real
second-hop key is 0x10000003; walking it the other way finds nothing. And
its claim that the interface volume pref applies is wrong — GetAttenuation
with ambient=0 multiplies by the EFFECT knob, so retail's
interface_sound_volume stays the dead knob lane 1 byte-decoded it to be.

EnvironSoundCueMap is an explicit 21-case table read straight out of
Handle_Admin__Environs @0x0055DE20, not arithmetic: codes 0x65..0x72 sit
0x11 below their SoundType, but 0x73/0x74 have no case, so 0x75 lands on
UI_Squeal (0x84) where an offset gives 0x86, and the switch ends at 0x7B
with no 0x7C case. Verified case-by-case against the decomp rather than
from the lane note, whose tail table was ambiguous.

Cues are attached where retail plays them: the teleport-animation
boundary for the portal pair, and the AdminEnvirons handler for the
stingers. PlaySoundFromCenter's pan-0 / distance-0 shape is what
PlayUiWave already implements after A2.

Retires TS-54. Narrows AP-115 to its notice-presentation residual.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:22:09 +02:00
Erik
e42b99482e feat(audio): Campaign A slice A2 — retail's 2D pan+gain mixer replaces AL 3D
Retail is not a 3D audio engine. Every gameplay buffer is created with
m_3D = 0 and the DirectSound 3D listener the client sets up is dead code;
spatialization is two CPU scalars per voice, frozen at emission. This
slice ports that math and demotes OpenAL to a voice bank.

RetailSoundMixer (new, Core) carries the byte-decoded curve from
SoundManager::GetAttenuation @0x00550020: g = dist < 5 ? vol : 25*vol/d2,
clamped to 1 BEFORE the single master multiply, db = ceil(20*log10 g),
with a hard -50 dB floor at which retail does not start the voice at all
(audible radius ~94.2 m at unity). Pan is PlaySoundInternal @0x00550170's
(int)(-15*sin(delta-bearing)) in whole decibels, truncating toward zero,
forced to dead centre when (int)distance < 5, with no front/back and no
elevation cue. Every AL source is now source-relative with rolloff 0 and
the global distance model is None: AL's InverseDistanceClamped was
first-power (2/d), quieter than retail up close and far louder at range
with no cutoff whatsoever. That was the largest audible divergence in the
subsystem (AP-28, retired here).

RetailVoicePool (new, Core) ports the allocator at 0x0054FEC0: ring scan
for a free or finished slot, then evict the first slot whose DAT priority
is strictly lower, else drop. Eviction compared GAIN before, so a loud
unimportant sound could silence a quiet important one. It lives in Core
because the engine's play path talks to native AL handles and could not
be tested; the pool now has 12 conformance tests.

The listener keeps using the camera position, which the decode shows is
retail-faithful (SmartBox::set_viewer @0x00452D36 hands the same collided
camera Position to SoundManager) — only the heading extraction changes,
since retail reads one compass bearing and never a forward/up basis. An
earlier draft of the plan called this a defect; corrected in the plan so
it is not fixed backwards.

Opus review found and this commit fixes: a linear pan-to-azimuth mapping
that saturated to full separation at 30 degrees (OpenAL Soft's own
speaker angle) where retail gives 15 dB — now inverts the constant-power
pan law, so full deflection reaches 0.776 of the arc and both channels
stay live; the stale FUN_00550ad0 / gain-eviction class header, which
contradicted the register row this commit writes; missing discriminating
tests for clamp order and pan truncation; dead PlayingGain state whose
comment invented a retail symbol; and a third in-tree copy of
Position::heading, now delegating to MoveToMath.PositionHeading.

MasterVolume folds into the mixer's one multiply instead of AL listener
gain, so the cutoff, radius and dB quantisation move with the slider.

Register: AP-28 retired; AP-173 (pan law), AP-174 (volume taxonomy),
TS-64 (two unimplemented sound prefs), TS-65 (volume-squared quirk,
applied on the ambient path only) filed. Research note corrected twice
where its summary contradicted its own decode (30 m dB, floor vs trunc).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:58:50 +02:00
Erik
c69b3bde04 fix(audio): Campaign A slice A1 — retail's sound probability gate (#355)
The SoundTable probability field is a Bernoulli play/skip gate applied at
the play site (SoundManager::PlayProbability @0x005500E0), not a selection
weight — and variant selection (SoundManager::GetSound @0x00550680) is a
uniform index over (n-1) that ignores probability entirely. SoundCookbook
did the opposite: a cumulative-distribution walk weighted BY probability,
short-circuiting single-entry lists before rolling at all.

A dat census says 4,183 of 4,184 entries are single-entry and 686 of those
carry probability < 1.0, so the gate was categorically absent: Speak1 idle
chatter authored at 0.05 fired every trigger (~20x too often), wound/attack/
swoosh variants never dropped, and six 0.0001 entries always played.

Split into retail's two steps (PickVariant + PlayProbability, composed by
Select) over a new ISoundRandom modelling both retail roll ranges: the
variant roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid,
which is why 0.0001 resolves to ~1.2e-4. PickVariant reproduces retail's
(n-1) off-by-one verbatim per the port-faithfully rule — the last variant
of a multi-entry sound is unreachable, costing exactly one wave
(0x0A00051E) in the shipped dats.

Also removes invented mechanism this review disproved: the dead Core
SoundEntry/ISoundCache scaffold (PitchMin/PitchMax, Loop, Is3D — retail
never calls SetFrequency, never sets the loop flag, and creates every
gameplay buffer 2D), the engine's pitch plumbing, the int 0..7 priority
cast (the dat field is a float in [0,1]; 4,100 entries collapsed to 0),
and the clamp-at-the-field on volume (an unbounded gain retail clamps only
after the distance divide).

Tests rewritten as conformance against the disassembled values, replacing
a self-referential suite that pinned the wrong model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 21:28:16 +02:00
Erik
81a9d85a1d fix(ui): spell-bar drag-reorder works — the per-frame rebuild was destroying the dragged cell (#354)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Everything already existed — the drag payloads, the favorite wire pair
(0x1E3 add-at-position / 0x1E4 remove, byte-confirmed against retail's
Event_AddSpellFavorite @0x006A0F70 and ACE), the insert-shift state
ops. The bug: lifting a favorite fires SpellbookChanged, the next
per-frame Tick rebuilt the bar, the rebuild flushed and recreated
every cell, and UiRoot's subtree-removal safety net canceled the
in-flight drag whose source had just been destroyed — one frame after
every lift, before any drop could land.

The rebuild now defers for the duration of the drag gesture, and the
drop ports retail's own -1-if-lifted-before-target index adjustment
(SpellCastSubMenu::AddFavorite @0x004C7060) so final positions are
byte-identical: insert-shift, not swap; drag-out still deletes (the
lift's removal stands on a missed drop, retail's shape). The
real-pointer-pipeline test fails against the pre-fix code with the
exact cancellation and passes after; a discriminator pins that
physical-item drop handlers reject the spell payload.

AP-172 files the one presentation divergence (mid-drag reflow happens
on release, not continuously) — renumbered from the agent's AP-171
draft, which collided with the same-day double-click row. #354 filed
and closed.

Clean-room complete solution: 11,541 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 18:28:47 +02:00
Erik
68568a3a59 fix(vendor): grand-gate findings — wire-truth container counts, the live split bar, arrival-gated use, prepend-order race
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Four live findings, each with the paper-verification failure named:

G1 the container-capacity guard counted containers by a local
type/capacity heuristic that over-classifies ordinary items;
retail buckets from the wire's ContainerProperties at insert. Now
reads ClientObjectTable's existing ContainerTypeHint (AP-168 narrowed
to the shop-stock half; a pre-check must never false-block).
G2 the amount bar never showed live because ACE never sets StackSize
on browse listings — DescStackSize is null for every real vendor item
and the C4 paper test hand-set the field, bypassing the materializer.
The materializer now falls back to the packed supply count (AP-169,
ACE adaptation); the new test drives the REAL materializer.
G3 an out-of-range Use now dispatches ON ARRIVAL (pickup's shape):
ACE's HandleActionUseItem only opens the vendor when the Use finds
the player in range — a click-time send is greeted and dropped
(AP-170, ACE adaptation; retail's server walks the player, ACE
does not).
G4 bought items appended because ACE's placement echo (UIQueue) can
beat the CreateObject (SmartboxQueue) — cross-queue, no ordering
guarantee — and the early echo was silently dropped. ClientObjectTable
now stashes unresolved placements and replays them at Ingest: buys
land at the retail list head. No register row — this RESTORES parity.

Clean-room complete solution: 11,521 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 14:03:57 +02:00
Erik
c68ad1e646 fix(vendor): 6b/6c review corrections — pre-send guards, accumulating staging, trade-note exemption, drag-over tab switch, full-stack sells
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All thirteen findings, each anchored in recovered bytes or pc reads:

Buy All now runs retail's four PRE-SEND guards in order (pyreal and
alt-currency affordability, container and item slot capacity; strings
recovered from .rdata at 0x007b57b4/0x007b5750) — a rejected batch can
no longer destroy the staged list. Staged adds ACCUMULATE with the
5000 cap ("I can't possibly sell you that much!..." @0x007b59d8) and
the shop rows decrement/restore per RemoveFromShop. The max-value sell
rejection exempts trade notes — the raw bytes at 0x005d1add are `not`
(bitwise), not the pseudo-C's misleading `!`, and the early ret skips
the min check too. BF_RETAINED gates selling end to end (the bit was
already on ClientObject; AP-164's three claims were all false once
traced — RETIRED). Dragging over the vendor window auto-opens the
Selling tab per UpdateDragOver — with a correction to the review's own
citation: token 0x100000cd is the SELLING page, the guard is
"don't reopen the current tab." Sells are full-stack-only (three
retail sites; "Cannot sell part of a stack" @0x007b57ec) and Sell Item
acts on the global selection unconditionally. The confirm string gains
its byte-true trailing '?', dies with the session, staged-row
highlights repaint, dead guids unstage with retail's shopping-list
notice, and move-to-use no longer walks to targets the dispatch would
refuse.

AP-162 narrowed, AP-164 retired, AP-167/AP-168 filed honest.

Clean-room complete solution: 11,508 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 12:50:28 +02:00
Erik
92ea3977b6 feat(vendor): Slice 6b/6c — move-to-use, buy staging, selling; the vendor arc is functionally complete
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
C1 an out-of-range Use now approaches first via the existing
client-predicted BeginApproach (Pickup's far-range shape mirrored;
retail's ItemHolder::UseObject @0x00588A80 has no range check and the
dispatch stays immediate). C2 Add-to-List stages into the Buying tab
via VendorStagingList (RemoveProfileFromList's two shapes,
pc:200497-200537), Buy All sends ONE batched 0x005F and flushes
staging on send exactly as retail does (SendShopEvent -> Flush,
pc:204075-204076 — not UseDone-gated), and X-close over a non-empty
staging list shows retail's confirm string recovered verbatim from the
binary data segment (0x007b5bd8) through the existing dialog factory.
C3 the Selling tab's list is the sole drop target (retail's single
IsAncestorOfMe gate, pc:204229-204246); VendorSellAcceptability ports
InqAcceptability with all rejection strings recovered verbatim from
the raw data segment; the sell side prices with BuyPrice (retail's
inverted naming: what the vendor PAYS) and 0x0060 carries no trailing
currency field, unlike Buy. C4 the status-bar reproduction test PASSES
against the production toolbar mount — retail's toolbar shows count +
name with the split bar and NO price parenthetical (that figure is the
vendor row's own cost text); no code change, the live gate referees.
C5 pack order verified correct, untouched.

Register: AP-161 narrowed to its two pre-existing cosmetic gaps;
AP-162 extended over Buy All; AP-164 (non-sellable bitfield
unmodeled), AP-165 (DescStackSize for _maxStackSize in the removal
test, bounded), AP-166 (purse text + pending-sell highlight cosmetic)
filed.

Clean-room complete solution: 11,482 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 11:43:11 +02:00
Erik
e602f84be2 fix(ui): Slice 5.4 review corrections — the dropdown renders from its authored popup, retail cost semantics, auto-select, icon overlays
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
All nine review findings closed at root (one sub-item consciously
deferred):

F1 the category dropdown now draws: sprites/fonts wired and the popup
geometry read from the vendor menu's own authored popup LayoutDesc
0x21000043 (root 0x1000034F — correcting the review's 0x1000014F
transcription) per UIElement_Menu::MakePopup (pc:120705); chat's menu
is untouched and its tests prove it. The new test drives selection
through the REAL open/hit path the review flagged as bypassed.
F2+F3 the selected-item cost display ports VendorItemsUI::UpdateItemsUI
verbatim: quantity via the 0xDC41CB0 split-size mask (whole-stack for
ammo, per-unit for groceries/components; mask lives at the toolbar
SEEDING site pc:198784), plural names with retail's
fall-back-to-singular (pc:409056 — correcting the review's "name+s"
guess), full cost sentences with comma grouping and the player's coin
total, and Buy/Add buttons that disable without a selection.
F4 category switches auto-select the first filtered item (pc:201180).
F5 icon underlay/overlay/effects + plural name forwarded from the
already-parsed wire fields through VendorShopItem to the icon
composer. F6 a DIFFERENT vendor opens on its own first category;
same-vendor refresh preserves per the clamp. F7 scroll resets on
rebuild and authored empty slots fill; the right-click examine route
is consciously DEFERRED (shop items are not in ClientObjectTable and
the appraisal panel hard-requires it — documented, not faked).
F8 VendorState.Apply's fanout gets the same per-listener isolation as
Close/Reset. F9 AP-110/AP-161 wording corrected ("quantity-correct
pricing") and AP-161 rewritten to exactly the remaining conscious
gaps.

Clean-room complete solution with the #348 cursor fix in the same
tree: 11,334 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 18:26:17 +02:00
Erik
609a2dfda0 fix(runtime/core): Slice 5.3 review corrections — retirement/transit close, per-unit pricing, guarded auto-close dispatch
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The adversarial review's three blocking findings, each fixed at root:

1. A vendor session now CLOSES when its entity retires (despawn,
   death, ObjectDelete) and at teleport BEGIN
   (HasPendingTeleportStart || IsTeleportActive at the existing
   per-frame seam — both hosts funnel through
   RuntimeWorldTransitState.TryQueueTeleportStart, which flips the
   pending flag strictly before activation). The previous permissive
   early-return stranded the session forever: panel pinned to a stale
   guid, ActiveVendorId swallowing Use for the rest of the session.
2. VendorShopItem carries the desc's stack size, and
   VendorPricing.PerUnitValue ports retail's stack-total division
   (VendorProfile::VendorSellPrice 0x005D1B00: <= 0 guard, integer
   division) — a stack of 50 arrows now prices per arrow, not at 50x.
3. VendorState.Close() guards its observer fanout with the
   dispatcher's catch-and-log semantics — a throwing panel listener
   can no longer propagate into the unprotected per-frame path.

Register honesty rides along: the 0.6 m UseRadius fallback was
acdream's invention (ACE's CheckClose has no fallback; retail passes
the raw authored radius) — removed, the watcher now uses the raw
radius and AP-160's citations are corrected and extended with the
accepted-position-snapshot cadence; AD-72 files VendorPricing's
double-vs-x87-extended narrowing (AD-33's class, bounded by the
±0.1 margin).

Nine tests added. Clean-room complete solution: 11,311 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 16:15:57 +02:00
Erik
70f37dbd5c feat(core): Slice 5.2 — VendorState + retail's exact vendor price math
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
VendorState sits beside ExternalContainerState (contract decision 1)
with the same shape: private setters, Changed event, Reset with
AggregateException fanout; domain-shaped like ContainerContentEntry
since Core cannot reference Core.Net. No Runtime wiring, no UI — 5.3's
job.

VendorPricing ports ShopSystem::BuyPrice/SellPrice (0x006B6120/
0x006B6180) faithfully: retail's literal three-way branch survives,
including the unreachable-with-real-data negative -1 sentinel that
ACE's Math.Max(1, ...) collapse erases — equivalence for legitimate
inputs is hand-proven and documented rather than silently assumed.
Seven conformance tests with hand-derived golden values (float32
semantics verified independently), covering rate=1.0, fractional
rates, value=0, the rounding-sensitive halfway case, stack
multipliers, the ItemType rate-override branch, and the sentinel.

Clean-room complete solution with 5.1+5.2 in place: 11,291 passed /
4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 15:05:47 +02:00
Erik
fa0c053ebf docs(physics): #347 closed WITHOUT a code change — retail's glide alternates exactly as ours does; AD-70 retired as a wrong inference
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The round-2 cdb capture is decisive: during a live retail glide,
edge_slide fired ~1.5 times per find_transitional_position — the
arm/move alternation's exact signature (3 entries on the arming tick,
0 on the moving tick) — with cliff_slide in lockstep, step_down at
2.5x, step_up 0, and every stack sample on our identical call path.
cliff_slide's bytes match our port and ACE's (compare constant at
0x794610 verified 0.0), and the user could not distinguish the two
clients side by side. The "retail redirects within the tick" premise
misread round-1's set_sliding_normal cadence (per-event, not
per-tick, so its 1:1 ratio with edge never discriminated anything).

The alternation-tolerant assertion in Issue345SteepSlopeGlideTests is
therefore the CORRECT retail-shape pin from both sides; its comment
now cites the capture instead of calling the shape a residual. The
#269 note is honest the other way: the hope that a within-tick port
would explain that feel residual is withdrawn with the premise.

The temporary Scratch347 diagnostic is deleted. Capture evidence:
345-glide-stacks.cdb.log (repo root, untracked, cited from the
contract's RESOLUTION section).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 14:05:53 +02:00
Erik
535f41bbdf docs(physics): #347 premise revision — retail may alternate too; ftp:edge ratio is the discriminator
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The cliff_slide arms are conformant in ACE, our port, and the bytes
(compare constant at 0x794610 verified 0.0), the round-1 slidn:edge
ratio (538:594) refutes a retail retry storm, and the user's
side-by-side speed observation fits alternation. Round-2 cdb script
now counts find_transitional_position; H-A (identical, retire AD-70)
vs H-B (within-tick yield) resolves on one ratio. The temporary
Scratch347 diagnostic test rides along until #347 closes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:42:14 +02:00
Erik
ab89ebdf92 fix(physics): #345 — a grounded mover glides along a too-steep face; validate_walkable's return is scoped as retail's bytes scope it
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Retail's OBJECTINFO::validate_walkable @0x0050d010 initializes its
return slot to OK (0x0050d025) and assigns ADJUSTED only inside the
below-plane guard, immediately after the push executes (0x0050d249).
The guard-fail path — grounded, OnWalkable, plane too steep — jumps
past the contact write, the push, and the assignment (0x0050d1b9 ->
0x0050d251): retail deliberately IGNORES the steep plane at primary
validation so the insert proceeds, the step-down phase fails on the
steep landing, and the edge family produces the per-tick lateral
glide. ACE flattened this into an unconditional return Adjusted
(ObjectInfo.cs:169) and we inherited it; our TransitionalInsert then
retried the byte-identical Adjusted forever — the user's
stop-instead-of-slide.

Evidence chain: the user's retail observation (the axiom), the live
cdb glide profile (edge_slide/cliff_slide 594 each in lockstep,
step_up 0), the D0 implementer's correct STOP (fixtures reproduced
the stuck fingerprint while faithfully executing the ACE-shaped
reading — refuting the reading, not the code), and the capstone
byte-decode both Opus reviewers re-derived independently, including
the stack-slot frame arithmetic and every ret site's eax.

The conformance fixture is the live topology: flat and steep terrain
triangles sharing ONE cell's diagonal (a cell-boundary face does NOT
reproduce the loop — the cell-scoped primary sample never validates a
neighbour's triangle — and is pinned as supplementary). Sabotage:
restoring the unconditional Adjusted reds the discriminator with the
exact stuck position (0.325 m lateral, 28/30 stuck ticks vs 2.602 m /
14/30 fixed; reviewer B's independent five-angle table is monotone
10-85 degrees). Stuck ticks are counted from positions so the
assertion survives the eventual probe strip.

In-game glide gate PASSED 2026-08-08: "Well it works, we are sliding.
I cant detect any speed change from retail."

Filed alongside: #347 + AD-70 (our glide alternates arm/move at half
retail's per-tick rate — retail redirects within the tick; next up by
user direction), AD-71 (the guard's mutable WalkableAllowance operand
vs retail's fixed is_valid_walkable global — now return-value-bearing),
and the reviewers' named residuals in the #345 closure entry
(placement-arm flip, other-cell coverage gap, EdgeSlide-less
projectiles, ACE's server-side shared misport predicting remote
drift-then-snap on steep terrain). The unported IsViewer arm of
validate_walkable is noted in the D0 doc.

Suite: clean-room complete solution 11,271 passed / 4 skipped / 0
failed; Core assembly re-run green after the review-driven test
hardening.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 13:32:51 +02:00
Erik
10efb5b1f9 fix(physics): AD-66 relands — the push-out uses retail's bare radius; plant-then-lift complete (#341 closed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Third attempt, landed on evidence where the first two correctly refused:
the ten-run stability gate passed 10/10 bit-identical (0x42667451, two
clean-room cycles among the runs), the recalibrated golden's every value
measured with derivations rather than guessed, and the historical
measurement flip stands recorded as unexplained-but-unreproducible
after 37 hunt runs plus these 10 found no divergence anywhere.

The mechanism, completing the S4b byte-pin: validate_walkable plants
the sphere at perpendicular r*N.z (byte-faithful, untouched); this push
fires once per settle and lifts to tangent equilibrium dist=r, where
the trigger goes quiet — retail's slope hover, arriving via the push
exactly as the original substitution's own comment predicted retail
had. Sabotage: restoring radius*N.z reddens the discriminating
exact-value test verbatim. AD-65 conformance, the uphill no-flap
guard, and the #331 absorb pin all green untouched.

AD-66 retired (the campaign's last withheld row); AD-69's seam-frame
correction deliberately unbundled, stays active as its own follow-up.
Clean-room suite 11,267 / 4 / 0 — the suite's two AD-66 skips are gone.

User's "port the retail pair" decision is now fully executed; the
hover-look slope gate is the remaining acceptance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 12:01:15 +02:00
Erik
e761761aa3 probe(physics): ACDREAM_DUMP_TRANSIT_FAIL — self-selecting transition-phase trace for #345
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Fires only on the stuck-tick predicate (>=1mm XY requested, <=0.1mm
achieved), buffering per-tick phase outcomes cheaply and flushing only
on a stuck tick: per-insert-attempt phase/state/normal/source, step-up
enter/exit verdicts, every ValidateWalkable branch with dist/waterDepth
and both SetCollisionNormal guards evaluated, and the tick's final
AdjustOffset pair. Zero cost when off (flag before any allocation — the
I1 zero-alloc gate stays green), mover id on every line, [ThreadStatic]
buffer per the referee-safety rule. Two tests: fires on a synthetic
wall-stuck tick, silent on ordinary movement.

Diagnostics only; no behavioral change. Suite 11,271 / 6 / 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 10:48:46 +02:00
Erik
c5443b3df9 test(physics): S6 — the camera provably reaches both PerfectClip TOI tails; contained, not dormant
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
AP-83/AP-91 claimed no current mover sets PerfectClip. The containment
proof found the opposite and the contract's honest-fallback fired: the
camera probe (the sole production setter) reaches BOTH ACE-derived
tails live — the viewer exemption is creature-only, the shadow-list
walk is unconditional, and static scenery with authored primitives is
a real non-creature population. Every reach is now recorded
(camera-live silently; any non-viewer mover loudly, one-shot), so a
future flag change cannot exercise unreviewed ACE-derived math
silently. Four tests drive the camera's exact call shape both ways;
the sabotage was intelligently adapted — there was no existing cut to
disable, so it flips the one axis the proof depends on (IsCreature)
and asserts reachability inverts. Both register rows rewritten
CONTAINED-not-dormant with severity narrowed to camera-feel (the probe
never commits a PhysicsBody).

Landing note: diagnostics-only diff (two guard calls + counters +
corrected stale comments), verified directly by the session lead
rather than a review cycle — the review budget went where behaviour
changed tonight.

Campaign S CLOSES with this landing: S1A/S1B/S2/S4/S5/S6 done, S3
cancelled, three user-passed gates, one honestly-open item — AD-66's
reland, twice self-refused by its own stability gate, blocked on the
#341 codegen-shape measurement instability whose ABA evidence and
first discriminating experiment are filed.

Clean-room suite: 11,257 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 09:21:40 +02:00
Erik
9671af0273 fix(physics): S2 — static publication emits authored Spheres as Spheres (AP-155 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Both static sites (LandblockPhysicsPublisher, the headless-only
LandblockPhysicsContentBuilder) emitted an authored Setup Sphere as a
base-anchored Cylinder of radius r and height 2r. The live path emits a
Sphere for the same data, so the same object collided differently by
arrival route, and the narrow phase met a flat cap where retail meets a
curved surface. Both sites now mirror ShadowShapeBuilder.FromSetup's
Sphere block exactly.

Combined Opus review: PASS. Its numeric verification of the dispatch
test's geometry (head-sphere clearance 0.201 m for the true sphere; the
cylinder counterfactual inside by 0.10 m XY with the Z band overlapping)
is what makes the discrimination claim more than a sabotage anecdote,
and its F8 finding is applied: the test now carries a POSITIVE control —
aiming straight through the boulder's centre must block — so a
membership/seed regression can no longer masquerade as a curve-hit
pass. F4 applied: CylHeight is asserted, not inferred (the C4 lesson).
F3 applied: the deleted Quaternion.Inverse base composition is recorded
as internally coherent for the old cylinder's world-Z axis — the defect
was the shape TYPE, not that rotation math.

The review also verified the deleted-cylinder blast radius: the F2
overlay's drawn span is IDENTICAL for both shapes (old [c-r, c+r], new
[c-r, c+r]); the flood sphere's centre rises by exactly r, which cannot
change outdoor membership (XY rectangle) and lands the indoor half on
Session B's dungeon gate alongside S1B; and the sphere-branch flood is
now pinned uncapped by a genuine eleventh-shape A/B test.
PublishStaticCollision — the headless static path — gains its first
test ever.

AP-155 is NARROWED, not deleted (review F13): the has-BSP source split
(entity.MeshRefs vs setup.Parts + AnimPartChanged) survives and keeps
the row active. The shared-primitive-emitter refactor that would make
route independence a compile-time property is the filed follow-up
(review F20).

Population: 3,506 of 5,935 installed Setups, structurally equal to
AP-157's third-branch count (byte-identical classifier — three
independent routes agree: 3,605 - 99 = 3,506).

Clean-room suite at implementation: 11,253 passed / 6 skipped / 0
failed on landed S1B. Post-review-hardening: Publisher tests 25/25,
Content tests 2/2, both green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 08:07:02 +02:00
Erik
b3e43d22c9 fix(physics): S1B — indoor cell membership admits on the part BOX, as retail does (#335, AP-159 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.

Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.

The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.

Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.

Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.

Clean-room suite: 11,248 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 07:46:57 +02:00
Erik
d73125d3b0 fix(physics): S4/AD-65 — the away-from-plane response snaps to the surface, as retail does
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Campaign S slice S4, the half that landed. Retail's CTransition::
adjust_offset @0x0050a370 branches on dot(offset, contactPlane.N) at
0x0050a4fa: moving INTO the plane subtracts the normal component
(0x0050a529), moving AWAY calls Plane::snap_to_plane @0x00509c50 —
which preserves X and Y and re-solves ONLY Z so the offset lies in the
plane (the d terms cancel algebraically), no-op under the
0.000199999995f |N.z| epsilon. acdream ran the orthogonal projection in
BOTH directions, shrinking downhill XY travel by cos^2(theta): 25% at
30 degrees, 50% at 45 — AD-65's recorded shortfall, now retired.

The combined Opus review independently re-derived the algebra, the
branch polarity, the epsilon's bit-identity (17b75139), and the
sabotage magnitude (the re-instated projection yields X = 0.75 =
cos^2 30 exactly), and verified the delta is 4 non-comment lines with
the into-plane arm, the crease arm, and both no-plane arms untouched.
Its blast-radius sweep found the away arm exercised but NOT
discriminated by any pre-existing test — every one asserts lower
bounds the snap over-satisfies — so the two new exact-value tests are
the only discriminating coverage, recorded in the test's class doc,
and the felt 33-100% downhill speed-up is the morning gate's one row.

AD-66 (the push-out's bare radius) is WITHHELD: byte-confirmed twice,
implemented, then pulled after the same clean-room binaries measured
contradictory absorbed-tick outcomes flipping with nothing but test
assert shape — issue #341 carries the observation matrix and the
apparatus plan; its two exact-value tests are [Skip]-ed; the retained
substitution's rationale is restored at the site per review F1, with
the review's remaining findings (F2/F3/F4/F5/F6) applied and F8 filed
as #342. AD-69 filed: the same block omits retail's get_block_offset
seam-frame correction, deferred to the AD-66 relanding for
attributability. #340 filed: a fifth load-sensitive flake.

Review verdict: PASS. AD-65 is provably unable to reach the #341
anomaly's code path (the absorb scenario takes the crease arm).
Clean-room suite: 11,239 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 02:45:03 +02:00
Erik
52aea775b9 test(physics): AP-157 measured — CylHeight half retired, sorting-sphere half proven collision-unreachable; AD-55 byte-decoded
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Campaign S S1A, both outcomes the measure-first rule exists for.

AP-157's CylHeight half is RETIRED as a non-divergence: retail's own
cylsphere overload (CObjCell::find_cell_list @0x0052b9f0) copies
localtoglobal(low_pt) + radius per cylsphere, capped at 10, and never
reads height — retail collapses a cylsphere to a base-point sphere
exactly as acdream does.

The sorting-sphere half measured REAL against retail's registration set
— 1,812 of 3,343 evaluated Setups (54%) fail containment at 1 mm, worst
shortfall 18.135 m — and then PROVEN collision-unreachable: for this
branch the flood spheres and the collision-test geometry are the same
per-part Sphere list, so every omitted cell is one the entity's test
geometry cannot reach, and retail's wider sorting-sphere registrations
are narrow-phase rejects on retail too. Fix deferred to the next
bake-schema revision rather than performing Slice I3 surgery for zero
behavioural delta. The measurement test stays in the tree as the
permanent record (population cross-checked against the dispatch test's
independently-committed constants: 3,506 = 3,605 - 99).

AD-55 is byte-decoded and RESOLVED against our constant: the binary
loads qword [0x007c6b28] = pi/18 exactly and executes FCOS — retail's
Sledding flatness threshold is cos(10 deg) = 0.984808. Our 0.99999536f
is cos(0.17453 DEGREES): the radian literal misread as degrees, which
makes the object-friction arm unreachable on real terrain (nothing is
flatter than 0.175 deg). Evidence note carries the full instruction
listing and the polarity of the test ah,0x41 / jp idiom; the one-line
fix + conformance test is S5, queued behind the running implementation
slice for build-slot reasons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:57:36 +02:00
Erik
332045c7ad fix(physics): split set_contact_plane from init_contact_plane (#32 local edge-slide)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Measured live at Rithwic 2026-08-06 with ACDREAM_DUMP_EDGE_SLIDE=1.
Six branch2/steep-cliffslide events, every one reporting
curN=(-0.954,0.000,0.301) lastN=(-0.954,0.000,0.301) angle=0.0000
apply=False, outcome degenerate-cross/last-known. That is decision-table
row 1 of the research doc, verbatim.

CTransition::cliff_slide @0x0050a6d0 takes its slide direction from
cross(steep contact normal, last_known_contact_plane.N) — it needs the
surface the mover was STANDING ON as the second vector. acdream's
CollisionInfo.SetContactPlane latched the last-known group on every
call, so by the time cliff_slide ran, last-known had already been
overwritten with the steep face itself: the cross product of a vector
with itself, which is zero. Degenerate direction, no slide, walk off
the cliff.

Retail's COLLISIONINFO::set_contact_plane @0x00509d80 is 22 bytes and
writes the CONTACT group only; the last-known group has four writers,
none of them that function. So the four writes are DELETED and a new
InitContactPlane mirrors CTransition::init_contact_plane @0x0050e850,
writing both — the start-of-transition seed, where there is no earlier
surface to remember. Only check_contact's SUCCESS branch calls it. The
other eleven call sites keep the narrowed setter. This is a port, not a
suppression: no guard, no grace period, no flag.

The user's own A/B was the discriminator: Neftet's block plateaus hold
(188 branch3/precipice-slide events, all before the teleport) while
Rithwic's terrain cliff fails (6 branch2 events, all after). I had
predicted the opposite — that terrain would be the flat-normal case —
and position plus timeline corrected me, not reasoning.

NEW DISCRIMINATING TEST, because the suite had none. It was green both
before and after the production change, so nothing in it defended this
behaviour. Issue32LastKnownContactPlaneTests seeds a walkable plane,
asserts a steep mid-transition contact leaves it intact, and asserts the
resulting cross product is non-degenerate. Sabotage-verified: restore
the four writes and both discriminating rows fail while the
InitContactPlane control keeps passing — the pair separates 'the latch
is gone' from 'nothing writes last-known at all'.

Two existing tests corrected rather than deleted.
PhysicsSetPositionTests.FailedCheck_MapsCollisionHandlerResultToRetailError
passed BECAUSE of the latch (the file the research named); its hook now
populates both groups explicitly, since it asserts report plumbing, not
setter semantics. RetailEdgeResponseOrderingTests.TransitionalInsert_
DegenerateCliffSlideOk_ContinuesOuterRetry was predicted to fail and did
not — it now passes for a DIFFERENT reason (last-known absent rather
than clobbered, which retail also answers with OK_TS). Its comment
described the deleted behaviour and is corrected to say so, and to say
it does not discriminate this fix.

Also repairs the #338 probe. Its first placement in
PlayerMovementController printed nothing across 11,523 live log lines —
the wrong one of two resolve call sites — so it moves to
PhysicsEngine.ResolveWithTransition where every caller passes through,
filtered to the player. The dead site is removed rather than left in
place; a probe that never fires is worse than none. The flag test now
precedes the interpolated string: building it eagerly cost 128 B per
resolve with the probe OFF, which Slice I1's zero-allocation gate caught.

Suite 11,234 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:15:24 +02:00
Erik
ea83b043df fix(physics): delete the query-site broadphase reach filter (#333, closing #337)
Transition.FindObjCollisionsInCell discarded a shadow candidate when
  |currPos - obj.Position| > sphereRadius + obj.Radius + movement.Length() + 2f

obj.Position is the part ORIGIN; obj.Radius is the physics-BSP ROOT
BOUNDING SPHERE's radius, measured about a centre AP-156 established is
frequently metres from that origin (376 of 973 installed physics-BSP
parts sit further from their part origin than half their own radius,
worst 20.762 m). Geometry deep inside the real bounding sphere was
therefore thrown away before BSPQuery ever ran: solid near the origin,
permeable in a bounded shell beyond it. For the Neftet rock 0xC8766009 /
gfx=0x01004751 the two points are 23.556 m apart, which is #337 — wedged
on the plateau, jumps sinking into the mesh, corpses falling through. A
live capture recorded 7,225 rejections on that one owner, every single
one with wouldAcceptAtCenter=True.

Deleted rather than re-centred. Retail has no distance pre-filter,
disassembled from the PDB-paired v11.4186 binary (CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from Binary Ninja:

  CObjCell::find_obj_collisions @0x0052b750 walks shadow_object_list and
  calls CPhysicsObj::FindObjCollisions (0x0052b78b) UNCONDITIONALLY; its
  only early-out is insert_type == INITIAL_PLACEMENT_INSERT (0x0052b759).
  CPhysicsObj::FindObjCollisions @0x0050f050 contains no float compare at
  all. CPartArray::FindObjCollisions @0x00518180 is a bare do/while over
  parts, and CPhysicsPart::find_obj_collisions @0x0050d8d0 is two null
  checks plus a call. Retail's only spatial rejection is the BSP node
  bounding-sphere test inside the walk — correctly centred, which is
  exactly what the deleted filter was not.

Re-centring it (carry BoundsCenter on ShadowEntry) would have preserved
an invention retail does not have, including a +2f slack and a
movement.Length() term with no retail counterpart, and left a second
reach budget to be tuned forever. Retail's own cross-cell slack constant
is F_EPSILON = 0.0002 m, not 2 m.

The method's comment claimed the filter was "the analog of the part
sorting-sphere early-outs inside retail's CPhysicsObj::FindObjCollisions
— response-neutral, pure perf". Both halves were false and cost #333 and
#337; it is replaced by the disassembly above.

Gate: Issue333BroadphaseReachFilterTests drives the production path
end-to-end (ResolveWithTransition -> FindObjCollisionsInCell ->
CollisionTraversal) on a DAT-free fixture so it runs everywhere, as a
discriminating pair. Sabotage-verified: restore the pre-check and
OffCentreBspFloorStopsAFallingMover reaches z=37.800 — exactly the
unobstructed fall, blockedAtLeastOnce=False — while
CentredBspFloorStopsAFallingMover keeps passing. Without the control a
fixture unable to fall would pass the first test for the wrong reason.

Issue337's skipped TheBroadphaseAdmitsTheSurfaceTheMoverIsStandingOn
asserted the now-deleted predicate and could never have gone green; it
is rewritten as installed-DAT evidence pinning BOTH halves of the
diagnosis and is no longer skipped.

Perf measured, not assumed (Release, synthetic all-BSP cell, per
ResolveWithTransition): at 38 candidates — the live maximum — 10.61 us ->
16.68 us (1.57x); at a deliberately unreachable 200, 17.34 -> 39.48 us
(2.28x); ~0.16 us per additional candidate tested. Over 19,701 live
[reach-q] samples the in-cell count is p50 = 9, p99 = 32, max 38.

The ACDREAM_PROBE_REACH rejectedReach column is kept and is now
structurally 0, so a post-fix capture stays comparable with the pre-fix
one; dropping it would make the two incomparable.

AP-158 retired (110 active AP rows). #333 and #337 closed pending the
user's live acceptance at Neftet.

Solution suite 11,231 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:16:53 +02:00
Erik
5a1eeace73 docs(physics): #337 diagnosed — it is #333's query-site broadphase, not the mesh
Report-only. No production code changed.

The collision mesh is present, correctly shaped, correctly placed in the
world, and the BSP traversal reaches every part of it. The mover never gets
as far as the query. FindObjCollisionsInCell's per-object broadphase measures
the mover's distance to the shadow entry's Position — the part ORIGIN — and
compares it against obj.Radius, which is the physics-BSP ROOT BOUNDING
SPHERE's radius. For 0xC8766009 those two points are 23.556 m apart, so a
mover standing on its plateau is inside the real bounding sphere by ~20 m of
margin and is still rejected. Same defect AP-156 fixed in the flood and #334
fixed in the registration extent walk, left in place at the query site.

Measured, not inferred. An offline replay against the installed DAT
reconstructs all eleven landblock-0x8766 owners and matches the live [geom]
placement exactly (0xC8766002 at (84.699,100.082,13.000) yaw -45.00 vs the
log's objPos + bspCentreOffset). At the position the client fell through, the
production swept query returns a hit on poly 31 at 0.037-0.366 m while the
filter rejects the candidate: distToOrigin=60.434 > maxReach=59.697, distance
to the bounding-sphere CENTRE 37.083 m against a 56.909 m radius. The live
capture recorded that rejection 7,225 times with the probe's own
wouldAcceptAtCenter=True on every one.

Bounded because the dead zone is the shell between maxReach and the true
sphere, up to ~23.5 m thick on the far side. movement.Length() is a budget
term: a 0.25 m walking step gives shortfall +0.60, a 0.72 m step +0.14, and
~0.86 m passes — which is exactly why jumping over the spot works, walking
into it does not, and a corpse falls through.

Three hypotheses refuted by measurement, not by argument:

- "the rock's own mesh never collides" — true of 0xC8766002 and it is
  INNOCENT; its geometry is 22.8 m from the wedge and it has zero brute-force
  hits over a 12,493-point lattice covering the plateau. It is a candidate
  only because it is a 130x147 m owner. The rock actually walked on is
  0xC8766009.
- wrong world transform — the offline placement reproduces the runtime
  exactly, and a uniform displacement cannot produce a bounded pocket.
- BSP traversal hole — a referee ran the production walk against brute force
  at 7,770 on-surface probes across all eleven owners plus 137,423 lattice
  points. Mismatch 0 everywhere. A 0.5 m hole map also shows continuous
  upward-facing coverage across the whole wedge region.

[geom]'s verdict=coincident was never able to decide this: LogGeometry
compares the physics box against the visual box in the object's OWN LOCAL
FRAME, so it proves shape agreement and says nothing about world placement.
Recorded in the doc so the next reader does not re-trust it.

Retail has no per-object distance filter on the BSP branch. Verified
instruction-by-instruction with cdb against the PDB-paired v11.4186 binary:
CPartArray::FindObjCollisions @0x00518180 is 14 instructions of bare
do/while over parts[i]; CPhysicsPart::find_obj_collisions @0x0050d8d0 is 17
instructions of two null checks plus the call to CGfxObj::find_obj_collisions
@0x00534700. No compare, no float math in either. The in-tree comment calling
the filter a retail analog and response-neutral is wrong on both counts.

The support=object cpNz=1.0000 readings inside the rock are not the rock:
ValidateTransition:6076 is retail's stationary-fall failsafe manufacturing a
flat plane through the sphere bottom, and :5997 is the LastKnownContactPlane
restore holding a stale plane. Both are retail-correct responses to a stuck
body, and they are why the client believes it is standing while ACE rejects
the position.

Preferred fix is to delete the pre-check for BSP entries and correct the
comment; fallback is to measure to the bounding-sphere centre, which also
needs BoundsCenter carried on ShadowEntry. Neither is landed.

The reproducer was confirmed to FAIL when un-skipped, with the numbers above
— this campaign has caught eleven green tests covering nothing, so a fixture
that cannot distinguish the bug is worse than none.

Gates: bin/obj deleted, Release build 0 errors, Core suite 4,287 passed /
2 skipped / 0 failed (baseline 4,286/1 plus three new dumps and the one
deliberately skipped reproducer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 21:16:31 +02:00
Erik
49a7e90652 probe(physics): ACDREAM_PROBE_SUPPORT + ACDREAM_WIRE_MESH — separate #337's three candidates
The user is wedged at the top of Neftet rock plateaus, jumps sink into the
mesh, and a corpse falls straight through. ACDREAM_PROBE_REACH already ruled
out its own domain: blocked=0, every candidate tested-ok. Three candidates
remain — terrain support, a collision mesh not where its visual is, or the
transition wedging on an unobstructed path.

ACDREAM_PROBE_RESOLVE alone cannot separate them. It prints a three-value
contact-plane token, no plane normal, no plane height, no terrain sample and
no plane provenance, so all three produce the same line. Two additions:

[support] — one line per resolve for EVERY body, not just the player. A corpse
is a plain physics body with no player-specific logic, so its fall-through is
the cheapest available control on "movement code vs geometry data", and it is
invisible to any player-filtered probe. The line samples the outdoor terrain
INDEPENDENTLY at the body's own out-XY and prints the contact plane's own
height at that same XY. Two heights at one point make support=terrain /
object / none a measurement rather than an inference, and cpSrc= names the
site that asserted the plane so provenance and classification cross-check.

[geom] — once per GfxObj that comes near a mover: the object's physics-BSP
vertex cloud against its visual mesh AABB in the same local frame, through the
same prepared accessors the resolver queries. verdict=coincident REFUTES the
working hypothesis for that object outright; no-physics-bsp / empty-physics-bsp
/ displaced / extent-mismatch each name a specific data defect. Built to
refute, not to confirm — two diagnoses on this defect's lineage have already
been refuted by measurement.

ACDREAM_WIRE_MESH upgrades the existing F2 overlay, which drew a broadphase
proxy cylinder for BSP objects and so could not answer the question at all, to
the real physics-BSP polygon edges (cyan) beside the visual mesh box (magenta)
and the terrain surface (yellow). Own class per code-structure rule 1.

The provenance latch lives on PhysicsDiagnostics, not on CollisionInfo. Two
fields there first — the obvious home — broke the flat/graph differential
referee and the scratch-reset poison test, both of which compare CollisionInfo
member-for-member. Teaching either to skip a member is a one-line green fix
that puts a permanent hole in a referee whose whole job is comparing
everything. Captured as feedback_probe_state_off_compared_types.

Seven tests cover the support classifier's boundaries: a wrong classifier does
not fail to answer, it answers confidently wrong.

Gates: Release build 0 errors; complete suite 11,225 passed / 4 skipped / 0
failed from a cleaned tree — baseline 11,218/4/0 plus exactly the seven new
tests, skips unchanged.

Issue #337 filed with the symptom set, what is ruled out, and a table of what
each possible output means. All of this is TEMPORARY and recorded for
stripping with the physics-probe family.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:49:59 +02:00
Erik
13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00