Commit graph

804 commits

Author SHA1 Message Date
Erik
614a1e055f feat(chat): Campaign CH slice CH3 — side-channel membership, wire, and echo parity
Ports retail's SendTurbineChat (@0x0057db10) local pre-send membership gate
so Roleplay/Society/Olthoi stop silently swallowing outbound chat: a new
TurbineChatMembershipGate checks Turbine availability and the player's own
Hear*Chat option before sending, raising "Turbine chat is not available."
or the 0x0551 YouAreNotListeningTo_Channel refusal through the CH2 AddText
chokepoint instead. Wired into both the graphical (LiveSessionCommandRouter)
and headless (DirectGameRuntimeCommandAdapter) send paths so they can't
diverge. Retracts the 26-day-old false "ACE doesn't run a TurbineChat
server" claim from ISSUES.md, the roadmap, and project_chat_pipeline.md —
ACE's TurbineChat implementation is complete and on by default; the real
bug was treating Hear*Chat as a display filter instead of room membership.

Also: implements SetSingleCharacterOption (0x0005), the only wire message
that actually joins/leaves a Turbine room, and wires the five Settings Chat
toggles to it (publish on Save, changed bits only) plus seeds ChatSettings
from the server's own CharacterOptions2 on every PlayerDescription. Fixes
the legacy-channel double-print (Fellow/Vassals/Patron/Monarch/CoVassals
skip the local echo now that ChatChannelInfo.IsSelfEchoChannel is finally
consulted). Routes /a to Turbine unconditionally (retail's @a never falls
back to the legacy bitflag) and adds /ab for the legacy AllegianceBroadcast
verb retail actually has. Surfaces a nonzero TurbineChat ack HResult instead
of discarding it silently. Deletes the malformed, callerless SetCharacterOptions
(0x01A1) and AddChannel/RemoveChannel (0x0145/0x0146) builders.

Files every AC-specific algorithm change cites the named retail decomp
(SendTurbineChat 0x0057db10, StartupTurbineChatSystem 0x0057EFB0,
GameActionSetSingleCharacterOption) plus ACE/holtburger cross-checks.
Register rows AP-181 (no client-side spam throttle) and UN-9 (an
incidentally-discovered CharacterOptions1.Default literal mismatch, not
investigated further) filed per the divergence-register rule.

11,957 passed / 4 skipped / 0 failed (full Release suite, up from the
11,916/4/0 baseline).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 19:39:44 +02:00
Erik
233c30d13f fix(chat): CH2 re-review nits — resize centering, top-aligned flow, sweep wording
Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES,
following the REJECT->rework at e0e78883):

1. SpewBoxController's centered Left was captured once via
   AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's
   Left/Right-both-false branch pins a fixed margin). Anchors is now
   AnchorEdges.None and Tick recomputes Left every frame against the
   current root width.

2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow
   (VerticalJustify honored only via ConfigureDatState, which this
   synthesized element never calls). Added UiText.HonorVerticalJustification
   so a non-DAT controller can opt the scrollable path into
   VerticalJustify without a full LayoutDesc binding; SpewBoxController
   sets VerticalJustify=Top so lines flow from the top of the 450x72 box,
   matching newest-at-top insert semantics. Noted as invented-pending-
   measurement in AP-178's row (no new row).

3. Documented the deliberate inversion of UiText.LinesProvider's
   oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds
   newest-first, which is correct specifically because the box is now
   top-aligned) and added a test pinning the rendered order (newer message
   is the topmost line), driving root.Tick.

4. Fixed the stale "retail's code default, 1" comment in
   SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's
   AUTHORED value, 4.

5. Added the matching unmapped-id diagnostics line to
   LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern
   GameEventWiring's WeenieError/WeenieErrorWithString handlers already
   use.

6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in
   SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's
   top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's
   own (which reports a count of ZERO for this type), so querying those
   ids against dats.Portal.TryGet established nothing about Portal either
   way. Corrected the same overclaim echoed in SpewBoxState's
   MaxConcurrentItems doc comment and in AP-178's register text (both the
   table row and the section-header history line). What's actually
   established: dats.Local hosts the SpewBox layout at 0x21000011; whether
   Portal also carries a copy remains unestablished.

7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox
   path for id 0x0561 (the 50-friends-cap refusal) in
   LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's
   ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory
   test in this tree is a source-text conformance grep, not an
   instantiation.

Ledger: CH2 ledger row's review column now reads REJECT -> reworked
e0e78883 -> re-review APPROVE-WITH-FIXES -> nits (this commit); Status
header flips CH2 to code-complete/closed pending the user gate, CH3 next.

Build green; touched-project tests green (19/19 new/changed,
4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed /
4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this
commit adds).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 18:48:13 +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
dd2cb92b99 chore(audio): Campaign A slice A6 — delete what retail does not have
Retail EoR has no music system: the linked winmm MIDI player has zero
callers, 'music' appears zero times in the 65 MB decomp, SoundType has no
music member, InitPrefs registers no music key, and the install ships no
music files. So PlayMusic/StopMusic/MusicVolume and the AudioSettings
Music knob are deleted rather than left as an API modelling dead code —
the string-keyed signature was the tell, since every other entry point is
DID-keyed. Old settings.json files carrying a 'music' key still load; the
reader ignores unknown keys and the next save drops it.

The Ambient slider is now surfaced, because slice A5 gave it something to
drive, and its default returns to retail's 1.0 from an invented 0.8 —
InitPrefs defaults every sound preference to unity. The panel rule is
unchanged: no slider that does nothing.

r05-audio-sound.md gets a SUPERSEDED banner naming its five wrong
sections (falloff, pan, voice pool, selection, music, ambient) so a
future reader reaches the lane notes instead of the Ghidra-era reads that
this campaign spent its first two slices undoing.

TS-9 re-scoped from 'any MP3 cue' to the measured blast radius: exactly 1
MP3 among 786 shipped waves, a ~2 s mono clip. Its original framing
assumed a music system that does not exist. The ADPCM count remains
unmeasured and is named as the open question.

Deferred deliberately: #321's sound-cache decode-dedup race. It is a
pre-existing concurrency flake rather than audio-parity behaviour, and
shipping a speculative fix to a race I have not reproduced is exactly the
shortcut this project's no-workarounds rule exists to prevent.

Campaign A is code-complete; the plan carries the closeout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:58:50 +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
02b735ba4a fix(vendor): evidence-based pass — max-first stack ceiling; the local player resolves never-animated MoveTo targets
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 chains pinned by the live [vendor-diag] run (vendor-diag.log)
after three code-reading rounds each failed:

The split bar: ACE serializes descStackSize=1 for EVERY browse row
(live wire, log 343-348) — the R1-era "ACE never populates desc"
claim is retracted with the line quoted. Retail's vendor sites read
pwd._maxStackSize directly (four sites, incl. UpdateItemsList
@0x004c1ea0 stamping min(remaining, _maxStackSize));
ResolveAuthoredStackSize flips to max-first for its vendor-only
consumers. Taper ceiling 1000, scarab 100, seed 1 for exempt.
Pricing still reads the desc (per-1 values on ACE).

Walk-to-use: the local player's getObjectA seam was bound to
TryGetPhysicsHost, which resolves only INSTALLED physics hosts — a
never-animated vendor has none, so TargetManager.SetTarget got null,
the MoveToObject armed with zero nodes, and UseTime never dispatched.
The log's natural=False completions were the user's own movement keys
(retail-correct input-edge cancels); attempt 4 worked because the
greeting animation had installed a host. RuntimePhysicsState gains
the retail CObjectMaint::GetObjectA seam (bound canonical resolver
with installed-host fallback); the graphical host binds the SAME
lazy-minimal-host resolver every remote already uses — whose own doc
comment names this exact never-animated hazard. The reservation
release was already correct (2b premise refuted with evidence); the
production-wiring invariants are now pinned by four new tests
including the pre-fix pathology as a permanent sabotage control.

AP-169 rewritten a second time, honestly. The [vendor-diag] probe
family (ACDREAM_DUMP_VENDOR) lands env-gated for future live triage.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 17:17:04 +02:00
Erik
d003449bb4 fix(vendor): re-gate residuals — MaxStackSize is the stack operand, wire-authored use radius, purse summaries
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
R1 the split bar's operand is the item's authored MaxStackSize —
three retail sites read pwd._maxStackSize directly (InqListSlotCount
pc:200052, buy-button cases pc:203996/204086) where ACE never fills
the desc stack and standard stock is unlimited. Threaded StackSizeMax
end to end with one shared resolver; the two literal _maxStackSize
sites are now byte-exact; AP-165 retired, AP-169 corrected.
R2 walk-to-vendor never opened because GetUseRadius used an UNCITED
3m Creature heuristic as the local stop distance while ACE's poll
demands the authored radius (default 0.6 m) — the walk stopped and
the Use fired far outside acceptance. Now reads the wire-authored
spawn UseRadius with ACE's exact fallback; heuristic constants
deleted. A first sabotage attempt was non-discriminating
(coincidental 0.6) and was corrected — the discriminating version is
what landed.
R3 the Buying/Selling purse summaries ("Buying %d %s worth %hsp" /
"You have %hsp") recovered from the binary data segment where BN
mis-attributes the Buy-side literal; wired to staging and money
changes on the four authored text elements; AP-166 narrowed to the
pending-sell highlight.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 15:23:10 +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
97cf873870 feat(vendor): Slice 6 buy arc — shop items are real objects, vendor selection is THE selection, and Buy works (0x005F)
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
Three ordered pieces in one landing (the shared controller/composition
files carry all three; the internal order was 6.1 -> 6.2 -> 6.3):

6.1 VendorShopItemMaterializer diff-merges the shop list into the live
ClientObjectTable on VendorState transitions (so client-local close and
session teardown retire the entries too) and never claims a guid it did
not add — ACE's UniqueItemsForSale can re-list a guid a player once
held (AP-163 files the collision-skip; no retail counterpart traced).
Right-click examine on shop items now routes through the ordinary
appraisal path — the 5.4 F7c blocker dissolves with the table entries.

6.2 SelectionChangeSource.Vendor: row clicks, auto-select, and examine
all flow through the canonical SelectionState; the status bar and the
existing byte-faithful StackSplitQuantityState slider light up
unmodified. VendorSplitPolicy is the single 0xDC41CB0 mask owner; the
slider VALUE seeds to 1 for exempt items while maxSplitSize keeps the
stack (the splitSize/maxSplitSize distinction, research §B.3).
Selection clears at retail's actual site — VendorItemsUI::RemoveFromShop
(pc:202848), not a CloseVendor-level clear that does not exist.

6.3 BuildBuy (0x005F): vendorGuid, count, (i32 amount, u32 guid) pairs,
and the trailing alternateCurrencyId the REAL client sends
(CM_Vendor::Event_Buy pc:689288) though ACE's reader ignores it.
TryBuy rides the EXISTING J5.2 one-request-at-a-time reservation and
completes on UseDone; the Buy button disables while a request is in
flight. The reconciliation round-trip (money property update, inventory
CreateObject, ApproachVendor refresh -> panel rebuild) is proven by a
synthetic-inbound test against existing machinery — no new owner.

Register: AP-161 narrowed (selection + examine residuals close;
staging/Sell remain; double-click-to-buy confirmed ABSENT from retail
with negative evidence cited — we match retail). AP-162 files the
conscious no-client-side-affordability-precheck deferral.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 20:28:26 +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
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
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
8c97084289 docs: close #338 — headline refuted by full-capture statistics; AD-68 files the real residual
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 three-site probe answered it in one run: prepare and publish carry
the authored 0.600/1.500 to the publication candidate, and resolve
receives exactly those values for the entire session after one early
0.400 reading. Re-reading the ORIGINAL 337-support.log with statistics
instead of an eyeball: authored pair 111,248 lines, 0.400 pair 358. The
filing was built on an early line of a 255k-line capture; the alleged
mechanism (values never wired to the mover) does not exist.

The 358 are AD-68, now registered: GetSetupMoverShape's placeholder
(empty spheres -> legacy capsule, 0.4/0.4 steps) during an entity's
async Setup-residency window, plus the local player's own seconds-long
window between controller construction and publication-candidate
adoption. Retail loads synchronously and has no such window. Left as-is
deliberately: shrinking it is streaming work.

The filing still paid for itself: three false doc-comment claims
corrected in PlayerMovementController (retail '~0.4 m' twice, and an
ApplyStepHeights writer that never existed anywhere in the tree —
replaced with the real writer chain), retail's actual fallback pinned at
0.04 (CTransition::step_up @0x0050b655), and the resolve probe now
prints the mover id, because the early 0.400 was most plausibly a
REMOTE player — remotes also carry IsPlayer — and the guid rule
(feedback_probe_identity_attribution) exists precisely to stop that
misread.

No production behaviour changed; nothing for the morning gate. AD
section 50 -> 51. Suite 11,234 / 4 / 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:44:42 +02:00
Erik
9b9bb6515f docs: the #32 'fix failed' verdict is VOID — the tested binary never contained the fix
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
Two checkouts, one relative launch path. Edits and builds ran in the
main repo; every client launch ran from a PowerShell shell whose cwd was
still the session worktree, so 'dotnet run --project src\AcDream.App\...'
executed the worktree's 08-06 22:35 binary — #333 present, #32 fix,
InitContactPlane and every #338 probe absent. Byte-proof both ways: 0
occurrences of the fix strings in the worktree's Core.dll, both present
in the main repo's.

Everything the previous entry concluded is therefore void: the
byte-identical capture was the OLD code re-running (expected), the three
probe silences were one fact (the instrumented binary never ran), and
the 26,358-write attribution table is pre-fix baseline data of the old
binary only. #32's fix returns to UNTESTED, with no evidence against it.

The verification that was supposed to catch this confirmed the wrong
binary: the DLL byte-check ran against the OTHER checkout's bin. So the
self-report now prints typeof(PhysicsDiagnostics).Assembly.Location as
its second line — binary identity becomes a recorded fact inside every
capture instead of an inference from file timestamps afterwards. Memory
updated with the multi-checkout rule: absolute launch paths, verify each
shell's cwd before the first launch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 00:27:57 +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
45d7154712 probe(physics): ACDREAM_PROBE_STEP_HEIGHTS — three readings along #338's chain
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 live reading of 0.400 says the controller held its default at that
instant, not why. Two very different causes produce it: the
Setup-derived prepare/publish path never runs for the local player, or
it runs and a later writer clobbers the result. Fixing without knowing
which is a coin flip.

One reading at each hop — prepare (Setup value computed and scaled),
publish (assigned to the controller), resolve (what the resolver is
actually handed) — with a decision table on the flag mapping each
pattern to its cause, including the 0.000 case that would mean a null
Setup took the retail dummy path.

Edge-triggered per site, so the per-tick resolve site prints once per
distinct pair and cannot drown the two one-shot sites it exists to be
compared against. Prepare prints the raw authored pair beside the
scaled one, so a surprise separates wrong-Setup from wrong-scale
without a second run.

Lives in PhysicsDiagnostics per code-structure rule 5 rather than as
per-call-site env reads. Zero cost when off.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 22:50:42 +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
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
Erik
b61f5fd4fc probe(physics): ACDREAM_PROBE_REACH — discriminate #334's three candidates
#334 is "large static formations can be walked through on flat ground, and
jumping over them drops you inside" (Neftet, user-reported, pre-existing —
reproduced on 52175aa1, before AP-22/AP-152/AP-156/AD-10). The report as
originally filed pointed at one mechanism. The user's clarification that the
formations are permeable on the ground generally, not only at a boundary
between two of them, widens it to three, and a DAT sweep can only say which
objects COULD fail, never which one IS failing at the spot. So: measure in
game, at the failing spot, and let the log choose.

The three outcomes this probe must tell apart, at the player's own collision
query:

  (a) the object IS a candidate in the cell and the broadphase reach filter
      rejects it before its BSP is consulted — AP-158 / #333. That filter
      measures |currPos - obj.Position|, the part ORIGIN, against the BSP
      root sphere's RADIUS plus an acdream-invented 2 m slack. The sphere's
      CENTRE is frequently not the part origin (376 of 973 installed
      physics-BSP parts sit further from it than half their own radius;
      worst 20.762 m), so geometry well inside the sphere can be rejected.
  (b) the object is not in the cell's candidate set at all — a membership or
      registration failure. AP-156's territory, which did not fix this.
  (c) the object is a candidate, is not rejected, and still contributes
      nothing because no usable physics BSP resolves for it.

Two line types from Transition.FindObjCollisionsInCell:

  [reach-obj] one per candidate, carrying mover guid, target entity id,
      GfxObj id, cell, and its disposition — exempt-self, exempt-missile,
      rejected-reach, exempt-rule, exempt-ethereal-stepdown, no-shape,
      bsp-only-skip, tested-{ok,collided,adjusted,slid}. For BSP candidates
      it also carries the origin-measured distance the filter used, the
      centre-measured distance it should have used, the budget, the
      shortfall, and wouldAcceptAtCenter — the boolean that separates a
      false rejection from an honest one. Identity is on every line
      (feedback_probe_identity_attribution).
  [reach-q]   one per cell query, with the per-disposition tallies AND the
      raw entry count, emitted EVEN WHEN THE CELL YIELDS ZERO. That last
      part is the point: without it, an absence of rejection lines could
      not distinguish "nothing was rejected" from "nothing was there", and
      a criterion that cannot fail in the presence of the bug it exists to
      catch is the trap this campaign has already been caught by once.
      `blocked` is the control — it proves the probe can see a working
      collision as well as a missing one.

The BSP root sphere is resolved through the SAME production accessor
registration uses (GetFlatGfxObj(id).PhysicsBsp root node, per
LiveEntityCollisionBuilder and ShadowShapeBuilder), so the probe cannot
report geometry differing from what the registry actually emitted — AP-156's
lesson was exactly that: one resolver.

Volume: [reach-obj] de-duplicates per (mover, target, cell) and re-emits at
once whenever the disposition changes or the shortfall crosses a 0.5 m
bucket, otherwise at most once a second; [reach-q] de-duplicates per (mover,
cell) on the whole tally tuple, so any change in what the cell yielded emits
immediately, otherwise at most twice a second. Both emit eagerly on change —
which is exactly when the player walks into the formation — and go quiet
when nothing is happening. Nothing is aggregated away.

Filtered to the player mover, matching PhysicsResolveCapture, so NPC and
remote dead-reckoning resolves do not pollute the capture. The helper is
static and takes everything by parameter so no closure display class enters
the resolve path: Slice I1's 0 B/resolve budget holds with the probe
compiled in and switched off.

TEMPORARY. Strip with the rest of the physics-probe family once #334 is
scored; both the flag and the call site say so.

Clean bin/obj, Release build, full suite 11,208 passed / 4 skipped / 0
failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 17:29:44 +02:00
Erik
e6457cc849 fix(physics): close the AP-156 fix review — real containment oracle, type-level invariant, AP-158
Both review lenses PASSED; this is the cleanup, not a rescue. Evidence:
docs/research/2026-08-06-ap156-review-closure.md (the review itself is
committed alongside it as the received artifact).

R1 — the load-bearing containment test could not fail. Its truth and flood
values were two hand-copies of the same expression over the same part set,
so the shortfall was algebraically identically zero for any DAT input. The
oracle is now PHYSICS-POLYGON VERTICES — a different DAT field from the
bounding sphere the builder emits, so the two sides can genuinely disagree.
Sabotage-verified three ways after full cleans: dropping the bounds centre
in production reddens it (428 Setups, worst 35.869 m on 0x0200129A, matching
an independent out-of-repo sweep exactly); dropping only the scale on the
centre reddens it (326); and corrupting the TEST's own bounds oracle reddens
it (467) where under the shipped oracle that same corruption was invisible
by algebra. Renamed accordingly. A6's stale "cap control" comment corrected:
that loop is the test's own uncapped re-implementation and cannot observe a
cap regression — the cap is covered in Core.

R2 — the population was understated. 172 is AP-152's DISPATCH population;
AP-156's is 530 BSP-bearing Setups, of which 525 have a flood sphere move
and 428 fail vertex containment before the fix (412 at a 1 cm tolerance —
the review's figure; the gap is 16 Setups between 1.4 mm and 10 mm, real
geometry). 0 fail after, at any tolerance down to zero. Corrected in the
AP-156 row, the section-3 header, the C5c handoff and two test docstrings.
Dated review artifacts are left as written — "170 of 172" was correct for
what they measured, and rewriting evidence to match a later measurement
loses provenance.

A1 — BoundsCenter = default reopened at the type what the commit closed at
the seam. Dropping the default alone would NOT have closed the review's own
scenario (a copied Cylinder call site would write Vector3.Zero explicitly
and stay green), so ShadowShape's constructor is now private and BSP shapes
are built only through ShadowShape.Bsp(..., FlatCollisionSphere localBounds),
which takes radius and centre as ONE value and scales them together. There
is no expression a caller can write that carries one and drops the other.
22 construction sites converted; the same sabotage now reddens 5 Core tests
where the review's sabotage A reached 4, because both BSP producers share
one scaling path.

A2 — #333 is real and bigger than filed, and its retail question is
answered. I disassembled CObjCell::find_obj_collisions @0x0052b750 from the
PDB-paired binary myself (check_exe_pdb.py MATCH) rather than inheriting the
claim: its only early-out is sphere_path.insert_type == INITIAL_PLACEMENT_
INSERT, then it calls FindObjCollisions on every unparented non-self shadow
object UNCONDITIONALLY. Retail has NO distance pre-filter, so acdream's
"+ movement + 2f" reach filter is an invention with no register row — filed
as AP-158, carrying the disassembly, the F_EPSILON = 0.0002 m contrast, and
the measured blast radius (118 of 477 unique installed physics-BSP GfxObjs
exceed its ~2.5 m budget, 46 exceed 5 m). Active AP rows 109 -> 110.

Recorded prominently in three places a reader will hit: TALL PROPS MAY SHOW
NO VISIBLE CHANGE UNTIL #333 LANDS, and a null result at the connected gate
is EXPECTED, not evidence against AP-156.

LOW items. R3: the comment claiming the cited evidence justified the whole
cap line is corrected, but int.MaxValue on the sorting-sphere branch stays —
capping at 1 would take Spheres[0], and retail's one sphere is
CSetup::sorting_sphere, a different DAT field; capping keeps the wrong field
AND flips the substitution under-inclusive (#98/#168 direction). AP-157
already owns it. R4: acdream scales the flood sphere where retail's
find_transit_cells never reads gfxobj_scale — added as a second residual on
AP-156. R5: retail's slack constant carried into AP-158 and #333. A3: the
per-call delegate allocation is back to a cached field, still derived from
the single bounds resolver. A5: noted; b52967de's message cannot be amended.

Gates: all 44 bin/obj deleted before every verdict-deciding build, each test
run gated on a verified "Build succeeded" in the same invocation. Release
build 0 errors / 21 pre-existing warnings. Complete suite 11,208 passed /
4 skipped / 0 failed — reconciles exactly with the e2b2d04c baseline; one
test renamed, none added, removed or skipped. Nothing conflated with the
known load-sensitive flakes #302 / #308 / #321.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 16:44:48 +02:00
Erik
b52967def3 fix(physics): AP-156 — flood the BSP sphere where the geometry is, not at the part origin
The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md)
FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took
each physics-BSP part's ROOT BOUNDING SPHERE RADIUS
(FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and
centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root
sphere's own Origin.

Re-measured independently against the installed client_portal.dat, reproducing
the reviewer's numbers exactly: 376 of 973 physics-BSP parts have
|origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted
flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73
CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup
0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and
for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one.
Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100
candidate through FindTransitCellsSphere), so a tall prop or door slab was
absent from EnvCells it physically occupies and therefore never a broadphase
candidate there (TransitionTypes.cs:3763 iterates only entries already in the
cell). That is the #98 / #168 class AP-152 exists to remove.

Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH,
CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved
back through named-retail/symbols.json:

  CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as
  CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and
  acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp).

  BSPTREE::GetSphere @0x005397e0
    8b01        mov eax,[ecx]     ; BSPTREE::root_node
    83c004      add eax,4         ; past BSPNODE::vfptr -> CSphere sphere
  So retail's per-part flood sphere IS the BSP root bounding sphere,
  ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... },
  CSphere { Vector3 center; float radius; } -> radius at +0xc).

  CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and
  then walks the PART ARRAY: 0x00511012 call 0x518160
  (CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with
  (num_parts, parts, cellarray). Its EnvCell body,
  CEnvCell::find_transit_cells @0x0052cae0:
    0x0052cb31  mov edx,[eax+0x20]   ; CPhysicsPart::gfxobj (CGfxObj**)
    0x0052cb36  mov esi,[ecx+0x74]   ; physics_sphere (else +0x90 drawing)
    0x0052cb4c  add eax,0x30         ; CPhysicsPart::pos
    0x0052cb5a  call Position::localtolocal   ; transform the sphere CENTRE
    0x0052cb65  fadd [esi+0xc]       ; only NOW the radius
  Retail transforms the centre through the part's own Position before it ever
  touches the radius. Carrying the radius alone is not an approximation of
  that; it is a different sphere.

Changes:

* `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the
  shape's own local frame, scaled like LocalPosition and Radius. Zero for
  Cylinder/Sphere shapes, whose LocalPosition already IS their centre.

* `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that
  supplies radius AND centre from ONE call, replacing the placeholder radius
  plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a
  single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate
  from it, so the gate and the geometry cannot disagree and the radius cannot
  be taken while the origin is dropped. That split is what produced this bug;
  it no longer exists.

* `FromLandblockBspParts` carries the centre too. A landblock-baked part array
  is the same CPartArray walk, so stair runs, fences and rock clusters had the
  identical defect. Both storage forms (flat BSP and the graph fallback) are
  covered.

* `BuildFloodSpheres` places each sphere at
  partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the
  ShadowEntry rows are.

* The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp
  is inside CObjCell::find_cell_list @0x0052b9f0
  (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and
  the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups
  carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their
  tail parts were dropped from the flood entirely. Without this the new
  containment assertion would have covered shapes production never floods
  from.

Register. AP-155 was two divergences with different code paths, populations
and gates under one id; it is NARROWED to its static-publication half and its
flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b)
recorded the approximation as over-inclusive — "floods MORE cells rather than
fewer, the safe direction for membership" — and that false direction was the
stated reason the residual was safe to defer. It was under-inclusive for 170
of 172. AP-156 records the correction, this fix, and the one genuine residual:
acdream's sphere-vs-portal traversal where retail walks each part's sphere
against the cell's own portal planes. AP-155(b)'s "acdream approximates
retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box.
AP-157 filed for the review's F4: retail's third branch floods from ONE
CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 =
CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one)
where acdream floods from every Sphere shape, and acdream's cylinder flood
ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint
population, different live gate. Active AP rows 107 -> 109, literal count.

Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f
at LocalPosition = Zero — the one configuration in which the defect cannot
appear. Every fixture is now off-centre by default, and
`FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production
`physicsBspBounds` seam instead of hand-substituting. Five new facts: the
flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by
the part rotation; it caps cylspheres at ten but never the BSP parts; the
landblock path carries the scaled centre in both storage forms; and an
installed-DAT containment sweep asserting every emitted BSP flood sphere
contains that part's real bounding sphere at entity scale 1.75, behind four
external controls — 973 parts, 376 off-centre, 172 affected, and 170
would-fail-if-the-origin-were-discarded, the last of which fails if the
population ever stops exercising the field.

Nine sabotages, each reverted and re-verified:
  A drop BoundsCenter from the flood       -> 3 Core
  B rotate by entity rot, not part rot     -> 1 Core (the rotation fact only)
  C FromSetup discards the origin          -> 1 Core + 2 App + 1 Content
     (the shipped defect, now caught in three projects)
  D drop entScale on BoundsCenter          -> 2 App + 1 Content
  E landblock flat branch drops the centre -> 1 Core
  F landblock graph branch drops it        -> 1 Core
  G drop partScale on the landblock centre -> 1 Core
  H re-apply the 10-cap to every branch    -> 1 Core
  I remove the cylsphere cap               -> 1 Core
AP-152's own two sabotages re-run against this tree: the step-0 gate disabled
still reddens exactly its five facts with Headless 89/89 green, and
cylinder-first flooding still reddens exactly one.

Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing
warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the
11,203 baseline at 4abd1b5e — Core 4264 -> 4268, Content 126 -> 127, App
unchanged (one rename, not an addition). No new skips.

NOT yet gated live. This moves shadow-cell membership for real objects, in
both directions, and the connected session must look for both: props and doors
that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups),
AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink
below their pre-4abd1b5e size, which is the regression this fixes). Tall
indoor props and door slabs — the ones whose sphere sits metres above the part
origin — are where the change is largest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 15:52:04 +02:00
Erik
4abd1b5eb7 fix(physics): AP-152 — dispatch collision shapes BSP-first, at emission and at the cell flood
The register row predicted "catching or stopping on a doorway sill". That
symptom could not have been occurring. `Transition.BspOnlyDispatch`
(TransitionTypes.cs:1348, landed 2026-05-25 as A6.P7) already skipped both
primitive branches (:3911, :3954) whenever the target's wire PhysicsState
carries HAS_PHYSICS_BSP_PS, and ACE sets that bit from CSetup.HasPhysicsBSP
for every affected Setup. The extra primitive was never tested for collision.

The live defect was CELL MEMBERSHIP. The same shape list feeds
`ShadowObjectRegistry.BuildFloodSpheres`, which had no such guard and
preferred Cylinders over everything whenever any Cylinder existed — retail's
SECOND priority applied ahead of its first. For the 73 CylSphere+BSP Setups
acdream therefore flooded shadow cells from the cylinder and never from the
slab: an object absent from cells it physically occupies, which is the
#98 / #168 symptom class, not the door-collision class the row named.

Retail, re-disassembled from the PDB-paired binary (v11.4186, CodeView GUID
9e847e2f-777c-4bd9-886c-22256bb87f32, check_exe_pdb.py MATCH) rather than
taken from Binary Ninja, which drops flag tests:

  CPhysicsObj::FindObjCollisions @0x0050f050
    0x0050f165  test dword [esi+0xa8], 0x10000
    0x0050f16f  je   0x50f1a2        ; clear -> primitive dispatch
    0x0050f18d  call 0x518180        ; CPartArray::FindObjCollisions
    0x0050f19d  jmp  0x50f2b0        ; UNCONDITIONAL, past BOTH primitive loops
                                     ; (CylSphere 0x50f1a2, Sphere 0x50f21d)
    0x0050f1d6  jae  0x50f317        ; CylSphere loop exhausted -> RETURN
    0x0050f22f  je   0x50f31b        ; zero Spheres -> RETURN seeded OK_TS

  CPhysicsObj::calc_cross_cells @0x00515230
    0x00515285  test dword [esi+0xa8], 0x10000
    0x0051528f  jne  0x515305 -> CPhysicsObj::find_bbox_cell_list @0x00510fc0
    0x005152d1  call 0x52b9f0        ; cylsphere branch, below the jump
    0x005152fb  call 0x52b990        ; sorting-sphere branch, below the jump

Priority at both consumers: BSP -> CylSphere -> Sphere -> nothing. BSP wins.
Every address above was resolved back to its symbol by exact lookup in
named-retail/symbols.json.

Changes:

* `ShadowShapeBuilder.FromSetup` gains a step-0 dispatch gate. Steps 1 and 2
  are skipped entirely when any part's EFFECTIVE GfxObj carries a physics
  BSP. The gate and step 3 now share one `EffectivePartGfxObjId` helper, so
  they cannot read different identities — a gate on `setup.Parts` would,
  after an ObjDesc swap, suppress the primitives while step 3 emitted
  nothing and `Build` returned null, deleting the entity's collision.
  Emission order is unchanged. This also removes acdream's undeclared
  reliance on the server sending the flag: the gate is derived from the
  parts, exactly as CPartArray::CacheHasPhysicsBSP @0x00518110 derives it.

* `ShadowObjectRegistry.BuildFloodSpheres` now applies calc_cross_cells'
  own order: BSP, else Cylinder, else everything. Given the gate above this
  is a no-op for every shape list acdream produces (FromSetup is now
  exclusive; both landblock-static publishers already emit homogeneous
  lists), so the measured membership delta remains attributable to the
  gate alone. It is kept for the same reason BspOnlyDispatch is kept: retail
  genuinely dispatches here, and it guards a future additive producer.

`Transition.BspOnlyDispatch` is deliberately untouched.

Register: AP-152 RETIRED with its four false statements corrected — the risk
statement (the symptom was already inert); "small and centred at the part
origin" (max primitive is 6.714 m, and 0x0200086E's sphere origin is
(0.759, 0.165, 5.842)); the cottage door's "~14 cm base Sphere" (it is
0.100 m; 0.141 is Setup.Radius, which AP-22 proved is never collision
geometry); and naming one pinning test where two existed. AP-153/154/155
filed: retail's dispatch flag is cached once at InitPartArrayObject+0x7e
where acdream's gate is live; the query-time guard takes a client-derived
flag off the wire; and the static publishers emit Setup Spheres as
height-capped Cylinders while BuildFloodSpheres approximates retail's
bounding box with bounding spheres.

Tests. Both pinning tests corrected, neither deleted:
`FromSetup_DoorSetup_ProducesFourShapes` -> `..._EmitsBspPartsOnly`;
`FromSetup_DoorSetup_SphereAtExpectedLocalOffset` re-hosted on
`_ => false`, the DAT-real configuration for the 3,605 Sphere-only Setups.
`FromSetup_ScaleFactor_MultipliesAllRadiiAndOffsets` was the campaign's
eighth green test covering nothing — its assertions sat inside
`if (CollisionType == Cylinder)` on a fixture with zero CylSpheres, so only
`Scale == 2.0f` ever ran. Proved empirically: with the sphere radius scale
deleted, the old body passes and the corrected body fails. Three new facts:
the effective-identity gate, the App-layer CylSphere+BSP registration (no
App fixture combined the two before), and the flood-set dispatch. One new
installed-DAT sweep pins 172 affected Setups (73 CylSphere+BSP, 99
Sphere+BSP) behind external bucket controls, re-measured independently and
agreeing exactly with the filing commit's separate sweep.

All eight sabotages run and reported; every discriminating fact reddens in
the intended direction and only there. Clean Release build after deleting
every bin/obj: 0 errors. Complete suite 11,203 passed / 4 skipped / 0
failed, +5 on the 11,198 baseline at ec29a732 — exactly the five added
facts, no new skips.

Blast radius, corrected: the FromSetup half is graphical-only (its sole
production caller is LiveEntityCollisionBuilder in AcDream.App, which
AcDream.Headless cannot reference — Headless -> Runtime -> Core/Content).
The BuildFloodSpheres half lives in AcDream.Core and DOES execute in
Headless via LandblockPhysicsContentBuilder, but is behaviour-neutral there
because both of that builder's registrations pass homogeneous lists.
Headless suite green at 89/89.

NOT yet gated live: this changes shadow-cell membership for 22 Setups used
by 151 Door weenies and 38 stationary props. Needs a connected session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:51:36 +02:00
Erik
7b3e2895cd docs: close the AD-10 review findings — AD-65's magnitude was half the truth
Both AD-10 review lenses PASS; the deletion stands. These are the findings
they raised. One production file touched, comment-only.

AD-65 WAS UNDERSTATED BY HALF, and it is the finding that matters. The row
states the factor as cos^2(theta) and then quantified 1-cos(theta): "13% at
30 degrees, 29% at 45". The correct figures are 25% and 50%. This is not
algebra alone — #331's probe in the same push measures 0.0735 m travelled for
a 0.1 m request at 30.96 degrees, i.e. 26.5% short, which is exactly
cos^2(30.96). AD-65 is a LEAD for #269's slope-slide residual; at the
understated magnitude it reads as marginal and could have been dismissed. At
50% short at 45 degrees it is a serious candidate. I repeated the wrong figure
in conversation before the review caught it.

"VERBATIM/FAITHFUL PORT" of Transition.AdjustOffset was asserted in five
places and was false as of the very next commit, which filed AD-65 and AD-66
against that same function. Corrected to "structurally exact, with exactly two
filed divergences" in the register row and the production doc comment.

RECORDED, and it favours the change: the redundancy measurement is CONTINGENT
on AD-65 — the two mechanisms agree today partly because both under-travel
downhill. That makes this deletion a PREREQUISITE for fixing AD-65 rather than
merely compatible with it; had the projection survived, correcting
AdjustOffset would have re-introduced a disagreement between two live
projections. The record claimed no such thing and should have.

UNTESTED AXIS recorded: the contract's T2 — its mandatory wrong-plane-versus-
right-plane discriminator — was dropped without record, breaching the
contract's own clause requiring exactly that to be written down. The
consequence is precise: the deletion is measured, but the change's only
claimed BENEFIT (a walkable non-terrain surface now gets the committed contact
plane instead of terrain far below) has zero automated coverage and rests on
source reasoning. Stated in the row rather than left implied.

#331 SEVERITY RAISED from UNKNOWN — the discriminator is known and it is not
the fixture. With `body: null` the same uphill sweep climbs (ok=True, moved
(0, -0.0999, +0.060)); with a body supplied it returns ok=False and zero
movement, under a call profile identical to the local player's
(IsPlayer|EdgeSlide + the human two-sphere Setup). A diagonal request keeps
cross-slope X and zeroes only up-slope Y, and it fires on a 1.1 degree ramp.
So "confined to the synthetic fixture" is no longer the comfortable default:
the failing call shape is the shape production uses. Nothing in the suite
asserts uphill progress on a walkable slope, which is why it was invisible —
the test that found it passed vacuously, because the body never moved.

Also: malformed XML doc on ComposeOffset (duplicate </summary> swallowed the
retirement note from tooling) fixed; the placement-cutover plan's item 5 and
its stale "After C5" line now record AP-22 and AD-10 as retired.

Core builds clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 10:08:53 +02:00
Erik
886333a2a9 refactor(physics): delete the redundant pre-sweep slope projection (AD-10 retired)
Stage 0's measurement (previous commit) says the projection is redundant,
so AD-10 retires by deletion rather than by narrowing.

The measurement. With the sample forced to null at BOTH fork sites, from a
clean build:

  * a remote running 30 ticks down a 31-degree walkable ramp produces a
    BIT-IDENTICAL trajectory, position for position;
  * on an 8.4-degree ramp the two differ by at most 2.8e-5 m in Z after 30
    ticks (0.03 mm) and are identical in X and Y — float ordering noise
    from projecting twice against the same plane rather than once;
  * the whole AcDream.Runtime.Tests suite is unchanged.

That is what redundancy looks like, and the arithmetic explains it. The
boundary projection and Transition.AdjustOffset are the same operation
(v -= N * dot(v, N)) against the same plane, and the composition is
idempotent: a vector already on the plane has dot(v, N) == 0, so the
sweep's own projection is a no-op on an already-projected offset and the
full-strength projection on an unprojected one. Either alone produces the
same offset. On terrain a THIRD mechanism, ValidateWalkable's push-out,
re-seats the sphere on the plane every sub-step regardless.

Deleted:
  * both RuntimeRemotePhysicsUpdater sample sites (the host and no-host
    fork branches carried the block verbatim — the AP-22 shape, a row
    naming one site where two exist);
  * the terrainNormal parameter and projection block on
    RemoteMotionCombiner.ComposeOffset;
  * the same block on ComputeOffset, which has no production callers but
    held a second copy of the divergence, so leaving it would have made
    the row's retirement false;
  * PhysicsEngine.SampleTerrainNormal, now callerless.

Removing the parameter rather than passing null is deliberate: it is what
makes a future one-site-only regression a compile error instead of a
silent half-fix.

Two tests went with it —
ComputeOffset_RootMotionFallback_SlopedTerrainNormal_ProjectsZOntoSlope and
its flat-ground twin. Both were weak on their own terms: they drove the
production-dead ComputeOffset and computed their expected values by
re-implementing the projection formula, so they could catch a wrong
MULTIPLY but never a wrong PLANE — which is exactly what the divergence
was. The surviving coverage is geometric and runs the production tick.

Three claims in the old row did not survive contact with the code and are
recorded in the retired row rather than quietly dropped: the justification
(remotes do run the sweep); the description of ComposeOffset's guard as
"interpolation-active" when the code reads `if (!interpolationOverwrote`;
and the roof clause, stale since Bug B gated the sample on OnWalkable —
a steep roof is OnWalkable == false, so the path never ran on #32's
geometry. The retail anchor is corrected too: pc:272296-272346 truncated
both the sliding-normal validity gate at the head and the entire safety
push-out block at the tail. The whole function is 0x0050a370,
pc:272271-272393.

This does not fix #32 and does not partially fix it. #32's remote half was
already closed at 204d0ae0. What deletion does improve is the case #32
never covered: a remote on a WALKABLE non-terrain surface — a bridge, a
dock, a gentle roof, a ramp inside a building — where the terrain sample
returned the plane of the ground far below and applied a wrong plane
rather than none. That surface now gets the body's own committed contact
plane, because that is the only projection left.

The planning contract this work executed is committed alongside as
docs/research/2026-08-06-ad10-contract.md.

Release build 0 errors. Complete solution suite 11,196 passed / 4 skipped
/ 0 failed against the ef976c6d baseline of 11,195 / 4 / 0 — reconciled
exactly as +3 new Runtime tests and -2 deleted Core tests.

Visual gate outstanding: G1 (the ~5 Hz staircase on rolling terrain) is
the veto criterion and runs first; then slope-descent smoothness, a
walkable non-terrain surface, the #32 roof scenario, and flat ground.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 09:35:22 +02:00
Erik
bc4679cda5 fix(physics): delete the invented Setup-radius collision cylinder (AP-22)
Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.

CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.

THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.

The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.

Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
  claim plus five EXTERNAL positive controls, so a broken enumeration cannot
  satisfy it vacuously. Inverting the claim reddens it; emptying the
  enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
  deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
  PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
  fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
  Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
  the landblock id.

Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.

AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.

Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 08:27:26 +02:00
Erik
fafc0b65d9 fix(physics): adopt the settle's resolved cell across an indoor seam (#276 remainder)
SpawnPlacementSettler committed settle.Position but discarded settle.CellId,
so a compressed first-gravity-frame settle that crossed a cell boundary left
the body's cell at the placement cell until some later resolve corrected it.
Now committed through the same guarded channel the per-tick resolve writeback
uses (RuntimeOrdinaryPhysicsUpdater): resolved cell when the transition
reports one, source cell otherwise, never a zeroed residency.

Retail anchor: CPhysicsObj::SetPositionInternal(CTransition const*)
0x00515330 commits both sphere_path.curr_pos.objcell_id and its frame,
including EnvCells.

WHERE THE DEFECT ACTUALLY BIT — #276's own framing is half wrong, and the
half it misses is the whole fix. PhysicsBody.Position's setter already
mirrors the world delta into the landblock-local frame and lets
LandDefs.AdjustToOutside recompute the 24 m cell index from it, so an
outdoor->outdoor settle already landed the right cell and dropping
settle.CellId cost nothing there. It cannot do that for an EnvCell: an
EnvCell id is not derivable from a position, so the mirror deliberately
PRESERVES it. settle.CellId is therefore the only carrier of a cell identity
across an indoor seam. The live defect is the issue's parenthetical
("outdoor/EnvCell seam, stacked EnvCells"), not its main clause — and the
change is consequently a no-op on the outdoor path that dominates
production, corrective only at the seam.

That finding is what made the test possible. The three existing settler
tests build bodies with NO CellPosition and pass identically with or without
this change — shipping against them would have repeated C5b finding D3, a
test that passed with its own change reverted. The new test seeds an EnvCell
id over plain outdoor terrain instead, so the stale-id preservation is the
discriminator and no EnvCell geometry fixture is needed.

Sabotage-verified: restoring `body.Position = settle.Position` fails exactly
the new test (1 failed / 4) and leaves the other three green — confirming
both that the new test discriminates and that the old ones never could.

Core suite 4,263 passed / 1 skipped / 0 failed, +1 for the new test.

Still open and unverified, deliberately not claimed closed: whether the
remote spawn-seed caller (LiveEntityNetworkUpdateController) hands in a body
that carries a CellPosition at all. CommitTransitionPosition early-returns on
a zero cell, so this fix is an inert no-op there and #276's remote half may
survive. The C3c local first-entry caller is confirmed — it passes
activation.Body.CellPosition.ObjCellId. Scoping detail in
docs/research/2026-08-06-276-remainder-scoping.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 06:37:46 +02:00
Erik
3aab05b0cc fix(streaming): derive the portal reveal window from the live streaming radii (#280)
The user watched far terrain visibly assemble after portal space exits.
The reveal gate was NOT missing a hold — Slice E's hold mechanism is
correct and already in place. The hold was measuring the wrong domain:
it opened at a hardcoded 3x3 landblock neighbourhood (~192 m) while the
visible world extends to the fog end (~2,189 m at the shipped High
preset, inside a 2,304 m Far window). An 11.4:1 ratio.

Retail's equivalent ratio is 1:1 BY CONSTRUCTION. `LScape` owns one
`mid_width x mid_width` array of `CLandBlock*` (`LScape::SetMidRadius`
@0x00504C00, `LScape::update_block` @0x005063A0), `mid_radius` is
assigned directly from the user's `Render.LandscapeDrawDistance`
preference (`SmartBox::SetRegion` @0x004531F0; values
`Render_LandscapeDrawDistance_Values` @0x007CA988 = {3,5,8,11,15,25},
default 8 — both byte-verified against the PDB-paired 2013 binary), and
that same square is simultaneously the prefetched set
(`LScape::PreFetchCells` @0x00505660), the drawn set (`block_draw_list`
over the same array), and the set the simulation blocks on
(`CellManager::blocking_for_cells`). There is no retail configuration in
which the client streams farther than it gates, because there is only
one number.

So the fix derives rather than duplicates. Four coupled parts, which is
why this is one commit and not four — D1 without D2 hangs the client and
D2 without D1 is dead code:

D1 `WorldRevealReadinessBarrier` takes a live `Func<StreamingRevealWindow>`
and stops being static: outdoor requires `FarRadius`, indoor still 0
(retail's `CEnvCell::PreFetchCells` @0x0052D1E0 arm). Read per
evaluation, never captured — the radii are runtime mutable through
Settings, and retail's answer to a mid-hold radius change is to reset,
re-radius, and re-arm the blocking prefetch at the NEW value
(`SmartBox::set_mid_radius` @0x00453180). `OutdoorNeighborhoodRadius`
is deleted; there is no constant left to drift.

D2 `StreamingController.IsRenderNeighborhoodResident` becomes tiered,
because acdream's loaded landscape is: inside `NearRadius`,
`IsNearTier && IsRenderReady`; out to `FarRadius`, `IsRenderReady` only.
Without this the fix cannot work at all — nothing outside the Near ring
is ever promoted, so any radius above `NearRadius` was unsatisfiable and
would have held the reveal forever. Proof obligation P1 (a Far-tier
landblock genuinely satisfies `IsRenderReady`) is now a test driven
through the real `PublicationKind.Far` pipeline against a real
`LandblockSpawnAdapter`, not an inference.

D7 `RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived
`indoor ? 0 : 1` and failed `invalid-readiness-shape` on any other
value, so changing the radius alone would have looked like "the fix
hangs the client". It is now a SHAPE invariant (`indoor => 0`,
`outdoor => >= 1`). Runtime does not own the graphical host's streaming
configuration and must not learn it; plumbing App radii into Runtime to
preserve the strict equality is exactly the assert-a-mechanism-that-does-
not-exist failure C5b was built to stop. Both non-graphical producers
keep emitting their centre-ring token and stay legal, annotated in place.

D6 `PhysicsEngine.IsNeighborhoodTerrainResident` rebuilt a full-map
`HashSet` on every call, every frame of every hold. At radius 1 that was
invisible; at radius 12 (625 ring members) it violates Slice I1's
0 B/resolve standard. Now an engine-owned scratch set, cleared in place;
measured at 0 bytes over 1,000 warmed radius-12 queries.

Also: the destination reservation opens at exactly the gate's radius and
reopens on the same generation when the radius changes mid-hold (retail
has one square for both, and no concept of prioritising an inner ring
differently). Composite warmup deliberately stays `NearRadius`-scoped —
the composite domain is entity-scoped and Far builds carry no entities,
so widening it would walk the outer window to warm nothing.
`ACDREAM_PROBE_REVEAL_RADIUS` is a measurement probe in a diagnostic
owner (CLAUDE.md rule 5) so the connected route can be run A/B on one
binary; it is NOT a user-facing prefetch knob, since a low setting would
reintroduce the decoupling this slice exists to close.

Register: AD-2 amended with the derived window, the two-tier split, and
the four new retail anchors. AP-149 FILED for the residual this does not
close — the outer ring accepts terrain-only publication where retail
requires LandBlockInfo and every building EnvCell, so a distant building
can still pop in at Far-ring distances. Do not let a later closeout
claim parity.

Docs: `ACDREAM_STREAM_RADIUS`'s CLAUDE.md description was wrong on every
clause (the default is unset, not 2; it forces `NearRadius`; it is
silently discarded by any Settings save) — corrected, since that is the
file every session reads. `reference_two_tier_streaming.md` corrected in
four ways, including "Far tier = terrain only": Far also publishes
terrain COLLISION, which is precisely what makes this fix viable.
#280's issue text had the right conclusion from a wrong premise (it
names a view-distance setting acdream does not have) — corrected, and
the missing Viewing Distance option filed separately as #326, with #327
(DDD progress readout) and #328 (hardcoded 5000 f far plane vs retail's
byte-verified 4000) filed alongside.

Expect LONGER holds and the "In Portal Space - Please Wait..." cue on
recalls MORE often. That is convergence toward retail, not away from it:
retail emits the byte-identical string for the whole duration of a
blocked prefetch and polls at 5 s intervals. The failure condition is
non-convergence, not duration.

Gates: Release build 0 errors. Complete suite 11,178 passed / 4 skipped
/ 0 failed, against a re-measured 11,142 / 4 / 0 baseline at 9ee9c1a1 —
+36, reconciled exactly as 36 new tests (App +23, Runtime +10, Core +3),
zero deleted, zero newly skipped. Nine discriminating tests
sabotage-verified in both directions. The connected/visual gate is
batched into C5's matrix; its recipe, its three positive artifacts, and
its required recall leg are written into the campaign plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:54:59 +02:00
Erik
6921a02744 refactor(physics): delete legacy PhysicsEngine.Resolve/ResolvePlacement/HasCellSurface (C5a, AP-1/AD-1)
Member-wise deletion of the three legacy resolver members named in
docs/research/2026-08-05-c5a-contract.md: PhysicsEngine.Resolve,
PhysicsEngine.HasCellSurface, and PhysicsEngine.ResolvePlacement. An
exhaustive receiver census over src/ found zero production callers of any
of the three — every production placement writer already reaches the
canonical PhysicsEngine.SetPosition transaction exclusively through
RuntimeSetPositionState (three call sites total). The deletion is purely
member-wise: IsSpawnCellReady and AdjustPosition, which shared the same
source region as the deleted members, are preserved byte-identical — every
remaining production caller of either (including PhysicsCameraCollisionProbe,
AdjustPosition's sole surviving production caller) is unaffected.

Companion changes:
- PlayerMovementController's 3-argument SetPosition test overload is renamed
  to SeedPlacementForTest (internal) and CommitPreparedPosition is deleted;
  83 call sites across 19 test files were mechanically renamed to match.
- Seven pinned test dispositions from the contract are executed:
  3.1 (PhysicsEngineTests.cs: 11 legacy-resolver tests deleted, 6
  ResolveWithTransition tests kept), 3.2/3.3/3.4 (re-point to canonical
  SetPosition, with TransitionScratchDifferentialTests.cs additionally
  gaining positive IsCommitted assertions after each bitwise comparison so
  the differential proves a placement actually committed, not just that two
  possibly-uncommitted results match), 3.5 (Runtime rename), and 3.6
  (PlayerMovementPlacementTransactionTests.cs rewritten — its xmldoc now
  states plainly that the render-root publish moved to
  RuntimeSetPositionState.cs, but the sticky-release relocation claim was
  false and is retracted; this disposition's coverage loss is the sticky
  release path, not silently absorbed elsewhere).
- Stale `PhysicsEngine.Resolve`/`Resolve` doc citations in CellTransit.cs,
  PlayerMovementController.cs, and HeadlessSessionWorldProjection.cs are
  corrected to name the surviving canonical entry points by symbol
  (SetPosition, AdjustSetPosition/AdjustPosition, ResolveWithTransition)
  rather than fragile line numbers.

Retires AP-1 and AD-1 in docs/architecture/retail-divergence-register.md:
both rows described production zero-delta placement routing remaining on
the legacy resolver pending the Slice 4B2/4B route cutover; that resolver
no longer exists, so the condition each row tracked is now structurally
false rather than merely narrowed. AP-145 (routed through the prior commit)
and this commit's AP-1/AD-1 together bring the section counts to 101 AP / 47
AD active rows.

Builds on the AP-145 fix (previous commit) — this commit's staged tree was
independently rebuilt and its four suites independently rerun on top of
that commit before this commit was created, in addition to the combined
rebuild/rerun below.

Full-solution build: 0 errors (21 pre-existing warnings, all unrelated).
Suite results (combined tree): Core 4270/4271 passed (1 skip; the single
DatSoundCacheTests concurrent-decode-dedup failure is a known load-sensitive
race, confirmed passing standalone and unrelated to this change), Runtime
1176/1176, Headless 86/86, App 4132/4135 (3 skips).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:11:31 +02:00
Erik
e0f96a55bf fix(physics): C4 route 3 — portal placement authority (local player)
Removes a duplicate placement authority for local-player portal arrival.
Portalling worked before this change and works after it — this is not a
bug fix, EXCEPT that it found and fixed one dead-code production bug.

THE PRODUCTION BUG: TryExecuteCanonicalPortalPlacement re-read the
accepted destination at Place time, but TryBeginPortalReveal already
consumes that slot at Aim time — so the arm was 100% dead code and every
real portal Place refused with host-token-unavailable. Found only
because we refused to accept 7 skipped tests instead of chasing the
count to zero.

RETAIL IS THE GENERIC PATH FOR THE THIRD ROUTE RUNNING:
SmartBox::TeleportPlayer @0x00453910 = SetPositionSimple(dest, 1) with
flags 0x1012, followed by PlayerPositionUpdated.

BOTH INVERSIONS, WITH THEIR ANCHORS: unlike route 2, the leash IS armed
here (ConstrainTo @0x0045418A) and velocity is zeroed
(set_velocity @0x004541B4); unlike route 4b-3, the local teleport_hook
runs AFTER placement (@0x004538AE).

THE THREE-ROUND DEFECT CHAIN, HONESTLY:
- Round 1 released the player at the pre-teleport position while the
  anim stream marched on — the contract wrongly assumed Place re-fires
  (process rule 1's third occurrence this campaign).
- Round 2's fix inferred commit from a global PendingCount, which three
  non-committing paths also clear — making the SAME bug complete
  cleanly and silently. Strictly worse than round 1: round 1 at least
  tripped portal-complete-before-materialized.
- Round 3 latches the commit where it actually happens
  (ReconcileAndAcknowledgePortal), keyed on reveal generation and
  teleport sequence, via TryConsumePortalCommit. Two of the three
  required regression tests landed and are sabotage-verified on both
  hosts (ParkedPlace_ForgottenByOrdinaryMergeDoesNotLatchAsCommitted /
  HeadlessPortalPrepareDestinationForgottenByOrdinaryMergeDoesNotLatchAsCommitted).
  The third (force-arm-takes-the-slot) was judged unnecessary on review:
  with the inference gone, PendingCount is only a "don't ask yet" guard
  at both gates, so a force operation occupying or vacating the slot no
  longer changes an input the commit decision reads — the case collapses
  into what the landed test already discriminates.

THE B2/P3 RESOLUTION: both round-2 reviews were right about different
branches of the same synchronous call. RuntimePlacementProjectionSubscription
.OnPlacement acknowledges the FIFO head only when TryApply returns true;
a Place whose portal authority went stale (transit ended/superseded
while parked) used to return false, wedging every later entity's
placement receipt behind it forever. Both sinks
(RuntimePlacementPresentationSink, HeadlessRuntimePlacementProjectionSink)
now acknowledge-and-ignore a stale-authority Place instead of refusing
it. The regression test (RuntimePlacementPresentationSinkTests
.PortalPlace_StaleTransitHostOrSequenceIsAcknowledgedAndIgnored) had
been asserting the old, wrong `false` behaviour; it now asserts and
sabotage-verifies the fix.

Also lands: AP-144 (register discipline — the portal movement-event
send reuses the stricter UsePositionFromServer gate where retail's
SendMovementEvent is the looser autonomy_level != 0 test, diverging
only at level 1, currently unreachable), AP-145 + issue #318 (the
local-player collision-shadow presentation write bypasses its own
publisher's ShadowObjects write via a direct cache .Set(), self-healing
only once dedup diverges — filed, not fixed, pending a composition
test), AD-42 deleted (its last citation retired by the canonical portal
arm), AD-2 updated (the wait-cue's trigger predicate now covers a
second cause), and two documentation corrections: the enter_world
misattribution (both call sites are in SmartBox::HandleCreateObject,
only one in the player branch — portal arrival is TeleportPlayer, not
enter_world) and the stale "local player never reaches this path"
comment on the generic-remote-render-pose write.

Suite: 11,090 passed / 4 skipped / 0 failed. No new skips, nothing
weakened.

STILL OWED: the connected two-client gate, with
ACDREAM_PROBE_LOCAL_TELEPORT=1, scored only if [local-tp] lines
actually appear in the capture — and explicitly NOT scored as covering
issue #318 (no composition test yet asserts PhysicsEngine.ShadowObjects
directly).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:57:37 +02:00
Erik
cd3129e9d6 fix(physics): C4 route 7 — child cell propagation moves from a render tick into Runtime
Retail re-cells children when their parent crosses a cell, recursively, to
unbounded depth. acdream did it from a RENDER tick, so headless parented
children were cell-less forever and the canonical cell had two writers. This
slice makes Runtime the sole authority and demotes App's tick to
presentation-only. Contract:
docs/research/2026-08-04-c4-route-7-contract.md; the research that unblocked
it is docs/research/2026-08-04-retail-parent-cell-propagation.md (ca96ea5e).

Retail: SetPositionInternal @0x00515330 branches on `this->cell == curr_cell`
@0x0051536d; the changed branch reaches change_cell @0x00513390, whose
delegates leave_cell @0x00510f50 and enter_cell @0x00510ed0 self-recurse over
children and write the FULL identity (add_object @0x00510ee2, objcell_id
@0x00510f1e, part-array cell id @0x00510f2b, cell pointer @0x00510f35).
change_cell itself has no child loop.

THE TRAP, recorded because it nearly shipped: the depth-1 loop
@0x0051539c-0x005153d8 is the SAME-CELL fast path (objcell_id and part-array
id only, deliberately not the cell pointer), NOT the propagation. An
implementer who finds it first concludes "depth-1, id-only" and strands every
equipped item at a landblock boundary — the #184 class. The clincher against
that reading: update_object @0x00515d10 early-returns on `parent != 0`
@0x00515d40, so a child never runs its own physics tick and parent
propagation is the ONLY mechanism maintaining its cell.

Route 7 performs NO placement (DoPickupEvent @0x00452240 = unset_parent +
leave_world; DoParentEvent @0x00452290 = set_parent + SetPlacementFrame), so
it arms ConstrainTo nowhere — the leash rule INVERTS relative to routes
2/4/5, and both reviewers confirmed nothing arms.

Propagation is an ITERATIVE WORKLIST, not recursion. The first implementation
recursed with a depth-64 cap; both reviews independently found the cap left a
truncated tail at a stale NON-ZERO cell — permanently unrecoverable, logged
only under a probe flag, and on the withdraw path exactly the #184 shape
AP-142 clause (a) exists to reject. Shipping a fresh #184 instance inside the
slice that fixes stranded children was not acceptable, so the cap was removed
rather than tuned. The worklist retires the cap, the constant, its register
clause, and the failure mode together. Termination: every record on the stack
is already at the target pair, so nothing can be pushed twice and a hostile
A->B->A cycle collapses without a visited set.

The child write deliberately bypasses the public RuntimeEntityDirectory
.SetFullCell and calls the record method directly. This is LOAD-BEARING:
the public method re-enters PropagateFullCellToChildren, which opens with
_propagationWorklist.Clear() — routing children through it mid-drain would
wipe the shared stack and silently drop every unprocessed sibling. Any future
side effect added to the public SetFullCell must be mirrored by hand at that
call site.

Deliberate divergence, recorded not disguised: retail's removal path leaves
children with a null cell pointer but a STALE nonzero objcell_id @0x005133c1.
acdream does not reproduce it, because FullCellId != 0 is the liveness
predicate at 45+ sites — faithful porting would mark dead children live.
AP-142 records this; clause (d) records that acdream cannot gate propagation
on HasPartArray the way enter_cell gates on part_array @0x00510ed8, because
the flag's only writers are graphical and headless never sets it — the reason
is Slice J LAYERING, not a semantic difference (retail's part_array is itself
a mesh-construction product, single assignment site makeAnimObject
@0x0050e930 -> CPartArray::CreateSetup @0x0050e93e).

D7 adopts retail's unset_parent-before-leave_world order @0x0045227f ->
@0x00452286, applied to BOTH pickup paths including the dormant executor
replay. Its inertness was verified by reverting it and finding all 12
propagation tests still green — reported honestly rather than papered over
with a manufactured test, and independently confirmed by both reviewers.

ClassifyLeaveWorld and its request/cause types are DELETED: retail has no
classification here, and method-per-cause IS the retail dispatch shape.
Wiring it would have forced a vacuous teleport-sequence predicate with the
#307 shape.

Two review rounds plus a coordinator-required third pass; 5 MAJORs. One was a
handoff failure worth recording: enter_cell's part_array guard was correctly
identified as load-bearing by the research, dropped by the contract when it
enumerated the writes, and inherited as an omission by the code — a right
finding that evaporated across two handoffs with nobody re-reading the source.
Another was a test that survived deleting the entire behaviour it claimed to
pin, because its assertion read a field written unconditionally one line
earlier.

NoProjection is structurally unreachable from TickChild (TryResolveExactAttachment
performs a strictly stronger form of the same guard one call earlier). Kept as
a fail-safe, unit-tested directly, and documented in two places rather than
wrapped in a fabricated end-to-end test.

Headless regression test — the direct gate for this defect, which FAILED
before this work because no code path existed:
RuntimeLiveEntitySessionControllerTests
.DirectSink_D5_StandaloneParentEventCommitsChildToParentsExactCell.

Probe: ACDREAM_PROBE_CHILD_CELL=1 emits [child-cell] lines at all four write
sites (attach / headless-attach / propagate / withdraw / delete). TEMPORARY.

Complete Release suite MEASURED at 11,079 passed / 4 skipped / 0 failed
(baseline 11,063 at cff52c44, +16). An allocation flake appeared once under
load and was proven NOT this slice by reachability — RuntimeCollisionReportingState
contains zero SetFullCell and zero ParentAttachments references.

STILL OWED: the two-client connected gate (equip/unequip, carry across
landblock boundaries, pickup, loot, reconnect) with ACDREAM_PROBE_CHILD_CELL=1,
and a session counts only if [child-cell] cause=propagate lines appear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 23:53:05 +02:00
Erik
6dc7ba51ee feat(physics): C4 route 4b-3 — remote teleport + cell-less through the canonical placement
Flips the last remote classification (SetPosition: teleport-advanced and
cell-less) onto 4b-1's RuntimeRemotePlacementDriveController, runs retail's
teleport_hook before the placement, and deletes the legacy remote-teleport
machinery. Contract: docs/research/2026-08-04-c4-route-4b-3-contract.md.

Retail: MoveOrTeleport @0x00516330's branch @0x00516386 -> teleport_hook
@0x005163EF -> SetFlags(0x1012) @0x00516414 -> SetPosition @0x00516420 ->
return 1 @0x00516438. The hook @0x00514ED0 runs BEFORE the placement and
regardless of its outcome. Retail places this branch unconditionally, at any
distance and any contact state (arg4 is read only @0x0051638E, after the
branch) — which is what retires AP-137's cell-less enqueue-vs-place delta.

D1 — the classifier's cell-less input is now the PRE-merge committed cell.
Retail's predicate is `this_1->cell == 0`, the BODY's own cell at
MoveOrTeleport entry (this_1 is assigned from this @0x00516334). acdream fed
the POST-merge canonical.FullCellId, which RefreshSnapshot ->
RefreshDerivedState -> SetFullCell has already stamped with the accepted wire
cell; a zero wire cell fails validation into RejectedData first. The shipped
remote cell-less predicate was therefore dead code, not merely different from
remotePlacementRequired. Threaded via a builder overload; route 1's overload
is untouched. The graphical !IsSpatiallyVisible arm of
projectionRequiresTeleportHook is deleted — a presentation predicate with no
retail analogue that fired the teleport machinery on a routine hot path.

Deleted: RemoteTeleportController (605), RemoteTeleportPlacement (85),
RemoteShadowPlacementSynchronizer (49), their 1,709 lines of tests, the
remotePlacementRequired predicate, the TeleportHookRequired plumbing, the
legacy pre-operation ConstrainTo fallback, and the player arm's legacy
!IsGrounded fallback. Net -2,030 lines.

Structural fix (two independent Opus reviews, round 1 FAIL/FAIL): three of the
four MAJORs were one defect — OnPosition carried two parallel inline copies of
the routing tail (player-guid, NPC-guid) that had drifted. Extracted
RunRemoteArmTail (3 call sites) and ApplyWireAirborneLeftoverBookkeeping (2),
both branches now share one implementation.

  A1  ToConstraintArm mapped AirborneSnap -> AirborneNoOperation, so the NPC
      arm armed ConstrainTo ZERO times for an out-of-contact wire-grounded
      creature — a regression this slice introduced while closing a
      structurally identical hole. Now maps to NearInterpolate; switch made
      total with a throwing default proven unreachable.
  R1  D2's write-nothing shape existed on the player arm only; NPC packets
      fell through and wrote the body. Retail makes no player/NPC distinction.
  R2  report_collision_end(this,1) @0x00514F31 was bound to
      ShadowObjects.Suspend, a port of a DIFFERENT retail function
      (remove_shadows_from_cells) that teleport_hook never calls. Now routes
      to RuntimeCollisionReportingState.LeaveWorld, which wraps the private
      ForceEnd in an admission-blocking transaction so a DoCollisionEnd
      callback cannot recreate the contact table.
  R3/A2 A teleported NPC synthesized ServerVelocity from the teleport distance
      (~1,000+ m/s) and planned a run cycle from it. Both the install and
      RemoteServerControlledVelocityCycle.Apply now gate on !isTeleportRoute.

BISECT HAZARD — A1's fix is correct only BECAUSE R1 landed. AirborneSnap is
reachable wire-airborne on the NPC arm only while D2's shape is missing there.
Reverting R1 alone silently inverts A1 into the opposite divergence: arming
where retail returns 0. Revert both or neither.

Also in the velocity hunk: the NPC block's two !IsPlayerGuid(update.Guid)
guards were dropped when it was wrapped in `if (!isTeleportRoute)`. Safe — all
five exit paths of the enclosing IsPlayerGuid block return, so the predicate is
unconditionally false below it — but it was unremarked by both reviews.

Register: AP-137 REWRITTEN (not deleted) to the surviving acdream-only
divergences — null classification during the login window and Rejected*
through UnroutedCatchUp keep a row. AD-42's RemoteTeleportController citation
retired; AP-136/AP-138 writer lists corrected to the two surviving non-Position
rebucket writers; AP-138 gains the teleport arm as a second producer of the
visible-without-collision residual (retirement path remains #309). AP-135 is
untouched and its two airborne bookkeeping writes are preserved on both arms.
AP-131 does not retire; #276 does not close.

Proof obligation 1: ParkCollisionResidents' overlap throw stays unreachable —
the teleport arm adds packets to the same TryBeginExclusiveAuthoredPlacement
one-operation-per-key machinery the far arm uses, opens no new operation shape,
and every DeferredCell outcome cancels synchronously with
restoreCancelledPark: true. The guarded property remains
HasOldPrefixPlacementDebt's stall, not a throw (4b-1's B2 caveat stands).

Correction to an earlier claim: LiveEntityPresentationController's
_activePlacementOwners was NOT write-never at HEAD —
remotePlacementRequired -> BeginPlacement -> Begin -> BeginAuthoritativePlacement
was a live writer chain. It becomes write-never BECAUSE this slice deletes that
chain, which is why deleting the dead half is behaviour-preserving.

Probe: ACDREAM_PROBE_REMOTE_TELEPORT=1 emits one [remote-teleport] line per
routed arm (guid, cause, hook-ran, placement status). TEMPORARY, strip with the
probe family.

Carried, disclosed not fixed: no dedicated bidirectional collision-partner test
for R2 (the wiring, not LeaveWorld itself, is what lacks coverage); the
stress test's teleport step drives hand-written field assignments rather than
the canonical arm; the per-packet runTeleportHook closure allocation (network
path, not the resolve path Slice I's 0 B discipline governs — file before
route 5 adds a fourth call site). B2: IRuntimeCollisionReportObserver has zero
production implementations, so retail's bidirectional DoCollisionEnd half still
reaches no gameplay consumer — this fix closes the wrong-function binding, not
that nobody listens.

Complete Release suite MEASURED at 11,013 passed / 4 skipped / 0 failed
(baseline 11,027/4/0; net -14 = ~33 deleted test cases against ~19 added).
Neither known flake fired (#302 PortalProjectionTests GC-allocation, #308
NakEmissionTests wall-clock).

STILL OWED: the two-client connected gate, which MUST use an NPC/creature
teleport target. Both round-1 MAJORs lived on the NPC arm and the velocity
cycle early-returns for 0x50xxxxxx guids, so a player target structurally
cannot observe A1, A2, or R3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:00:10 +02:00
Erik
204d0ae047 fix(physics): remote bodies slide on steep faces instead of freezing (#32)
A remote observed in acdream landed on a sloped roof and froze; the server slid
on, the gap passed AP-87's 4 m threshold, and the body snapped — the visible
blip. Live probe capture, two adjacent ticks 63 ms apart:

  t=88420671  rsInContact=True rsOnWalkable=False rsIsOnGround=True
              bodyCpNz=0.6097 floorZ=0.6642 steep=True gravity=True
              vel=(2.146,2.264,-3.549)
  t=88420734  contact=True onWalkable=True   <- forced against the sweep
              gravity=False                   <- cleared
              velBeforeZero=(2.146,2.264,0.000)
              moved=0.0000                    <- and every tick after

The roof is 52.4 degrees against a 48.4 degree limit, so acdream's classifier
was CORRECT and was then overruled. Four independent links each froze the body
on their own: a per-tick force of Contact|OnWalkable, a per-tick velocity zero,
a Gravity clear at landing, and a landing edge testing IsOnGround
(= inContact || ...) instead of OnWalkable. The tick called
HandleAllCollisions alone — the tail of SetPositionInternal without its prefix.

Retail simulates remotes locally and derives these bits rather than asserting
them: CPhysics::UseTime @0x00509950 iterates the whole object table;
update_object @0x00515D10 gates only on parent/cell/FROZEN with no
is_player fork; SetPositionInternal @0x00515330 sets CONTACT from
contact_plane_valid @0x00515430 and ON_WALKABLE from contact_plane.N.z vs
floor_z @0x00515465-@0x0051548E before handle_all_collisions @0x005154FE;
set_on_walkable @0x00511310 fires HitGround @0x00511364 / LeaveGround
@0x00511346 edge-triggered with no ownership gate; calc_acceleration
@0x00510950 zeroes only when CONTACT && ON_WALKABLE && !Sledding @0x0051096B;
calc_friction @0x0050EE70 returns at its first line when ON_WALKABLE is clear.
acdream had copied retail's airborne no-op WITHOUT retail's local simulation.

The fix is mostly deletion: stop forging the transients, stop discarding the
authoritative velocity, stop clearing Gravity, and route the remote tick
through the same SetPositionInternal commit TickHidden and the local player
already use, with the landing edge derived from the sweep's own OnWalkable.
AP-87's threshold and conditions and InterpolationManager's node_fail_counter
snap-to-tail are deliberately untouched — this removes the CAUSE of the
divergence rather than weakening the backstop.

Cross-checked against ACE: its only creature-side VectorUpdate emitters are the
jump broadcast and spell projectiles, so integrating the wire velocity cannot
double-move a walking remote; and PhysicsGlobals.DefaultState already carries
Gravity, so deleting the manufactured State |= Gravity is safe.

Register: AP-81 narrowed (its GRAVITY half retired outright), AP-87 annotated,
AP-139 filed (the interpolation-queue clear on the landing edge), AP-140 filed
(the two routing gates select snap-vs-interpolate on walkability where retail
uses CONTACT — adjust_offset @0x00555D30 gates on transient_state & 1
@0x00555D52). AP-140's follow-up is deliberately shaped as "point the two gates
at Body.InContact", NOT "re-derive Airborne", which would perturb five writers
and collide with a pinned RemoteTeleportPlacementTests assertion.

Three gaps recorded in #32 rather than papered over: the new LeaveGround
dispatch is untested for chatter; a persistently !Ok transition can latch a
remote airborne; and — the visual-gate watch item — the deleted forge was a
blanket guarantee of Contact|OnWalkable, and contact_allows_move @0x00528dd0
silently refuses action animations without both, which is the literal root
cause of closed #270. Retail-correct on a steep face, a regression anywhere
else.

10 discriminating tests over a real PhysicsEngine landblock whose contact
normal Z is 0.61 against FloorZ 0.6642 — the live roof's exact relationship.
Suite 11,019 passed / 4 skipped / 0 failed. Includes the temporary
ACDREAM_PROBE_REMOTE_LANDING / ACDREAM_PROBE_REMOTE_SLIDE probe family that
produced the capture above; strip with the family.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:21:16 +02:00
Erik
7f1c1f5aa6 feat(physics): C4 route 4b-2 — remote far snap through the canonical placement
Flips the SetPositionSimple classification (contact, PlayerDistance >= 96 m) for
remotes onto 4b-1's drive controller and deletes both legacy far blocks, both
duplicated 96f/4f constant pairs, and both `?? Vector3.Zero` fabrications. The
4 m constant now exists exactly once. Teleport and cell-less stay legacy for
4b-3.

Retail: MoveOrTeleport @0x00516330's far branch runs StopInterpolating
@0x005163CB before SetPositionSimple @0x005163D9 and returns 1 @0x005163E8
regardless — the SetPositionError is discarded — so HandleReceivedPosition arms
ConstrainTo @0x00454272 post-move on commit AND on failure. The x87 parity
decode at @0x00516393-@0x0051639E puts exactly 96.0 on the far branch.
SetPositionSimple @0x005162B0 builds flags 0x1012 at @0x005162C4.

Non-commit outcomes still advance the body, because retail's SetPositionInternal
@0x00515BD0 commits the destination via store_position @0x00515CE2 when no cell
resolves. The partition is by STAGE, not heuristic, enforced by an exhaustive
switch: Refused/Contention/NotApplicable/RejectedPreparation store (the placement
never executed); Committed/Deferred/RejectedByPlacement do not (the engine ran
and refused, matching retail's non-storing returns @0x00515CB2 and @0x00515CD5).
Without this a refused far snap froze the remote with an emptied queue.

Also fixes a shipped defect this route made live: ParkDeferred's quiescence parks
withdrew the entity (InWorld=false, clock suspended, residency removed) and were
never restorable, while Forget(restoreCancelledPark: true) runs for every
accepted Position on every entity. The restorable decision now lives inside
ParkDeferred AFTER SnapToCell, reading body.CellPosition.ObjCellId — the value
RestoreParkWithdrawal actually restores at — against every live quiescence
rather than one minimum-OperationId token. The three pre-snap fields are hoisted
into locals because SnapToCell ends with InWorld = true. ParkCollisionResidents
passes restorableOnCancel: false explicitly; the plain unplaceable park is
provably unchanged. RestoreParkWithdrawal re-tests the prefix at restore time so
a retained route-2 park cannot re-admit into a prefix that began quiescing
during the park.

CanAttemptDestination is retained as an OPTIMISATION only, with the two Core
predicates it cannot reproduce written down at the pre-flight, plus the two
properties that depend on it staying there.

Four fix rounds and eight Opus reviews. The slice was fully green at 10,990,
10,997 and 11,004 while containing real defects — a frozen remote pinned as
correct by its own test, a fallback that over-wrote on the exact retail paths
that decline to store, and a park guard incomplete on two independent axes.

Register: AP-137 (leftover classifications take AP-87's catch-up; states the
cell-less enqueue-vs-place delta deferred to 4b-3, that RejectedData is applied
anyway, and the headless divergence), AP-138 (the refusable far placement),
AP-136 narrowed to match the relocation. #309's acceptance steps rewritten —
step 5 previously asserted a recovery the code does not perform — and gated on a
new ACDREAM_PROBE_PARK=1 signal so the check cannot pass while broken.

Suite 11,009 passed / 4 skipped / 0 failed against a measured 10,968 baseline.
The 10,973 figure recorded earlier was wrong and is corrected here.

Connected gate outstanding: the two-client far-snap walk and #309.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 07:55:56 +02:00
Erik
eeec4fb42a diag(physics): remote landing-edge probe; record the two live jump defects
The user live-tested route 4a and reported two defects on player remotes: a
remote holds the falling animation after landing before finally landing, and a
remote jumping onto a house plants on the roof where retail slides off, then
blips to the slid-down position.

Neither is a route 4a regression. Do NOT revert 44830a0e — reverting would
restore the per-packet render slam 4a removed without touching either defect.

Bug B's root cause is identified and already covered by open issue #32, whose
text names both symptoms in one sentence. Both landing sites assert
TransientState |= Contact | OnWalkable unconditionally, where retail derives it
from the contact plane — CPhysicsObj::SetPositionInternal @0x00515330
(`if (contact_plane.N.z < floor_z) set_on_walkable(0) else set_on_walkable(1)`).
A steep roof is contact but NOT on_walkable; asserting both suppresses the slide
response, so the body sits until the server's positions walk 4 m away and
AP-87's threshold snaps it. That is the blip. Verified byte-identical pre-4a via
`git show 19d95094:`.

Bug B's *visible shape* IS 4a's: pre-4a every packet slammed the render entity
to the wire pose, so a stuck body flickered toward the true sliding position
5-10x per second — jitter rather than a clean hold.

Bug A stops at the goal's stop-condition rather than getting a speculative fix.
Three hypotheses with non-overlapping fixes; picking wrong means changing a
retail-ported gate on a guess. Retail's mechanism is already fully decoded, so
what is missing is OUR runtime state — no cdb trace against retail is needed.

Adds ACDREAM_PROBE_REMOTE_LANDING (PhysicsDiagnostics, read once at startup per
the diagnostic-owner rule, one bool check when off). It logs both landing sites
immediately before HitGround, and — the most diagnostic signal — emits a
separate line when a site is reached but the gravity gate is about to no-op,
which is hypothesis 1 (a wholesale Body.State write wiping the transient Gravity
bit mid-air, exactly AP-81's stated risk). Temporary instrumentation, marked for
stripping once the evidence is in.

Evidence recorded rather than new bugs filed: #32 gains the observation, the
root cause and the #173/AD-10 dependency caveat; AP-87 gains a live instance of
its stated risk; AD-10's stale file:line is corrected to RemoteMotionCombiner
with a note that its terrain-only normal cannot see a house roof at all.

Also files #308 — a SECOND flaky test, distinct from #302, which was twice
misattributed to it before being written down. #302 is a GC-allocation assertion
in App.Tests; #308 is a wall-clock deadline loop in Core.Net.Tests that fails
only under full-suite CPU contention (0 failures in 4 isolated runs). Conflating
them hides one, and an agent told to "ignore the known flake" would wave through
a real transport regression.

Gates: complete Release solution 10,938 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:10:03 +02:00