Commit graph

133 commits

Author SHA1 Message Date
Erik
cb6502c8a5 feat(platform): Campaign LA LA0 — extract ApplicationPathSet to AcDream.Platform
The launcher (LA3/LA4) needs the XDG/Windows path contract
(ApplicationPathSet/IApplicationPathEnvironment) without pulling in any
gameplay assembly. Move it out of AcDream.Runtime into a new BCL-only
AcDream.Platform project so the launcher-side Launcher.Core project can
reference it directly per the campaign plan (docs/plans/2026-08-14-launcher-campaign.md,
LA0). Namespace renamed AcDream.Runtime.Platform -> AcDream.Platform;
code is otherwise byte-identical (no logic changes).

AcDream.Runtime now carries a ProjectReference to AcDream.Platform and
re-exports it transitively, so App and Headless keep resolving the type
without a direct reference and K0's Headless single-ProjectReference
guard (HeadlessAssemblyReferencesOnlyTheRuntimeProject) stands unchanged.
The sibling Runtime dependency-boundary guard
(RuntimeProjectDeclaresOnlyApprovedProjectDependencies) does assert
Runtime's own project-reference set, so it needed a deliberate,
documented addition of AcDream.Platform to its expected list.

Moved tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs to
a new tests/AcDream.Platform.Tests/ project (namespace
AcDream.Platform.Tests) referencing only AcDream.Platform. Registered
both new projects in AcDream.slnx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:19:02 +02:00
Erik
067cbea8a5 feat: secure trade with other players - wire, RuntimeTradeState, the
authored gmSecureTradeUI window, and both retail open paths

Three-lane research first (docs/research/2026-08-14-trade-lane{A,B,C}):
retail gmSecureTradeUI decode, the byte-exact ACE/decomp/holtburger
three-way wire agreement, and the acdream seam map (which found both
open paths ALREADY classified by the ported policy - OpenSecureTrade on
Use-a-player, StartSecureTrade on drag-item-onto-player with the
DragItemOnPlayerOpensSecureTrade option - dead-ending at a stub toast).

- Core.Net: TradeRequests builders (0x1F6-0x204, retail's CM_Trade
  senders byte-checked against ACE's readers; the ACE-discarded
  AcceptTrade echo carries zero-count item lists - AD-94), corrected +
  completed inbound parsers (0x1FD-0x208; the old AddToTrade parser
  missed the SIDE dword, TradeFailure missed the reason), delegate-hole
  registrars, six WorldSession sends. 10 golden-byte tests.
- Runtime: RuntimeTradeState, the third sibling J-owner (fellowship/
  allegiance shape): session-scoped, clears at generation reset (new
  stage Trade=14), staged teardown stage 11 (Identity/EntityObjects
  shift 12/13, TeardownStageCount 14 - the FA2-era per-stage-flag test
  caught the mapping exactly as designed), combined ownership ledger,
  event routing with ACE's wrong-initiator RegisterTrade landmine
  honored (partner = whichever guid is not mine). 7 conformance tests.
- App: SecureTradeUiController binds the dedicated authored LayoutDesc
  0x2100000D (root 0x1000007A - gmSecureTradeUI::PostInit's exact ids):
  partner name/status/count/grid, the authored 'Trade' accept toggle
  (accept <-> decline withdraw), 'Clear All' (ACE clears BOTH sides -
  surfaced honestly), the X close, drop-on-your-grid staging, per-mode
  accept cues (partner icon's authored Highlight state + Trade button
  Selected latch). Mounted via the vendor recipe (nine-slice chrome,
  hidden until RegisterTrade). ItemInteractionController's two policy
  arms now raise SecureTradeRequested instead of the stub toast; the
  drag path queues the dragged item until the window registers
  (ClientTradeSystem::AttemptToTradeItem @0x0056DF80's shape).

Register: AD-94 (accept-echo zero-count lists), AD-95 (numeric-only
count texts pending template verification).

Suites: App 4,990/3, Core.Net 905, Runtime 1,626 - all green. The
panel itself is user-gate acceptance (two-client connected trade), the
#372-class lesson: fixture-green alone is not acceptance for a mount.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 11:49:13 +02:00
Erik
1390f9477d fix: airborne jump refusal fires at RELEASE, not press (user retail
gate; supersedes CH round-1 item A)

The user's retail description matches the decomp exactly:
charge_jump @0x005281c0 has NO grounded check - it refuses only 0x49
(CanJump encumbrance) and 0x48 (fallen/crouch-family forward commands).
Pressing jump while airborne begins the powerbar and charges normally.
The 0x24 "You can't jump while in the air" comes exclusively from the
RELEASE path (ClientCombatSystem::DoJump @0x0056B110 ->
CMotionInterp::jump -> jump_is_allowed, whose airborne 0x24 our port
already carries test-pinned). A charge held through landing executes a
normal jump on the grounded release.

PlayerMovementController's input orchestration now mirrors
CommenceJump/DoJump:
- Press edge: ChargeJump() decides; a refused charge (0x48/0x49)
  reports and never begins the bar (retail's jump_pending stays 0).
  The invented airborne press-edge 0x24 report (CH user-gate round 1
  item A - added when the press/release split was not yet known) is
  deleted; CommenceJump's in-air fallback text is unreachable with a
  faithful charge_jump.
- Hold: accumulates grounded OR airborne; leaving the ground mid-charge
  no longer force-fires the jump.
- Release: fires jump(); an airborne release refuses 0x24 there.

Tests: the round-1 press-edge test is replaced by two release-semantics
tests (airborne release reports once; held-through-landing grounded
release jumps silently). Runtime 1,619, App 4,987/3, Core jump family
159.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 08:31:22 +02:00
Erik
ab79b91f1b fix(headless): FA6 — name-match the Recruit bot instead of nearest-any-player
The second live gate run exposed a real environmental hazard: this shared
ACE dev instance has a THIRD player character online (+Je, guid
0x50000001), and after @teleallto it ended up nearer to the Leader bot than
the actual Recruit bot (+Horan, 0x5000000B). RuntimeFriendlyTargetQuery.
FindClosestOtherPlayer — "nearest ANY other player" — picked +Je, and the
fellowship recruit sent to it obviously never completed (confirmed live:
WaitRecruited/WaitForRecruit both timed out, both bots quarantined and
gracefully logged out cleanly).

RuntimeFriendlyTargetQuery.FindPlayerByName resolves the nearest player
whose streamed name matches exactly, with 3 new conformance tests
(preferring the named player over a closer stranger, returning null when
absent, and case-sensitivity/hidden/no-draw/self rejection).

FellowshipAllegianceGateCoordinator (AcDream.Headless.Policies) is a small
same-process, no-locking (single update thread) carrier for the Recruit
bot's own discovered character name — set by its own HeadlessSessionHost
the instant CharacterList selection resolves it, which IS D8's "discover it
live" mechanism, not a hard-coded value. Constructed once per
HeadlessProcessHost and threaded through HeadlessBotPolicyFactory.Create
into the Leader policy, which now name-matches instead of taking whichever
player entity happens to be closest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:53:46 +02:00
Erik
6b8e29cde6 feat(runtime): FA6 — RuntimeFriendlyTargetQuery, the friendly-target counterpart
RuntimeHostileTargetQuery only classifies hostile monsters (via
CombatTargetPolicy.IsHostileMonster) — the FA6 two-bot fellowship/allegiance
headless gate needs the OTHER bot's server guid as a FRIENDLY target instead.
RuntimeFriendlyTargetQuery.FindClosestOtherPlayer mirrors the hostile query's
shape exactly (same hidden/no-draw filtering, same landblock-absolute
distance metric), substituting the retail PWD-bitfield IsPlayer bit (0x8,
via the existing EntityCollisionFlagsExt.FromPwdBitfield decoder) for hostile
classification. TryGetName resolves the streamed WeenieHeader name for
reporting/logging.

4 new conformance tests mirror RuntimeHostileTargetQueryTests's fixture
pattern: cross-landblock distance, hidden/no-draw/self/non-player rejection,
null-without-player-or-target, and unresolved-guid name lookup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 09:39:42 +02:00
Erik
357d203202 feat(runtime): FA4 -- fellowship roster enumeration + Social selection source
IRuntimeFellowshipView.GetMembers() gives the fellowship panel a way to
enumerate the whole roster (TryGetMember alone needs the guid first,
which a UI roster build doesn't have yet). Implemented on
RuntimeFellowshipState.FellowshipView as a materialized snapshot under
the same lock every other read there uses.

SelectionChangeSource.Social covers a fellowship-roster row click
(gmFellowshipUI's list-selection arm calls the same
ACCWeenieObject::SetSelectedObject primitive every other selection
origin uses -- lane B docs/research/2026-08-11-fa-panel-structure.md
§6.2/§2.8).

Runtime tests: +4 (RuntimeFellowshipStateTests.GetMembers_*).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 04:40:19 +02:00
Erik
ded23067aa fix(net,runtime): FA2 fix-round SHOULD-FIX -- fellowship mechanism parity, lookup reuse, router test, checkpoint defaults
Remaining SHOULD-FIX findings from the FA2 mechanism/blast reviews:

Mechanism SF-3/SF-4 -- RuntimeFellowshipState.ApplyUpdateFellow now ports
Fellowship::RecalculateEvenXPSplitting @0x005B92E0 (called from retail's
AddFellow/UpdateFellow/RemoveFellow on every upsert/removal, but never
from a full update -- that carries the server's own authoritative flag
verbatim, lane B 6.2) and Fellowship::AddFellow @0x005B9480's
locked/departed admission gate (a brand-new guid is refused while
_locked unless it appears in the 0x02BE field-8 _fellows_departed table
within 900s, @0x005B94A5). ApplyFullUpdate now stores update.Departed
instead of discarding it. A TimeProvider dependency (defaulting to
TimeProvider.System, matching the RuntimeCharacterOptionsState precedent)
makes the 900s grace window testable.

Mechanism SF-5 -- RuntimeAllegianceState's TryGetMember/TryGetPatron/
GetVassals now reuse ClientCommandResponses.AllegianceProfileLookups
(promoted private -> internal, AcDream.Runtime added to Core.Net's
InternalsVisibleTo) instead of re-implementing the retail walk a second
time.

Mechanism SF-6 -- RuntimeStateCheckpoint's Fellowship/Allegiance
parameters are no longer trailing-optional. `default(RuntimeFellowshipSnapshot)`/
`default(RuntimeAllegianceSnapshot)` zero-init Name/AllegianceName to
null, and C# does not allow a non-constant `new(...)` as an optional
parameter's default value (CS1736) even when the struct declares an
explicit parameterless constructor -- so the only way to guarantee a
non-null default was to make the parameters required. Both snapshot types
still gained an explicit parameterless constructor for callers that want
an empty-but-safe `new()`.

Blast SF-4 -- LiveSessionEventRouterTests gains
FellowshipQuit_RoutesSelfGuidToClearAndOtherGuidToRemove, wiring real
RuntimeFellowshipState/RuntimeAllegianceState owners through the one
production registration site and dispatching a real 0x00A3 envelope for
both a self-quit and an other-quit -- the one non-trivial lambda in the
slice (the self-guid source that decides "remove one member" vs "clear
the whole snapshot") was previously untested; every other router test
defaults Fellowship/Allegiance to null.

Blast SF-5 -- RuntimeFellowshipState.ResetSession dropped its disposed
guard to match the precedent its own doc comment names
(RuntimeInventoryState.ResetExternalContainer,
RuntimeCommunicationState.ResetNegotiatedChannels -- both bare delegations
with no disposal guard); the reset transaction is retryable and disposal
is terminal, so a throwing guard could never converge on retry.
RuntimeAllegianceState.ResetSession (new this fix round) matches the same
shape from the start.

Blast SF-7 -- IRuntimeAllegianceView.GetVassals' per-call List<> allocation
is now documented as an intentional exception to the file's "Snapshot +
TryGet*, no allocation" view convention (C# cannot yield-return from
inside a lock).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:17:56 +02:00
Erik
4272ad0ea4 fix(net,runtime): FA2 fix-round MUST-FIX -- allegiance clears at reset, 0x027C stops seeding
Two MUST-FIX findings from the FA2 mechanism/blast reviews
(docs/research/2026-08-12-fa2-review-mechanism.md,
docs/research/2026-08-12-fa2-review-blast.md):

MF-1 (mechanism) -- RuntimeAllegianceState survived a generation reset,
contradicting retail (ClientAllegianceSystem::OnEndCharacterSession
@0x00569FA0 tail-calls AllegianceProfile::Clear at the same boundary
Fellowship already clears at), contradicting the precedent it cited
(RuntimeCharacterOptionsState.ResetSession clears-and-relatches, it does
not persist), and pinned by a test asserting the wrong behavior. Fixed:
RuntimeAllegianceState.ResetSession() clears the profile and drops
HasServerSeed; a new RuntimeGenerationResetStage.Allegiance stage runs it
on every generation reset, mirroring RuntimeFellowshipState exactly.
RuntimeGenerationResetTests' FellowshipClearsAtResetButAllegianceSurvivesReconnect
inverted to FellowshipAndAllegianceBothClearAtGenerationReset.

MF-2 (mechanism) / blast MF-2 -- 0x027C AllegianceInfoResponse fed the
Runtime allegiance owner (self-gated). Retail's own handler for 0x027C
(CM_Allegiance::DispatchUI_AllegianceInfoResponseEvent @0x006a7470) unpacks
into a stack-local profile destroyed on return; the consumer
(Handle_Allegiance__AllegianceInfoResponseEvent @0x0056a1d0) only prints
AddTextToScroll lines. Retail's panel is fed exclusively by 0x0020
AllegianceUpdate. The removed seeding also fabricated
RuntimeAllegianceSnapshot.Rank (0x027C carries no rank field) on any
client whose first allegiance message was a self @allegiance info query.
Fixed: dropped ApplyInfoResponseSelf, the onAllegianceInfoResponseSelf
delegate hole, and the self-gate; 0x027C is text-only again, matching
retail and the pre-FA2 shape.

Also covers blast SHOULD-FIX 1 in the same edit to LiveSessionEventRouter.cs:
the fellowship/allegiance delegate holes are now passed conditionally on
the owner being supplied, so GameEventDispatcher.GetUnhandledCount reads
correctly for callers without an owner (bare-ChatLog tests, a future
partial host) instead of silently reading 0 for 9 event types whose parse
result was discarded.

RuntimeAllegianceState.cs and the two owners' Apply* mutators also move
their ObjectDisposedException.ThrowIf checks inside the lock they already
take (mechanism SHOULD-FIX 2) -- the prior check-then-lock shape let an
inbound event on the decode thread race Dispose on the host thread and
repopulate state after _disposed = true, permanently falsifying
CaptureOwnership().IsConverged at teardown.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 02:17:04 +02:00
Erik
369729f06a feat(runtime): FA2 -- RuntimeFellowshipState + RuntimeAllegianceState sibling J-owners
Two new sibling Runtime owners under GameRuntime per the Slice-J
pattern (D2): RuntimeFellowshipState is session-scoped (a new
RuntimeGenerationResetStage.Fellowship clears it at every generation
reset, matching the ExternalContainer precedent); RuntimeAllegianceState
survives reconnect behind a HasServerSeed-style one-way latch and
participates in NO reset stage (its data persists like a real
disconnect does not sever allegiance membership).

Fellowship: full-update REPLACE, incremental-fellow UPSERT, self-vs-
other quit/dismiss removal (self clears the whole snapshot), disband
clear, and retail's leader hand-off rule for the Quit button
(RequiresLeaderHandoffBeforeQuit -- the current leader quitting WITHOUT
disbanding must send 0x0290 AssignNewLeader before 0x00A3, lane B
§2.5/§3.6).

Allegiance: seeded by AllegianceUpdate (0x0020, always self) and,
self-gated on TargetGuid == playerGuid(), by AllegianceInfoResponse
(0x027C); wraps the FA1-assembled flat AllegianceMemberRecord list
directly (AllegianceTree was deleted at FA1 -- nothing left to wrap).

Both apply the full 8-edit J-owner template: construction + fault
points + Owner/View properties + CaptureOwnership + a new
GameRuntimeTeardownStage pair (FellowshipDisposed/AllegianceDisposed,
stage count 11->13) + RuntimeGameplayOwnershipSnapshot inclusion +
RuntimeStateCheckpoint/trace fields. IRuntimeFellowshipCommands/
IRuntimeAllegianceCommands added to IGameRuntimeCommands and
implemented on DirectGameRuntimeCommandAdapter. No IRuntimeEventObserver
member added (D2) -- consumers poll Snapshot.Revision.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 04:31:34 +02:00
Erik
7b60e71b85 fix(headless,runtime): OP7 review fixes + docs: OP3 re-review REOPEN (narrow)
TWO work products share this commit (a staged-index collision between the
coordinator's docs commit and the OP7 fixer's staged files — content
verified complete and coherent; only this message was wrong before the
amend):

1. OP7 review fixes (all nine findings from
   docs/research/2026-08-11-op7-review.md):
   - M1: HeadlessSessionDescriptor is a record; WithAccount uses 'with' non-destructive record copy,
     so a future property cannot be silently dropped; direct-CLI
     regression test proves CharacterOptions survives --user/--password.
   - M2 root fix: LiveSessionEventRouter skips BOTH Replace and the
     options notification on a trailer-truncated PlayerDescription — a
     truncated re-seed can no longer install zeroed words under an armed
     latch for OP7's automation to flush into 0x01A1.
   - SF1: schema keys validate as ordinal strings against the allowed
     names (numeric / comma-combined aliases rejected). SF2: both-true
     fellowship exclusion rejected at load, naming both keys. SF3: the
     onLoginCompleteSent observer moved after transit.EndTeleport().
     SF4: production-hook coverage for all three LoginComplete sites.
     SF5: test-script OP7 wire expectation corrected (batched ids ride
     only the 0x01A1).

2. docs/research/2026-08-11-op3-rereview.md — OP3 re-review verdict
   REOPEN (narrow): M1 byte-decode independently re-verified (6a 07 at
   all six sites); residuals R1 (gate script promises a timestamp prefix
   acdream doesn't render), R2 (null-controller player-mode still
   refuses), R3 (dormancy pin lacks stimulus) — coordinator fixes follow.

Full Release suite at this tree: 12,956 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 03:22:54 +02:00
Erik
6f48e34152 fix(runtime,net): OP1 re-review residuals R1/R2/R3 (coordinator pass)
R1: the K4-load-bearing IsDirty pre-check moves into GameRuntime's two
once-allocated hook lambdas — SendBlob's closure environment is allocated
in FlushCharacterOptions's PROLOGUE, ahead of any guard inside the body,
so the clean-tick fast path must never enter the method at all. The body
keeps its check as idempotent defense only; the doc comment now describes
the real mechanism instead of overclaiming.

R2: RuntimeCharacterOptionsState gains a dirty-generation token. MarkDirty
bumps it on EVERY call (including while already dirty); TryFlush /
TryFlushIfAutoSaveDue capture it before invoking the callback and only
clear IsDirty when it is unchanged after — a dirtying change landing
DURING a flush (cross-thread, or re-entrant from the callback itself,
the re-review's NOTE-6 case) now stays dirty and flushes on its own later
trigger instead of being silently erased by the trailing clear. The S2
interleaving test now asserts the retained dirty state it previously
ignored; a deterministic re-entrancy test pins the same-thread shape.

R3: a trailer-truncated PlayerDescription parse carries zero placeholder
option words, not server truth — GameEventWiring now forwards
TrailerTruncated, LiveSessionEventRouter passes armServerSeed:
!trailerTruncated, and Replace withholds the 0x01A1 flush authorization
for truncated seeds while still installing the words (pre-existing local
behavior unchanged). Newly wire-reaching via the R1/MF-1 timer, hence
closed now rather than left a NOTE.

Full Release suite: 12,870 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 01:13:57 +02:00
Erik
09029f9f4b fix(runtime,net): OP1 review fixes — server-seed gate, tick-wired auto-save/logout flush, fellowship mutual exclusion
Closes the two mechanism-lens and blast-lens dual reviews of Campaign OP
slice OP1 (86c0a7e0): docs/research/2026-08-10-op1-review-mechanism.md and
docs/research/2026-08-10-op1-review-blast.md.

MUST-FIX M1 (blast): RuntimeCharacterOptionsState gains a HasServerSeed
latch, set by Replace (the PlayerDescription seed) and cleared by
ResetSession. TryFlush/TryFlushIfAutoSaveDue now refuse before the seed
arrives — closing the window where a bot (or, after this commit, the
timer/logout triggers) could flush client-default option words over a
character's real server-side options before any PlayerDescription ever
landed.

MUST-FIX 1 (mechanism): the 480 s auto-save timer and the pre-logoff
flush are now wired into production, closing TS-71 (retired). Both ride
LiveSessionController's own tick/stop transaction via two new hooks
(ConfigureAutoSaveTick/ConfigurePreLogoffFlush), wired once by
GameRuntime's constructor — a Runtime-internal change requiring zero
host edits, exactly as the review identified. The flush body talks to
WorldSession directly rather than through App's LiveSessionCommandRouter,
which is what keeps this off the S2 lock-order hazard (below). Filed
TS-73 for the two OnChanged side-effect cases (weather/day/combat-
target/fog) TrySetOption still doesn't model — pre-anchored to OP4's
Group B consumer binds.

SHOULD-FIX S2 (blast, prerequisite for MUST-FIX 1): TryFlush/
TryFlushIfAutoSaveDue no longer invoke the flush callback while holding
_dirtyGate — the decision is made and cleared under the lock, but the
callback itself runs outside it, closing the lock-inversion hazard the
natural timer wiring would have hit (Runtime tick's _dirtyGate-then-
_gate vs the router's _gate-then-_dirtyGate).

SHOULD-FIX MF-2 (mechanism): TrySetOption now ports the two
PlayerModule-state-mutating cases of CPlayerModule::OnChanged's local
side-effect switch — turning ON IgnoreFellowshipRequests or
FellowshipAutoAcceptRequests clears the other through a real recursive
TrySetOption call, reproducing retail's second 0x0005 (the clear's send
reaches the wire before the primary option's own send, matching the
nested-call order in the decomp). The signature widened from
Action sendAutoSave to Action<uint,bool> so the recursion can send a
different (id, value) than the caller's own; every production call site
now passes WorldSession.SendSetSingleCharacterOption directly.

SHOULD-FIX MF-3 (mechanism): a hand-transcribed 53-row (id, isOptions1,
mask) theory in CharacterOptionTableTests, independently re-derived from
acclient.h's PlayerOption/CharacterOption/CharacterOptions2 enums rather
than copied from CharacterOptionTable.cs — closes the one column with no
id-by-id pin. Also added the pairwise-distinctness check blast NOTE N7
named.

SHOULD-FIX S1 (blast): LiveSessionCommandRouterTests' CH3/CH4 regression
test now drives the REAL TrySetOption binding instead of a hand-rolled
SetOptionBit substitute that had silently drifted from production after
OP1.

SHOULD-FIX S3 (blast): RuntimeCharacterOwnershipSnapshot gains
OptionsAreClean (!Options.IsDirty), included in IsConverged — a module
whose two words happen to cycle back to their default bit pattern while
still dirty is now caught by the combined ownership ledger, not just by
OptionsAreDefaults.

SHOULD-FIX S4 (blast): SaveOptions no longer encodes "did it actually
flush" as PrimaryObjectId 1u/0u (which read as object guid 0x00000001 in
the K2 event stream). Both host adapters now report the identical shape
(Accepted, objectId 0) — the graphical host never could report this
anyway (LiveCommandBus.Publish has no return channel).

SHOULD-FIX S5 (blast): Replace (the server-seed arrival) now also clears
IsDirty/FirstDirtiedAt — a wholesale re-seed supersedes any pending
batched-but-unflushed local intent (retail's own PlayerModule has no
partial-merge path either), documented at the member.

SHOULD-FIX S6 (blast): a cross-check theory asserting CharacterOptionTable's
masks equal PlayerDescriptionParser.CharacterOptions1/2's independently
(the write path vs the read path TurbineChatMembershipGate/
RuntimeSettingsController consume) — guards the exact CH3 failure class.

Also fixed a real allocation regression found while landing MUST-FIX 1:
the naive per-tick flush closure would have allocated on EVERY
LiveSessionController.Tick() call regardless of dirty state, which broke
the K4 headless 30-session resource-envelope gate. GameRuntime.
FlushCharacterOptions now pre-checks Options.IsDirty (itself retail-
faithful — CPlayerModule::UseTime opens with the identical m_bDirty byte
compare) before allocating the flush closure, so the allocation only
happens on the rare tick that might actually flush.

Dispositions on findings not changed this round:
- Mechanism NOTE 6 / not independently re-flagged: a re-entrant MarkDirty
  from inside a flush callback can still be erased by the trailing
  "_isDirty = false" — pre-existing, unchanged by the S2 lock restructure
  (same outcome whether the callback runs inside or outside the lock),
  not reachable from any current caller, not a one-liner to close
  correctly (needs a per-dirty-period generation token). Left as documented
  in the review; worth closing before the Options panel ever flushes from
  inside a change handler.
- Mechanism NOTE 9, blast N2/N3/N4/N5/N6/N8: informational or require
  touching files this round doesn't otherwise edit (SocialActions.cs,
  CharacterOptionsBlobSource.cs, GameRuntimeContractTests.cs) — left per
  the "one-liner in a file already being edited" instruction.

Register: TS-71 retired (both remaining SetCharacterOptions flush
triggers now production-wired); TS-73 filed (the two unmodeled OnChanged
presentation-binding cases, pre-anchored to OP4).

Quality bar: Release build green; full solution suite 12,853 passed / 4
skipped / 0 failed (baseline 12,770/4/0 post-OP2 — 83 new tests added,
zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 00:39:51 +02:00
Erik
86c0a7e0ee feat(runtime,net): Campaign OP slice OP1 — full character-option table, dirty model, real 0x01A1 blob builder
The retail Options panel (Campaign OP) needs a Runtime-owned option map
covering all 53 PlayerOption ids and the real batched SetCharacterOptions
(0x01A1) blob before any UI can be built on top of it. Today's surface only
modeled 6 ListenTo*Chat ids and the 0x01A1 builder was a malformed 16-byte
stub (deleted at Campaign CH slice CH3, docs/research/2026-08-09-chat-side-
channels-vs-ace.md).

- CharacterOptionTable.cs: the ONE typed table, PlayerOption id (0x00..0x34)
  -> (Options1/Options2 word, mask, IsAutoSave, ClientDefault), transcribed
  from acclient.h's verbatim CharacterOption/CharacterOptions2/PlayerOption
  enums and byte-verified against IsAutoSaveOption @0x0059A600 (the 21-id
  auto-save table) and GetDefaultOptionValue @0x005D2A30 (the Defaults-
  button table). Reconstructing CharacterOptions1/2 defaults from the
  ClientDefault column independently reproduces 0x50C4A54A / 0x00008700,
  cross-confirming the id-mask mapping. CharacterOptionId (SocialActions.cs)
  widened from 6 to all 53 ids to match.
- RuntimeCharacterOptionsState: SetOptionBit now resolves through the full
  table (was a 6-case switch). New TrySetOption is the ONE shared local-
  write-then-send/dirty seam — mirrors CPlayerModule::OnChanged exactly:
  write the bit locally first, then either send 0x0005 immediately (auto-
  save ids) or MarkDirty for the batched blob, no-op on an unchanged value
  (retail's own early-return) or an unmodeled id. New dirty model (IsDirty/
  FirstDirtiedAt/MarkDirty/TryFlush/TryFlushIfAutoSaveDue) uses an injected
  TimeProvider so it's fully unit-testable without a live clock.
- Both IRuntimeCharacterCommands.SetSingleOption adapters (Direct + Current)
  now route through TrySetOption instead of duplicating the write; this
  fixes the headless local-write gap the OP1 research flagged (the direct
  adapter previously sent the wire message without writing the bit first,
  same class of bug CH4 fixed for the graphical host). Both also reject an
  id outside the table instead of silently accepting it. LiveSessionRuntime
  Factory's SendSingleCharacterOption closure now delegates to the same
  seam instead of duplicating write-then-send inline.
- New IRuntimeCharacterCommands.SaveOptions(generation) — the explicit
  blob-flush verb (retail's SaveToServer(force: 0)) — wired end-to-end in
  both adapters, including a new SaveCharacterOptionsRuntimeCmd on the
  graphical router.
- SocialActions.BuildSetCharacterOptions + WorldSession.SendSetCharacterOptions:
  the real PlayerModule::Pack body per the wire research's field-by-field
  layout — header always 0x460 OR'd with 0x001/0x008 when shortcuts/desired
  comps are non-empty, favorite spells always 8 lists, never sets 0x100 or
  0x200. Echoes last-parsed shortcuts/favorites/desired-comps/spellbook
  filters (via new CharacterOptionsBlobSource) instead of zeroing them.
  Conformance: a hand-computed golden byte vector (not generated by the
  builder under test — the CH3 builder died of tests that pinned a wrong
  shape and looked green) plus a round-trip through PlayerDescriptionParser.

Contract deviation: the 480 s auto-save timer and the flush-before-logout
trigger are implemented as fully-tested pure state-machine logic
(TryFlushIfAutoSaveDue) but are NOT wired into either host's live per-frame
loop or graceful-shutdown sequence in this slice — only the explicit
SaveOptions verb is production-wired. Wiring the timer touches App's
UpdateFrameOrchestrator graph and Headless's tick loop (outside this
slice's Runtime/wire-layer scope); wiring logout risks the already-fragile
graceful-shutdown sequence CLAUDE.md flags. Filed as TS-71 per the plan's
own escape valve ("target: not deferred" with a register row if deferred).
Also filed: AP-193 (the 0x34 HearPKDeathMessages id/mask is ACE-sourced,
unverifiable against the 2013 binary) and AP-194 (GetDefaultOptionValue's
table disagrees with the constructor default for ConfirmVolatileRareUse/
ShowHelm/ShowCloak — retail's own quirk, reproduced not fixed).

Tests: table completeness x53, auto-save/client-default split pinned
id-by-id against the byte-verified tables, unknown/reserved-id rejection
(0x35/0x36 landmines), local-write-then-send on both adapters + the router,
the dirty/flush state machine, SaveOptions, and the wire golden vector +
PlayerDescriptionParser round-trip. Full Release suite: 12,745 passed / 4
skipped / 0 failed (baseline 12,611/4/0 — slice adds 134 passing tests,
zero regressions).

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:10:20 +02:00
Erik
41b408f3e6 fix(headless): #365 — collision-admission-open window drove the first-entry conductor into a permanent seal refusal
Root cause (measured live via ACDREAM_PROBE_PARK=1): HeadlessSessionWorldProjection
drove the first-entry conductor unconditionally, including while
HeadlessCollisionNeighborhood's own 3x3 publication plan held a genuinely open
RuntimeCollisionAdmission for the local player's landblock. Every
TrySealCollisionEvaluationAuthority attempt during that window failed
(IsCollisionEvaluationPrefixAdmissible false) and retried forever without
recovering — measured verdict: "seal-refused" repeating with no preceding
[rearm] verdict= line (the operation never even reached the AwaitingCell park).
This is the diagnosis doc's "structural half" mechanism; no evidence of the
"circular HasOldPrefixPlacementDebt" hypothesis was observed, so that shape
was not needed.

Step 1 (enabler): HeadlessStaticStateAudit.ValidateProcessIsolation now takes
sessionCount and only refuses process-global physics probes for
sessionCount > 1 — its own multi-root-attribution rationale never applied to
a single session, and it was blocking the exact probe built to diagnose this
class of stall.

Step 3a (root cause): new IHeadlessCollisionNeighborhood.IsQuiescent gates
ProjectSpawn/ProjectPosition/PumpFirstEntry's conductor-drive calls — the
conductor is never driven while the neighborhood's own publication owns
collision authority for that tick.

Step 4 (defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer now
requires Controller.CanExecuteLiveMovement instead of just a non-null
controller — the headless-only gap that turned the (now-fixed) hydration
stall into a hard crash reaching SuspendObjectUpdate on a dormant controller.
RuntimeLocalPlayerFrameController's three shared entry points gained the same
guard, contract-preserving for the graphical host.

Verified end-to-end against live ACE (jump-probe policy, three runs):
hydration succeeds cleanly (136 entities load vs. 0 before), no seal-refused
spam, no crash from the original bug, graceful logout every time. Full
airborne-transition confirmation is blocked by a separate, newly-discovered,
pre-existing defect filed as #368 (the headless scheduler's
Task.Delay(...).ConfigureAwait(false) tick loop can resume on a different
ThreadPool thread mid collision-generation, tripping
EnsureCollisionMutationThread) — explicitly out of scope here, not mentioned
anywhere in the #365 diagnosis, and unsafe to fix without graphical-host
verification this session was constrained not to perform.

New tests: the real-admission hydration test (fails on the pre-Step-3a tree,
verified by temporarily reverting the three gates and confirming failure,
then restoring), the PumpFirstEntry quiescence-gate test, the
CanAdvancePlayer publication-lifecycle test, the dormant-controller
sabotage tests for RuntimeLocalPlayerFrameController, and the audit
single/multi-session tests. RuntimeLocalPlayerPhysicsPublicationStateTests
is untouched.

Full Release suite: 12,343 passed / 4 skipped / 0 failed (baseline ~12,330/4
plus 11 new tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 11:34:36 +02:00
Erik
a5a7eb4fb6 fix(runtime): production controller install never wired OnInterfaceText — the jump-in-air silence
Round-3 probe evidence pinpointed it: '[jump] ReportJumpRefusal
result=NotGrounded hasCallback=False' — the edge detection, OnWalkable
clearing, and refusal dispatch all worked; the callback was null because
CommitRuntimeOwnedController (the C3c production publication path) writes
_controller directly and never applied _onInterfaceText the way the
internal setter does. Two install paths, one wired. Regression test pins
the commit path.

Runtime tests 1,323/0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 10:01:05 +02:00
Erik
47e40900f3 fix(chat): Campaign CH user-gate round 1 — jump-in-air edge, portal cue cadence, wrap/prefix/color fixes
The user tested Campaign CH's CODE-COMPLETE build live and reported ten
defects (docs/plans/2026-08-09-chat-parity-campaign.md, "User gate —
round 1"). Items A-G are fixed here; the remaining three (extra chat
windows on 1/2/3/4, resize working in only one corner, transparency/
artifacts) are out of scope for a fix and filed as slice CH6.

A. Jump-in-air refusal never fired live: the jump block only ever
   evaluated input.Jump inside the grounded-charge or already-charging
   branches. PlayerMovementController now detects the press RISING EDGE
   while airborne and reports WeenieError.NotGrounded once per press,
   leaving the grounded charge/fire path untouched.
B. ChatVM's invented "[System] " prefix is dropped — retail prints
   system text bare. [Popup] is unchanged (AP-175).
C. SpewBoxController's color is now the user-pinned exact value
   (1, 1, 0.247, 1), the same bright yellow as an incoming Tell.
   Register row AP-178 updated: color CLOSES, size/position/font stay
   open per the user's live report that they still differ.
D. Closes #329: PortalTunnelPresentation now emits the portal wait cue
   unconditionally on every rotation-segment boundary, matching
   gmSmartBoxUI::UseTime's decompiled else-arm exactly instead of gating
   on a 5-second hold local transits never reached. PortalWaitNotice
   Controller now renders it in the same pinned yellow as item C.
   Register row AP-150 retired.
E. Closes #362: new ClientCommandResponses.cs parses and renders the
   four previously-unhandled inbound GameEvents (ChannelIndex,
   ChannelList, AvailableHouses, AllegianceInfoResponse), each ported
   line-for-line from the named-retail decomp's inbound handlers.
   Register row TS-70 retired.
F. ChatWindowController.WrapText now splits on embedded '\n'/'\r\n'
   first, then word-wraps each segment independently — server text like
   /help's reply no longer collapses onto one line.
G. The chat input field's right edge no longer holds a fixed absolute
   pixel position across a window resize; Bind now upgrades it to
   retail edge-mode 1 (UiLayoutPolicy) or the AnchorEdges.Right stretch
   fallback so it tracks the window's client width instead of
   overflowing past a narrower resize.

Full Release suite: 12,247 passed / 4 skipped / 0 failed (baseline
12,221/4/0 + 26 new tests across items A, E, F, G).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 23:42:32 +02:00
Erik
724ef2d389 fix(chat): CH4 review fixes — allegiance ownership guard, house-abandon confirmation
Blocker 1: an unrecognized "@allegiance <sub>" subcommand escaped
TryMatchAllegiance (which only claimed "info"/"hometown") and fell through
the unregistered-tag channel fallback, broadcasting the raw subcommand
text to the Allegiance chat channel (0x02000000). Retail's own
DoAllegiance never reaches DoChannelCommand for an unrecognized
subcommand — it claims the whole verb and prints its own client-local
refusal. TryMatchAllegiance now claims "allegiance"/"all" unconditionally
and shows retail's "Please see @help Allegiance..." text; ChatCommandRouter
also gained a blanket RetailClientCommandCatalog.KnownVerbs ownership
guard in TryDispatchChannelFallback as defense in depth.

Blocker 2: "@house abandon" sent 0x021F immediately with no confirmation.
Retail runs a real two-stage dialog before Event_AbandonHouse(); ported
both verbatim strings and chained two ShowConfirmation calls.

Should-fixes: a bare unregistered tag with no text now passes through
silently instead of showing a refusal that belongs to a different retail
function; @join/@leave update RuntimeCharacterOptionsState locally (new
SetOptionBit) before the wire push so the Turbine membership gate stops
refusing a just-joined room; @permit accepts multi-word names; @clist/
@on/@off validate shape only and raise WeenieError 0x422 for an unknown
tag; @mr/@pr help text is now the verbatim retail strings; corrected
issue #360, register row TS-68, the campaign doc's B.7 note, and a stale
RetailChannelTagTable comment; filed issue #363 + register row AP-183 for
the deferred error-typing debt.

Nits: fixed TryMatchHouse's stale doc comment, the AP-182/@title "stores
the value" comments (the binding is a no-op), IsUnregisteredFallbackTag's
olthoi false-positive, added /g and /rp binding-level conformance pins,
made @index ignore extra arguments, and noted the six removed invented
verbs in ISSUES.md.

Suite: 12,216 passed / 4 skipped / 0 failed (Release), up from CH4's
12,190/4/0 — net +26 tests, no removals.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 20:24:29 +02:00
Erik
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
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
78b981cca0 fix(runtime): transient collision-seal failure no longer terminal for login (#357)
Login could hang forever at reveal ready=True with the world never
revealed: UI and sky drawn, geometry absent, client healthy. The player's
first-entry conductor was being terminally dropped by a TRANSIENT
condition.

Mechanism, pinned by probes: the C3c-F2 rearm guard validates the exact
destination cell's prefix admissibility before moving the dormant lease
out of AwaitingCell, but the placement transaction's ring search touches
NEIGHBOUR landblocks and TrySealCollisionEvaluationAuthority covers every
touched prefix. A hard login recenter admits nine landblocks at once, so
a rearm taken while a neighbour's admission was still registered passed
the guard and failed the seal. The operation was left in
AwaitingPreparation, IsDormantLocalActivationAwaitingCell went false, and
EvaluateActivation had no way to say 'retry' - it fell through to
RejectedAuthority, which RuntimeFirstEntryDriveController treats as
terminal. The local player left the pump (pending=0), the movement
controller never published, auto-entry never fired, the reveal never
completed. Timing-flipped: the same binary worked when the rearm landed
outside a neighbour's admission window, then lost that race consistently.

Fix is classification, not state: EvaluateActivation reports DeferredCell
when the abort happens while the dormant lease is still current
(IsDormantLocalActivationLeaseCurrent), so the conductor keeps retrying.
The operation deliberately stays in AwaitingPreparation - the retry
re-runs the full evaluation against fresh state, which is the recovery
contract the publication-state tests already pin (the SAME token
evaluates Evaluated once the authority settles). Genuine discards still
report RejectedAuthority. A first attempt that re-parked the lease to
AwaitingCell was rejected by the test matrix: recovery would then need
the rearm gate, which is stricter than the seal, and the
reentrant-restriction-mutation recoveries hung in DeferredCell.

Seven publication-state tests move their transient-abort assertion from
RejectedAuthority to DeferredCell; the two genuinely-terminal tests
(lease retired) are unchanged. The [wake]/[rearm]/[pump] probes that
pinned the mechanism stay behind ACDREAM_PROBE_PARK=1 with the rest of
the C4 family.

Exonerated by experiment before the fix: ACE (wire capture shows
PlayerCreate sent; retail logs in fine) and the portal-cue commit
2914e43a (full revert stalled identically).

Verified: 2/2 live logins reach auto-entered player mode and reveal
event=complete, with the probe showing seal-refused -> retry -> recovery
in flight; full Release suite 11,740 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 12:32:53 +02:00
Erik
8bc458fb88 feat(audio): Campaign A slice A3 — the server sound channel (0xF750)
acdream never parsed retail's Sound event, so every server-driven cue was
silent: melee hits and wounds, wield/unwield, pickup/drop, lockpicking,
lifestone bind, spell resist, trap triggers, item mana depletion.

SoundEvent parses the 16-byte message (guid, SoundType, f32 volume) whose
layout three oracles agree on: retail CM_Physics::DispatchSB_SoundEvent
@0x006AC760 reading buf+4/+8/+0xC, ACE's GameMessageSound at declared
length 16, and holtburger's PlaySoundData.

Playback reuses EntityEffectController's existing per-guid queue rather
than adding a second one, because retail routes sounds through the SAME
CObjectMaint blob queue as F754/F755: an event for a guid the client does
not know yet is parked and drained by HandleCreateObject, so a creature
that spawns and immediately grunts still grunts. Dropping it — the
obvious alternative — would silently lose the cue. Sound joins Direct and
Typed as a third PendingEffect kind so one readiness edge releases the
whole mixed stream in order.

AudioHookSink.PlayServerSound reproduces two decoded asymmetries with the
animation-hook path: the sound plays at the WIRE volume and the
SoundTable entry's volume is ignored (the hook path does the opposite),
while the entry's probability still gates it and its priority still
drives eviction. An object with no SoundTable plays nothing, matching
CPhysicsObj::play_sound @0x0050F460's early return.

The no-window host parses and discards, exactly as it does for F754/F755
— sound is presentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 22:07:23 +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
5224e43890 fix(vendor): gate-findings pass — the X button HIDES like retail, clicks return, the dropdown scrolls, pyreal suffix, staged-tab slots
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 user's connected gate found five issues; each fixed at the root:

G4 (the discovery): retail's vendor X button calls only SetVisible(0)
(pc:204147-204182) — the SESSION stays open and re-using the vendor
lands on the same-session refresh; the range watcher remains the sole
real close. Our port invented a full teardown on X, which is exactly
why reopening died. The Runtime fixture proves the wire dispatch was
never the problem; ACE has no already-open short-circuit.

G3 (regression from the drag-suppression fix): denying IsDragSource
also dropped press capture, so clicks fell through to window-drag.
UiItemSlot.HandlesClick now claims presses for any occupied cell
independent of drag eligibility — clickable and draggable are separate
concerns.

G5: the authored popup 0x21000043 is ONE scrollable column with a real
scrollbar subtree (live-dat scan: ListBox 0x10000350 + scrollbar
0x10000351), not a 3x6 grid. UiMenu gains an authored-driven
Scrollable mode (wheel, thumb drag, track paging, up/down buttons);
chat's menu is untouched and its ten tests prove it.

G1: retail's cost format is "%s %hsp (you have %hsp)" — the p after
each %hs is a LITERAL pyreal suffix the port swallowed as part of the
specifier. Restored.

G2: the Buying/Selling pages' authored lists (same cell template as
Items) get the empty-slot fill, presentation-only until staging.

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-08 10:29:39 +02:00
Erik
3c9fc57adb fix(vendor): Slice 6 review corrections — ownership-checked retire, live slider display, drag-proof shop rows, hardened buy reservation
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 findings from the buy-arc review, at root:

F1 the materializer's retire pass re-checks ownership (guid->vendorId
map; remove only while the live object's ContainerId still equals the
recording vendor) — buying a player-sold UNIQUE no longer deletes the
item you just purchased; the discriminating reparent-then-refresh test
pins it. F2 the cost/name display subscribes to the live split state
and shares ONE quantity computation with Buy (retail re-renders per
slider tick: RecvNotice_StackSliderChanged 0x004C4500) — the sentence
and the charge can no longer disagree. F3 shop rows never mint drag
payloads (UiItemSlot.AllowDragSource gates both IsDragSource AND
GetDragPayload — the second gate was caught by this pass's own test).
F4 sendBuy reports whether anything was sent; a null-session buy
cancels the reservation instead of leaking BusyCount forever.
F5 the retire loop snapshots, isolates per-guid observer failures, and
clears its tracking in finally and Dispose — teardown convergence can
no longer wedge. F6 auto-select is retail's unconditional
first-filtered-item shape (pc:201180-201184; the survival-check was
our invention and the comment claiming otherwise is corrected).
F7 non-stack buys clamp to quantity 1 locally (BuySingleItem
pc:201669). F8 the Add button is hard-disabled until staging exists.
F9 AP-161/162/163 rewritten to the post-fix reality.

Clean-room complete solution: 11,378 passed / 4 skipped / 0 failed.
The #350 render-ledger overflow observed this session is under
separate investigation and is NOT addressed here.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 23:12:50 +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
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
9796d71522 feat(runtime): Slice 5.3 — RuntimeInventoryState owns the vendor browse session
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 sole VendorState joins the J4.2 inventory owners: populated by the
new 0x0062 ApproachVendor route (parse via VendorApproach, wire-to-
domain mapping at the routing seam, silent-drop on malformed like every
sibling), borrowed by both graphical and headless hosts, and torn down
through the EXISTING ExternalContainer reset stage — session reset,
portal-out, and logout all funnel through the one mechanism. Close is
client-local per retail (nothing on the wire): a range watcher rides
the existing per-advanced-frame publishMovement callback, using the
vendor's own authored UseRadius (ACE's 0.6 m fallback when absent).
The dormant ItemInteractionController ActiveVendorId seam is finally
wired as a live delegate — real id while open, 0 the moment the
session clears.

AP-160 filed in this same commit: the watcher measures plain 3D center
distance rather than retail's cylinder-gap, because Runtime has no
per-NPC collision radius/height source; bounded sub-meter, client-
local UI only.

Twelve Runtime tests: populate/field mapping, vendor replacement,
range clear + within-range retention, all three generation teardowns,
the ActiveVendorId seam, malformed-event drop. Clean-room complete
solution: 11,302 passed / 4 skipped / 0 failed.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-08-07 15:37:12 +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
ec29a732f5 test(physics): settle #331 — the uphill "refusal" is the #137 anti-parallel absorb, not a defect
#331 reported that `PhysicsEngine.ResolveWithTransition` refuses ALL uphill
motion whenever a `body:` is supplied. It does not. It refuses a step whose
sub-step offset is exactly anti-parallel to a live sliding normal — the
#137-family absorb this project already recorded as retail-faithful.

Measured on the same fixture, same gradient, same body, varying only the
heading relative to the slope gradient:

  (0,     -0.1, 0)  cross-slope 0        -> zero movement, latched
  (0.0001,-0.1, 0)  cross-slope 0.0001 m -> zero movement, latched
  (0.001, -0.1, 0)  cross-slope 0.001 m  -> climbs 0.176 m in 5 ticks
  (0.01,  -0.1, 0)  cross-slope 0.01 m   -> climbs 0.176 m in 5 ticks

The threshold is retail's own F_EPSILON small-offset abort (0.0002 m): about
0.11 degrees off the exact gradient at a 0.1 m step. `RemoteRampHarness`
builds a ramp whose gradient is exactly along Y and the original probe pushed
exactly along -Y, so it hit the measure-zero case with probability 1.

The latch itself is production-real in mechanism — a pure gravity fall under
the production RuntimeRemotePhysicsUpdater, with no fixture settle seam
involved, lands leaving Contact|OnWalkable|Sliding with slidingNormal (0,1,0)
— but every link is faithful to retail, verified in the PDB-paired binary
rather than Binary Ninja (BN typed find_transitional_position `void` and
dropped the load-bearing return value):

  validate_walkable sets collision_normal from the terrain plane when
    OBJECTINFO CONTACT is clear      0x0050d251 / 0x0050d261 / 0x0050d26c
  validate_transition converts it unconditionally  0x0050ac19-0x0050ac30
  set_sliding_normal zeroes Z AND re-normalizes    0x0050a060
  SetPositionInternal persists SLIDING_TS          0x005154c2 / 0x005154e1
  get_object_info re-seeds it next frame           0x00511d44 / 0x00511d4f
  find_transitional_position returns
    `i != 0 && state == OK` on the step-0 abort     0x0050c0ed -> 0x0050c089

ACE agrees (Transition.cs:1027, CollisionInfo.cs:58). No production code
changed; no divergence introduced, so no register row.

What lands is the coverage whose absence made this invisible — nothing in the
suite asserted that a body-bearing mover makes uphill progress on a walkable
slope, and the test that found #331 passed vacuously because the body never
moved:

  RuntimeRemoteUphillProgressTests.ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt
    per-tick climb + surface tracking under a realistic off-gradient heading.
    SAB-A1 AdjustOffset -> Vector3.Zero            reddens at tick 1
    SAB-A2 fixture gradient -> 0 (flat)            reddens at tick 1
  RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal
    characterization pin for the absorb, with the retail anchors inline.
    SAB-B1 delete the get_object_info sliding seed  reddens (climbs to 57.7544)
    SAB-A1                                          reddens
    SAB-A2                                          reddens
    NON-discriminating, measured and documented: making the final tick
    exactly up-slope leaves it green — by then the latch is already cleared.

RemoteRampHarness gains a warning block naming the axis-alignment trap so the
next vacuous uphill assertion is caught at authoring time.

Suite re-measured from a full clean (43 bin/obj removed): 11,198 passed /
4 skipped / 0 failed, against the 11,196/4/0 baseline at 0d62a5ff — exactly
the two tests added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 14:08:22 +02:00
Erik
fe6ee877d1 test(physics): measure whether the remote sweep alone tracks surface Z (AD-10 Stage 0)
AD-10 claims the remote slope projection is "relocated" out of the sweep
because "remote bodies don't run a full local transition sweep". That
justification is false at HEAD: RuntimeRemotePhysicsUpdater.Tick calls
PhysicsEngine.ResolveWithTransition with the remote's own body, and that
sweep runs acdream's verbatim port of CTransition::adjust_offset
(0x0050a370, pc:272271-272393) once per sub-step. So the boundary
projection is an EXTRA layer, not a relocation — and whether it is doing
anything the sweep does not is a measurement, never an argument.

This commit builds the fixture for that measurement and changes no
production code.

RuntimeRemoteSteepContactSlideTests' private Harness is extracted to
RemoteRampHarness so the new tests share it instead of cloning ~180 lines.
The extraction is behaviour-preserving; its only additions are the
fixture's own TerrainSurface (so an assertion about "is the body on the
surface" is answered by the surface geometry rather than by
re-implementing what the code under test computed), a SurfaceZ helper, and
a Tick overload that supplies a per-frame body-local root displacement —
the locomotion-cycle push a running remote actually carries. All ten Bug B
tests pass unchanged against it.

RuntimeRemoteSlopeProjectionTests then drives the production tick 30 ticks
down a 31-degree walkable ramp and asserts, on EVERY tick rather than at
the end, that the body's root stays within 5 mm of its settled offset from
the terrain beneath it. A staircase catching up on the final tick would
pass a start/end comparison; 30 unprojected ticks accumulate ~1.8 m.

Sabotage results, all from clean builds (bin/obj deleted), reported in
both directions:

  * Discard the sweep's answer (Body.Position = postIntegratePos instead
    of resolveResult.Position): RED at tick 1, body 0.05999 m off the
    surface. This is the tracking test's discriminating sabotage.
  * Flatten the ramp to gradient 0: RED on the anti-vacuity guard
    (dz = 0.0000 m). That guard exists because the tracking assertion
    passes trivially on flat ground, where Z never has to move.
  * Short-circuit Transition.AdjustOffset to `return offset;`: GREEN.
    Recorded, not hidden — it is the reason the contract's proposed T1
    sabotage was rejected. On terrain the sweep has a SECOND independent
    way to plant Z: ValidateWalkable's push-out re-seats the sphere at its
    natural resting distance from the terrain plane every sub-step.
    Removing the step-down probe as well does not change it either
    (measured). The tests therefore assert the OUTCOME the projection
    exists for, and say in their own doc comments that they are not unit
    tests of adjust_offset and must not be cited as such.

One test the contract asked for is deliberately absent. An uphill
counterpart was written, passed, and was then found VACUOUS: on this
fixture ResolveWithTransition returns ok=False for uphill motion and the
body does not move at all, so it "tracked the surface" by standing still.
That finding is filed separately rather than shipped as a green test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 09:34:56 +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
9ee9c1a1a6 fix(runtime): close the C5b re-review findings — Gate A narrowing filed, no-window payload gate, bisect hazard recorded
Both C5b re-reviews returned PASS on 02578441..ff100cf3. This lands the
bookkeeping corrections they left, the one gate asymmetry both found
independently, and one wrong retail fact neither of them caught.

1. AP-148 / #325 — Gate A's teleport test, wrong on primary source twice.

The C5b contract stated retail's Gate A teleport term as "TELEPORT_TS
equal" (and, in the trace block, as "must NOT be newer") and blessed
acdream's `teleport == _timestamps[Teleport]` as retail-exact. Disassembly
of the PDB-paired binary at SmartBox::HandleReceivedPosition
0x0045402B-0x00454054 says otherwise: the shortcut is taken iff the wire
stamp is equal OR newer (wrap-safe) — `sbb eax,eax / neg eax` materialises
the carry of the compare and the branch skips Gate A on CF, i.e. only when
the wire stamp is strictly OLDER. It is CPhysicsObj::newer_event
@0x00451B10's identical idiom with the operands swapped. Binary Ninja drops
the flag test and renders it `if (-((eax_7 - eax_7)) == 0)`, always true —
which is why two rounds of reading pseudo-C recorded it backwards.

So acdream's ForcePosition disposition is a strict SUBSET of retail's Gate
A set, and a local ForcePosition carrying a newer teleport stamp is
misrouted into a full Apply: wire heading instead of preserved heading,
unparent, possible placement frame, zeroed velocity, TELEPORT_TS advanced,
and OfferTeleportDestination called for a packet retail never starts
presentation for.

PhysicsTimestampGate.cs is NOT changed. The predicate exists twice (also
ValidAcceptedAuthority's PreviousTeleport == AcceptedTeleport), and the fix
has to decide TELEPORT_TS's disposition on a Gate A path that has never
seen a stale-but-equal pair. #325 records all of it and says explicitly
that it is not a one-line comparison swap. C5b made this marginally
better, not worse: clearParent was unconditionally true before C5b and is
unchanged; installPlacementFrame moved toward retail's HasAnims gate.

2. Retail F2 / architecture L-A — the no-window route had no pre-merge
payload validation. Root fix, not a documented asymmetry.

The graphical route validates before the merge (OnPosition's payloadIsValid
-> LiveEntityInboundAuthorityGate's !payloadIsValid return); despite its
name CanAcceptPositionPayload is not projectile-scoped. The no-window route
had no equivalent, and since D1 fed an unvalidated LandblockId into
CommitWireCellRebucket — where 0 is the withdrawal shape, silently
de-residencing the entity in the field every bot reads as CellId.

RuntimeLiveEntitySessionController.OnPositionUpdated now applies the same
rule at the same point, reusing
RuntimeAuthoritativePositionRouteClassifier.IsValidCreateWirePosition plus
the finite-velocity term — the exact pair TryApplyPosition already applies
on its initial-residence branch. Chosen over documenting it because the fix
is five lines and leaving it would have left two written claims falsified
by the code. It is a behaviour change: headless now drops packets it
merged. Against ACE the set is empty, and the graphical host has carried
this gate since it was written; the argument is recorded in the contract's
§15.2 rather than gated.

Two test fixtures carried cell ids retail's own inbound_valid_cellid
rejects (low words 0x41 and 0x51, above the 0x40 landcell ceiling). Their
constants were corrected; their assertions were not.

New test sabotage-verified in both directions: gate removed -> red at the
withdrawal-shape assertion; gate moved to guard only the cell commit ->
red at the pose assertion, which is what makes it a before-the-MERGE test
rather than a before-the-commit test.

3. Register and doc corrections.

- AD-64: "deliberately absent" was presented as the complete difference
  list and was not. Adds (a) the residence gate is weaker than the merge's
  own — both hosts' commits use TryGetCurrent while TryApplyPosition's FIFO
  branch uses TryGetTransaction, so the wire cell can commit ahead of the
  continuation that will replay it; (b) the two missile gates are two
  different expressions that agree today; (c) the payload gate, now
  present. Risk column records that (a) and (b) have no discriminating test
  on either side.
- AP-147: amended for D1 — pre-D1 the no-window host published [Updated]
  alone and lost the Rebucketed, so a headless event log is now a real
  instance of the "consumer that snapshots a delta" the row warns about.
- AD-60: "Matches retail exactly" scoped to the withhold, since the row's
  body documents two channels that do not.
- CommitWireCellRebucket: notes the unreachable ThrowIfNull /
  EnsureNotDisposed precedence inversion.
- TryCommitAcceptedWireCell: the discarded commit bool is explained rather
  than left bare — false means IsCurrent went stale, unreachable three
  statements after a synchronous TryGetActive.

4. Bisect hazard recorded in the C4 closeout handoff (the doc CLAUDE.md
sends readers to before any C5 work) and in the contract's §15.3: commits
735f0a72..23aa62f2 contain a live headless defect — every remote's
FullCellId frozen for the session — introduced by 735f0a72 and fixed only
at ff100cf3. Nothing throws and no test in the range fails.

Gates: Release build 0 errors/0 warnings. Complete suite 11,142 passed /
4 skipped / 0 failed against the 11,141 / 4 / 0 baseline — net +1, exactly
the one new test. No flake appeared (#302, #308, #321 all green).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 23:10:24 +02:00
Erik
ff100cf33f fix(runtime): give the no-window host a post-merge canonical cell commit (D1, AD-60/AD-64, AP-146/#320)
C5b (735f0a72) made the steady-state accepted-Position merge stop writing
residency. That is retail-correct — HandleReceivedPosition @0x00453FD0 reads
the wire objcell_id into a local and never assigns the object's cell — and it
stays. What C5b did not account for is that its replacement writers both live
in AcDream.App: the OnPosition prologue rebucket (AD-60's W2) and the
post-routing wire-cell adopt (W3, AP-135).

The two hosts run parallel, non-shared inbound routes. LiveEntitySessionController
-> LiveEntityNetworkUpdateController.OnPosition is graphical-only;
RuntimeLiveEntitySessionController.OnPositionUpdated is the no-window route and
is constructed only at HeadlessSessionHost.cs:682. So AcDream.Headless had NO
post-merge cell writer at all. Every remote's FullCellId was written at
create/placement and then frozen for the session — and RuntimeEntityObjectViews
.Snapshot projects exactly that field as RuntimeEntitySnapshot.CellId, i.e. every
bot's entire world view. The local player lost one of AP-146's three refresh
edges, which matters beyond cosmetics: RuntimeSetPositionState
.IsAffectedCollisionResident reads FullCellId to pick which bodies a landblock
retirement parks, so a bot running A->B without teleporting would have retired A
while parking a body physically in B.

The fix, in three parts:

1. RuntimeEntityObjectLifetime.CommitWireCellRebucket — a new Runtime owner for
   the committed VALUE, extracted verbatim from LiveEntityRuntime
   .RebucketLiveEntity. This is also the root-cause fix for the layering
   inversion the review found: AD-60 was documenting its own correctness by
   naming an App class the Runtime assembly cannot reference. Behaviour on the
   graphical side is unchanged — record.FullCellId is a proxy for
   record.Canonical.FullCellId, which is the record the callee reads, and the
   commit is still CommitRebucket. Verified load-bearing for BOTH hosts:
   sabotaging the preserve branch reddens the graphical
   LiveEntityRuntimeTests.CanonicalOnlyRebucket_DoesNotOverwriteAuthoritativeFullCell
   as well as the new headless assertion.

2. RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell — the no-window
   W2, under the same reachability rules the graphical route applies: Rejected
   writes nothing (the shape the App authority gate produces by returning false);
   a bound-projectile packet writes nothing (routed by the graphical host through
   the canonical projectile placement owner, which returns before W2); an active
   initial-create residence writes nothing (RebucketLiveEntity's own early
   return — while the lease is live the SetPosition conductor is the sole cell
   authority); a local ForcePosition writes only when the accepted-Position drive
   declined it (NotApplicable), because a handled force is
   placement-receipt-authoritative. W2/W3 themselves are untouched.

3. On the committed value (the landblock-vs-cell trap). RebucketLiveEntity's
   preserve branch fires on a LANDBLOCK-shaped id — low 16 bits 0xFFFF — and
   exists for LocalPlayerProjectionController.Project, the per-frame local
   movement caller that emits exactly that shape. An inbound wire objcell_id is
   never landblock-shaped, so on the accepted-Position route the branch is not
   taken and the exact wire cell is committed. That is what W2 commits today and
   what this now commits; the no-window host has no per-frame caller at all.

Ordering is matched, not improved on: the force drive submits its placement
before the commit, so its first submit still reads the pre-commit FullCellId —
AP-138's amended route-2 CurrentCellId measurement.

Bookkeeping in this commit:
- AD-60 corrected. Its surviving-channel enumeration presented "the local force
  path, the missile arm" as exhaustive; the entire no-window host belonged in it.
  23aa62f2's W2/W3-redundancy measurement is preserved verbatim.
- AP-146 and #320 amended the same way — their three-edge list was written from
  the graphical host and silently assumed both hosts shared it. The no-window
  host had two of three; it now has all three.
- AD-64 filed: the reachability decision is now expressed once per host. The
  value is single-sourced; the gate set is not.
- #324 filed: unifying the two session controllers is the genuinely correct fix
  and is campaign-sized (presentation recovery, hydration, the equipped-child
  renderer, and the remote/projectile routing arms only one host has). Not
  attempted here, per the fix brief.

Gates. Release build 0 errors. Complete suite 11,141 passed / 4 skipped /
0 failed, against the 11,134 / 4 / 0 baseline at 23aa62f2 — net +7, exactly the
7 tests added. Eight sabotages verified, each red on at least one discriminating
test and green when reverted: remote commit removed (2 Runtime + the end-to-end
Headless test); local ordinary commit removed; local NotApplicable-force commit
removed; force commit made unconditional; residence gate removed; missile gate
removed; Rejected gate removed; preserve branch broken (red on both hosts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 22:36:31 +02:00
Erik
23aa62f292 fix(review): close the C5b architecture-review findings (D2/D3/D4, L1-L5, S1)
Follow-up to C5b (735f0a72). The retail-conformance review passed, so no
production behaviour line moved: the flag truth table and the
refreshPosition:false withhold are untouched. This is blast radius, test
discrimination and documentation fidelity - plus two findings I could not
confirm and am rebutting rather than complying with.

D3 - THE PUBLISH-CONSERVATION TEST DID NOT DISCRIMINATE. The reviewer was
right and it was the worst finding here: proof obligation 3's test passed
identically with C5b reverted. Its only delta assertion FILTERED
(Assert.Single(deltas, Rebucketed && parentGuid)), so the pre-C5b stream
[Rebucketed] and the post-C5b stream [Updated, Rebucketed] both satisfied
it, and childSpatialBefore+1 held in both worlds because whichever site did
not move the cell propagated idempotently. It now asserts the complete
ordered parent stream plus each element's CellId and Position.ObjCellId.
Sabotage: restoring refreshPosition:acceptedPosition turns it red (it was
green before), together with the withhold test and the new L5 test.

That cardinality change was itself unfiled and is now AP-147: a
cell-changing accepted Position publishes TWO entity deltas where it
published one, and the intermediate Updated pairs the OLD CellId with the
NEW wire Position - a torn pair that did not exist pre-C5b, since both
halves used to move inside one publish. No production consumer reads a
delta's paired fields, but a recorder/plugin/bot event log would capture
it. The row states why suppressing the Updated is not available at that
layer (the merge cannot know whether its caller reaches W2).

D4 - THE PROJECTILE DOC COMMENT WAS FALSE AND ITS RETAIL ARGUMENT WAS
INVERTED. SyncPresentationFromResolvedBody claimed record.FullCellId is
"the WIRE cell ... stamped by the merge's RefreshDerivedState/SetFullCell,
before classification ever runs" and argued from retail's store_position
@0x00515CE2 that the destination cell is the right one. C5b falsified the
premise; the missile arm also returns before W2, so nothing stamps the wire
cell for a projectile at all. Rewritten. The honest conclusion, which the
old text would have called wrong: on a stored outcome presentation now
pairs the DESTINATION world position with the SOURCE cell. That is not a
choice this method can make differently - StoreAcceptedDestinationPose
writes only Position/Orientation, so record.FullCellId and
body.CellPosition.ObjCellId now hold the same source cell and reading
either yields the same value. The divergence is AP-138 item (1)'s
store-writes-pose-but-not-cell residual, retiring via #309, not a field
choice here. Projecting the wire cell instead would invent a residency the
placement declined - the AP-1 shape C5b closed.

L3/L4/L5 - PINNING GAPS, ALL THREE CONFIRMED AND CLOSED.
L3: the matrix's oracle passed HasAnimations as a literal, so the merge's
old.MotionTableId ?? old.Physics?.MotionTableId and
RuntimeAcceptedPositionRouteRequests.Build's canonical-snapshot twin were
textually identical and pinned by nothing. The oracle is now BUILT by the
production constructor.
L4: every fixture set both MotionTableId halves to the same value, so
deleting either operand of the ?? was undetectable while the production
comment said the mixed case is the real-world one. Six mixed rows added,
including the explicit-zero row (a present-but-zero top half is not null,
so ?? never reaches the physics half).
L5: the retained Rebucketed ternary had zero coverage through
TryApplyPosition - every restoreCancelledPark test called Forget directly.
Now driven through the real merge, with the wire cell deliberately the
SOURCE while the park's committed body cell is the DESTINATION, so the
restored residency can only have come from the rollback.
Sabotage (each red, each restored): merge ?? -> top half only, 1 red;
-> physics half only, 2 red; Build's ?? -> physics half only, 2 red;
ternary -> constant Updated, exactly the L5 test red.

L1/L2 - THE MISSING TEST IS ADDED; THE DEFECT IS NOT THERE. The reviewer
was right that C5b's "no fixture covers pickup at that layer" was
inaccurate - LiveEntityNetworkOnPositionCollapseMatrixTests drives the real
OnPosition at ~26 sites - and the end-to-end test is added: withdraw ->
accepted Position -> IsSpatiallyProjected && FullCellId == wireCell, both
guid classes.

But ChildUnparentDisposition.Pending is NOT a live defect, because it is
production-unreachable. The sole production _withdrawProjection binding
(LivePresentationComposition.cs:599) is
LiveEntityProjectionWithdrawalController.WithdrawExact, whose only Pending
mint is inside its catch block and therefore always carries a non-null
Failure - and AdvanceUnparentTransition rethrows at
EquippedChildRenderController.cs:1307 BEFORE the return Pending at :1309.
The named drop scenario does not reach it anyway (BeginDetachedRemoval has
already emptied the capture list) and would be correct if it did: a
previously-equipped child is LegacyImmediate, so the FullCellId != 0u gate
at DatLiveEntityProjectionMaterializer.cs:767 is never consulted and
re-projection uses the wire cell at LiveEntityRuntime.cs:824.

Measured while building that test, and NOT what C5b assumed: W2 and W3 are
REDUNDANT on the remote tail. Sabotaging W2 alone - adopting the committed
cell instead of the wire cell, OR skipping the rebucket outright - leaves
the whole file green, because W3's RemoteMotion.CellId write reads through
to canonical FullCellId via CommitCanonicalCell, whose CellCommitted
recovery re-installs the bucket. Only removing BOTH goes red, and then the
new test is the only red in the file. So it is named for what it pins, and
AD-60 is amended with the measurement: neither channel is individually
load-bearing, so a future retirement of one is caught by nothing else.

D2 - REBUTTED, WITH THE REAL GAP FILED INSTEAD. The reviewer's hypothesis
was that TryApplyInitialCreateCompletionPresentation's staleness guard lost
its ability to detect an intervening steady-state Position when C5b stopped
the merge stamping the wire cell, and asked for a PositionAuthorityVersion
term. I do not think that is right and did not add it.

The receipt's facts are the canonical BODY's pose and cell at publish
(PublishExecutorCompletion builds both from the record). Exactly two owners
can move them: a Runtime SetPosition commit/withdrawal, every one of which
calls AdvancePlacementCommit - the only caller family is
RuntimeSetPositionState - and a rebucket, which moves FullCellId. Both are
already covered by the two existing terms. An accepted steady-state
Position is neither, and C5b did not make it one: the merge refreshes the
snapshot and advances PositionAuthorityVersion but never wrote the body,
and the App generic tail writes the RENDER entity. The wire-cell half stays
covered because W2/W3 commit it in the same call; the paths that return
before them leave the record at the last committed cell, which IS the
receipt's own cell - correctly not a supersession.

Adding the term would decline receipts whose facts are still true, on the
entity's FIRST world-visible moment: the pose write and
RebucketLiveEntityPresentationOnly would be skipped while TryPublishPlace
still publishes, so a packet returning before the render write would leave
the sidecar visible at its materialized pose in a wrong bucket. That is the
handoff's own "removed the invariant failure while leaving the bug" shape.

There IS one supersession neither term covers, and it predates C5b:
RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose writes
body.Position/Orientation on the far-snap Refused/Contention arm with no
placement commit and no cell move. Filed as #323 with the FIFO-blocking
argument for why a receipt can still be pending when it lands, an explicit
"not established as reachable", and an explicit "do not fix it with
PositionAuthorityVersion". The guard's comment now carries the whole
argument instead of one sentence.

S1 - DANGLING POINTER CLOSED. InboundPhysicsStateController.cs:610 still
said the two-callers-one-rule debt was "tracked for the eventual cutover
unification ... See docs/ISSUES.md", which pointed at nothing after C5b
closed #275 without a successor. Filed #322, cited from both the comment
and #275's closure, including why widening TryApplyPosition's signature to
take a route would be the wrong unification.

AP-138 amended: C5b staled its round-3 measurement that "both
accepted-Position callers commit the accepted wire cell to
record.FullCellId before submitting". Route 2 submits from
TryExecuteAcceptedLocalPosition ahead of W2, so on a first submit
PlacementTouchesPrefix's CurrentCellId arm now names the SOURCE landblock,
not the destination. Confined to which prefix the quiescence pre-flight
matches, which that row already established is not the correctness
mechanism.

GATES. Release build 0 errors. Complete suite 11,134 passed / 4 skipped /
0 failed, from the 11,125 / 4 baseline at ed806997: net +9, all new tests,
no test deleted or weakened, no new skip. Runtime.Tests 1195 -> 1202 (+6
mixed-motion-table rows, +1 park-rollback fact); App.Tests 4132 -> 4134
(+2 guid rows). None of #302/#308/#321 appeared. Not connected-gated -
nothing here changes runtime behaviour.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 22:08:46 +02:00
Erik
735f0a72af fix(physics): classify before merge on every steady-state Position (C5b, #275, AP-131/AD-60)
The steady-state accepted-Position merge did two things retail never does,
on every single Position packet: it installed the wire placement frame and
unparented unconditionally, and it derived the record's FullCellId from
bare wire acceptance. Both are now correct, and they land together - a
half-flipped intermediate (classified flags with the wire stamp, or vice
versa) is exactly the mixed-residency state this campaign keeps paying for.

WHY the flags need no route. SmartBox::HandleReceivedPosition @0x00453FD0
decides both pre-placement writes BEFORE MoveOrTeleport is consulted: Gate A
@0x0045400C returns @0x0045409D ahead of unset_parent @0x00454129 and ahead
of the HasAnims SetPlacementFrame gate @0x00454137. Neither gate reads the
near/far/teleport classification. So the two flags are a pure function of
(disposition, hasAnimations) and are computable inside the merge, pre-merge,
with no signature change, no route construction and no playerDistance - the
scoping's ~150-400-line route-plumbing estimate over-counted because it did
not see this. That truth table IS
RuntimeAuthoritativePositionRouteClassifier.ClassifyAcceptedPosition's own
ApplyPlacementFrameBeforeRouting/UnparentBeforeRouting rows; the classifier
stays the oracle and the equality is pinned by test, not by a shared path,
so each computation remains separately sabotage-verifiable.

WHY the cell is withheld. HandleReceivedPosition reads the wire objcell_id
into a LOCAL @0x00453FE3 and hands it only to BlipPlayer / TeleportPlayer /
MoveOrTeleport / ConstrainTo; it never assigns the object's cell. The
object's cell moves inside the placement family (SetPositionInternal
@0x00515BD0 to set_cell, enter_world) or per-frame transit, and nowhere
else. The continuation executor has encoded that rule since the executor
slice; this caller now matches it verbatim.

WHAT DELIBERATELY SURVIVES. Two steady-state wire-cell writers stay,
downstream of the merge and outside the classification window: the
OnPosition prologue rebucket (W2, into CommitRebucket), which is also the
local player's own cell-freshness path, and the post-routing wire-cell adopt
for non-placing arms (W3, AP-135). Gating W2 "for symmetry" would freeze the
player's canonical cell between teleports and #319's child-cell equality
would inherit the freeze. AD-60's rewrite names both so the retirement
cannot be misread as "wire acceptance never changes residency anywhere".

REGISTER. AP-131 RETIRED - the unconditional literals no longer exist; the
caller was corrected, not deleted, so the row's own "deleted at the
production cutover" framing is overtaken. AD-60's legacy half RETIRED and
the row REWRITTEN rather than deleted, naming W2/W3 (route 4b-3's D8
precedent: a silent whole-row deletion would hide surviving channels).
AP-130 amended - the merge consumes the same static HasAnimations proxy,
deliberately not escalated to a live animation-queue read. AP-146 and #320
amended - their "accepted inbound Position (RefreshSnapshot into
RuntimeEntityRecord.cs:234)" local-player cell writer is now the generic
tail's CommitRebucket, and a ForcePosition (which returns before that tail)
is placement-receipt-authoritative. #275 closed.

HEADLINE BEHAVIOURAL DELTA, stated once: a refused or contended local
ForcePosition now leaves FullCellId at the last committed cell where the
merge used to stamp the refused packet's wire cell. Retail cannot refuse
(AD-62) and its body keeps its last placed cell, so the new shape is the
retail-reachable one.

THREE CONSUMER SITES THE CONTRACT'S BLAST-RADIUS SURVEY MISSED, all
D2-caused, all found by the suite rather than by reading, all intended
semantics rather than regressions (recorded in the contract's new section
14):
(1) DatLiveEntityProjectionMaterializer's self-projection branch reads
    FullCellId inside OnPosition's prologue recovery, ahead of W2. It now
    correctly declines to project from an unplaced wire claim; production
    installs the bucket at W2 in the same call (verified: no return between
    the recovery call and W2 is conditioned on IsSpatiallyProjected or
    FullCellId). Two hydration tests asserted the bucket at the recovery
    boundary and now drive the production W2 step - the same shape as trap
    T2, one layer up.
(2) ProjectileController.SyncPresentationFromResolvedBody writes
    ParentCellId = record.FullCellId. On a refused missile placement that is
    now the committed source cell. The MAJOR-1 invariant is unchanged and is
    now asserted as the identity it always meant rather than as a wire-cell
    constant.
(3) The merge's Rebucketed ternary does NOT become always-Updated as the
    contract predicted, and is deliberately kept: the
    Forget(restoreCancelledPark: true) above it can roll a wakeable
    lost-cell park back, and RestoreParkWithdrawal restores canonical
    residency. That is a real cell edge produced inside this method by a
    placement owner.

TEST-COUNT RECONCILIATION. Baseline measured at this HEAD by stashing the
change: Runtime.Tests 1176, App.Tests 4135 (4132 passed / 3 skipped),
solution 11,106 passed / 4 skipped - matching the recorded figure at
6921a027 exactly. Post-change: Runtime.Tests 1195, App.Tests 4135 unchanged,
solution 11,125 passed / 4 skipped / 0 failed. Net +19, entirely new Runtime
tests: 3 facts plus a 12-row matrix theory in
InboundPhysicsStateControllerTests, 1 fact plus a 2-row theory in the new
RuntimeSteadyStatePositionMergeTests, and 1 fact in
RuntimeAcceptedPositionDriveControllerTests. No test was deleted; five
existing tests were rewritten in place, never delete-only. No new skip; none
of #302/#308/#321 appeared.

SABOTAGE VERIFICATIONS (each new discriminating test, both directions;
production line broken, suite run, line restored):
  installPlacementFrame (!force && !hasAnimations) to (!force)
    5 fail: ApplyOnAnimatedEntity_NeverInstallsTheWirePlacementFrame plus
    the 4 animated non-force matrix rows.
  installPlacementFrame to false
    6 fail: ApplyOnNonAnimatedEntity_InstallsTheWirePlacementFrame,
    PositionPlacementAbsentAndPresentZeroBothApplyRetailZero plus the 4
    non-animated non-force matrix rows.
  clearParent (!force) to true
    3 fail: ForcePositionOnParentedLocalPlayer_RetainsTheParentAttachment
    plus the 2 force+parented matrix rows.
  clearParent (!force) to false
    4 fail: the 4 Apply+parented matrix rows.
  refreshPosition false to acceptedPosition
    4 fail: AcceptedPosition_WithholdsTheWireCellAtTheMergeBoundary,
    ContendedForcePosition_WritesNoResidencyAnywhere,
    ReentrantNewerPositionDuringPickupDiscardSuppressesStalePickupDelta,
    MissileFarRefused_...ParentCellIdAgreesWithCommittedCell. Confirmed a
    second time by the baseline measurement above, where the withhold test
    was the sole red.
  CommitRebucket publishes Updated instead of Rebucketed
    2 fail: both parent classes of
    CellChangingAcceptedPosition_ConservesOneRebucketAndOneChildPropagation.
  RuntimeEntityDirectory.SetFullCell drops PropagateFullCellToChildren
    2 fail: the same two rows.
T4 respected: the ForcePosition placement-frame half is inert
(appliedPlacement keeps old.PlacementId under either flag value), so the
force row's discriminating assertion is parent retention, never the frame.

NOT DONE, deliberately: the executor is still not wired into the
steady-state path (#275's alternative branch); W2/W3 are untouched; no probe
added or stripped; AP-130's proxy not escalated; no while-here unification
of the two merge callsites. No automated OnPosition-level test drives the
full pickup / drop / reproject sequence (no fixture covers pickup at that
layer); the contract's connected gate recipe item 1 is the positive evidence
for it and has NOT been run - this commit is not connected-gated.

Contract: docs/research/2026-08-05-c5b-contract.md (committed here, with its
section 14 implementation outcome appended).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 21:17:44 +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
392c1e22c1 fix(physics): bind a parented child to the parent's live incarnation (#319)
A player-parented child never received a canonical cell. Its FullCellId stayed
0 for its whole attached lifetime, so it could not follow the player across a
boundary. Scope was wider than the local player: every REMOTE player's
equipment too.

ROOT CAUSE. EquippedChildRenderController hardcoded ParentInstanceSequence: 0
for a parented CreateObject. Correct for creatures and statics, which really
are sequence 0; wrong for players, whose ObjectInstance is Character.TotalLogins
(ACE Player_Networking.cs:37). The relation filed under (playerGuid, 0) while
the record carried TotalLogins, so both route-7 write sites — D1's attach
re-cell and D2's propagation lookup — keyed on an incarnation that never
matched. TryCommitParent did not validate the sequence, so the attach
succeeded and printed normally. Silent.

A ROUTE 7 REGRESSION (cd3129e9) that un-masked a latent bug: the TickChild call
route 7 deleted was keyed on the child guid alone and was structurally immune
to a wrong parent key.

THE FIX IS TO STOP TREATING PLAYERS DIFFERENTLY, not to special-case them.
Retail's attach path is guid-only end to end — PhysicsDesc::get_parent_id
@0x00558a18 -> CObjectMaint::GetObjectA @0x00558a2d -> set_parent @0x00558a3e,
with SetChildren @0x00509370 hash-walking by guid — and neither set_parent
overload (@0x00515A90, @0x00515B50) nor enter_cell @0x00510ED0 contains any
player test or instance-sequence read. Our player/non-player split was purely
an artifact of keying relations by (guid, incarnation) against a wire message
that carries no parent incarnation. Late-binding to whoever currently holds the
guid is retail's own semantics. Fixed at BOTH producers: OnSpawn and
OnCreateParentAccepted, the second carrying the byte-identical defect and not
named in the contract's scope line.

THE INVARIANT IS EQUALITY, NOT FRESHNESS. The contract rejected both framings I
offered: every one of the 45 FullCellId liveness predicates excludes a
committed child on a NON-cell clause first, so the child inherits only the
parent record's existing staleness, which is already present today with no
symptom. The key fix alone restores child-equals-parent for every parent class.

TWO SITES GATED, inert only because the cell was zero and would have woken
wrongly: the hydration candidate loop (a nonzero-cell child would take the
legacy RebucketLiveEntity -> CommitRebucket, a second canonical writer — route
7's exact defect class) and RestoreShadow (would install a broadphase row for
the weapon, the #184 shape, contradicting route 7's P4). Retail anchor:
update_object's parent != 0 early-out @0x00515D40 — children are never
independently re-placed.

THREE MAJORS WERE FIXED BY DELETION. The first pass added a deferral queue for
an unaddressable parent, carrying a missing child-freshness gate (A2), a
sentinel-0 collision with the generation filters (A3), and unbounded
accumulation (A5). Both reviewers then proved the deferred branch unreachable
for BOTH producers — RegisterEntityCore defers the entire CreateObject one
layer above, reading the same ?? chain, and CreateParentUpdate is produced only
inside AcceptCreateCore, after that gate passes. The machinery was deleted
rather than repaired, and the diff SHRANK to 76 added / 13 removed from 91/24
while gaining the A1 fix. Retail confirmed the deletion does not diverge:
acdream's real port of retail's per-guid replay (QueueBlobForObject) is a
different, untouched layer, and the deleted queue was a third redundant one
downstream of it.

THE GUARD MUST NOT TEAR WHAT IT PROTECTS. The first pass threw
InvalidOperationException AFTER the canonical half had committed, so the one
time it fired it left the child parented with no committed relation and a
staged one blocking Resolve — a torn transaction, the exact outcome the
contract pinned against. Now a pure CanCommitIncarnation precondition checked
BEFORE the commit at both sites, with a logged refusal instead of a throw.
Route 3's N3 principle (do not make a transient fatal on a host that must
survive 30 sessions x 2 hours) reinforces it, but the tearing argument stands
alone.

TEST QUALITY, the recurring lesson in its most refined form. The A1 test
initially passed sabotage FOR THE WRONG REASON: a mismatched ChildPositionSequence
meant TryCommitParent's own gate refused in either ordering, so the three
assertions carrying A1's meaning passed both ways and only an incidental
staging assertion failed. It failed on stranding, not tearing. Corrected, the
sabotage now names line 925 — Assert.Null(snapshot.ParentGuid), with the
parent's guid in it — proving the canonical mutation happened before the catch.
"Fails under sabotage" is necessary, not sufficient; WHICH assertion fails is
the real question.

The dual parent-class matrix (player 0x5… incarnation > 1 vs creature 0x8…
incarnation 0, identical outcomes, sabotage-verified in both directions) is the
structural fix for how this survived a full dual review and two connected
sessions: every prior test and both captured gate logs used sequence-0 parents.

Register: AP-142 clause (f); AP-132 amended to distinguish the two producers;
new row AP-146 for the local player's coarse canonical cell (retail writes it
per tick at SetPositionInternal @0x00515330 — which, per the retail review, ALSO
walks this->children writing each child's objcell_id @0x005153AE-@0x005153D8,
so retail's per-tick child propagation lives in the same function). That
divergence had no row at all, a standing rule-1 violation now corrected.
Follow-up #320 filed for making the player's cell track ordinary movement —
deliberately excluded here: it touches the landblock-preserve contract, the
Rebucketed cadence, route-2/4b-3 classification inputs AP-136/AP-138 spent four
review rounds pinning, and the portal-space frozen-source-cell race.

Two dual review rounds; 6 architecture MAJORs and 2 retail MAJORs closed.
Diagnostic refusals are latched per child guid and the latch clears on
Clear()/RemoveChild, so a recycled guid's next incarnation still logs rather
than being silently suppressed.

Complete Release suite MEASURED at 11,112 passed / 4 skipped / 0 failed
(baseline 11,090 at 52175aa1, +22). Neither known flake fired.

STILL OWED: the connected gate, with the CORRECTED positive criterion — assert
the equipped child's FullCellId EQUALS the parent's after a crossing (a zero is
a failure, not a silence), run with BOTH a player and a creature parent, plus
the new step carrying an armed creature across a landblock unload/reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:56: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
36255af0f6 fix(physics): C4 route 5 — projectile authoritative placement (#276 partial)
Ports retail's missile Position handling into the canonical Runtime
placement owner instead of the deleted ApplyAuthoritativePosition
short-circuit. The Create/residence-window halves of the projectile
pipeline (RuntimeProjectile binding, TryBind's adopted-body branch,
the collision/shadow registration) were already canonical from prior
slices; this closes the remaining gap — how an ACCEPTED Position for
an in-flight missile is classified, placed, and presented.

Byte-decode (Step 1 hard gate, before any code was written):
CPhysicsObj::MoveOrTeleport @0x00516330-0x00516438 disassembled from
the PDB-paired binary (Capstone, x86 32-bit thiscall). `ret 0x10`
establishes four stack args; [esp+0x7c] (arg5, the velocity pointer)
is never referenced in any of the three branches (teleport/near/far).
The retail reviewer independently reproduced this by searching the
whole function body for the `24 7c` mod/rm+disp8 encoding a
`[esp+0x7c]` read would require and found zero occurrences. This
retired a fabricated `?? Vector3.Zero` fallback in the deleted method
— retail's PositionPack::UnPack initializes an absent velocity to
zero and MoveOrTeleport never installs it; the projectile's Vector
channel (RuntimeProjectilePhysicsUpdater.ApplyAuthoritativeVector)
remains the sole velocity authority for a missile. D-P5 in the
contract; the Runtime seam commits no velocity from the Position
packet at all.

The unbound-missile fix: RuntimeEntityObjectLifetime's
ClassifyRemoteAcceptedPosition now derives ProjectileAuthoritative
from a CONJUNCTIVE predicate — the Missile bit AND a bound
RuntimeProjectile whose Body is the canonical PhysicsBody — never the
bit alone. Retail places every non-player CPhysicsObj unconditionally
(there is no missile-specific placement gate in MoveOrTeleport or its
callers), so an unbindable or not-yet-bound missile taking the
ordinary remote tail is retail-faithful, not a fallback: the earlier
bit-only discriminator would have silently frozen it instead.

AP-141 records this as a deliberate, recorded divergence, not
fidelity. Retail mechanically WOULD arm a missile's ConstrainTo leash
on any nonzero MoveOrTeleport return: HandleReceivedPosition
@0x00453FD0's only kind test is player-vs-not, ConstrainTo
@0x00454272 has no kind test of its own, and CPhysicsObj::ConstrainTo
@0x00510520 creates a PositionManager on demand via
MakePositionManager @0x00510523 if one doesn't exist. acdream
deliberately does not construct that EntityPhysicsHost/
PositionManager/InterpolationManager chain for a ballistic body — the
route-5b split the C4 route 5 contract rejected — so a live missile
never shows an armed leash and never catches up via the near/
UnroutedCatchUp policy. This divergence is safe specifically because
ACE never sends UpdatePosition for a missile
(references/ACE/Source/ACE.Server/WorldObjects/WorldObject_Tick.cs:
333-334, SendUpdatePosition() commented out inside the
PhysicsState.Missile branch at :265) — every half of this row is
deterministic-test-gated only, never exercised against a real server.

AP-141 also records the surviving ConstrainTo re-anchor divergence
under clause (b): for the adopted-body case (TryBind's shared-body
branch — an ordinary remote whose Missile bit is set by a later
State packet, so it still carries a live RemoteMotion), acdream now
ports retail's teleport-branch and far-branch StopInterpolating
action (Interp.Clear()), but never re-arms or re-anchors the
inherited ConstrainTo leash the way retail's HandleReceivedPosition
@0x00454254/@0x00454272 does on every nonzero return. The risk
column's earlier wording — that a stale leash "would drag the body
toward a stale anchor" — was wrong and is retracted in this same
commit: ConstraintManager.ConstraintPos is write-only in both retail
and the port (never read by AdjustOffset), and
ConstraintManager::adjust_offset @0x00556180 only tapers or zeroes an
already-composed per-tick offset while InContact — a leash brakes
motion the interp/sticky chain already produced, it cannot pull
anything toward the anchor. The real residual is one tick of un-reset
brake accumulator, contact-gated, and it cannot move an airborne
far-snapped missile at all (the clamp branch does not run while
airborne).

NO CONNECTED GATE EXISTS for this route, by design: ACE never sends a
missile UpdatePosition (see above), so retail's own server never
exercises this code path in play. Every proof obligation here is
test-gated only — Runtime and App-level fixtures constructing the
packet directly — never a live client/server capture.

Three review rounds closed 8 MAJOR findings before this landed:
round 1 (A1 App discarded the seam's status; A2/R1 silent swallow on
an unbound missile; A3/R2 the adopted-body teleport_hook never
wired; A4/A5 zero Runtime/App test coverage); round 2 (a
ParentCellId regression introduced by round 1's own R6 finding,
which the retail reviewer retracted the following round as factually
wrong — the fix here is the REVERT to record.FullCellId, not the
relocation round 1 shipped; B2 the far-branch StopInterpolating skip
never extended to the adopted-body case; residual App/Runtime store-
path coverage; a per-packet closure contradicting the file's own
#315 cached-delegate pattern). Round 3 closed on coverage alone (no
defect): the Advance() retry arm's projectile branch — added at
round 2, semantically reordered at round 2's B5 fix (skip prediction
invalidation on a re-parked Contention, since it writes nothing) —
had never been executed by any test; two new tests drive it directly
and are sabotage-verified against both the reordering and the
retry-arm's own SyncProjectilePresentation call site. The one
recorded defect this campaign produced (the ParentCellId regression)
was caused by complying with a review finding that its own author
later retracted — the standing lesson recorded for future rounds is
that review findings are evidence to re-verify against the code, not
commands to obey unconditionally.

Complete Release suite: 11,063 passed / 4 skipped / 0 failed
(baseline 11,036 at 30d3d114, +27 new tests across this campaign).

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