User gate report (Campaign OP happy-testing round, 2026-08-13): every Config-tab dropdown drew its text gold + left-aligned and its popup a fixed 6 rows regardless of item count. All three were unmeasured styling divergences — the authored data (new probe menuprobe3, live DAT) says: - button label child 0x10000355: fontColor white, hJustify=Center - row template 0x1000035A: fontColor white, hJustify=Center - popup ListBox 0x10000358: edge-docked L=T=R=B=1, the authored condition arming retail UIElement_Menu::RecalculatePopupSize @0x0046caf0 — popup resizes to the ListBox's summed content height, uncapped (0x0046e5f4..0046e66c via ResizeScrollableArea's 0x32 broadcast) UiMenu gains three opt-in properties (ButtonTextCentered, ItemTextCentered, PopupSizeToContent) plus retail Open @0x0046cc42's empty-list gate; chat + vendor keep the class defaults, so their shipped behavior is untouched. ConfigOptionsPageController.ApplyMenuChrome wires all four corrections for the 8 Config menus with the probe citation. The same probe found vendor's authored popup ListBox is ALSO docked while our vendor dropdown ships G5's fixed 6-row window — filed as #386 + register row AD-88 (UNCLEAR: the G5 retail screenshot and the decomp mechanism conflict) instead of silently reworking a user-gated surface. The "resolution change resizes the window" observation from the same report is #374's designed windowed-mode behavior (display-mode switching is #376/#377) — no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 MiB
acdream — known issues + small deferred features
Rolling tactical list. What goes here:
- Bugs: user-visible defects we've observed but haven't fixed yet.
- Small deferred features: work that fits in one or two commits. Anything larger should be a named Phase in the roadmap.
What does NOT go here:
- Large multi-commit work → add a Phase to the roadmap instead.
- Ideas / wishlist →
docs/plans/. - Design questions → open a
docs/research/*.mdnote.
Conventions
- Sequential integer IDs (
#1,#2, …). Commits that close an issue reference the ID in the message (e.g.fix #3: periodic TimeSync parsing). StatusisOPEN,IN-PROGRESS, orDONE. The status inside each issue is authoritative. This long-lived file still has completed items interleaved with open history; physical placement under an older heading is not status.- New DONE items should move to Recently closed when practical. Do not do a mechanical move of old blocks merely to tidy the file; preserve their research context until a deliberate archive split.
- Every session: scan OPEN issues at start; promote/close anything we touched during the session before ending.
- Promoting to a Phase: mark as
DONE (promoted to Phase X)+ commit SHA where the Phase entry landed.
#386 — Vendor category dropdown: authored ListBox is edge-docked — retail would size the popup to content, our shipped 6-row window may diverge
Status: OPEN — filed 2026-08-13 while fixing #385. The #385 probe
(OptionsPanelLiveMountProbeTests.ProbeMenuPopupSizingAndTextStyle,
menuprobe3) measured the vendor category popup's authored ListBox
(0x21000043/0x10000350) as edge-docked on all four sides (L=T=R=B=1) —
the exact authored condition that arms retail's
UIElement_Menu::RecalculatePopupSize @0x0046caf0 size-to-content path
(popup grows/shrinks to the summed row heights, uncapped). Our vendor
dropdown ships G5's fixed 6-row scrollable window instead, which the G5
retail screenshot ("~one-column-with-scrollbar look") appeared to support
and the vendor connected gate user-passed. The two pieces of evidence
conflict: the decomp mechanism says an 18-category popup should open
full-height (~324 px) with an inert stretched scrollbar strip; the G5
screenshot was read as a 6-row scroll window. Next step is a retail
side-by-side of the vendor category dropdown specifically (open the
category menu at a vendor with many categories). If retail shows the
full-height popup, flip UiMenu.PopupSizeToContent = true in
VendorUiController (one line — the mechanism shipped with #385) and
retire the divergence; if retail truly shows a 6-row window, document WHY
the docked ListBox does not trigger RecalculatePopupSize there (a message
routing difference is plausible: the vendor popup's items are inserted
BEFORE RegisterForElementMessages, so the 0x32 broadcast may never reach
the menu). Register row AD-88 (unclear) tracks it.
#385 — Options-panel dropdowns: gold left-aligned text + fixed 6-row popup (retail: white, centered, size-to-content)
Status: DONE 2026-08-13 (this commit). User gate report (Campaign OP
happy-testing round): every Config-tab dropdown (Sound Features,
Resolution, …) drew its text yellow and left-aligned, and the popup a
fixed 6 rows regardless of item count. All three symptoms were
unmeasured-styling divergences in UiMenu/ApplyMenuChrome — the
authored data (menuprobe3, live-DAT) says: button label child
0x10000355 white + hJustify=Center; row template 0x1000035A white +
hJustify=Center; popup ListBox 0x10000358 edge-docked, arming retail's
RecalculatePopupSize @0x0046caf0 size-to-content resize (content =
summed laid-out row heights, uncapped — 0x0046e5f4..0046e66c). Fix:
three opt-in UiMenu properties (ButtonTextCentered, ItemTextCentered,
PopupSizeToContent — chat + vendor keep class defaults) plus retail's
Open @0x0046cc42 empty-list gate, wired for all 8 Config menus in
ConfigOptionsPageController.ApplyMenuChrome. The resolution-row
"changing resolution resizes the window" observation from the same report
is the #374 designed windowed-mode behavior (true display-mode switching
is #376/#377) — no change.
#384 — FA6 allegiance-swear bot gate: ACE returns no response to 0x001D swear (no confirmation/0x0020/error)
Status: OPEN — filed 2026-08-12 at Campaign FA slice FA6. The two-bot
headless fellowship/allegiance connected gate
(src/AcDream.Headless/Policies/HeadlessBotPolicy.cs,
FellowshipAllegianceLeaderBotPolicy/FellowshipAllegianceRecruitBotPolicy)
ran live against local ACE (127.0.0.1:9000, testaccount/+Acdream as
Leader, testaccount2/+Horan as Recruit) six times across the session.
Fellowship half: PASSES live, three separate runs, and is the shipped
automated gate. The decisive cross-session assertion — the RECRUIT bot's
own RuntimeFellowshipState (a separate process's canonical Runtime owner,
not the Leader's local echo) flipping IsInFellowship=true,
MemberCount=2, LeaderGuid=0x5000000A — passed identically in runs 1, 3,
5, and 6. Proximity via retail's admin @teleallto plus
RuntimeFriendlyTargetQuery.FindPlayerByName (added this slice) reliably
resolves the Recruit bot's guid even with a third, unrelated character
online on the same shared ACE dev instance (+Je, 0x50000001 — see the
FindPlayerByName doc comment and its conformance tests for that finding).
Allegiance half: BLOCKED, disabled by default
(AllegianceGateEnabled = false in both policy classes). After the
fellowship establishes, the Recruit bot sends Event_SwearAllegiance
(0x001D) with the Leader's guid as targetGuid. Per
docs/research/2026-08-11-fa-allegiance-wire.md §1.3, retail's server
should then send the Leader (the would-be patron) a generic
Character.ConfirmationRequest (0x0274, ConfirmationType=1
ALLEGIANCE_SWEAR_CONFIRM), which the Leader answers with
Event_ConfirmationResponse (0x0275) before ACE forms the allegiance and
broadcasts 0x0020 to both. Live evidence (run6, with a distance
diagnostic and a confirmation-arrival diagnostic both added for this
investigation and kept permanently in the code):
[fa6-diag] distance self(0x5000000B)->patron(0x5000000A) = 0.005 m— the two bots were essentially coincident at the moment of swear, ruling out retail's server-side 2.0 m swear-distance gate as the cause.HeadlessSessionHost'sOnConfirmationRequest(wired this slice — it wasnullpre-FA6, so headless bots dropped every confirmation regardless) never fires: no[fa6-diag] OnConfirmationRequest receivedline ever appears after the swear is sent, in any of runs 1, 3, 4, 5, or 6 (run 2 targeted the wrong player entirely, see theFindPlayerByNamehistory above, and is not evidence either way).- No
[weenie-error]line appears after the swear either (the two0x051Dlines present in every run's log are pre-existing noise already noted in the OP7 gate result, unrelated and present before any FA6 action fires).
ACE returns absolutely nothing — no confirmation, no tree update, no
error — to a 0x001D sent at 0.005 m. This is ambiguous between (a) a
defect in acdream's own 0x001D wire builder (AllegianceRequests. BuildSwear) that ACE silently can't parse, (b) an ACE-side rule this
specific test pair trips that this campaign's research didn't surface
(GM-flagged accounts, a self/rank/loyalty precondition, a +-prefixed
test-character exclusion), or (c) a genuine drop somewhere in the
session's send path. Disambiguating (a) from (b)/(c) needs visibility this
automated harness doesn't have — either an ACE server-side console/log, or
a WireMCP capture correlated tightly enough to confirm bytes actually left
the process (the two capture attempts this session used the wrong
interface/tooling and were inconclusive).
Not investigated further this session per user direction — the fellowship half is the proven, shipped automated gate; the allegiance half is deferred to the user's own connected gate (manual swear via the graphical client between two characters), which will settle whether the symptom reproduces outside the headless harness at all.
To re-enable: flip AllegianceGateEnabled to true in both
FellowshipAllegianceLeaderBotPolicy and
FellowshipAllegianceRecruitBotPolicy — every allegiance stage (Leader's
WaitForVassal, Recruit's Swear/WaitSwornSeed/Break/
WaitBrokenSeed) is still fully written and wired, just unreachable while
the flag is off.
#383 — Installed-DAT vs committed-fixture drift: regeneration produces large diffs in existing UI fixtures
Status: OPEN — filed 2026-08-12 at Campaign FA slice FA3. Running the
env-gated fixture generator (ACDREAM_REGENERATE_UI_FIXTURES=1) on this
machine to dump the NEW social-panel fixture also silently rewrote
keyboard_config_21000009.json and options_2100002B.json with LARGE
diffs — the currently-installed DATs under
%USERPROFILE%\Documents\Asheron's Call no longer match the DAT state
those fixtures were committed from. [FA3 fix-round correction, blast
SF-6: the original filing said "days ago, same machine" — git says
otherwise. keyboard_config_21000009.json was committed at b4edee97
(2026-08-11 09:19) and options_2100002B.json at e71e5a96 (2026-08-11
06:25); the FA3 regeneration run was 2026-08-12 ~02:58 — ~18h and ~21h
earlier, the previous day, not multi-day drift. That materially tightens
the investigation window below: a same-day change is far easier to
correlate against tooling activity than a multi-day one.]** The FA3
implementer reverted both to HEAD and committed only the new fixture.
Possible cause observed in passing: local mudsort tooling artifacts in
the Documents DAT folder (a DAT-modifying tool may have touched the
files). Impact/risk: the committed fixtures drive the conformance
suites; the live client reads the INSTALLED DATs — if they diverge, the
fixture-green/live-broken class this project keeps meeting gets a new
systemic cause. The env-gated live-mount probes (which read the installed
DATs directly) are the cross-check that still holds. [FA3 fix-round
addendum, mechanism review's no-drift finding: the NEW social-panel
fixture itself is NOT part of this drift — the mechanism reviewer
cross-checked the committed social_panel_2100006E_1000018F.json against
the same live-mount probe's dump on root extent, child count and order,
the full tab table, all four pages' P0x57 values, and both allegiance
blocks' geometry (including the two differently-sized 0x10000490
instances). No drift; this fixture is faithful to the installed DAT as
committed. The issue therefore narrows to exactly the two pre-existing,
OP-era fixtures (keyboard_config_21000009.json, options_2100002B.json)
— the social-panel fixture is not implicated.]** Investigation
needed before anyone regenerates fixtures on this machine again: diff
the two fixture regenerations structurally (what changed — geometry?
string ids? media?), determine WHAT modified the installed DATs in that
~18-21h same-day window, and decide the canonical DAT source for fixtures
(a pristine copy vs the live install). Do not regenerate-and-commit
existing fixtures until the drift is understood.
#382 — Floating chat-window tab buttons are invisible until first hovered
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate.
Filed 2026-08-11 at Campaign OP gate 4 (user report: nothing renders at
rest on the four main-chat-window indicator buttons — 0x10000522-
0x10000525, ChatWindowController.Indicator1Id-Indicator4Id — hover
reveals the correct orange art).
A prior session's extensive static trace found no bug and left a
live-mount probe for this session (see the earlier revision of this
entry, preserved in git history). Running that probe with reference-
identity verification (RuntimeHelpers.GetHashCode + ReferenceEquals
against a build-time-captured instance, not just re-reading a possibly-
different widget) found the actual defect: the SAME UiButton instance
resolves ActiveState="Normal" correctly at its own construction, then
gets blanked to "" moments later — still inside the SAME
LayoutImporter.Build call, before the probe ever reads it.
ROOT CAUSE. The indicator column's backing panel (0x10000600)
authors PassToChildren=true on its OWN empty DirectState (confirmed
live: States[0xFFFFFFFF].PassToChildren == true — almost certainly
intended to route the panel's OTHER named states, HideDetail/ShowDetail,
to an unrelated sibling, not Normal/Highlight to these buttons).
LayoutImporter.BuildWidget reapplies every widget's own default state
AFTER its children are attached (so retained PassToChildren TABS get
their authored Open/Closed child media — see that method's own comment);
when the PANEL's reapply runs, UiDatElement.TrySetRetailState cascades
its DirectStateId to every IUiDatStateful child, including the four
ALREADY-correctly-resolved buttons. UiButton.TrySetRetailState's
DirectStateId branch used to accept that cascade because
_mediaInfo.States structurally carries a DirectStateId entry on EVERY
button (it is the property bag for ToggleBehavior/RolloverEnabled/etc,
independent of whether the button authors any blank sprite — see
UiButtonTests.AddBoolProperty), so TryFindState(DirectStateId) found
that entry and blanked ActiveState even though StateMedia has no ""
key at all. A synthetic hover "fixed" it only because
UiButtonStateMachine.RequestedState resolves to the SAME canonical
Normal id regardless of PointerOver when RolloverEnabled is false, so
the next UpdateVisualState() call (from the hover event) re-picks
"Normal" from _availableStates — the blanking was a one-shot
construction-time event, not a persistent state.
Retail's own decompiled UIElement::SetState @0x00464e70 does the exact
same unconditional-commit-plus-blind-cascade (ElementDesc::AccessStateDesc
finding ANY StateDesc, media or not, is enough to commit m_curStateDesc/
m_state and cascade to every child when PassToChildren is set).
Retail avoids this specific bug purely through construction TIMING:
UIElement::Initialize's SetState(m_defaultState) call is literally the
second operation in the function, before any child-tree construction —
so a PassToChildren cascade fired during import always iterates ZERO
children in retail. Our port's LayoutImporter.BuildWidget deliberately
reapplies in the opposite order (children built first, then the parent's
default is reapplied and cascades DOWN into the now-existing children),
which is what makes this literal state-machine port hit a case retail's
own timing never exercises.
Fix: UiButton.TrySetRetailState's DirectStateId branch now requires
REAL "" media (HasStateMedia("")) before accepting the transition — a
structurally-present-but-media-less States entry no longer counts. Scoped
to UiButton only; UiDatElement.TrySetRetailState's parallel branch and
the cascade mechanism itself are unchanged, so CharacterStatController's
own three-chrome-children PassToChildren cascade (which depends on the
SAME reapply ordering) is unaffected. Register row AP-206 records the
divergence from retail's literal unconditional-commit semantics.
Regressed by two new fast unit tests in
tests/AcDream.App.Tests/UI/UiButtonTests.cs
(DirectStateCascade_WithoutRealMedia_DoesNotBlankAnAlreadyResolvedState,
DirectStateTransition_WithRealMedia_StillSucceeds — the companion
positive case, confirming an AUTHORED blank DirectState can still be
entered explicitly) plus a rewritten, now-asserting live-mount probe
(ChatIndicatorButtonLiveMountProbeTests. IndicatorButtons_ResolveNormalStateAtRest_ThroughTheLiveImportPath,
ACDREAM_PROBE_LIVE_MOUNT=1) that confirms the fix against the real
installed DAT: all four buttons now resolve ActiveState="Normal"
immediately after import, with no hover required.
Re-gate: the four floating-window indicator buttons (mail/chat-tab style LEDs at the top-left of the main chat window) should show their correct orange numbered art immediately on window open, with no hover needed.
#381 — Options-panel footer (Apply/Reset/Defaults) needs an opaque backing field; list content shows through between the buttons
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report).
ROOT CAUSE — confirmed a genuine acdream synthesis, retail authors no
backing element either. A live-DAT probe dumped the Character/Chat/
Config page roots' (0x100001F9/0x100001FF/0x1000050A) full
top-level child inventory: each has EXACTLY five children — the row
ListBox, its scrollbar, and the three physical buttons — with ZERO
direct-state media on the page root itself. Retail's own footer strip has
no authored backdrop; the bleed-through was a rendering gap, not a
missing import. Fix: a new minimal widget, UiSolidSpriteFill, tiles
RetailChromeSprites.CenterFill (the SAME panel-background sprite the
Options window's own chrome already draws behind everything, not an
invented color) across the footer strip's rect (derived from the three
buttons' own resolved Top/Height, spanning the full page width),
z-ordered strictly behind every other child so it can never occlude the
buttons. Register row AP-205 records the synthesis (Configure Keyboard
was NOT touched — its own footer strip was not probed and is out of this
fix's scope; file a follow-up if it shows the same bleed-through).
Regressed by
tests/AcDream.App.Tests/UI/Layout/OptionsPanelControllerTests.cs
(Bind_SynthesizesOneOpaqueFooterBacking_PerPageWithApplyResetDefaults
— pins exactly one backing field per page, sized from the live button
rects, z-ordered behind every sibling).
Re-gate (§OP4/OP5/OP6, "the list does not clip/overlap the Apply/ Reset/Defaults buttons" steps): scrolled content should no longer be visible through or around the three footer buttons on any of the three tabs.
#380 — Chat tab: the two opacity sliders are missing their retail row captions
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report: "I also miss the text next to the bars for Inactive and Active Opacity").
ROOT CAUSE — a DAT-resident runtime catalog, not a compiled symbol,
that nothing ever queried. PlayerOptionPage::AddSliderOption never
sets a row's name-label text (byte-verified — no StringInfo write in
its pseudo-C body); the caption comes from a SEPARATE mechanism,
UIOption_Slider::SetGameplayOptionProperty @0x00485030's own
UIOption::InqGameplayOptionNameAndTooltip @0x004ef750 catalog lookup —
a SECOND DBCache::GetDIDFromEnumStatic sub-map lookup
((0x15, 2), sibling to the ALREADY-PORTED (0x16, 2) defaults lookup)
resolving to DID 0x78000000 (confirmed a DIFFERENT object from the
defaults catalog's 0x78000001), a DBProperties with one ArrayBase- Property of per-GameplayOptionProperty entries (name/tooltip
StringInfo + the owning property id). Live-DAT-read: the array has
exactly two entries, resolving via string table 0x2300000D (the SAME
table #372 already established as this campaign's runtime-string home)
to "Inactive Opacity" / "Active Opacity" — the user's own two words.
Fix: ChatOptionsDatCaptions.TryRead ports the lookup (mirroring the
existing ChatOptionsDatDefaults shape); ChatOptionsPageController
wires the resolved captions onto element 0x1000021B (the row's own
name-label child, present on BOTH slider templates but never referenced
by this controller before) and the tooltips onto each slider. Regressed
by tests/AcDream.App.Tests/UI/Layout/ChatOptionsPageControllerTests.cs
(Bind_WiresEachSlidersOwnRowCaption_FromTheResolvedDatCatalog,
Bind_MissingCaption_RendersNoText_NeverInventsEnglish) and a new live-
mount probe (ProbeChatOpacityCaptions in
OptionsPanelLiveMountProbeTests.cs) that exercises the production
ChatOptionsDatCaptions.TryRead against the real DAT and asserts the
exact two strings.
Re-gate (§OP5, opacity sliders section): both slider rows should now show their own caption ("Inactive Opacity" / "Active Opacity") next to the bar, not just the Transparent/Opaque endpoint labels on the second slider.
#379 — Chat-window opacity applies to ALL retained windows/panels, not only the chat windows
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user report: "When I change the opacity for the chat window only the chatwindows shall change not the other panels").
ROOT CAUSE — confirmed structural, not just AP-190's known divergence.
Grepped acclient_2013_pseudo_c.txt for every call site of
ChatInterface::SetDefaultOpacity/SetActiveOpacity: there are exactly
two, gmFloatyMainChatUI::UpdateFromPlayerModule/
RecvNotice_GameplayOptionChanged (the main chat window) and
gmFloatyChatUI::UpdateFromPlayerModule (the four floating windows),
each calling the method on itself. No other gmPanelUI sibling derives
from ChatInterface, so no other window class even has these methods in
its vtable — retail's scope is structural, not a runtime choice. Fix:
RetailWindowOpacityController now scopes Attach/OnWindowRegistered/
ReapplyAll/Dispose to exactly the five ChatWindowNames (main chat +
ChatWindow1-4) instead of every window RetailWindowManager
registers. Register row AP-190 updated in the same commit (the scope
divergence it recorded is now closed; the default-value/easing/focus-
predicate residuals it also recorded are unaffected). Regressed by
tests/AcDream.App.Tests/UI/RetailWindowOpacityControllerTests.cs
(OpacityFade_AppliesOnlyToChatWindows_NeverOtherPanels pins the exact
applied-window set; the pre-existing tests were updated to register
windows under their real WindowNames so the scope check is exercised
by name, matching production).
Re-gate (§OP5 step 4, rewritten in the gate script): use a chat window (main or floating) as the "other window," not the toolbar/vitals/another panel — a non-chat window should now stay fully opaque regardless of the slider position.
#378 — Config-tab dropdown menus render bare (no button well, no arrow) and no popup opens on click
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP gate 4 (user screenshots: retail's Resolution row shows a sunken value well + green arrow cap; acdream's same rows rendered as bare text with no button chrome).
ROOT CAUSE — nothing wired the Config-tab UiMenu leaves at all.
ConfigOptionsPageController.BuildMenuRow/BuildStringMenuRow built the
row's UiMenu widget from the template but never touched a single one of
its sprite/geometry properties — SpriteResolve stayed null and every
sprite id stayed 0, so OnDraw/OnDrawOverlay early-returned on every
frame (drawing literal nothing) even though click ROUTING (#374) was
already correct. A live-DAT probe (raw ElementDesc walk against
DatCollectionAdapter, bypassing the widget layer) traced the menu
leaf's (0x10000224) full retail inheritance chain — base 0x10000353
in LayoutDesc 0x21000043, retail's shared popup/dropdown catalog — and
found it BYTE-IDENTICAL in every sprite id to VendorUiController's own
already-fixed dropdown (base 0x1000034B, same layout): arrow cap
0x060012B1/B2, face/row sprite 0x060012B3/B4, and the full
6-sprite scrollbar chrome 0x06004C5F/60/63/66/69/6C. Attribute 7 (the
popup catalog LayoutDesc) is 0x21000043 for BOTH menus — not an
approximation, a measured fact, so no register row was needed. Fix:
ConfigOptionsPageController.ApplyMenuChrome wires the SAME chrome
VendorUiController already established, threaded from
RetailUiRuntime's existing Assets.ResolveSprite/DefaultFont/
DebugFont bindings through ConfigOptionsPageController.Bind's three
new optional parameters. Regressed by
tests/AcDream.App.Tests/UI/Layout/ConfigOptionsPageControllerTests.cs
(MenuRow_SoundFeatures_OpensAndSelectsThroughRealHitPath_UsingAuthoredPopupGeometry
— asserts every sprite id is non-zero, drives the real click-to-open +
item-pick event path, and confirms the applied value reaches
AudioSettings) and extended live-mount probes in
OptionsPanelLiveMountProbeTests.cs (ProbeConfigMenuChrome/
ProbeConfigMenuPopupChrome).
Re-gate (§OP6 step 8, and every other Config-tab dropdown): every Config menu row should now show the sunken value-well face + green arrow cap and open a real bordered, scrollable popup on click.
#377 — Startup CRASH (0xC0000005 in Glfw.GetVideoMode) when settings.json has fullscreen: true
Status: OPEN — filed 2026-08-11. Reproduced deterministically on this
machine: with the persisted display settings carrying fullscreen: true
(left behind by #374's stolen dropdown click during gate 2), the client
dies during GameWindow.OnLoad → GameWindowCompositionPipeline.Run →
Silk.NET.GLFW.Glfw.GetVideoMode(Monitor*) with an access violation —
a native AV, not a managed exception, so no graceful error path runs.
Windowed startup (fullscreen: false) is unaffected. Likely site:
DisplayFramePacingController reading _window.Monitor?.VideoMode
(src/AcDream.App/Rendering/DisplayFramePacingController.cs:35) while
the window is mid-fullscreen-transition (or Monitor returning a
non-null but invalid handle in that state) — to CONFIRM, not assume.
Root-cause before fixing; the fix must make fullscreen startup safe, not
suppress the read (no workarounds rule). Until then: a user whose
settings carry fullscreen: true cannot launch — workaround is editing
settings.json back to false by hand. Related: #376 (fullscreen video-
mode switching), #374 (how the value got corrupted — that entry path is
fixed).
#376 — Fullscreen resolution picks cannot switch the display mode (Silk API limit; needs native glfwSetWindowMonitor)
Status: OPEN — filed 2026-08-11, split from #374's investigation.
While FULLSCREEN, the visible resolution is the display's video mode, and
Silk's abstract windowing API cannot change it:
IViewProperties.VideoMode is read-only, and Silk fullscreen is
desktop-mode borderless. SilkRuntimeDisplayWindowTarget.Apply
(src/AcDream.App/Settings/RuntimeSettingsTargets.cs) therefore applies a
Config-tab resolution pick to the WINDOWED size only — visible immediately
in windowed mode, and on the next return to windowed when picked while
fullscreen. Retail's own fullscreen switch is a real display-mode change
(Device::ForceDisplayResolution, gmClient::Init @0x004047af). Fix
shape: a native GLFW port — reach the underlying handle and call
glfwSetWindowMonitor(window, monitor, 0, 0, width, height, refresh)
through Silk.NET.GLFW when fullscreen, keeping the abstract path for
windowed. Needs a physical gate (mode switches can black-screen on bad
modes; validate against the monitor's mode list first).
#375 — Configure Keyboard screen renders as a visual mess at the live mount (missing button/tab captions, buttons outside the window, overlapping text)
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP's second connected gate (user report, verbatim): "all buttons lacked descriptive text", "some buttons were outside of the window", "lacked text in the tabs", "text next to the buttons was overlapping. Looked like a mess."
TWO root causes, both proven by the live-DAT probe
(tests/AcDream.App.Tests/UI/Layout/KeyboardConfigLiveMountProbeTests.cs,
ACDREAM_PROBE_LIVE_MOUNT=1 — the #372-class fixture suite was green
throughout, again):
- The missing string resolver ("buttons lacked text" + "no text in
tabs"):
RetailUiRuntime.MountKeyboardConfig's mainLayoutImporter.Buildcall was the ONE mount in that class not passingstrings.Resolve— every AUTHORED caption (OK / Cancel / Defaults / Revert / Load File... / Save As..., the six ActionClass tab labels, the Command / Mapping 1-3 column headers) built empty, while the controller's ownresolveStringlookups (row captions) worked, which is why the screen was recognizable but textless. The probe builds the same layout both ways: resolver-less = every caption''; with resolver =Movement/Camera/Combat/UI/CharacterSettings/ Emotes,Command,Mapping 1/2/3,OK,Cancel, ... Fix: pass the resolver, like every sibling mount. - Parked row-template prototypes ("buttons outside the window" +
"overlapping text"):
gmKeyboardUI(0x21000009) authors its ListBox row templates — the header text0x1000002Eand the action row0x1000002Fcarrying the three 100x32 key buttons — as ordinary TOP-LEVEL siblings of the screen, referenced by dat property0x64(the template list). Retail never instantiates template-list elements as live widgets (AddItemFromTemplateListclones from the desc — the same re-import ourUiTemplateListBox.TemplateResolverperforms), butLayoutImporter.ImportInfosbuilt them as live elements parked at the screen's (0,0): three key buttons at screen top y=0..32, ABOVE the framed panel (which starts at y=62) — the "outside the window" buttons — with the 570x40 header text overlapping them and the window's top chrome. Fix:ImportInfos(dats, layoutId)now skips top-level elements referenced by a SAME-LAYOUT template list (the same skip class as the existing BaseElement-prototype filter; same-layout only, because element ids collide across layouts —0x10000211is a page in BOTH the options and keyboard layouts). Post-fix the import collapses to the framed 600x476 panel with every screen button inside its bounds.
Blocks the OP8 connected gate — re-gate §OP8 after this commit.
#374 — Config tab: picking a new Resolution does not resize the window (live gate failure)
Status: ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate. Filed 2026-08-11 at Campaign OP's second connected gate ("I was not able to change the screen resolution").
ROOT CAUSE — popup hit-test priority, a UiRoot-level routing hole.
UiElement.HitTest walks siblings front-to-back by z-order; an OPEN
UiMenu extends its hit area beyond its own rect (the button+popup union
in UiMenu.OnHitTest), but any sibling added AFTER the menu whose rect
overlaps the popup area wins the walk before the menu's extended
hit-test is ever consulted. On the Config tab every dropdown has rows
BELOW it — so clicking a Resolution popup item actually clicked the rows
underneath (the session's persisted fullscreen: true + vsync: false
flips were exactly such stolen clicks toggling the Full Screen / VSync
rows under the open popup). Vendor's and chat's menus only ever worked
because no overlapping sibling sat in front of them — the hole was
latent since UiMenu existed. Fix: an open popup registers with
UiRoot (SetActivePopup) and gets FIRST claim on mouse-down, scroll,
and hover routing; a press outside the popup dismisses it and is
SWALLOWED (the standard dropdown-dismiss gesture — the dismissing click
must not act on whatever sat underneath); hidden/detached owners
self-heal the registration. Regressed by
tests/AcDream.App.Tests/UI/UiMenuPopupRoutingTests.cs (4 tests, with
an in-test overlap CONTROL so the assertions cannot pass vacuously).
The investigation also surfaced the fullscreen half — a resolution pick
while fullscreen cannot switch the display mode through Silk's abstract
API at all — split out as #376. And the same session's log showed 64
full settings.json disk writes from slider drags (one per drag tick);
noted here as a minor perf observation, not yet its own issue.
Re-gate (§OP6 step 8): test the Resolution row in WINDOWED mode — the pick should now register and resize immediately; fullscreen mode switching stays #376.
#373 — Configure Keyboard: DAT ActionMap.ConflictingMaps not consulted — the combat cluster raises false conflict prompts
Status: OPEN — filed 2026-08-11 at Campaign OP slice OP8's re-review round 2 (R1's scope boundary).
The DAT ActionMap (DID 0x26000000) carries a ConflictingMaps table
retail's UIOption_ActionKeyMap consults when deciding whether two rows
that share a chord ACTUALLY conflict: contexts the table marks as
non-conflicting may legitimately share a key. acdream's
KeyboardConfigController.FindConflicts
(src/AcDream.App/UI/Layout/KeyboardConfigController.cs) ignores the
table entirely — it treats ANY two live rows sharing a chord as a
conflict and opens the N-way overwrite confirm dialog. The visible
symptom is the combat cluster: Insert/Delete/End/PageUp/PageDown are
retail-authored onto multiple rows across contexts the ConflictingMaps
table permits to coexist, so rebinding one of those keys (or binding a
new action to one) prompts "overwrite N bindings?" where retail prompts
for fewer or none. Accepting the prompt then strips retail-default
bindings that should have survived.
The OP8 round-2 fix already excluded store-only rows (MappedAction is null) from the conflict universe — those cannot collide because they
never reach the InputDispatcher — but retail-mapped cross-context
sharing needs the real table. Fix: parse ConflictingMaps in
RetailActionMap (the reader already round-trips the field —
RetailActionMapReaderTests constructs it), and make FindConflicts
consult it: two rows sharing a chord conflict only if their contexts'
ConflictingMaps entries say so. Conformance-test against the combat
cluster's authored defaults (five keys, multi-row each, zero prompts on
a no-op rebind). The gate script's §OP8 warns the user off treating the
false prompts as new breakage until this lands.
#372 — Options panel: Character/Chat/Config tabs render BLANK on screen and most Gameplay buttons do nothing (connected-gate failure)
Status: BLANK-TABS ROOT-CAUSED + FIXED (c3ed32fb probe, fix this
commit); the Gameplay-buttons observation still needs a re-gate. Filed
2026-08-11 at Campaign OP's first connected gate.
ROOT CAUSE (blank tabs) — found + fixed. UiTemplateListBox creates its
row viewport lazily (post-Build, during a page controller's Bind) at
0×0 with Left|Top|Right|Bottom fill-anchors. The anchor system captures
its baseline from that 0-size rect (mR = parentW − (Left+Width) = parentW),
and UiElement.ComputeAnchoredRect's left+right branch then yields
w = parentW − mR − mL = 0 (and h = 0) permanently. A 0-tall viewport
makes UiScrollablePanel.LayoutScrollableChildren cull every row
(top + height ≤ Height is false for any real row at Height 0), so every
ListBox-backed tab (Character/Chat/Config) draws empty. Gameplay works
because it has no viewport — its buttons are authored static children sized
at Build, so they never hit the lazy-0×0 path. Fix: seed the viewport to
the ListBox's current extent at creation (UiTemplateListBox.cs Viewport
getter), so the fill-anchor baseline is mR = parentW − parentW = 0 and the
viewport tracks the ListBox. Regressed by
tests/AcDream.App.Tests/UI/UiTemplateListBoxViewportTests.cs (RED→GREEN)
and the live-DAT mount probe. Every fixture conformance test stayed green
throughout — the false-negative class this issue documents — so the
regression test drives the anchor+cull layout path the suite never did.
Still owed — the Gameplay-buttons half is NOT root-caused. Of the seven
buttons the user found only Exit Game acting. Two (Configure Keyboard,
In-Game Help) are correctly INERT. The other four (Exit-to-CharSel confirm
dialog, Use-Mouse-Turning chat lines, Urgent Assistance / Report Abuse
failure text) have effects that may have gone unnoticed rather than failed —
no evidence either way yet. Needs a re-gate observation (per-button: does a
click produce ANY visible response) before investigating; do not guess-fix.
Separately, the live log showed all 13 ID_ChatOption_TextFilter_* Chat-tab
filter labels failing to resolve from string table 0x23000003 (rows render
blank-captioned by the honest-fallback path; masks/behaviour unaffected) — a
minor label-resolution bug to fix (wrong table id or key spelling for that
family), tracked here until split out.
(filed OPEN) — 2026-08-11 at Campaign OP's first connected gate
(ACDREAM_RETAIL_UI=1, live ACE). The user found: opening the Options panel
(F11/toolbar) shows the Gameplay tab, but switching to Character/Chat/Config
shows a BLANK page, and of the seven Gameplay buttons only Exit Game
visibly did anything.
NOT a missing-layout bug — the panel builds fully. A live-DAT mount probe
(tests/AcDream.App.Tests/UI/Layout/OptionsPanelLiveMountProbeTests.cs,
ACDREAM_PROBE_LIVE_MOUNT=1) confirms against the real DATs that the
production mount (ImportInfos(0x2100006E, 0x1000018D)) resolves the root as
a UiTabPanel with a 4-entry tab table, all four page slots
(0x10000212/11/1000050C/13), all three page ListBoxes as UiTemplateListBox
with their row templates (Character 3, Chat 9, Config 8), and all seven
Gameplay buttons as UiButton. The three page controllers' Bind() also run
at mount (the live log's ChatOptionsPageController … 'ID_ChatOption_TextFilter_*' did not resolve spam proves ChatOptionsPageController.Bind executed) — and
every one of those Bind paths is green in the fixture-driven conformance
suite.
So the defect is in the LIVE render/input path the tests never exercise —
the mounted → ActivateTabs() → tab-click → SwitchTo (page-slot
Visible flip) → row draw / button hit-test chain. Every campaign test
exercises Bind() in isolation and asserts widget structure; none drives a
real tab switch on a mounted panel and asserts the switched-in page's rows
actually draw, nor a real click reaching a Gameplay button's handler. This is
the exact structural-false-negative class the OP2 blast review named (green
tests over a live-only failure). Leading hypotheses to run down in the fix
(not yet root-caused): (a) page-slot Visible flips false→true AFTER the
ListBox/UiScrollablePanel computed its layout while hidden, so rows are
zero-height/culled until a relayout; (b) the switched-in page slot or the
Type-8 root's draw/hit-test doesn't cascade to descendants built post-mount;
(c) the "dead" Gameplay buttons (Exit-to-CharSel dialog, Use-Mouse-Turning
chat lines, UA/RA failure text) each have an invisible EFFECT rather than a
dead click — needs per-button confirmation. Configure Keyboard + In-Game
Help ARE correctly inert (OP8 pending).
Blocks the OP3/OP4/OP5/OP6 connected gates — they cannot pass until the non-Gameplay tabs render and the Gameplay buttons act. Fix is a dedicated debug slice (live render-path instrumentation, NOT a guess), then a gate-representative test that mounts+activates+switches+asserts-drawn so this can never regress green again.
#371 — Options-panel row viewport culls whole rows instead of clipping; tall filter blocks can vanish entirely at some scroll offsets
Status: DONE — fixed 2026-08-11 at the Campaign OP gate-3 fix round.
The user's gate-3 screenshot review caught the predicted symptom at the
DEFAULT scroll offset ("the chat tab looks like it is missing per window
config" — Chat Window 1's header rendered over a void, its 260px filter
block whole-row-culled, windows 2-4 below the fold). By fix time the UI
renderer HAD grown a clip stack (UiRenderContext.PushClip, already
honored by the generic draw walk and hit-test via ClipsChildren), so
the fix is exactly the shape this filing asked for: UiScrollablePanel
now sets ClipsChildren => true and culls by INTERSECTION instead of
full containment — straddling rows render their visible slice, clipped
at the viewport edge for both drawing and clicks. Register row AP-201
retired in the same commit. Pinned by
UiScrollablePanelTests.StraddlingRow_StaysVisible_AndClipsInsteadOfVanishing
and ViewportClipsChildDrawingAndHitTesting.
(filed OPEN) — 2026-08-11 at the OP5 review-fix round (S2).
UiScrollablePanel.LayoutScrollableChildren (src/AcDream.App/UI/UiScrollablePanel.cs:69)
has no scissor stack, so a row that straddles the viewport's visible edge is
hidden WHOLE (child.Visible = top >= -0.5f && top + child.Height <= Height + 0.5f) rather than clipped to its visible portion. Every row in
this viewport (used by UiTemplateListBox, the Character/Chat/Config
Options-panel tabs) was 8-36px until Campaign OP slice OP5 added five
self-sized filter blocks (240-260px, AP-195's self-sizing) to the Chat
tab — a block that size straddling the viewport edge now disappears
entirely for a range of scroll offsets instead of clipping, a visible pop
that the pre-OP5 small rows never made noticeable. Register row AP-201.
Fix: add a real per-row clip rect (scissor test, or per-row UV/geometry
clip in the draw path) to UiScrollablePanel.OnDraw/LayoutScrollableChildren
so a straddling row renders its visible slice instead of being culled
outright. Deliberately NOT attempted in the OP5 fix round (out of scope —
a renderer-level change, not a Chat-tab content fix); see AP-201 for the
full analysis and the OP5 gate script's step 2 for the exact observable
symptom.
#360 — @allegiance/@house management dispatchers only port their simple subcommands
Status: OPEN — filed 2026-08-09, Campaign CH slice CH4; corrected
2026-08-09 at the CH4 REJECT-review (Blocker 1). Retail's
@allegiance/@all and @house/@hou are 12- and 15-subcommand local
command dispatchers (ClientCommunicationSystem::DoAllegiance @ 0x0057D5A0 / DoHouse @ 0x00580860). CH4 ports the subset with simple
parameterless/single-string-field wire shapes — allegiance info,
hometown/ho (also the standalone @alh/@ah); house recall/re,
mansion_recall/alleg_recall/ma (already shipped pre-CH4), and
abandon. The remaining ~22 subcommands (allegiance boot/ban/officer/
title/name/lock/chat/broadcast/motd; house open/close/storage/remove/
boot_all/remove_all/guest/available/hooks/on/off) plus the standalone
@motd verb need real GameAction wire builders, most requiring
target-name/guid resolution, confirmation dialogs, or multi-field payloads
this session did not attempt to build without byte-level verification
against both the retail decomp and ACE's reader — see the doc's own
framing ("largest single item; deserves its own slice"). For @house,
these subcommands correctly fall through to ACE as server-passthrough
text (RetailClientCommandCatalog.TryMatchHouse) rather than being
swallowed locally, which was the Tier-1 correctness fix CH4 landed — but
they don't yet execute. For @allegiance/@all, the original filing's
"falls through to ACE" claim was wrong: retail's own DoAllegiance
never reaches server passthrough for an unrecognized subcommand — it
prints "Please see @help Allegiance for more information on how to use
this command." locally and stays entirely client-side
(ClientCommunicationSystem::DoAllegiance, label at 0x0057DA4B). The
CH4 REJECT-review found acdream had instead been broadcasting the
unmatched subcommand text to the Allegiance chat channel — a real
chat-visible bug, now fixed (TryMatchAllegiance claims ownership
unconditionally and shows retail's own refusal text).
Corrected again 2026-08-09 at the CH4 re-review (SHOULD-FIX 3), for
precision: the nine allegiance subcommands (boot, ban, officer, title,
motd, name, lock, house, chat, broadcast) are NOT refused by retail —
DoAllegiance's dispatcher table EXECUTES each one locally through its
own handler (e.g. DoAllegianceBoot @ 0x0057D646 is the dispatcher's
call site into ClientCommunicationSystem::DoAllegianceBoot, with
DoAllegianceBan/DoAllegianceOfficer/DoAllegianceOfficerTitle/
DoMotd/DoAllegianceName/DoAllegianceLock/DoAllegianceHouse its
siblings in the same table). acdream shows the same unrecognized-
subcommand refusal for all nine because none of those handlers is
ported yet, pending this issue. What matches retail is the OWNERSHIP
RULE — the verb never reaches DoChannelCommand/the server regardless
of subcommand — NOT the subcommand's actual behavior, which retail
executes and acdream does not.
The 22 subcommands themselves still don't execute; only the fallback
behavior changed. Register row: TS-68. Registry doc:
docs/research/2026-08-09-chat-retail-command-registry.md §2.5/§2.5b.
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
slice CH4).
#361 — @day / @log / @render pure-local commands recognized in help only, not executed
Status: OPEN — filed 2026-08-09, Campaign CH slice CH4. Three
retail-registered pure-local verbs are not yet wired to real behavior:
@day (daylight override — needs a sky/time-of-day hook the renderer
doesn't expose), @log (chat-to-file logging — deferred to avoid an
unaudited file-handle lifecycle across session reconnects; see AP/TS-69
for the reasoning), and @render (retail's SmartBox::HandleRenderOption
— acdream has no equivalent render-option surface). All three are
recognized by /help <verb> (RetailCommandHelpTable) with retail's own
extracted help text, but fall through to server passthrough on execution.
Register row: TS-69.
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
slice CH4).
#363 — Chat refusal/usage call sites are typed ClientLocal 0x00 where retail types several 0x1A
Status: CLOSED 2026-08-10. ChatVM gained a typed interface-text seam
(OnInterfaceText init hook + ShowInterfaceText(text)) that the App-layer
composition (InteractionRetainedUiComposition.CreateRetainedUi) wires to
RuntimeCommunicationState.AddText(text, RetailLogTextType.ClientLocal) —
the same SpewBox chokepoint every other interface-text producer uses.
UI.Abstractions still never references Runtime directly (Code Structure
Rules); the hook is the seam. Unwired callers (headless, the automation
probe runner, plain test fixtures) fall back to the ordinary chat log
tagged ClientLocal, so no text is ever silently dropped.
Every site AP-183 named now routes through the seam: DoStupidChannelHack
("You must specify the text you wish to say!", newly wired — the six
legacy channel verbs previously fell through ChatInputParser.Parse's pure
return null shape with NO message at all), DoChannelList/DoChannelOn/
DoChannelOff ("Please specify the channel name.", reclassified),
DoAllegiance ("Please see @help Allegiance...", reclassified),
DoHouseAvailableList (reclassified AND corrected to retail's own
"Please see @help hslist for more information on how to use this command"
string — verified at acclient_2013_pseudo_c.txt:381481/1029383,
replacing the acdream-synthesized "Usage: /hslist " fallback),
and DoReply ("Someone must @tell you first!", newly wired for the
"message but no last teller" branch — bare /r with no message at all is
a separate retail branch, deliberately out of scope, not named by AP-183).
DoSpeaker/DoEndurance/DoTitle are untouched — already correct at
0x00 (their text is produced by ClientCommandController, not
ChatCommandRouter).
The generic bad-args fallback is also fixed: ChatCommandRouter.Submit's
catalog dispatch now falls to WeenieErrorMessages.Resolve(0x026u, null)
("That is not a valid command.", the exact port of retail's
HandleFailureEvent(0x26)) instead of synthesizing "Usage: {Usage}" —
verified 5 decompiled handlers (DoDie, DoChannelList/On/Off,
DoAllegiance, DoHouseAvailableList) are ALL 0x1A, confirming the
uniform routing decision. This also closes #367 (the "Unknown command"
DoHelp fallback and the degenerate-prefix "Unknown command: {verb}."
refusal both now use the same seam) and retires register row AP-186 —
see that row's retirement note.
Tests: tests/AcDream.UI.Abstractions.Tests/Panels/Chat/ChatVMRetellAndProvidersTests.cs
(seam wired / null-fallback), ChatInputParserTests.cs
(IsBareRegisteredChannelVerb/IsReplyMissingLastTeller pure-predicate
coverage), ChatCommandRouterTests.cs (per-site routing pinned both ways
for every reclassified/newly-wired site, plus a Turbine-only-channel
negative case and a 0x00-site-stays-in-chat sanity check).
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
CH4 REJECT-review; closed in the goal-window follow-up).
#365 — Headless host cannot move at head: session quarantines on the first advance tick; world never hydrates
Status: CLOSED 2026-08-10. Root-caused and fixed per
docs/research/2026-08-10-365-headless-hydration-diagnosis.md (see its
OUTCOME section for the measured verdict and fix shape). Distinct from #330
(no collision) and #332 (no remote DR): this was the LOCAL player.
Correction to the evidence chain below: entities: 0 in the headless
JSON is NOT evidence of failed hydration. HeadlessDiagnosticWriter.Lifecycle
fires at exactly four points (constructed / start-result / reconnect-deferred
/ stopped), none of which run after a CreateObject stream has had any chance
to populate the entity table — a perfectly healthy run prints the SAME
entities: 0 on every lifecycle line. entityCount was a logging artifact,
not a hydration symptom.
Actual root cause (confirmed via live capture with ACDREAM_PROBE_PARK=1,
unblocked by the Step-1 audit fix below): the headless host's ONLY
collision publisher is a 3×3 landblock plan STARTED BY the local player's own
CreateObject (HeadlessCollisionNeighborhood) — unlike the graphical host's
publisher, which runs on the streaming cadence ahead of the Create burst.
HeadlessSessionWorldProjection.PumpFirstEntry/ProjectSpawn/ProjectPosition
drove the first-entry conductor UNCONDITIONALLY, including while the
neighborhood's own publication held a genuinely open
RuntimeCollisionAdmission for the same landblock the player's placement
needed. Every TrySealCollisionEvaluationAuthority attempt during that
window failed (IsCollisionEvaluationPrefixAdmissible false) and retried
every tick without ever recovering while the window stayed open. Measured
verdict: seal-refused repeating with NO preceding [rearm] verdict= line —
the operation never even reached the AwaitingCell park; it failed the seal
immediately on every attempt while AwaitingPreparation. This is the
diagnosis doc's "structural half — CONFIRMED" mechanism, not its "circular
HasOldPrefixPlacementDebt" hypothesis (no prefix-inadmissible rearm
verdicts were ever observed).
Fix (Step 3a): new IHeadlessCollisionNeighborhood.IsQuiescent
(_pendingPublication is null && _publicationQueue.Count == 0 && !_pendingPublicationCancellation) gates all three drive call sites
(ProjectSpawn, ProjectPosition, PumpFirstEntry) — the conductor is
never driven while the neighborhood's own publication owns collision
authority for that tick.
Fix (Step 4, defense-in-depth): HeadlessLocalPlayerFrameHost.CanAdvancePlayer
now requires Controller is { CanExecuteLiveMovement: true } instead of just
Controller is not null — the exact bug that turned the (now-fixed)
hydration stall into a hard crash (a dormant, unpublished controller reaching
SuspendObjectUpdate). RuntimeLocalPlayerFrameController's three shared
entry points (AdvanceBeforeNetwork/RunPostNetworkCommandPhase/
TryGetPresentationAfterNetwork) gained the same
controller.CanExecuteLiveMovement guard, contract-preserving for the
graphical host.
Enabler (Step 1): HeadlessStaticStateAudit.ValidateProcessIsolation
now takes sessionCount and only refuses process-global physics probes for
sessionCount > 1 (logging, not refusing, for the single-session case) — the
audit's own rationale (multi-root attribution ambiguity) never applied to a
single session, and it was blocking the exact probe (ACDREAM_PROBE_PARK=1)
built to diagnose this class of stall.
End-to-end verification: confirmed via three live jump-probe runs
against local ACE — hydration now succeeds cleanly (136 entities load,
"local player present" fires promptly, [jump-probe] releasing jump (fire)
reached — no seal-refused spam, no crash from the original bug) and the
session exits gracefully every time ([session] graceful logout confirmed,
zero leases at disposal). Full airborne-transition True confirmation is
blocked by a SEPARATE, newly-discovered, pre-existing defect — see #368 below
— not by anything in this issue's scope. (2026-08-10 update: #368 is CLOSED
at b7f59923; the airborne residual survived that fix and is now #370.) A diagnostic-only run with #368's
guard temporarily neutralized (never shipped, reverted before commit)
confirmed the #365 fix produces the correct behavior once past that unrelated
blocker: full hydration, the jump-probe policy running to completion, exit
code 0.
Also observed in the same runs: [weenie-error] unmapped code=0x051D —
an ACE-only id outside retail's 344-case HandleFailureEvent switch; CH2's
silent-toward-player + diagnostics-line fallback handled it as designed (no
action needed, noted for completeness).
Original evidence chain (2026-08-10, superseded by the root cause above):
- First quarantine:
RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:93unconditionally re-assignedcontroller.LocalEntityId— a sealed configuration property — on a still-dormant controller. FIXED in the probing session: a same-value re-assertion is now a no-op (a different id while sealed still throws). - Second quarantine (one layer deeper): the frame controller takes the
ObjectClockDisposition == Suspendbranch and callsSuspendObjectUpdate→EnsurePublishedForRuntimeOperationthrows —_host.CanAdvancePlayeris true while the controller is unpublished. - Root condition (at the time): believed to be "the session's world never
hydrates" from the
entities: 0artifact — corrected above.
Repro: dotnet run --project src/AcDream.Headless -c Release -- run --config <cfg> with a jump-probe policy session against local ACE
(config shape: version 1, endpoint 127.0.0.1:9000, credential provider
Environment). HeadlessDiagnosticWriter.Failure now emits errorDetail
(full exception) — added during this diagnosis.
#368 — Headless scheduler's async tick loop can run collision-generation calls on different threads, tripping EnsureCollisionMutationThread
Status: CLOSED 2026-08-10 — fixed in b7f59923. One dedicated update
thread (acdream-headless-update, spawned by HeadlessProcessHost.RunAsync)
now owns the whole session-side lifecycle: Start() (the live connect
transaction, where the first collision-mutating call can already happen),
every scheduler turn, and the post-loop resource captures.
HeadlessProcessScheduler.Run(CancellationToken) replaced RunAsync — the
same deadline math and counters, but fully synchronous on the calling
thread, with waits going through one rearmed TimeProvider timer
signalling an event instead of await Task.Delay, so the loop never leaves
its thread. EnsureCollisionMutationThread is untouched — the invariant it
guards is real, and the headless host now satisfies it the same way the
graphical host's game-loop thread does. Zero shared Runtime changes, so the
graphical host is unaffected by construction. Evidence: new
ProcessHostRunsStartAndEveryTickOnOneDedicatedUpdateThread test (RED
pre-fix — Start ran on the caller's thread, ticks migrated to pool
workers), Headless suite 97/97, full Release suite 12,554 / 4 skips / 0
failures, and three live jump-probe runs against local ACE that each
crossed [wake] begin gen=1 — the exact point all three pre-fix runs
quarantined — with zero faults, 204–205 hydrated entities, policy
completion, ACE-confirmed graceful logout, converged disposed samples, and
exit 0. The jump-airborne timeout this issue carried as an open question
persists 3/3 on the fixed tree — the threading-artifact hypothesis is
refuted; split off as #370.
Filed 2026-08-10 during #365's end-to-end verification. Explicitly OUT OF SCOPE for #365 — orthogonal mechanism, not mentioned anywhere in that diagnosis.
Symptom: a real headless run against live ACE (jump-probe policy,
ACDREAM_PROBE_PARK=1) that survives long enough for the local player's own
landblock collision generation to span more than a couple of scheduler ticks
reliably quarantines with:
System.InvalidOperationException: Collision generations must be staged and
committed on one update thread.
at AcDream.Runtime.Physics.RuntimePhysicsState.EnsureCollisionMutationThread()
at AcDream.Runtime.Physics.RuntimePhysicsState.AdvanceCollisionGenerationSeal(...)
at AcDream.Headless.Hosting.HeadlessCollisionGenerationTransaction.Advance()
at AcDream.Headless.Hosting.HeadlessCollisionNeighborhood.AdvanceWork()
at AcDream.Headless.Hosting.HeadlessCollisionNeighborhood.IsReady(...)
at AcDream.Headless.Hosting.HeadlessSessionWorldProjection.PumpFirstEntry()
at AcDream.Headless.Hosting.HeadlessSessionHost.Tick(...)
at AcDream.Headless.Hosting.HeadlessProcessScheduler.DispatchSessionDue(...)
Reproduced identically across 3 separate live-ACE runs (2026-08-10), each
time at the same point ([wake] begin lb=0x0904FFFF gen=1 — right after the
jump-probe policy's "local player present" line) — not a one-off timing
fluke.
**Root cause (confirmed by reading RuntimePhysicsState.EnsureCollisionMutationThread
ResetSessionPhysics's own doc comment):** the guard binds the FIRST thread that calls any collision-mutating method for a generation (_collisionMutationThreadId,Interlocked.CompareExchange) and requires every later call on that generation to match — a real invariant for the graphical host, whose whole session runs on one dedicated update thread.HeadlessProcessScheduler.RunAsyncinstead drives its ticks throughawait Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false)— a console app has noSynchronizationContext, so each resumption after the delay can legitimately land on a different ThreadPool worker. The FIRST collision-mutating call (during the session's opening synchronous tick, still on the process's original thread) binds the guard to that thread; any LATER tick that resumes on a different pooled thread and also calls into collision generation trips it.
Why K1–K4's connected gates never caught it: those gates' own collision
generations apparently completed within tick sequences that stayed on the
same pooled thread (low contention on those runs), or the exact interleaving
needed to cross a real Task.Delay resumption boundary mid-generation never
occurred. Every fixture test in this repo drives Tick() synchronously and
directly, never through the real HeadlessProcessScheduler.RunAsync await
loop — so none of them exercise this path either. A genuine coverage gap,
not a regression from a specific commit.
Why the dedicated-thread shape (not an invariant redesign): the guard
is only the ENFORCER — Runtime's single-update-thread contract is
documented all over (RuntimeEntityDirectory "single update-thread
authority", RuntimeLocalPlayerPhysicsPublicationState "this single
Runtime update thread", RuntimePlacementProjectionChannel), mostly
without enforcement. An async tick loop violates the whole contract, not
one check; weakening the check would have silenced the one place that
noticed while leaving every unenforced assumption exposed, and would have
degraded the guard for the graphical host where migration is always a bug.
The dedicated thread fixes the entire class. Start() had to move onto the
same thread too: the first collision-mutating call can land during
connect, and a caller-thread Start would bind the guard there and trip the
very first dedicated tick. Disposal legitimately stays on the lifecycle
thread — ResetSessionPhysics's doc comment designs for exactly that, and
every prior graceful-teardown run (including the quarantined ones)
exercised it.
Repro (historical): run the jump-probe policy against local ACE with
ACDREAM_PROBE_PARK=1 for long enough that the local player's own landblock
collision generation spans more than a couple of scheduler ticks (the
default case against a real DAT-loaded landblock).
#370 — Headless jump-probe: the released jump never registers as airborne (proven NOT a threading artifact)
Status: OPEN — filed 2026-08-10 during #368's fix verification.
Symptom: with #368 fixed (one dedicated update thread, thread
migration provably gone — the new affinity test pins it), the jump-probe
policy reaches [jump-probe] releasing jump (fire) and then reports
TIMEOUT waiting for airborne after jump fire -- the released jump never registered as airborne. Reproduced 3/3 on the fixed tree at
lb=0x1134FFFF; the #365 session saw the identical timeout in its
guard-neutralized diagnostic run at lb=0x0904FFFF
(docs/research/2026-08-10-365-headless-hydration-diagnosis.md §8), so it
is location-independent and pre-existing. The run still exits 0 with
ACE-confirmed graceful logout — the probe treats its timeout as
completion, so nothing quarantines.
What this refutes: the #365 OUTCOME's hypothesis that the timeout was
"plausibly a downstream artifact of the same unsynchronized-thread
condition" — threads are now single and the timeout persists unchanged.
This is a distinct defect (or probe-expectation gap) in the headless jump
path: either the policy's charge/release never becomes a real jump on the
wire/local motion, or the airborne signal the policy polls is never set on
the headless projection. Everything before the fire provably works
(hydration 204–205 entities, local player present, movement owner
publication per #365's fix).
Where to start: JumpProbeHeadlessBotPolicy (what it reads as
"airborne"), the J5.4 RuntimeLocalPlayerMovementState jump intent/outbound
cadence path, and whether the graphical host's airborne transition has a
presentation-side dependency the headless projection lacks.
Repro: #368's recipe (jump-probe vs local ACE, ACDREAM_PROBE_PARK=1);
the timeout fires seconds after releasing jump (fire).
#369 — Unconfirmed whether retail's floating chat windows share the main window's currently-selected talk-focus channel
Status: OPEN — filed 2026-08-10, Campaign CH slice CH6b (register row
AP-188). The floating chat window LayoutDesc (0x2100005B) authors no
talk-focus menu (docs/research/2026-08-09-chat-retail-window-shell.md
§2.2 — only the main window's 0x2100006F has one, element 0x10000014),
so acdream's FloatingChatWindowController hardcodes every floaty window's
chat entry to send on ChatChannelKind.Say. What is UNVERIFIED is retail's
actual send path: does a floaty ChatInterface instance's typed message go
out on a per-window channel (also always Say, since there's nothing to pick
from), or does it read the single globally-current talk-focus
channel/target the MAIN window's menu (and gmMainChatUI::UseTime @0x004CDB20's selected-target tracking) last set? If the latter, a real
retail floaty window sends on whatever channel the player most recently
picked from the main window — acdream would then need to promote
ChatWindowController's private _activeChannel to a shared owner all
five window controllers read, rather than each owning its own (the main
window keeps its own local state; the four floaties currently have no
state at all, just the Say constant).
Where: src/AcDream.App/UI/Layout/FloatingChatWindowController.cs
(Bind's OnSubmit); src/AcDream.App/UI/Layout/ChatWindowController.cs
(_activeChannel, the eventual shared-state candidate).
Fix shape (needs research first): trace gmCCommunicationSystem's
send-command path starting from a floaty ChatInterface instance (not the
main window) to confirm which channel/target it actually uses; if it's
shared, wire a single shared active-channel owner (Runtime-level, matching
the J4.1 pattern the rest of chat state now follows) that all five
controllers read instead of the main window's private field.
#366 — Chat window's new-unseen-text indicator (0x1000048C) imports but is never independently wired
Status: OPEN — filed 2026-08-10, Campaign CH slice CH6a. The retail main
chat window authors a 16×16 "new unseen text" indicator button
(0x1000048C, base 0x10000527/0x21000040) as a CHILD of the transcript
text element 0x10000011 (position (0,57) relative to the transcript,
i.e. bottom-left of the transcript pane), confirmed in the 0x2100006F
LayoutDesc dump. UiText.ConsumesDatChildren is true (Type-12 behavioral
widgets reproduce their dat sub-elements procedurally per
DatWidgetFactory's own doc comment), so LayoutImporter.BuildWidget
never builds 0x1000048C as a separate widget — it is silently swallowed,
same as under the wrong 0x21000006 layout before it (not a CH6a
regression). No controller anywhere binds or drives its visible state.
Not in CH6a's scope (transcript/input/scrollbar/1-4-buttons only) and
not obviously CH6b/CH6c's either — files here as a standalone gap. Fix
shape: either give UiText an opt-in mechanism to keep specific named
non-Type-3 children (mirroring UiMeter's existing text-overlay carve-out
in DatWidgetFactory.BuildWidget), or handle 0x1000048C as a special
case the same way. Needs research first: what triggers retail's "new text"
indicator (unread-since-scroll-position?) and what it visually does on
click — not decoded by CH6a.
Where: src/AcDream.App/UI/Layout/ChatWindowController.cs;
src/AcDream.App/UI/Layout/LayoutImporter.cs
(BuildWidget/ConsumesDatChildren handling); src/AcDream.App/UI/UiText.cs.
#367 — ChatCommandRouter's local-presentation fallbacks type-0x1A text still lands in the chat scroll, never the SpewBox
Status: CLOSED 2026-08-10, closed as a side effect of #363's
interface-text seam (fix shape (a) from this issue's own filing).
ChatVM.OnInterfaceText is exactly the hook this issue asked for; both
named fallbacks (RetailCommandHelpTable.UnknownCommand in
ChatCommandRouter.EmitVerbHelp, and the degenerate-prefix "Unknown
command: {verb}." refusal in ChatCommandRouter.Submit's main body) now
call vm.ShowInterfaceText(...) instead of vm.ShowSystemMessage(...),
reaching the SpewBox through RuntimeCommunicationState.AddText via the
App-layer composition wiring. See #363's closure note for the full
mechanism and test list. Register row AP-186 retired in the same commit.
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
user gate round 3; closed in the goal-window follow-up).
#364 — Three /help group topics still partial: HelpStupidChannelHack unresolved
Status: CLOSED 2026-08-10 — Campaign CH round 4. The blocker in the
original filing (below) was a wrong belief, not a real limitation:
ClientCommunicationSystem::HelpStupidChannelHack @0x0056f290's three
"vtable slot" operands are the SAME pooled/mislabeled-data artifact this
file's register entry AP-113 already documented elsewhere — real DATA
pointers into .rdata, not vtable dispatch. Reading the function's own
disassembly for the push imm32 immediately preceding each
PStringBase::PStringBase constructor call (instead of trusting Binary
Ninja's line-grouped rendering, which hides the true instruction order)
resolves all three operands directly: the function builds
"@" + tag + " - Sends a broadcast to your " + ChannelName + ".\n", where
tag is one character sliced out of a shared wide literal U"fvpca"
(reading a WIDE string through a NARROW char* truncates at the first
zero high byte — the "hack" the function's own retail name calls out) and
ChannelName comes from ChannelSystem::GetChannelName's own literal
switch table (also read directly: "Allegiance", "Co-vassals", "Monarch",
"Patron", "Vassals", "Fellowship"). ChannelsGroupDetail,
ChattingGroupDetail (whose "@reply" entry also needed
HelpReply@0x00577A50's Summary-branch decoded — it unconditionally
concatenates reply+pr+mr, a genuine retail quirk ported as found), and
CommandsGroupDetail (HelpAllGroup, a straight-line concatenation of
every other group's Detail branch, including a CONFIRMED retail
saveui/loadui duplicate) are now COMPLETE verbatim listings, matching the
four (death/status/text/allegiances) already complete. Register row
AP-184 RETIRED with the full citation trail. The 5 remaining
ByVerb-only channel one-liners (fellowship/monarch/patron/vassals/
covassal, as standalone /help f-style lookups rather than group-listing
entries) are UNCHANGED — their own standalone help registration was never
confirmed independently of this mechanism, so they are deliberately left
as acdream summaries rather than spliced in speculatively.
Original filing (2026-08-09, Campaign CH user-gate round 2, item 3):
The user caught /help death printing an acdream meta-message instead of
retail's real listing; all 7 ClientCommunicationSystem::HelpXxxGroup
nodes were re-extracted verbatim from the PDB-paired binary via a
generalized tools/pdb-extract/sweep_weenie_strings.py --ascii-only.
4 of 7 (death/status/text/allegiances) were COMPLETE verbatim listings
(RetailCommandHelpTable.DeathGroupDetail etc.); 3 remained PARTIAL
(ChannelsGroupDetail, ChattingGroupDetail, CommandsGroupDetail) with
an explicit UNVERIFIED note, believed genuinely not decodable from a
static string sweep — see the CLOSED note above for why that turned out
to be wrong.
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
user gate round 2).
Note — six invented chat verbs removed for registry parity (2026-08-09)
Campaign CH slice CH4 deleted /gen, /cv, /lookingforgroup, /tr,
/role, /h from ChatInputParser/ChatCommandRouter — none are
retail-registered verbs; the retail command registry doc
(docs/research/2026-08-09-chat-retail-command-registry.md §4,
"candidates for removal") confirmed none exist in the real client. Not a
bug, no issue number — recorded here so they aren't reintroduced later as
"missing aliases." RetailCommandRegistryConformanceTests's two
reverse-direction ownership tests now fail the build if any of the six
(or any other invented verb) resurfaces.
#356 — Alt-tab during login crashed the client: focus loss faulted on an unpublished movement controller
Status: CLOSED 2026-08-08 — 972c7ab3. Window focus loss runs
MouseLookController.EndForLifecycle → PlayerMovementController.EndMouseLook,
whose EnsurePublishedForRuntimeOperation throws for a controller that exists
but is UNPUBLISHED (mid-login) or retired (post-logout); a focus callback can
land in either window, so alt-tabbing during the login stream killed the
process with an unhandled InvalidOperationException. Hit live during the
Campaign A listening-session launches. Fixed by completing the existing
null-guard with CanExecuteLiveMovement (the exact lifecycle set the throw
helper accepts); cursor restore still runs unconditionally, and
published-controller behaviour is unchanged.
#358 — Ctrl+M mute keybind never fires from the dispatcher
Status: DONE — root cause found and fixed. Filed 2026-08-08; closed via the CH6c-fix-adjacent session that wrote the full-production-wiring repro test the deferred investigation asked for.
Mechanism (confirmed, not inferred): InputAction.AcdreamToggleAudioMute
was bound to Ctrl+M only in KeyBindings.AcdreamCurrentDefaults()
(src/AcDream.UI.Abstractions/Input/KeyBindings.cs:123) — the pre-K.1c
WASD-only preset, whose own doc comment says it is preserved solely "as a
regression anchor for tests that pin the older modifier-blind WASD layout"
and is explicitly "NOT the GameWindow startup source after K.1c."
KeyBindings.RetailDefaults() (the method KeyBindings.LoadOrDefault
actually falls back to when no keybinds.json exists on disk — the verified
state on the affected machine) never carried the Ctrl+M binding over: it has
its own "Acdream debug actions: relocated to Ctrl+F* to avoid retail
conflicts" block (Ctrl+F1/F2/F3/F7/F8/F9/F10, Ctrl+Shift+F) but
AcdreamToggleAudioMute was simply never added to it. The live dispatcher
therefore had no Ctrl+M entry in its binding table at all — not a modifier-
matching bug, not a scope bug, not a retained-UI-capture bug. Same class as
the a5a7eb4f jump fix (two construction paths, one wired to production),
except here it's two DEFAULT-BINDING-SET methods rather than two controller
instances, and the binding was added to the wrong one.
How it was found: tests/AcDream.UI.Abstractions.Tests/Input/MuteChordDispatchTests.cs
(CtrlM_WithNoWidgetFocused_FiresAcdreamToggleAudioMute) reproduces the full
production wiring shape — real KeyBindings.RetailDefaults() (not an ad-hoc
test binding), the dispatcher's actual default [Always, Game] scope stack
(production never calls PushScope/PopScope anywhere — grepped clean
across src/AcDream.App, so Chat/EditField/Dialog/etc. scopes are dead code
today), and a synthetic Ctrl+M keydown with nothing capturing the keyboard.
It failed with an EMPTY fired collection before the fix (not a wrong-action
mismatch), which is what pinpointed "missing table entry" over the other
hypotheses. This also resolves the prior "loaded 152 bindings both before and
after" mystery: the count correctly did NOT change across that prior session
because the earlier binding addition went into AcdreamCurrentDefaults(),
which nothing in production ever loads or counts.
Ruled out, but real and now pinned as a separate regression test
(CtrlM_WhileAnyWidgetHoldsKeyboardFocus_IsSuppressed):
InputDispatcher.OnKeyDown returns before calling FindActive at all when
_mouse.WantCaptureKeyboard is true, and production wires that to
UiRoot.WantsKeyboard = "KeyboardFocus is not null" — ANY focused widget
(in practice only UiField instances ever set AcceptsFocus = true in the
retained UI, so this means any focused text-entry field), not scoped to a
specific text box. This gate is total and pre-empts every chord, not just
Ctrl+M, but the baseline repro test proved the bug reproduced with nothing
focused at all, so this was not #358's cause.
Fix: src/AcDream.UI.Abstractions/Input/KeyBindings.cs —
RetailDefaults() now also binds Ctrl+M → AcdreamToggleAudioMute in the
Acdream-debug-actions block. Retail's own keymap
(docs/research/named-retail/retail-default.keymap.txt) has no Ctrl+M
binding, so this doesn't collide with anything retail-faithful.
The mechanism behind the key (already landed, 2cf94dbc): engine
Muted sets the AL listener gain 0/1 — unused since A2 moved mixing to the
CPU, so it silences already-playing voices instantly without touching the
retail mixing math or persisted volumes. The trigger now works; no further
audio-side work is needed. Connected verification (does Ctrl+M actually mute
in a live client) is still owed the next time a client launch is available —
this session's hard constraints excluded client launches.
#359 — 0x019E PlayerKilled line prints to participants — retail suppresses it
Status: OPEN — filed 2026-08-09 at the CH1 Opus review. Pre-existing (not introduced by CH1); candidate for CH4/CH5.
Symptom: ChatLog.OnPlayerKilled (src/AcDream.Core/Chat/ChatLog.cs)
always appends the death message for every recipient of the 0x019E
PlayerKilled GameEvent. Retail's ClientCombatSystem::HandlePlayerDeathEvent @0x0056C320 skips the AddTextToScroll call when the receiving player IS a
participant — player_id == victim || player_id == killer — so the victim
and killer see the notification through their own dedicated
Victim/KillerNotification lines (0x01AC/0x01AD) instead, and would see it
twice if the bystander-facing PlayerKilled line were not suppressed for
them. acdream has no such guard: OnPlayerKilled prints unconditionally
regardless of whether the local player is the victim, the killer, or an
uninvolved bystander.
Fix shape: thread the local player's guid into OnPlayerKilled (or its
caller) and skip the append when it matches victimGuid or killerGuid,
matching retail's participant check.
Campaign: docs/plans/2026-08-09-chat-parity-campaign.md (Campaign CH,
filed at the CH1 review).
#357 — Login stalls: reveal reaches ready=True but the player is never placed; UI + sky render, world never opens
Status: CLOSED 2026-08-08 — root-caused and fixed same session (see the
commit referencing this issue). Root cause: a transient collision-
authority seal failure was classified as TERMINAL for the login conductor.
The C3c-F2 rearm guard validates the EXACT destination cell's prefix before
leaving AwaitingCell, but the placement transaction's ring search touches
NEIGHBOUR landblocks, and TrySealCollisionEvaluationAuthority covers every
touched prefix — so a rearm taken while a neighbour's collision admission
was still registered (a hard login recenter admits nine at once) passed the
guard and then failed the seal. The operation was left in
AwaitingPreparation, which made IsDormantLocalActivationAwaitingCell
false, which forced EvaluateActivation to report RejectedAuthority —
terminal for RuntimeFirstEntryDriveController, which dropped the local
player from its pump (pending=0), so the movement controller never
published, auto-entry never fired, and the reveal never completed.
Probe signature: [rearm] verdict=OK once, then silence.
Fix (classification, not state): EvaluateActivation now reports
DeferredCell when the evaluation aborts while the dormant lease is still
current (IsDormantLocalActivationLeaseCurrent), keeping the conductor
retrying; the operation deliberately stays in AwaitingPreparation so the
retry re-runs the full evaluation against fresh state — the recovery path
the publication-state tests already pin (the same token evaluates
Evaluated once the authority settles). Genuine discards (lease retired /
not current) still report RejectedAuthority. A first attempt that
re-parked the operation back to AwaitingCell was REJECTED by the test
matrix: recovery would then require the rearm gate, which is stricter than
the seal, and the reentrant-restriction-mutation tests hung in
DeferredCell.
Seven publication-state tests updated from RejectedAuthority to
DeferredCell at their transient-abort assertions (their substance —
abort now, retained pending activation, same-token recovery — was already
the retryable contract; only the status name told the conductor to give
up). The [wake]/[rearm]/[pump] probes that pinned the mechanism are
kept behind ACDREAM_PROBE_PARK=1 with the rest of the C4 family.
The portal-cue commit (2914e43a) was suspected and EXONERATED by
experiment: a build with it fully reverted stalled with the identical
probe signature. Wire capture had already exonerated ACE.
Original filing (evidence chain preserved below): — filed 2026-08-08 during the Campaign A listening session,
which it blocks. This is a placement/streaming bug, not an audio bug —
read docs/research/2026-08-05-c4-closeout-handoff.md and
claude-memory/project_physics_collision_digest.md before touching it.
Symptom: login proceeds normally (handshake, CharacterList, EnterWorld,
~6,670 CreateObjects streamed, first player position received, streaming
recentered to (9,4) @0x09040008, all reveal domains converge —
render=True composites=True collision=True ready=True) — and then nothing.
materialized=False completed=False visible=False forever. The user sees the
retail UI and the sky/background; the world viewport never opens. Client is
healthy: no stderr, frame loop ticking (~15% of one core), graceful close
works.
Evidence chain (all from 2026-08-08, exact binary aa82ff7b + rebuilds):
- Nondeterministic on the SAME binary. Two launches at ~08:51/08:53
reached
auto-entered player modeandevent=complete; every launch from ~08:56 onward stalls identically. No code change flips it: a control run withoutACDREAM_RETAIL_UI, a run with the uncommitted focus-crash fix reverted (pure committed tree), and post-ACE-restart runs all stall. - Not the server. Loopback capture (35 s,
login-capture.pcapin the session scratchpad): ACE sendsPlayerCreate(0xF746, from :9001) and the player guid0x5000000Aappears in 48 payloads. A retail client logs into the same ACE fine (user-confirmed). ACDREAM_PROBE_PARK=1shows a park storm: 19 bodies (remotes 0x8xxxxxxx + generated statics 0x709xxxxx in landblocks 0x0904/0x0905) all park withcause=unplaceable,eligible=True captured=True, and zero restores over 30+ s.ACDREAM_PROBE_PLACEMENT_FAIL=1emits nothing.- The player guid appears in ZERO park lines — the local player's route-1
Place edge never executes at all, so
PlayerMovementControllernever publishes,PlayerModeAutoEntry.IsPlayerControllerReadynever becomes true, auto-entry never fires, and the reveal never completes. In the working 08:51 run,[step-h]player resolves appear BEFORE the final readiness event and auto-entry lands immediately after it.
Working hypothesis (unverified): a pre-existing streaming/placement race
in the login path — collision reveal-readiness and the placement pipeline's
placeability read different convergence points, and a timing shift (machine
warmth / page cache) made the losing interleaving consistent. The
restore-pump silence (19 eligible parks, zero restores) suggests whatever
re-drives parked placements after collision publication is not firing for
this interleaving; the same mechanism failing globally would also explain the
player's Place edge never arming. Smells adjacent to the
feedback_streaming_residence_race class (#168/#169) and the C4 route-1
machinery, but NOT confirmed — nobody has read the route-1 executor against
this trace yet.
Repro: launch Release live against local ACE (standard env) on a warm
machine; stall reproduces every time as of filing. Probes:
ACDREAM_PROBE_PARK=1 ACDREAM_PROBE_PLACEMENT_FAIL=1.
Next step: read the C4 handoff's route-1 recipe, then instrument the route-1 accepted-position drive (what arms the login Place edge, and what re-drives parked operations when a collision generation publishes) against a stalled run. Do NOT band-aid with a retry loop — find the missed edge.
#355 — Sound probability was never applied: every gated cue played on every trigger
Status: CLOSED 2026-08-08 (Campaign A slice A1) — user gate finding ("we get incorrect ambient and stuff like that"), root-caused during the six-lane audio review.
Retail's SoundTable entries carry a probability field that is a Bernoulli
play/skip gate applied at the play site (SoundManager::PlayProbability @
0x005500E0: rand() * (1/32767) < probability, else silence), entirely
separate from variant selection (SoundManager::GetSound @ 0x00550680:
idx = (int)(roll * (n - 1)), which ignores probability).
SoundCookbook.Roll instead treated probability as a cumulative selection
weight AND short-circuited single-entry lists before rolling at all:
if (entries.Count == 1) return entries[0]; // probability never consulted
An independent walk of the shipped dats found 4,183 of 4,184 entries are
single-entry lists, and 686 of those carry probability < 1.0 — so the gate
was categorically absent from the client. Loudest symptom: Speak1 creature
idle chatter (49 entries authored at 0.05) fired ~20× too often; wound / attack
/ swoosh variants never dropped; six entries authored at 0.0001 played on every
trigger. Affected 20 of 123 SoundTypes.
Fixed by splitting the model into retail's two steps
(SoundCookbook.PickVariant + PlayProbability, composed by Select) over a
new ISoundRandom that reproduces both of retail's roll ranges — the variant
roll clamped below 1.0 (0x00797D48) and the gate's 1/32767 grid, which is
what makes a 0.0001 probability resolve to ~1.2e-4 rather than 1e-4.
PickVariant deliberately reproduces retail's (n-1) off-by-one (the last
variant of a multi-entry sound is unreachable; blast radius in the shipped dats
is exactly one wave, 0x0A00051E).
Evidence: docs/research/2026-08-08-audio-retail-dat-layer.md §2 (census +
disassembly, both elided by Binary Ninja — BN also renders PlayProbability's
branch inverted, so porting its rendering would have played sounds exactly
when retail stays silent). Campaign:
docs/plans/2026-08-08-audio-parity-campaign.md.
#354 — Spell-bar drag reorder did not work: lifting a favorite canceled the drag before the drop could land
Status: CLOSED 2026-08-08 — user gate finding ("I should be able to
rearrange spells on the spell bar. Like dragging one out and dropping it
in another position. That does not work today"), diagnosed and fixed same
session. Root cause: SpellcastingUiController.BeginFavoriteDrag performs
retail's press-time removal (gmSpellcastingUI::RecvNotice_ItemListBeginDrag
@0x004C7360 → RemoveSpellFromMenu, matching PlayerModule::RemoveSpellFavorite
@0x005D4910) — correct and pre-existing — but that removal fires
SpellbookChanged, and the very next per-frame Tick() (production drives
this unconditionally via RetailUiRuntime.Tick) called Rebuild(), which
flushes and recreates every favorite-bar cell (UiItemList.Flush →
RemoveChild). UiRoot's subtree-removal safety net
(ClearSubtreeOwnership) cancels any drag whose source widget is
destroyed — so the drag was silently canceled one frame after every lift,
before the user could complete a drop. Empirically confirmed: a real-pointer-
path test (DragFavoriteOntoAnotherSlot_ThroughTheRealPointerPipeline_ReordersAndSyncsWire)
driving UiRoot.OnMouseDown/OnMouseMove/a mid-drag Tick/OnMouseUp fails
with screen.DragSource == null against the pre-fix code, and passes after
it. Fix (SpellcastingUiController.cs): defer the favorite-list rebuild for
the whole drag gesture (_favoriteDragActive), and compensate the drop-time
target index for the resulting stale sibling numbering by porting retail's
own SpellCastSubMenu::AddFavorite @0x004C7060 index adjustment (decrement
the target by one when the lifted item's original index was before it) —
same insert-shift semantics PlayerModule::AddSpellFavorite @0x005D43E0's
InsertPos already implements, now reachable through a live drag. Recorded
as AP-172 in the divergence register (the mid-drag visual reflow now happens
on release rather than continuously, final positions/wire are retail-exact).
Tests: SpellcastingUiControllerTests.cs (the real-pointer-path reorder
test + a payload-discriminator sabotage check confirming a spell-favorite
payload is rejected by a physical IItemListDragHandler), SpellbookTests.cs
(SetFavorite/RemoveFavorite insert-shift unit coverage). Wire golden
bytes for AddSpellFavorite/RemoveSpellFavorite (opcodes 0x1E3/0x1E4) and
RuntimeCharacterState.TryAddFavorite/TryRemoveFavorite were already
covered and needed no change.
UPDATE 2026-08-08 (follow-up session, drop-ring gate finding): the user's
next gate finding — "Should be the green ring indicator where the spell icon
should land, like in retail" — is implemented. Retail's mechanism (grepped and
byte-confirmed): the ring is a per-cell authored slot STATE, not a synthetic
overlay — SpellCastSubMenu::OnItemListDragOver @0x004C5990 sets the shared
UIItem prototype's m_elem_Icon_DragAccept child (element 0x1000045A, bound in
UIElement_UIItem::PostInit @0x004E1870, catalog LayoutDesc 0x21000037) to
ItemSlot_DragOver_Accept (UIStateId 0x10000040 → authored art 0x060011F9)
whenever the dragged payload carries a spell id; leave resets to
ItemSlot_DragOver_Normal 0x1000003F @0x004E1438. Wired through
UiCatalogSlot.DragOverAcceptance (the catalog port of retail's per-list drag
handler) → the shared UiItemSlot.DrawDragAcceptOverlay. Two corrections
landed with it: (1) the accept/reject UIStateId labels were SWAPPED in
UiItemSlot/InventoryController comments and in the 2026-06-16 / 2026-07-13
research docs (art-per-semantic was always right; polarity pinned by paperdoll
AutoWearIsLegal @0x004A3AC9/0x004A3AEB, VendorSellUI @0x004C2327/0x004C2336,
DatReaderWriter's UIStateId enum, and the 2026-06-25 layout dump); (2) a real
off-by-one in #354's drop path: the -1 adjustment double-corrected the
empty-tail cell's live-count-clamped index, landing a lifted non-last favorite
second-to-last instead of last — retail's adjustment is gated on
RemoveSpellFromMenu's return (@0x004C7157), which is -1 (no adjustment) at
drop time because the spell left the live list at lift. FavoriteDropIndex is
now THE one landing computation shared by the ring and the drop
(discriminator-verified: the pre-fix computation fails
SpellFavoriteDrag_DroppedOnTheEmptyTail_AppendsAtTheEnd with landed index 1
vs 2). AP-172 narrowed + corrected in the same change-set. New tests: ring
appears/tracks/survives-a-tick/clears-on-drop, empty-tail append, ring clears
on leave + off-bar release keeps the lift removal, physical payloads stay
neutral while both spell payload kinds ring.
#353 — Toolbar selected-object text: count field ignores authored HJustify; name field does not wrap to its authored two lines
Status: CLOSED 2026-08-08 — user-passed ("Ok slider bar looks ok!" + the wrap confirmed); the OneLine routing fix (4cfcc8b3) completed it. (RightAligned on the authored HJustify=2 entry; two stacked centered one-line labels wrapping at the authored 140 px via WrapNameTwoLines).
User gate findings (pre-existing, not vendor-introduced). The authored
toolbar layout 0x21000016 is decisive: the stack-count entry 0x100001A3
is X=0 Y=13 W=50 H=14 HJustify=2 (right) — flush against the slider
0x100001A4 at X=50 Y=13 W=90 H=14, same row — but UiField has no
justify support, so the number renders at the left edge (the user's
screenshot). The name field 0x100001A2 is W=140 H=31 (two lines);
UiField already has WrappedLine machinery but the name widget renders
one line, so long names overflow instead of breaking at the authored
140 px. Fix: honor HJustify in UiField (right-justify the text run) and
engage two-line wrap for the name element per its authored extent —
wrap threshold is the authored PIXEL width, not a character count.
#352 — Vendor range-watcher cylinder metric: discriminating unit test deferred
Status: OPEN (filed 2026-08-08). The EnforceRange cylinder-gap fix (acceptance-band self-close, vendor-verify-gate.log evidence) landed with the existing 17-range/lifecycle tests green but WITHOUT a unit test that discriminates cylinder-vs-center (needs an IPhysicsObjHost fake — 38 members — bound via BindObjectTableHostResolver; center>radius while cylinder<=radius stays open, radii-sabotage closes). Write it next session; the live gate covered the behavior.
#351 — LandblockBuildOriginTests.FarLoad_StripsEnvCellsAndPhysics flake (Debug, load-sensitive)
Status: OPEN (filed 2026-08-08). Seen three times today in Debug runs under parallel-agent load (5.4 implementer, Slice 6 corrections, dropdown polish), each time proven pre-existing by re-running against the pristine base commit; never reproduces in clean-room Release. An async streaming timing assumption most likely; deflake when it next blocks a verdict.
#350 — Render-shadow ledger overflow after 2h42m: lifetime int counters in a never-reset accumulator
Status: FIXED IN TREE 2026-08-08 (pending clean-room + landing).
Evidence: vendor-buy-gate.log ~1606 — checked OverflowException in
RenderSceneShadowRuntime.Add from UpdateFrameOrchestrator.Tick, exit 82,
2h42m into a SINGLE world generation (login 20:28 -> crash 23:10, one
[world-reveal] generation=1, no portal).
Mechanism (investigated, vendor coupling REFUTED):
_cumulativeApply sums nine int fields once per tick for the lifetime of a
world generation and is never reset in place (production has zero Clear()
call sites; only generation replacement constructs a fresh runtime).
SynchronizeActiveSources feeds per-entity churn from TWO call sites per
frame; at uncapped frame rates a long session's ordinary churn crosses
int.MaxValue. Introduced 0eb66485 (2026-07-24) with the class's OTHER
lifetime counters already ulong/long — only these nine were undersized.
The vendor materializer was exonerated by routing analysis: shop-item
Ingest/Remove reaches inventory deltas only, never the render journal;
the correlation was that buy-gate testing produced the first multi-hour
single-generation soak.
Fix: widen RenderDeltaApplyResult's nine fields to long (the
per-tick builder stays int and widens implicitly); arithmetic stays
checked. No clamp, no reset-behavior change — the counters are
legitimately unbounded lifetime telemetry that was simply undersized.
#348 — Render-loop death by Win32 cursor-handle exhaustion: Silk recreates the native cursor on every alternation
Status: FIX IN TREE (2026-08-08) pending the vendor-gate relaunch.
Evidence: vendor-gate.log — Silk.NET.GLFW.GlfwException: PlatformError: Win32: Failed to create cursor: Not enough memory thrown from
RetailCursorManager.ApplyGlobal inside RenderFrameOrchestrator.Render,
exit 82 after ~minutes standing at a Holtburg vendor NPC. (The clean
single-exception stack instead of masked shutdown noise is #343's fix
working as designed.)
Mechanism: RetailCursorManager's dedup only suppresses a STEADY
cursor. Any per-frame alternation between two cursor states — the pick
cursor flickering between kinds while hovering an ANIMATED NPC whose
moving parts cross the cursor ray, exactly the "stand at a vendor"
posture — reassigns ICursor.Image every flip, and Silk's GLFW backend
creates a fresh native Win32 cursor per assignment without reusing the
old ones. ~10,000 flips exhausts the USER-object quota and CreateCursor
dies. Earlier same-day sessions (slope gates) never crashed because
nobody hovers an animated NPC for minutes while moving.
Fix (root cause): GlfwCursorCache — one glfwCreateCursor per
distinct cursor media for the process lifetime (retail's own shape: it
loads each MediaDescCursor once), O(1) glfwSetCursor per switch,
rejected media cached as permanent misses, disposal destroys all.
RetailCursorManager.AttachNativeWindow opts in when a native GLFW
window exists; tests and windowless hosts keep the Silk path.
#32 — CLOSED 2026-08-07: local edge-slide fixed at 332045c7, USER-PASSED on its first genuine live run
"Yes works now." — the user at the Rithwic cliff, on the first launch that
actually contained the fix (assembly identity printed in the capture:
acdream/src/AcDream.App/bin/Release/net10.0/AcDream.Core.dll).
The player now slides along the rim instead of running off.
The fix is the set_contact_plane / init_contact_plane split
(COLLISIONINFO::set_contact_plane @0x00509d80 writes the contact group only;
CTransition::init_contact_plane @0x0050e850 seeds both), gated by
Issue32LastKnownContactPlaneTests (sabotage-verified pair + control).
Remote half was closed 2026-08-04 at 204d0ae0; with this, the issue's
edge-slide family is done. Register: AD-67 filed in the closeout commit —
the narrowed setter still writes ContactPlaneCellId, which retail writes only
in init_contact_plane (@0x0050e8ca); acdream's callers rely on the current
cell id.
The blast-radius items from research §3.5 stay OPEN as watch items, now
strictly more reachable than before the fix: last-known validity is
narrower, so ValidateTransition's StopVelocity recovery and
transitional_insert's phase-3 reset take their last-known-INVALID branches
more often. Nothing observed misbehaving in the passing session; check here
first if slope-feel or landing regressions appear. Carried into Campaign S's
S4 slice notes.
The three prior update entries below record a wrong-binary detour (the fix was tested against a client that did not contain it) — retained as the record; their verdicts are void and superseded by this closure.
#32 UPDATE 2026-08-07, SECOND CORRECTION — the fix was NEVER IN THE TESTED BINARY; every conclusion in the entry below is void
Root cause of the contradiction: two checkouts and a relative launch path.
The Bash shell (edits, builds, byte-checks) worked in the MAIN repo. The
PowerShell shell (every client launch) had its working directory pinned to the
resume-session-e0bd03e1-d5bf45 WORKTREE, and the launch command uses the
relative src\AcDream.App\AcDream.App.csproj — so every post-merge launch ran
the worktree's binary, built 08-06 22:35, which contains #333 but neither the
#32 fix, InitContactPlane, nor any #338 probe. Byte-proof: the worktree's
AcDream.Core.dll has 0 occurrences of both strings; the main repo's has both.
Consequently:
- "The fix did not change the live behaviour" is VOID. The fix was not present. The byte-identical capture is the EXPECTED result of re-running the old code, and says nothing about the fix.
- The probe silences and the unconditional self-report's silence are all the same fact: the instrumented binary never ran.
- The 26,358-write attribution table below is a capture OF THE OLD BINARY. Its line numbers map to old source. It remains useful as pre-fix baseline data and nothing else.
- #32's fix at
332045c7returns to UNTESTED status, awaiting its first actual live run. The suspected body round-trip loop (seed-from-body / write-back-to-body) remains a real question to check IF the genuine fix still fails — but there is currently no evidence against the fix at all.
Process rule, added to the stale-artifacts memory: a multi-checkout session
must launch by ABSOLUTE project path, and the first line of any A/B run's log
must print the loaded assembly's path (typeof(PhysicsEngine).Assembly.Location)
so binary identity is in the capture itself, not inferred afterwards.
The entry below is retained as the record of the error.
#32 UPDATE 2026-08-07 — the set_contact_plane split is NECESSARY BUT NOT SUFFICIENT [VOID — see above]
The fix at 332045c7 did not change the live behaviour. Re-run at Rithwic
with the same probe produced a byte-identical capture: six
branch2/steep-cliffslide events, curN = lastN = (-0.954,0.000,0.301),
angle=0.0000, apply=False, outcome degenerate-cross/last-known. The user
still falls straight through the edge.
The commit is not wrong and is not reverted. It genuinely restores retail's
setter split (COLLISIONINFO::set_contact_plane @0x00509d80 writes the contact
group only) and is sabotage-verified by Issue32LastKnownContactPlaneTests.
It closes one writer. It is simply not the writer that matters here, and the
research's Section 7 — which enumerated SetContactPlane's 13 call sites —
did not consider the one below.
What the caller-attributed capture shows
ACDREAM_PROBE_CONTACT_PLANE=1, writers of LastKnownContactPlaneValid:
| writes | caller |
|---|---|
| 26,358 | PhysicsEngine.ResolveWithTransition |
| 278 | PhysicsEngine.ResolveWithTransition (second site) |
| 30 | Transition.ValidateWalkable |
| 3 | FlatBspQuery.StepSphereDown |
The only last-known writes in that method are PhysicsEngine.cs:2041-2044 —
check_contact's FAILURE branch, which seeds
ci.LastKnownContactPlane = body.ContactPlane.
The loop this suggests — NOT YET PROVEN
PhysicsEngine.cs:2041-2044 seeds ci.LastKnown FROM body.ContactPlane at
the start of a resolve, and PhysicsEngine.cs:2168-2174 writes
body.ContactPlane FROM ci.LastKnownContactPlane at the end of one. The
plane therefore round-trips through the body between frames. If the steep face
enters that loop once, it persists — and SetContactPlane no longer needs to
latch anything for cliff_slide to see lastN == curN.
That is a hypothesis built on a line-number mapping, and the line numbers are from an optimised Release build where inlining makes attribution approximate. Do not act on it without confirming which of the two branches actually runs.
⚠ BLOCKER — resolve this BEFORE trusting any probe result in this area
An unconditional one-shot Console.WriteLine placed at the top of
ResolveWithTransition (PhysicsDiagnostics.AnnounceStepHeightProbeOnce)
printed zero times in a run where that same method is attributed 26,358
plane writes. Both cannot be true.
Candidate explanations, none verified:
- The running process loaded
AcDream.Core.dllfrom somewhere other thansrc/AcDream.App/bin/Release/net10.0/— the byte check confirmed the probe string is in THAT copy, not that the process loaded it. - The caller attribution's method name is wrong (it comes from a stack walk, which an optimised build can misattribute after inlining), so the 26,358 writes are from a different method entirely.
- Something resets the one-shot flag, or the write goes to a stream not
captured by
Tee-Object.
Settle this with a check that cannot be explained away — e.g. print
typeof(PhysicsEngine).Assembly.Location at startup — before doing any more
work on #32 or #338. Three conclusions were drawn from probe silence in this
session and all three were premature; the instrument must be trusted before its
output is.
#338 is blocked on the same thing
[step-h] has now been silent through two placements. Same blocker, same
resolution.
#347 — Steep-slope glide alternation — CLOSED 2026-08-08: RETAIL DOES THE SAME; the "half-rate" premise was a wrong inference
The round-2 cdb capture (345-glide-stacks.cdb.log) closes this without
a code change. During the glide window retail fired ~145 edge_slide
entries per ~100 find_transitional_position calls — ~1.5 per player tick,
which is EXACTLY the arm/move alternation's signature (3 entries on the
arming tick, 0 on the moving tick). cliff_slide ran in strict lockstep,
step_down at ~2.5x edge (our 2-probe plan + edge's internal call), step_up
0, and the six stack samples show the identical call path
(transitional_insert -> find_transitional_position -> CPhysicsObj::
transition). Combined with cliff_slide's arms being byte-identical across
ACE/acdream/the raw binary (compare constant at 0x794610 verified 0.0) and
the user's side-by-side observation ("I cant detect any speed change from
retail"), the conclusion is that retail alternates exactly as we do —
dig-retries included. The original "retail redirects within the tick"
premise came from misreading the round-1 counters (set_sliding_normal's
cadence is per-event, not per-tick, so its 1:1 ratio with edge never
discriminated anything). AD-70 is retired as a wrong inference, not fixed.
The alternation-tolerant assertion in Issue345SteepSlopeGlideTests is
the CORRECT retail-shape pin and stays. Consequence for #269: the hope
that a within-tick port would explain the slope-slide feel residual is
withdrawn — #269 keeps its original "needs a live cdb A/B trace" plan.
Original filing (premise since refuted):
With #345 fixed, the glide works but alternates in a strict two-tick cycle:
the arming tick absorbs the whole request while the edge response sets the
sliding normal ((0.707,-0.707,0) on the conformance fixture), and only the
NEXT tick's AdjustOffset pre-projection consumes it and moves (+0.115,
+0.115 per moving tick on the 45-degree fixture) — then the clean move
clears the normal and the cycle repeats. Fixture trace: 14 of 30 post-
crossing ticks stuck, positions advancing every other tick
(Issue345SteepSlopeGlideTests + the deleted Scratch345 dump, 2026-08-08).
Retail instead redirects WITHIN the tick: the live cdb profile
(345-retail-glide.cdb.log) fired edge_slide/cliff_slide 594 times
EACH in lockstep with set_sliding_normal 538 over a ~15 s glide — every
30 Hz tick, which an alternation would halve — so retail's
transitional_insert re-enters the redirected offset in the SAME
transition and yields motion every tick. Ours ends the transition on the
arming tick with zero yield. The fix lives in the edge-family response
semantics (EdgeSlideAfterStepDownFailed and the insert's continuation
after an applied edge constraint) — deliberately NOT touched by the #345
landing (fresh AD-66 in the same block; the Campaign S response-layer
landings all user-gated). Net user-visible effect: gliding along a steep
face at ~half retail's lateral speed; direction and angle-scaling correct.
If the #345 in-game gate reports "slides but slower than retail," this is
the mechanism, already filed.
#345 — Walking angled into a too-steep slope: 100% of input eaten with an UP collision normal, no slide — CLOSED 2026-08-08 (in-game glide gate PASSED: "Well it works, we are sliding. I cant detect any speed change from retail")
Dual Opus review verdicts: CONFIRMED-FAITHFUL (conformance, independent byte re-decode incl. the stack-slot frame arithmetic and every ret site) and SAFE (blast radius, truth-table instrumented: exactly one row moves; the placement/teleport family proven immune via mover-flag grep + the RuntimeSetPositionMoverPreparationTests pin; independent five-angle sabotage table, monotone angle→lateral 10°–85°). Named non-blocking residuals, all retail-consistent or filed: the placement-arm REJECT→ACCEPT flip at DoStepDown's final insert and the Collide-branch re-test (retail's validate_walkable is insert-type-agnostic — same behavior, untested topology); the other-cell ValidateWalkable site has zero fixture coverage (a neighbouring-cell steep fixture would close it); projectiles (no EdgeSlide flag) resting on a steep face commit below-plane instead of pushing out (retail-mover-agnostic, narrow); and ACE SHARES the misport server-side, so NPCs/remotes may show lateral drift-then-snap at steep terrain until the next UpdatePosition — name that mechanism before misdiagnosing any future remote-prediction report. AD-71 (mutable WalkableAllowance operand) filed the same session.
FIX (one conditional return, byte-proven): retail's
OBJECTINFO::validate_walkable @0x0050d010 initializes its return slot to
OK (0x0050d025: mov [esp+0xc], 1) and assigns ADJUSTED only INSIDE the
below-plane guard, immediately after the push executes (0x0050d249,
after add_offset_to_check_pos). The guard-fail path — grounded mover,
OnWalkable, plane too steep (0x0050d1b9 je 0x50d251) — skips the
contact write, the push, AND the Adjusted assignment: retail returns OK
and simply IGNORES the steep plane at primary validation, letting the
insert proceed so the step-down phase fails and the edge family produces
the glide. ACE flattened this into an unconditional return Adjusted
(ObjectInfo.cs:169) and we inherited it — the dead loop was our
TransitionalInsert retrying the byte-identical Adjusted forever.
ValidateWalkable's below-plane arm now scopes the return exactly as the
bytes do. Evidence chain: the live cdb glide profile (594 edge/cliff
lockstep, step_up=0), the capstone byte-decode
(docs/research/2026-08-08-345-d0-branch-pin.md), the D0 implementer's
correct STOP (synthetic fixtures reproduced the stuck fingerprint while
faithfully executing the ACE-shaped reading — refuting the reading, not
the code), and the discriminating conformance fixture (in-cell diagonal
split, flat+steep triangles sharing one cell — sabotage red with the
exact stuck position; the cell-boundary topology does NOT discriminate
and is pinned as supplementary). Residual: #347 (half-rate alternation).
Original report: OPEN — HIGH (user-felt, capture-backed). A/B COMPLETE, same
morning: PRE-EXISTING, not a campaign regression. The pre-campaign binary
(d4e956b4, built in a throwaway worktree, same recorder, same slope, same
protocol) shows the identical signature — eleven consecutive 2-second windows
of sustained ~30 m requested with 0.0% yield (uphill-AB-precampaign.jsonl).
Campaign S is exonerated wholesale; the defect is older and was simply never
noticed before the user's slope attention this week. MECHANISM CAUGHT 2026-08-08 (345-mechanism.log, ACDREAM_DUMP_TRANSIT_FAIL):
275 uniform player stuck ticks. Per tick: the run step's target puts the
sphere's foot point 0.268 m BELOW a too-steep plane (N=(0.799,0.050,0.599),
N.z just under FloorZ 0.664); ValidateWalkable's below branch returns
Adjusted (push-up) on EVERY transition attempt — and every attempt reports
the IDENTICAL dist=-0.26801: the adjustment does not carry forward between
attempts. env/building/objects phases all OK each attempt; ~6 attempts per
tick; attempts exhaust; the transition fails and restores the original
position. Zero yield. (oiContact=True, spStepDown=False, guardPassed=False
on every line — the morning's collN=(0,0,1) comes from the failure path's
result-filling, not from ValidateWalkable's guard, which never passes here.)
D0 VERDICT, same day — the fingerprint IS retail's own algorithm; the fix attempt STOPPED itself
The fix implementer's mandatory pseudocode pass traced all five links from
the named decomp (addresses in
docs/research/2026-08-08-345-pseudocode.md) and found the mechanism
paragraph above named the right symptom with the WRONG cause: the below-push
never executes at all (its guard is step_down || !OnWalkable || walkable,
and the player IS OnWalkable on the flat approach — the probe printed the
SetCollisionNormal guards, not this one); Adjusted retrying the whole
insert from scratch is retail-identical (transitional_insert @0x0050b6f0);
and validate_transition @0x0050aa70 on failure kills velocity, restores
the flat plane, DEFAULTS the collision normal to (0,0,1), reverts CheckPos,
and forces OK — producing every field of the captured fingerprint from
retail's own code. Stopping dead here may simply BE retail.
RETAIL OBSERVED 2026-08-08 — THE AXIOM: "it glides, faster the more angle you run towards it." The user ran the comparison in their retail client: angled approaches GLIDE laterally along the steep hillside, scaling with approach angle; perpendicular stops. acdream stops dead at every angle. The divergence is CONFIRMED — and since D0 proved the RESPONSE path faithful line-by-line, the divergence is UPSTREAM of it: the movement request's shape when it reaches the transition (retail's sub-step/walk_interp progression?), the broadphase/cell question, or state seeding. Next step is the designated Step -1 tool: attach cdb to the PDB-paired retail client at that exact slope and trace validate_walkable @0x0050d010 / transitional_insert @0x0050b6f0 / adjust_sphere_to_plane @0x00538210 WHILE the glide happens — the diff between retail's live inputs and ours in the same scenario IS the answer.
LIVE RETAIL TRACE 2026-08-08 — the glide IS cliff_slide, firing every tick; the divergent branch is pinned
cdb attached to the PDB-paired retail client while the user glided the
45-degree protocol (345-retail-glide.cdb.log): edge_slide and
cliff_slide fired 594 times each, in lockstep (~30/s through the glide
windows) with set_sliding_normal tracking at 538; step_up fired ZERO
times; adjust_sphere_to_plane/walkable_hits_sphere zero (outdoor
terrain walkables go through validate_walkable only). Ours in the same
scenario: the edge family fired 18 times total and the stuck ticks
dead-looped on insert retries without reaching it.
The divergence, exactly: retail's transitional_insert, when the
walkable validation refuses the steep plane, proceeds INTO the
step-down-failed/edge_slide path on every tick — the D0 pass's
"Adjusted just retries from scratch" reading missed the branch retail
actually takes here. Ours retries the insert without entering the edge
family, exhausts attempts, restores. The fix target is that one branch in
our TransitionalInsert against retail's @0x0050b6f0, with the edge-entry
anchors already mapped in the #32 research (step-down-failed at
~0x0050b8xx-0x0050b921). The response machinery downstream is already
proven faithful AND live-healthy (this morning's 18 firings all applied
clean constraints).
The two prior validations (both now resolved — the user's observation answers 1 and mandates 2's runtime session):
- The cheapest decisive test needs no debugger: the USER walks their RETAIL client into a comparable just-too-steep hillside at ~45° and reports slide vs dead stop. Their expectation of sliding is currently the only evidence against retail-faithfulness.
- If retail visibly slides: the remaining suspect is UPSTREAM of the
walkable response — whether retail's
find_cell_listbroadphase even queries the steep cell from this position (the pseudocode doc's open question) — a runtime question for the cdb toolchain, not code-reading.
The fix contract's question (superseded by the verdict above, retained): what does RETAIL's
transitional_insert do with validate_walkable's Adjusted on a non-walkable
plane — does the adjusted CheckPos feed the NEXT attempt (convergence), or
does retail take a different branch entirely (slide/collision) instead of
re-adjusting from scratch? Grep-named-first targets:
OBJECTINFO::validate_walkable's callers, CTransition::transitional_insert's
attempt loop and its use of the adjusted check position, and the
walk_interp bookkeeping. The non-carry between attempts is the defect
candidate; do not touch the #331/#32/AD-65 machinery.
Filed: 2026-08-08 morning, from the user's report ("I stop instead of
sliding") + a directed 45-degree capture.
Measured (uphill-45deg-capture.jsonl, 3,098 player resolves; the
protocol held 45-deg-left / 45-deg-right / perpendicular for ~10 s each at the
Rithwic steep face, cell 0x2F32003B):
- Both ANGLED directions: 0.0% yield across sustained ~30 requested metres per 2 s window — the LATERAL component dies too, which retail's slide-along would preserve.
- The 838 stuck ticks are uniform:
slidingNormal=(0,0,0)(NOT the #331 absorb), carried contact = nearly-flat ground(-0.083,0.083,0.993)(~6.8 deg — not the steep face),transient=0x3(Contact|OnWalkable), and the resolve returnscollisionNormalValid=TruewithcollN=(0,0,1)— straight UP — position byte-identical in/out. - The steep face itself was reached only 18 times all session
(
uphill-slide-capture.log), every one taking branch2 cliff-slide with a HEALTHY constraint (ok/last-known, apply=True, lastN=(0,0,1) — the #32 retention visibly working). The stop happens UPSTREAM of the face.
What it is NOT (measured): not the sliding-normal absorb (no latch); not the cliff-slide degeneracy (#32 fixed and visibly healthy); not the steep face's own rejection (barely reached).
Suspect space for the A/B, in order: the step-up/step-down recursion on the approach terrain (an UP collision normal with zero displacement is the step-down-refusal shape); AD-65's away-arm snap interacting with near-flat normals on the approach; something older that the user only now noticed. DO NOT theorize past the A/B — this family has burned four reasoned-from-source diagnoses.
#346 — PortalProjectionTests.ProjectToClipLease_ReusesPooledWorkWithoutResultArrays is a SIXTH load-sensitive flake
Status: OPEN. LOW. Allocation-count assertion, passed in isolation and on two subsequent full runs. Same FILE as #302 but a DIFFERENT test — filed separately per the never-conflate rule. Filed: 2026-08-08, observed during #344's suite runs.
#344 — Mid-teleport crash: world-frame owners disagree during a long portal into a dungeon
Status: FIXED 2026-08-08 — defer-don't-crash, discriminated on the
canonical transit authority. TryEnsureAgreesWithRuntimeFrame defers (the
materializer's existing "not yet" outcome) when
RuntimeWorldTransitState.IsTeleportActive, and STILL THROWS otherwise —
the #283 invariant stays loud for genuine corruption, and the sabotage run
proved the discriminator's removal reddens the original #283 tests, not
just the new ones. The retry ride is OnLandblockLoaded's re-attempt loop,
whose ordering GUARANTEES agreement on retry: the recenter coordinator
calls Recenter before TryCommitOriginRecenter unblocks new landblock
loads (verified at source at landing). Entity projected exactly once,
never dropped. Clean-room suite 11,261/6/0.
Original filing:
Status (original): OPEN — HIGH, user-hit during live play 2026-08-07 evening.
Filed: 2026-08-07 (339-fix-gate.log, full stack).
During a long-distance portal into dungeon landblock 0x5A48, entity
materialization threw unhandled on the render path:
InvalidOperationException: World-frame owners disagree: Runtime centre (90,72) vs streamed origin (240,126) while projecting landblock 0x5A4801C2. That offsets the entity by (-28800m,-10368m) from its geometry. —
LiveWorldOriginState.EnsureAgreesWithRuntimeFrame:80 ←
DatLiveEntityProjectionMaterializer.TryMaterialize:166 ←
LiveEntityHydrationController.ProjectExact.
Two halves, keep them separate:
- The GUARD IS CORRECT — it refused to project an entity 28.8 km from its geometry. Do not weaken it.
- The RACE is the defect: during teleport recentre, the Runtime frame moved to the destination while the streamed origin still held the source, and a spawn projection ran in the window. The fix is ordering (defer projection until the streamed origin recentres, the same family as AD-64/#324's parallel inbound routes), and the failure mode should be a deferred retry, not an unhandled render-thread crash.
The user's report ("weenie error 0x04a7") is the crash dialog for this exception. Same session validated the #339 fix (zero overflows) and the relaunch logged in INSIDE the destination dungeon cleanly — the race is mid-flight-teleport-only. Next-session queue: #344, #343, then S4b/S6.
#343 — Shutdown after a wounded render loop: Silk Reset called inside the render loop, exit 82
Status: FIXED 2026-08-08. Root cause pinned by IL-decompiling Silk:
ViewImplementationBase._inRenderLoop is cleared only on a frame callback's
NORMAL return, so a throwing callback leaves it armed forever and the
disposal's Reset() throws. The fix mirrors that bracket exactly
(GameWindow._renderLoopArmed, deliberately not cleared in a finally),
and ReleaseNativeWindow defers when armed: best-effort Close(), no
Dispose(), new terminal status CompleteWithDeferredNativeRelease with
Error kept null so the ORIGINAL wounding exception remains the primary
report. Sabotage-verified; healthy paths byte-unchanged. Clean-room suite
11,262 passed / 6 skipped / 1 failed = #340's documented flake, which
passed standalone (its second recorded firing).
Original status: OPEN. LOW-MEDIUM — only reachable after a render-frame exception
(#339's crash was the trigger), but it turns a diagnosable failure into an
AbandonedIncomplete shutdown. GameWindowLifetime.ReleaseNativeWindow:294
calls ViewImplementationBase.Dispose → Reset while the render loop is
formally still active. Fix belongs with the #339 remediation session.
Filed: 2026-08-07 evening, from session-b-dungeon.log.
#341 — AD-66's landing is blocked by an unexplained measurement flip — CLOSED 2026-08-08: relanded under its gate, 10/10 bit-identical
The third reland passed the ten-run stability gate 10/10 bit-identical (0x42667451), with the recalibrated golden's every value measured and derived, sabotage discriminating, and the clean-room suite 11,267/4/0 (the two AD-66 skips retired). AD-66's register row is retired; the historical flip stands recorded as unexplained-but-unreproducible. AD-69 remains the one follow-up in that block. Pending: the user's hover-look slope gate.
Status: OPEN — HIGH priority for the next physics session; the fix itself is byte-proven, the BLOCKER is that the measurement chain contradicted itself. Filed: 2026-08-07 (overnight), at the S4 landing split.
Retail's adjust_offset safety push-out uses the BARE sphere radius in both
its trigger and its zDist numerator (byte-anchored twice in AD-66's register
row). Landing that in Transition.AdjustOffset made exactly one suite test
fail — RuntimeRemoteUphillProgressTests.AnExactlyUpSlopeOffsetIsAbsorbedByThePersistedSlidingNormal,
the #331 absorb characterization pin — and the attempt to recalibrate it
produced OBSERVATIONS THAT FLIP WITH THE SHAPE OF THE TEST'S POST-TICK
ASSERTS, which is not physically possible for honestly-measured stored state:
| test-code variant (identical code before/during the 5 absorbed ticks) | observed Z after ticks | runs |
|---|---|---|
original exact-latch Assert.Equal(latched, body.Position) |
latched + 0.0798 (LIFT) | implementer's suite run + 6 consecutive runs + 1 more after restore |
recalibrated: compute restingLift from body.ContactPlane post-tick, then component asserts |
latched exactly (NO lift) | 1 run (version A) + 4+ runs (version C) incl. a full bin/obj clean-room |
Both shapes were run against binaries proven to contain the AD-66 fix (the S4
conformance exact-value tests passed in the same clean-room). 0.0798 m =
0.48 * (1/cos31° − 1), the delta between the two resting heights, so BOTH
outcomes are physically coherent stories — the problem is that the same
binaries told both.
LIVE A/B, 2026-08-07 morning — the user's slope run settles the stakes
341-slope-capture.jsonl, 3,870 player resolves at Rithwic (landings on the
face, uphill holds, traversal, standing): of 2,955 contact-seeded ticks,
the RETAINED trigger (dist < r*N.z - eps) fired 0 times — the current
push-out is completely inert in ordinary play — while retail's BARE trigger
(dist < r - eps) would have fired on 2,471 (84%), with per-fire lifts of
2 mm to 88 mm (p50 27 mm). The body demonstrably rests at its natural
distance r*N.z every grounded slope tick, so the bare-radius port is not a
quiet conformance fix: it would engage on virtually every slope step and
fight whatever plants the feet back on the surface — the per-tick
oscillation the original substitution's author described ("fires spuriously
on every slope… flickered the Falling animation").
The REAL question this exposes sits one level upstream: our grounded
placement plants the sphere centre VERTICALLY above the surface (perpendicular
distance r*N.z), and if retail's walkable contact instead rests the sphere
TANGENT to the plane (perpendicular distance r), then retail's bare trigger
is inert in retail for the same reason ours is inert here — and porting the
trigger without the placement geometry imports a fight retail never has. That
retail-side resting geometry is UNVERIFIED either way and is now the first
task of the apparatus session (grep/decompile retail's walkable placement:
does step_down/find_walkable leave perpendicular-r or vertical-r?). It also
offers a mechanical suspect for the harness flip below: the two assert shapes
plausibly differ in what settle state the harness left (r*N.z vs r),
which is test-ORDER state, not physics.
MECHANISM RESOLVED, same morning — the trigger and the resting geometry are one family
CPolygon::adjust_sphere_to_plane @0x00538210 (pseudo-C 322032, cross-
confirmed in Ghidra, which restores Binary Ninja's garbled denominator: the
solve is t = (dist ∓ r) / dot(N, stepDir), a ray-vs-plane interpolation to
the point where the sphere's PERPENDICULAR distance to the walkable plane
equals the RADIUS, sign per approach side, guarded by ±F_EPSILON on the
denominator and a [−0.5, walk_interp) window on the fraction) — the sphere
rests TANGENT to the slope. Two independent decompilers now agree. Consequences, closing the loop on the live A/B:
- Retail: tangent rest (perp = r) makes the bare-radius trigger
(
dist < r − ε) structurally INERT — equality minus epsilon. No fight, no oscillation. The visible corollary: retail characters' feet float vertically above a slope byr·(secθ − 1)— 2.7 cm on a 31° slope, ~20 cm near the walkable limit — which matches AC's known slope look. - acdream: planted rest (feet on surface, perp = r·N.z) makes OUR retained trigger structurally inert here for the same reason. Each engine's trigger matches its own resting geometry; both pairs are internally coherent, and the live A/B's 84% fire rate is what happens when you mix retail's trigger with our placement.
- Therefore AD-66 is NOT a standalone row. The faithful unit is the PAIR: tangent placement + bare trigger, ported together — or our pair kept together as one deliberate divergence. Porting either half alone manufactures a fight neither engine has.
- The harness flip's suspect is strengthened: a tangent-rest start is stable under the bare trigger, a planted start lifts once — which of the two the harness settle leaves is plausibly order-dependent test state.
RE-DECIDED 2026-08-07 night (S4b's D0 STOP fired and REFUTED the tangent-
placement premise): retail's OBJECTINFO::validate_walkable @0x0050d010 is
PLANTED (vertical foot point) for every normal mover — Ghidra + BN + ACE all
agree, and acdream's ValidateWalkable is ALREADY a byte-faithful port; only
the IsViewer/camera branch is tangent. The coherent retail mechanism is
therefore PLANT-THEN-LIFT: validate_walkable plants, adjust_offset's
bare-radius push fires once per settle and raises the body to tangent
equilibrium (dist=r), where BOTH checks go quiet — the slope float comes
from the PUSH, not the placement, exactly as the original AD-66 code
comment's "retail itself has the spurious lift" argued. The 84% live fire
rate measured the trigger against our push-disabled planted steady state;
post-fix, bodies settle tangent on first contact and the trigger goes
silent. The user's "port the retail pair" therefore maps to RELANDING
AD-66's bare radius ALONE (validate_walkable and adjust_sphere_to_plane
need nothing), and the #341 flip now has a mechanical story: whether the
harness's settle had already performed the one-time lift is order-dependent
state, which is what the assert-shape correlation was reflecting.
Byte-pin doc: docs/research/2026-08-07-s4b-validate-walkable-bytepin.md.
Original decision record: DECIDED 2026-08-07 by the user: "port the retail pair." Tangent resting placement + bare-radius trigger land TOGETHER as one slice (S4b), gated by the user's eyes on a slope. The slice must first establish WHERE our planted rest comes from (the live capture's r*N.z rest was measured on outdoor TERRAIN — the prime suspect is the outdoor ground placement rather than the ported BSP walkable solve, which should already be tangent if the port of @0x00538210 is faithful). AD-69's seam-frame correction rides along.
RELAND ROUND 2, 2026-08-07 night — the flip survives a same-session ABA; property reads ruled out
The bare-radius reland ran its ten-run protocol: round 1 was STABLE-WITH-LIFT (10/10 identical, delta 0.07976 = the predicted formula). The recalibrated golden's FIRST run then showed NO lift, and a same-binary same-session ABA nailed it:
| assertion shape after the identical Tick(5) | observed Z |
|---|---|
original one-line Assert.Equal(latched, body.Position) |
57.61359 — LIFTED |
3 float compares + a live ContactPlane.Normal.Z read first |
57.53383 — NOT lifted |
| same 3-compare shape, read replaced by a HARDCODED constant | 57.53383 — NOT lifted |
| reverted to the original one-liner, same session | 57.61359 — LIFTED again |
The physics completes before any of this code runs; no async in the path; the hardcoded-constant control kills the property-side-effect hypothesis. What remains is codegen-shape sensitivity: the test method's IL shape plausibly changes JIT inlining/tiering of the settle/tick chain it calls, and SOMEWHERE in that chain a computation sits on an exact float boundary that decides whether the one-time lift happens during the HARNESS SETTLE (→ absorbed ticks latch exact) or on the first absorbed tick (→ observed lift). Finding that boundary is the investigation; until it is found and made robust, AD-66 stays withheld — the stop clause fired twice and the production tree is unchanged. Do NOT attempt a third reland without first locating the boundary-sensitive computation (per-tick instrumented settle trace, tiering pinned via DOTNET_TieredCompilation=0 as the first discriminating experiment: if the flip vanishes with tiering off, the boundary is real and the fix is making the trigger robust to it).
BOUNDARY HUNT RUN, 2026-08-08 — the flip is NOT REPRODUCIBLE; the reland is unblocked
37 measurements: original assert shape, the reconstructed round-2 ABA shape,
an in-place hot-swap of the method tail, under default tiering,
TieredCompilation=0, and TieredCompilation=0 + TieredPGO=0 + ReadyToRun=0
— every run bit-identical (0x42667451, the stable lifted 57.61359). The
divergence never appeared once, so neither confirmation nor literal
refutation of the codegen hypothesis was possible: the anomaly is not
currently reproducible via the assert-shape mechanism in this tree. Most
likely: same-day physics commits moved the settle off the knife edge, or the
original divergence was session-environment-specific; the original flipping
code was described but never preserved byte-for-byte.
Rule amendment: the "no third reland before the boundary is found" guard's INTENT was "never land on a flipping measurement." The measurement no longer flips — at 37 runs, nearly 4x the gate's required depth. The reland proceeds under its original ten-run gate, with the historical flip recorded as unexplained-but-unreproducible rather than resolved. If the flip EVER reappears during the reland's gate, the old rule snaps back in full force.
Hypotheses deliberately NOT chased at 04:00: a property-read side effect
(reading body.ContactPlane between tick and assert — should be impossible);
xUnit execution-order/parallelism interacting with harness or engine state;
JIT/tiering differences by method shape; yet another artifact-staleness vector
not covered by bin/obj deletion. Next session: instrument the scenario
itself (per-tick position prints inside the test, a matrix of assert-shape ×
clean-room state), per feedback_apparatus_for_physics_bugs — three
contradictory reads means apparatus, not a fourth guess.
Until resolved: the AD-66 production code is REVERTED to the radius*N.z
substitution (comment block at the site names this issue), its two exact-value
conformance tests are [Skip]-ed with pointers here, and the register row
stays ACTIVE. The byte evidence was never the open question — do not
"resolve" this by re-deriving it a third time.
#342 — Issue265SteepSlopeCaptureBisectTests.cs:920 is a tautology: Assert.Equal(x.Z > 0.01f, x.Z > 0.01f)
Status: OPEN. LOW — a dead assertion that can never fail, in the steep-slope family Campaign S leans on. Found by the S4 review (F8), out of that slice's scope. Fix = recover the intended comparison from the test's context, not just delete. Filed: 2026-08-07.
#340 — StreamingWorkBudgetTests.DestinationAndEmptyUnloadPriorityNeverBypassPublicationBudget is a FIFTH load-sensitive flake
Status: OPEN. LOW. Filed: 2026-08-07 (overnight), first observed in a clean-room full-suite run; passes standalone immediately after. Distinct from #302, #308, #321 and #336 per the never-conflate rule. Same class: load-sensitive, deterministic in isolation.
#339 — Stuck in portal space / at login: the reveal never becomes ready — MECHANISM CAUGHT 2026-08-07 evening, full stack
BREAK: the crash is caught. The Session-B gate launch reproduced the hang
at LOGIN (cell 0xA8B4002F, all readiness flags False forever) and this time
the log carries an unhandled System.OverflowException with a full stack:
ObjectMeshManager.PrepareMeshDataAsync (ObjectMeshManager.cs:1028 region)
<- EnsureRenderDataReady:1302
<- WbMeshAdapter.IsRenderDataReady:340
<- LandblockSpawnAdapter.IsLandblockRenderReady:147
<- GpuWorldState.IsRenderReady:180
<- StreamingController.IsRenderNeighborhoodResident:280
<- WorldRevealReadinessBarrier.Prepare:142
<- WorldRevealCoordinator.PrepareAndEvaluate:188
<- RuntimeRenderFrameLivePreparation.Prepare
The defect: PrepareMeshDataAsync line ~1082 does
checked((uint)id) on the Setup/GfxObj arm. EnvCell GEOMETRY ids are packed
64-bit; they are supposed to take the EnvCell arm via _envCellDescriptors.
EnsureRenderDataReady (line 1302) reaches the fallback when the id is
owned but descriptor-less: the release path (~line 712) removes the
descriptor while resident render data parks on the LRU; a re-acquire restores
ownership, but nothing re-registers the descriptor until a full EnvCell
prepare is re-requested. A readiness probe arriving in that window falls into
the 32-bit cast and the OverflowException rides the RENDER FRAME path —
after which the reveal is never evaluated again. Demote-then-revisit
ordering explains the intermittency and why the same destination passed
twice earlier: the window only exists after an evict/re-acquire cycle.
Status: FIXED 2026-08-07 evening, LIVE-VALIDATED the same night. The fix
is the type-dispatch correction, not a suppression: EnsureRenderDataReady
now answers "not yet" for a packed id in the acquire→prepare window (the
scheduler's PrepareEnvCellGeomMeshDataAsync re-registers the descriptor on
the same landblock build, so not-ready is the true answer, not a dodge), and
PrepareMeshDataAsync converts the blind checked cast into a typed, loud
invariant failure naming the id kind and this issue. Validation: the crash
was DETERMINISTIC at login cell 0xA8B4002F (two consecutive hard failures);
with the fix, the same login revealed cleanly and a full play session
(16,585 entities, five portal generations, the Session-B dungeon gate) ran
with zero overflows and zero guard fires. The original design note below is
retained; the "band-aid" candidates it names remain rejected — this fix
corrects the dispatch, it does not swallow the error.
Original status: mechanism established; ROOT-CAUSE FIX NOT YET DESIGNED. The fix
is NOT "catch the exception" and NOT "skip 64-bit ids" (band-aids both):
the acquire/re-request ordering must make owned-implies-descriptor an
invariant again, or EnsureRenderDataReady must legitimately re-request the
EnvCell prepare from its own retained request data. Next session designs it
against the acquire path (_ownership acquire sites vs descriptor
registration at line 965). The S1B/S2 landings are NOT implicated — neither
touches this pipeline, and the readiness signature predates both (the
original filing below).
Also spun off this crash: #343 — the shutdown after the wounded render
loop threw InvalidOperationException: You cannot call Reset inside of the render loop! (GameWindowLifetime.ReleaseNativeWindow:294), exit 82,
status=AbandonedIncomplete, blocked=native window. Secondary, filed
separately below.
Original filing:
#339 (original) — Stuck in portal space: the destination reveal generation never becomes ready
Status: OPEN — observed live 2026-08-07, evidence captured. Not chased; user directed it be fixed later. Severity: HIGH when it fires — the session is unrecoverable without closing the client. The player never leaves portal space. Component: streaming / world reveal (NOT physics — see below).
What the log shows
session-a2.log, generation 2, destination cell 0x3032001C (landblock
0x3032, Rithwic):
245: world-reveal event=begin gen=2 kind=Portal cell=0x3032001C render=False composites=False collision=False ready=False
246: world-reveal event=readiness gen=2 kind=Portal cell=0x3032001C radius=12 render=False composites=False collision=False ready=False
265: world-reveal event=wait-cue elapsedMs=5004 (AP-150's five-second arming)
285: world-reveal event=cancel gen=2 render=False composites=False collision=False ready=False
The three readiness flags never flipped. render, composites and
collision are False at begin and still False at cancel — the destination
never completed, so complete and world-visible never fired and the wait cue
sat there until the user closed the client. The cancel at line 285 is the
shutdown, not a recovery.
What makes this worth a separate issue
The same destination succeeded twice in the previous session. In
session-a.log, generations 2 and 3 both teleported to this exact cell
0x3032001C and both reached world-visible. So it is not a permanently
broken landblock — it is intermittent, which is the harder shape.
NOT established
- Whether it is related to the #32 fix landed minutes earlier. The user
called it unrelated and mechanically that is very likely right: #32 changes
CollisionInfo's per-transition contact-plane writes, while thecollisionflag in these lines is the prepared-collision publication for the destination landblock — a different subsystem on a different thread. But "very likely" is not "established", and this fired on the first run after that change. Reproduce once on a binary WITHOUT #32 before ruling it out; that is one A/B run, far cheaper than being wrong. - Which of the three flags is the blocker, or whether all three stall on a common upstream dependency. The readiness line reports them together and nothing here separates them.
- Whether it is the same class as #280's D-1 (the unrecoverable portal hang found and fixed at the C5c review). D-1 was a reveal-gate hang with the same visible symptom. If this is D-1 recurring, that is a regression in a fix already accepted; if it is a second mechanism with the same symptom, it needs its own name. Do not assume either.
Related
AP-149 / AP-151 (reveal-gate strictness versus retail's prefetch predicate), AP-150 (the five-second wait-cue arming, which is what produced line 265), #280 and its D-1 fix.
#338 — The player resolves with stepUp/stepDown 0.400 where Setup 0x02000001 authors 0.600 / 1.500
Status: CLOSED 2026-08-07 — headline REFUTED by full-capture statistics; the residual is AD-68, an async-residency placeholder, not a wiring defect.
The three-site probe (ACDREAM_PROBE_STEP_HEIGHTS=1) answered everything in
one run:
site=prepare stepUp=0.600 stepDown=1.500 authored=(0.600,1.500) scale=1.000
site=publish stepUp=0.600 stepDown=1.500 localEntityId=1000002
site=resolve stepUp=0.400 stepDown=0.400 onGround=False <- once, early
site=resolve stepUp=0.600 stepDown=1.500 onGround=True <- the whole session
The probe is edge-triggered per site, so the single early 0.400 followed by
0.600/1.500 with no further change means the steady state carried the authored
values for the entire session — including the passing #32 cliff test.
Re-reading the ORIGINAL 337-support.log that motivated this filing, with
statistics instead of an eyeball: the authored pair appears 111,248 times,
the 0.400 pair 358 times. The filing was built on an early line of a
255k-line capture; the mechanism it alleged (values never wired to the mover)
does not exist.
What the 358 actually are — AD-68. GetSetupMoverShape returns a
placeholder (empty spheres -> legacy capsule, 0.4/0.4 steps) while an entity's
flat Setup is not yet resident; the local player has the same seconds-long
window between controller construction and publication-candidate adoption
(CommitRuntimeOwnedController). Retail loads synchronously and has no such
window. The early 0.400 in tonight's capture was most plausibly a REMOTE
player in that window — remotes also carry the IsPlayer mover flag, which is
why the probe now prints the mover id (feedback_probe_identity_attribution).
What the filing was still worth: it caught three false doc-comment claims
in PlayerMovementController (retail "~0.4 m" twice; a
PlayerModeController.ApplyStepHeights writer that never existed — corrected
to the real writer chain), pinned retail's actual fallback (0.04, not 0.4,
CTransition::step_up @0x0050b655), and produced AD-68's register row for a
previously unregistered adaptation.
No production behaviour changed at this closure — there is nothing to verify in a live gate.
Original filing below, retained for the record.
Status (original): OPEN
Severity: unknown until measured, plausibly medium. A 1.5 m step-down is
what keeps a mover attached to a descending slope; 0.4 m is not, so this is a
candidate contributor to descent/edge feel — but that link is NOT established
and must not be assumed.
Filed: 2026-08-06, spotted in the #337 [support] capture while chasing a
different defect. Deliberately not chased there: it does not cause the Neftet
wedge, and folding it in would have made that fix unfalsifiable.
Component: physics / movement.
The observation
The human Setup 0x02000001 authors StepUpHeight = 0.600 and
StepDownHeight = 1.500. The live [support] probe lines show the player
resolving with stepUp=0.400 stepDown=0.400.
ANSWERED 2026-08-06 — retail DOES read the authored field, so this is real
The gating question is closed. CTransition::step_up @0x0050b610
(acclient_2013_pseudo_c.txt:273109-273117):
0050b655 float step_up_height = 0.0399999991f; // fallback
0050b661 if ((this->object_info.state & 2) != 0) { // <- the gate
0050b665 OBJECTINFO::get_walkable_z(this);
0050b671 step_up_height = this->object_info.step_up_height; // authored
}
0050b6ba CTransition::step_down(this, step_up_height, arg2)
step_down has the same shape at 0x0050b852 (default 0.04, conditional
substitution) and reads the authored value unconditionally at 0x0050c232,
where it is then halved against the sphere radius if it exceeds a diameter.
Two facts fall out of this, and the second is the more useful one.
(1) The fallback is 0.04, not 0.4. Our value matches neither retail's
fallback nor the authored 0.600/1.500. It is an order of magnitude above
retail's fallback and well below the authored value.
(2) state & 2 is OnWalkable in our own ObjectInfoState. So retail
applies the authored step height ONLY while standing on walkable ground, and
drops to 0.04 otherwise. We already port that gate correctly —
Transition.DoStepUp (TransitionTypes.cs ~5836) is a faithful copy,
including stepDownHeight = oi.StepUpHeight, which reads oddly but is exactly
what retail passes. The gate is not the defect. Only the VALUE fed into it
is.
Where the 0.4 comes from — mapped, with one hop unproven
PlayerMovementController._stepUpHeight/_stepDownHeightare initialised to0.4f(PlayerMovementController.cs:159-160).- The
StepUpHeightproperty's own doc comment says the authoritative source is the player'sSetup.StepUpHeight, set byPlayerModeController.ApplyStepHeights. That method does not exist anywhere in the tree — the identifier appears exactly once, inside that comment. Either the wiring was removed and the comment survived, or it never landed. - Remotes and live entities are NOT affected. They get Setup-derived
values:
LiveEntityMotionRuntimeController.cs:321(setup.StepUpHeight * scale, falling back to0.4f) andRuntimeSetPositionMoverPreparation.cs:180. The local player is the odd one out — which is the population that matters, since it is what the user feels.
NOT ESTABLISHED, and must be before any fix. There IS one real writer:
RuntimeLocalPlayerPhysicsPublicationState.cs:215-216 assigns from
command.Physics.StepUpHeight, and that command is built by
RuntimeSetPositionMoverPreparation — which does compute the Setup-derived
value. So the plumbing exists. Whether it runs for the local player, or runs
and is then overwritten, is unproven; the live probe reading 0.400 says the
controller held its default at that moment, not why. Print the controller's
two values at world entry and at the first resolve before changing anything.
A fix that sets the field without knowing which path won will be a coin flip.
Remaining open question
- Whether it has any observable consequence. Do not open this by reasoning from the source; the #337 lineage already burned two diagnoses that way.
Related
Sits next to #32 (local-player cliff edge-slide), which has its own research
at 38db9fff and needs a live ACDREAM_DUMP_EDGE_SLIDE=1 capture before a
fix. If both turn out to touch descent feel, do NOT bundle them — they have
different mechanisms and need separate gates.
#337 — Neftet rock plateaus: wedged at the top, jumps sink into the mesh, corpses fall through — FIXED, awaiting live acceptance
Status: FIXED 2026-08-06 by #333's fix — the query-site broadphase reach
filter is deleted, because retail has none. Awaiting the user's live
acceptance at the Neftet plateau; the offline gate is
Issue333BroadphaseReachFilterTests.OffCentreBspFloorStopsAFallingMover,
sabotage-verified (restore the filter and it falls straight through to the
unobstructed height while the centred control keeps passing).
The mechanism, proven offline 2026-08-06, is #333: the per-object broadphase in
Transition.FindObjCollisionsInCell measures to the shadow entry's part
ORIGIN and compares against the BSP ROOT BOUNDING SPHERE's radius. Those are
23.6 m apart for 0xC8766009 / gfx=0x01004751, so a mover on the plateau is
rejected before the query it would have passed. Retail has no such filter
(CPartArray::FindObjCollisions @0x00518180 and
CPhysicsPart::find_obj_collisions @0x0050d8d0 verified instruction-by-
instruction on the PDB-paired binary). 0xC8766002, the owner with 11,014
tested-ok and zero hits, is innocent — its geometry is 22.8 m away.
Full evidence + the proposed fix:
docs/research/2026-08-06-337-neftet-wedge-mechanism.md.
Reproducer + offline replay:
tests/AcDream.Core.Tests/Physics/Issue337NeftetRockGeometryInspectionTests.cs.
Its installed-DAT evidence row is
TheOldBroadphaseMeasuredToTheOriginAndSoRejectedGeometryItStoodOn, which pins
BOTH halves of the diagnosis for this rock — origin-measured distance outside
the old budget, centre-measured distance comfortably inside the same radius.
The production gate is the separate DAT-free
Issue333BroadphaseReachFilterTests.
Historical framing below is superseded by that document — in particular "the mesh never collides" was true only of the innocent neighbour, and both the wrong-transform and BSP-traversal-hole hypotheses are refuted by measurement. Severity: HIGH — walk-through, fall-through, and a hard movement stop on world geometry. Filed: 2026-08-06, user-reported in live play after #334's fix landed. Component: physics / collision — possibly geometry data rather than movement code.
Symptoms, all from the user in live play
- Walks up a rock face onto a plateau fine, then cannot pass at the top — wedged, position frozen.
- Jumping is "swallowed half way by the rock" — the body sinks into the visual geometry.
- A monster corpse falls straight through the rock.
Symptom 3 is the load-bearing one. A corpse is a plain physics body with no player-specific movement logic, so a fall-through there cannot be explained by anything in the player's controller.
What is already RULED OUT
ACDREAM_PROBE_REACH (334-fix-gate.log, and the earlier
334-neftet-probe.log). At the frozen position: blocked=0, and every
candidate returns tested-ok — including the landblock's own rock mesh
gfx=0x010046DE in cell 0x8766002B. No object is blocking the player.
That probe can only see shadow objects, so it has ruled out its own domain and
can say nothing about terrain or the transition.
Two diagnoses have already been refuted by measurement on this defect's lineage: the broadphase reach filter (#333/AP-158) and the edge-slide family. Do not open a third by reasoning from the source.
The remaining candidates — and what is NOT yet established
- (a) terrain is what supports/blocks the body (walkable slope limit, step-up refusal, terrain Z).
- (b) a collision mesh placed somewhere other than its visual, so the body interacts with geometry that is not where the rock is drawn.
- (c) the transition wedging despite an unobstructed path.
(b) is the current working hypothesis and is NOT ESTABLISHED. It is plausible — a corpse falling through and a jump sinking in are both what absent-or-displaced collision looks like — but no measurement supports it yet, and the instruments below were built to REFUTE it, not to confirm it.
Possibly relevant, possibly coincidence: landblock 0x8766 carries the
largest single collision owner in the game, an 81-cell (9×9) footprint
measured during #334 — larger than anything else by a wide margin.
Instruments (2026-08-06 — TEMPORARY, strip with the physics-probe family)
ACDREAM_PROBE_RESOLVE alone does not separate (a), (b) and (c): it prints
a three-value contact-plane token, no plane normal, no plane height, no terrain
sample and no plane provenance, so all three candidates produce the same line.
Two additions close that:
ACDREAM_PROBE_SUPPORT=1→[support]+[geom].[support], one per resolve per body (players AND corpses): samples the outdoor terrain independently at the body's own out-XY and prints the contact plane's own height at that same XY.support=terrain/support=object/support=noneis then a measurement, not an inference, andcpSrc=names the code site that wrote the plane so provenance and classification cross-check each other.[geom], once per GfxObj that comes near the mover: compares the object's physics-BSP vertex cloud against its visual mesh AABB in the same local frame.verdict=coincidentrefutes (b) for that object outright;no-physics-bsp/empty-physics-bsp/displaced/extent-mismatcheach name a specific data defect.
ACDREAM_WIRE_MESH=1upgrades the existing F2 collision overlay from a broadphase proxy cylinder to the objects' real physics-BSP polygon edges (cyan) beside their visual mesh boxes (magenta) and the terrain surface (yellow). Settles "visual versus collision" by eye.
How to read the capture
| Observation | What it means |
|---|---|
[geom] verdict=no-physics-bsp or empty-physics-bsp on the rock |
The rock has no collision geometry. All three symptoms follow; nothing on the movement side needs explaining. |
[geom] verdict=displaced |
(b) confirmed. Fix the placement/registration transform. |
[geom] verdict=coincident on every nearby object |
(b) refuted. The cause is (a) or (c); read [support]. |
[support] support=terrain while standing on the visible plateau |
(a): terrain, not the rock, is the support — terrain Z near the plateau top is the thing to look at. |
[support] support=object with cpAboveTerr ≈ the plateau height |
The rock IS supporting the body; the wedge is (c). |
[support] stalled=true ok=true with cpWalkable=true |
(c): the transition accepts the move and advances nothing. |
[support] support=none on the corpse throughout its fall |
Nothing ever contacts it — consistent with absent collision, and [geom] says whose. |
No [support] line at all for the corpse's guid while it visibly falls |
The client is not simulating that body — the descent is server-driven or presentational, and the client-side collision path is not the place to look. An absence here is a real answer, not a gap in the capture. |
[support] cpWalkable=false at the freeze |
Slope-limit refusal — compare cpNz against floorZ on the same line. |
#335 — The INDOOR half of retail's part-array find_transit_cells is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX
Status: CLOSED 2026-08-07 (Campaign S S1B). The indoor part-array arm is
ported (CellTransit.FindTransitCellsBox), dual-reviewed PASS, sabotage-
verified, with the box-vs-cell BSP traversal in both representations under a
pinned 20,000-comparison installed referee. The severity line below
("over-inclusive only... never a missed one") is RETIRED with the port: at
production shape ratios the box legitimately exceeds the sphere (whole-vertex
AABB vs physics-polygon root sphere), and the measured sweep shows the
loaded-neighbour gate ADDING a cell the sphere test missed (1 in 950
production-ratio placements) — retail-correct in both directions. Remainders
(building bridge with its inverted portal-side convention, both its traps
byte-settled; the one-ULP WhichSide tie) live in the narrowed AP-159 row.
Original entry:
Status (original): OPEN Severity: low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed. Filed: 2026-08-06, at the #334 fix. Component: physics / cell membership Register row: AP-159.
#334 ported CPhysicsObj::find_bbox_cell_list @0x00510fc0 and the OUTDOOR arm of the part-array find_transit_cells it dispatches to (CLandCell::find_transit_cells @0x00533840 → add_all_outside_cells @0x00533360 → add_cell_block @0x005331d0). The INDOOR arm of that same dispatch is still acdream's sphere traversal.
What retail does (CEnvCell::find_transit_cells @0x0052cae0, disassembled from the PDB-paired 2013-09-06 binary), per portal × per part:
- cheap reject: the part's
CGfxObj::physics_spherecentre throughPosition::localtolocal(0x0052cb5a), tested against the portal plane witheps = F_EPSILON + radius(0x0052cb65); - on pass, the ADMITTING test is box-vs-plane:
CPhysicsPart::GetBoundingBox@0x0050d600 (0x0052cbdd) →BBox::LocalToLocal@0x005b1e60 (0x0052cbf9) →Plane::intersect_box@0x005aa170 (0x0052cc05); - if the side differs from
portal_side:other_cell_id == 0xFFFFFFFF(0x0052cc1e) sets the “leads outside” flag; otherwiseCCellPortal::GetOtherCell@0x0053ba30 (0x0052cc2b) — a THISCALL on the portal record (ecxset at0x0052cc18) taking ONE explicit argument,cellarray->do_not_load_cells(0x0052cc27 mov eax,[edi+4]; the CELLARRAY layout is +0added_outside, +4do_not_load_cells, +8num_cells, +0xccells, cross-checked againstfind_bbox_cell_list's0x00510fc8/0x00510fcfzeroing andadd_all_outside_cells'0x0053336cread of[arg3]). That RESOLVES the #334 contract's open question 11.4 in the AFFIRMATIVE — the flag IS threaded through, as the single explicit argument, not omitted. ThenBBox::LocalToLocalinto the destination andCCellStruct::box_intersects_cell@0x00533910 →BSPTREE@0x0053c880 gates the add (0x0052cc5a); - after all portals, the outside flag runs
add_all_outside_cells(0x0052ccea).
What acdream does: CellTransit.BuildShadowCellSetFromParts's indoor arm calls FindTransitCellsSphere with the per-part BSP root spheres (ShadowObjectRegistry.BuildBspPartSpheres), i.e. step 1's cheap reject used as the admitting test. Same for the outdoor building bridge (CEnvCell::check_building_transit @0x0052c5d0).
Why it was deferred rather than folded into #334: closing it needs a BOX traversal of the containment BSP in BOTH the graph (BSPQuery) and the production flat (FlatBspQuery) representations, plus their exact referee — a separately gateable change with no bearing on #334's outdoor defect, and one that no #334 gate would exercise. Adding ~150 lines of unverified geometry under a green-but-uncovering test is the failure mode this campaign has now hit ten times.
Files: src/AcDream.Core/Physics/CellTransit.cs (BuildShadowCellSetFromParts indoor arm, FindTransitCellsSphere); src/AcDream.Core/Physics/ShadowObjectRegistry.cs (BuildBspPartSpheres).
#333 — The shadow broadphase reach filter measures from the PART ORIGIN, so an off-centre BSP part can be in the right cell and still never be tested
Status: FIXED 2026-08-06 — the filter is deleted, not re-centred. Re-centring it would have kept an invention retail does not have; the disassembly below establishes that retail walks the cell's shadow list unconditionally. Cell membership IS retail's broad phase, and the BSP walk's own root-node bounding-sphere test — correctly centred, which is exactly what this filter was not — is the early-out that made a second one unnecessary. AP-158 is retired by the same commit.
This closes #337 (the Neftet plateau: wedged at the top, jumps sink in, corpses fall through), whose mechanism this is.
Gates. Issue333BroadphaseReachFilterTests drives the production path
end-to-end (ResolveWithTransition → FindObjCollisionsInCell →
CollisionTraversal) on a DAT-free fixture so it runs everywhere, and is
sabotage-verified as a discriminating pair: restore the maxReach pre-check
and OffCentreBspFloorStopsAFallingMover falls straight through to the
unobstructed 37.800 while CentredBspFloorStopsAFallingMover keeps passing —
so it cannot pass for the trivial reason that the fixture is unable to fall.
Perf, measured rather than assumed. Deleting a filter costs whatever the
candidates it used to reject now cost. Measured in Release on a synthetic
all-BSP cell, per ResolveWithTransition:
| candidates in cell | with filter | without | delta |
|---|---|---|---|
| 38 (the live max) | 10.61 µs | 16.68 µs | +6.07 µs (1.57×) |
| 200 (5× worse than anything observed) | 17.34 µs | 39.48 µs | +22.1 µs (2.28×) |
≈0.16 µs per additional candidate actually tested. The live population is the
bound that matters: over 19,701 [reach-q] samples in the Neftet and outdoor
captures (334-fix-gate.log, 334-neftet-probe.log, 334-neftet.log) the
in-cell candidate count is p50 = 9, p99 = 32, max 38. The 200-object row is
included only to show the curve is linear, not to suggest it is reachable.
Severity (when open): high for the tall-prop population. It was the gate immediately
downstream of the AP-156 membership fix, so that fix alone may not be enough to
make the worst objects block.
Filed: 2026-08-06 at the AP-156 fix (commit b52967de), which surfaced it.
Updated 2026-08-06 at the AP-156 fix review: the retail question below is
now ANSWERED, and the filter has its own divergence row, AP-158.
Do NOT bundle with AP-156. Different code path (collision query, not cell
membership).
ANSWERED — retail has no distance pre-filter at all
Disassembled from the PDB-paired binary (C:\Users\erikn\Downloads\acclient.exe,
check_exe_pdb.py → MATCH, CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32)
for this update, not inherited from Binary Ninja:
CObjCell::find_obj_collisions @0x0052b750
0x0052b759 cmp dword [ebx+0x174], 2 ; sphere_path.insert_type
0x0052b765 je 0x52b7a0 ; INITIAL_PLACEMENT_INSERT -> return OK_TS
0x0052b773 mov ecx,[edi+0xc8] ; shadow_object_list.data
0x0052b77f mov edx,[ecx+0x40] ; physobj->parent
0x0052b784 jne 0x52b795 ; parented -> skip
0x0052b786 cmp ecx,[ebx] ; physobj == mover?
0x0052b788 je 0x52b795 ; self -> skip
0x0052b78b call 0x50f050 ; CPhysicsObj::FindObjCollisions — UNCONDITIONAL
0x0052b79e jb 0x52b773 ; loop, bound = [edi+0xc4]
Agrees with acclient_2013_pseudo_c.txt:308916-308940. There is no distance
test in the function. So the + 2f slack and the movement.Length() term are
acdream's own invention, which is why AP-158 exists. For scale: retail's own
cross-cell slack constant is F_EPSILON = 1.9999999e-4 — 0.2 mm, read at
0x0052cb5f fld dword [0x7c8c70] — not 2 m.
Measured blast radius
Over the installed client_portal.dat, by an independent scratch sweep outside
the repo: 118 of the 477 unique physics-BSP GfxObjs have a root-sphere
offset above the filter's roughly 2.5 m walking budget, and 46 above 5 m. At
a test scale of 1.75 those offsets become 4.4 m and 8.75 m against an unchanged
budget.
Consequence for the AP-156 connected gate — SUPERSEDED by the fix above
The caveat below applied while this issue was open. It no longer holds: the filter is gone, so AP-156's connected gate is now expected to show its benefit on tall props, and a null result there IS evidence against AP-156. Retained for the record.
Tall props may show NO VISIBLE CHANGE at all until this issue is fixed, and a null result there is EXPECTED rather than evidence against AP-156. AP-156 puts the geometry into the correct cell; this filter then discards it one layer down, for exactly the largest-offset objects AP-156's commit body points the user at.
The mechanism
src/AcDream.Core/Physics/TransitionTypes.cs:3756-3764, the shadow broadphase:
Vector3 deltaToCurr = currPos - obj.Position;
...
float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f;
if (distToCurr > maxReach)
continue; // candidate discarded, never tested
obj.Position is the shadow row's PART placement —
entityWorldPos + rotate(shape.LocalPosition, entityWorldRot) — while
obj.Radius is the physics-BSP ROOT BOUNDING SPHERE radius, which is measured
about that sphere's own centre, not about the part origin. AP-156 established
that the two are frequently far apart: 376 of 973 installed physics-BSP parts
have a root-sphere origin further from the part origin than half their own
radius, worst 20.762 m on a 27.708 m sphere.
Write d for the distance from part origin to the true sphere centre, R for
obj.Radius, r for the mover's sphere radius. A mover just touching the
geometry is R + r from the sphere's TRUE centre, hence up to d + R + r from
obj.Position. The filter admits it only when
d + R + r <= r + R + movement + 2, i.e. only when d <= movement + 2. Per
physics tick the movement term is well under a metre, so any part whose root
sphere sits more than about 2 m from its part origin can have a genuine contact
discarded before BSPQuery ever runs.
Worked case — Setup 0x02000255, one part, root sphere
origin (0.000, -0.007, 9.911), radius 10.522, Setup.Height = 18.692.
d = 9.911 against a budget of roughly 2.5 m. A player standing against the
upper half of that prop is about 20.4 m from the part origin while maxReach
is about 13.5 m. Discarded.
Why it matters now
Before AP-156 those objects were usually not registered in the cell at all, so this filter never got the chance to reject them. AP-156 puts them into the correct cells; this filter is the next gate they hit. If the connected session finds a tall prop that still does not block after AP-156, look here first.
What to establish before fixing
Does retail have this pre-filter at all?ANSWERED above: no. The register row is filed as AP-158. What remains open is a judgement call, not a research question: keep the filter as a deliberate optimisation with a correct measurement point, or delete it and walk the list as retail does. Deleting it is the retail-faithful option and should be costed first —ShadowEntrySnapshot.Capturealready bounds the per-cell list.- If it is kept, it must measure from where the geometry is:
ShadowEntryneeds theBoundsCenterthatShadowShapenow carries, anddeltaToCurrmust be taken againstobj.Position + rotate(obj.BoundsCenter, obj.Rotation). - Cylinder rows are unaffected (
BoundsCenter == Zeroby construction) — the change must not move them.
Test to write first
A mover adjacent to an off-centre BSP part's geometry, with the part origin far
outside maxReach. It must reach BSPQuery. Sabotage by restoring the
part-origin measurement; it must go back to reporting no collision.
#332 — Headless bots appear to have no remote dead-reckoning at all
Status: OPEN (observation, not yet established as a defect) Severity: for the headless owner to judge — it depends entirely on what headless bots are for. Filed: 2026-08-06, from the AD-10 blast-radius census. Adjacent to #330 (headless registers no live-entity collision), but a separate mechanism.
The evidence
RuntimeRemotePhysicsUpdater — the per-tick owner that advances every remote
entity between server UpdatePosition bursts (interpolation catch-up, root
motion, gravity, the collision sweep) — has exactly ONE production
instantiation:
$ grep -rn "new RuntimeRemotePhysicsUpdater" --include=*.cs src/ tests/
src/AcDream.App/Physics/RemotePhysicsUpdater.cs:46 <- only production site
tests/AcDream.Runtime.Tests/Physics/... <- fixtures only
src/AcDream.Headless/ contains no reference to RemoteMotion,
RemotePhysicsUpdater, or OrdinaryPhysicsUpdater. The class is internal to
AcDream.Runtime and reaches production only through InternalsVisibleTo into
AcDream.App. So on the headless host remote entities would move only at
UpdatePosition cadence — roughly 5 Hz teleport-stride — with no interpolation
between bursts.
Why it is filed rather than fixed
Whether this matters depends on what headless bots need to observe. A bot that only reads positions from the wire may not care; one that makes decisions from observed remote motion, or that is used as a second client in a two-client visual gate, would.
The reasoning trap it exposes, worth recording on its own
RemoteMotionCombiner is in AcDream.Core and RuntimeRemotePhysicsUpdater is
in AcDream.Runtime, so "therefore headless runs it too" is the available
inference — and it is false. This is the C5b lesson running in the opposite
direction: C5b's survey missed AcDream.Headless by only walking the graphical
host's call graph, and the natural correction ("check Core and Runtime, those
are shared") produces the wrong answer here. Assembly placement is not
reachability; the instantiation census is. AD-10's closeout deliberately
designed NO headless gate for that reason — a passing headless run would have
been vacuous evidence.
#331 — an exactly-up-slope step is absorbed by the sliding normal a landing leaves behind (headline claim REFUTED; behaviour is retail-faithful)
Status: DONE — settled 2026-08-06. Not a defect. The headline "refuses ALL uphill motion" is false; it was an artifact of an axis-aligned fixture driven by axis-aligned motion. Coverage added, fixture annotated. Severity: none as a defect. The mechanism below is real and reachable in production, but it is retail-faithful at the instruction level and already on the #137 DO-NOT-RETRY list.
The verdict
ResolveWithTransition refuses a step whose sub-step offset is exactly
anti-parallel to a live sliding normal, not uphill motion as such. On the
same fixture, at the same gradient, with the same body:
| per-tick root motion | cross-slope component | result over 5 ticks |
|---|---|---|
(0, -0.1, 0) |
0 | zero movement, latched |
(0.0001, -0.1, 0) |
0.0001 m | zero movement, latched |
(0.001, -0.1, 0) |
0.001 m | climbs 0.176 m, latch clears on tick 1 |
(0.01, -0.1, 0) |
0.01 m | climbs 0.176 m, latch clears on tick 1 |
The escape threshold is exactly retail's F_EPSILON small-offset abort: the
adjusted offset must exceed 0.0002 m. At a 0.1 m sub-step that is a heading
more than about 0.11° off the exact gradient — a ±0.11° window out of 360°.
The mechanism, end to end
- A landing (or the spawn settle that compresses it,
SpawnPlacementSettler) reachesOBJECTINFO::validate_walkablewith the OBJECTINFOCONTACTbit clear, so it callsset_collision_normalwith the terrain plane normal. Verified in the PDB-paired binary:0x0050d251 test byte ptr [ebp+4],1 / jnethen0x0050d261 test eax,eax(step_down) then0x0050d26c call set_collision_normal. acdreamTransitionTypes.csValidateWalkablematches (!oi.Contact && !sp.StepDown). CTransition::validate_transitionunconditionally converts it:0x0050ac19 test eax,eax / 0x0050ac21 je / 0x0050ac30 call set_sliding_normal. acdream matches.COLLISIONINFO::set_sliding_normal(0x0050a060) zeroes Z and re-normalizes, so even a 1° slope produces a full-length horizontal normal pointing downhill. acdream matches.SetPositionInternalpersists it asSLIDING_TS(0x005154c2/0x005154e1);get_object_infore-seeds it next frame (0x00511d44 test / 0x00511d4f call init_sliding_normal). acdream matches.CTransition::adjust_offsetseesdot(offset, sliding) < 0and projects the step onto the creasecross(sliding, contact)— a purely horizontal, purely cross-slope axis. An exactly-up-slope offset has zero component on it.- The sweep aborts at step 0 and reports failure:
0x0050c0ed test ebx,ebx / jne 0x0050c089->cmp [esp+14h],1 / jne->xor eax,eax. Retail returnsi != 0 && state == OK— byte-identical to acdream'sFindTransitionalPosition(Binary Ninja typed this functionvoidand dropped the return value; the disassembly settles it). - Because the transition failed, the writeback never runs, so the sliding state is never cleared -> self-latching until a step with a surviving component succeeds.
Cross-checked against ACE (Transition.cs:1027,
CollisionInfo.cs:58) — same shape.
Why it read as "ALL uphill motion"
RemoteRampHarness builds a ramp whose gradient is exactly along Y, and the
probe pushed exactly along -Y. Axis-aligned fixture x axis-aligned motion hits
the measure-zero anti-parallel case with probability 1. Everything the original
report ruled out (gradient, step size, cell boundaries, Z seating, AD-10) was
correctly ruled out; the variable it did not vary was the heading relative to
the gradient.
Production reachability — stated honestly
The latch is production-real in mechanism: a pure gravity fall driven by the
production RuntimeRemotePhysicsUpdater, with no fixture settle seam involved,
lands on the ramp and leaves Contact | OnWalkable | Sliding with
slidingNormal = (0, 1, 0). The local player runs the same
ResolveWithTransition with the same body and the same
IsPlayer | EdgeSlide profile. So in production:
- After any landing on a slope, the first step's up-slope component is deleted (one frame). This is retail behaviour.
- Holding a heading within ~0.11° of the exact gradient sticks you until you turn. Also retail behaviour as written, and only reachable where a real terrain triangle's gradient happens to align with the held heading.
NOT established: a live DAT-terrain / connected-client reproduction. It was not run because the discriminator turned out to be offset-vs-gradient alignment, not terrain provenance — the same triangle plane is produced either way. If a player ever reports "stuck facing uphill until I turn", this is the mechanism.
What landed
tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteUphillProgressTests.cs— the missing coverage (ARemoteWithABodyClimbsAWalkableSlopeAndKeepsItsFeetOnIt, per-tick climb + surface tracking under a realistic heading) plus a characterization pin for the absorb, both sabotage-verified in both directions.RemoteRampHarnessnow carries a warning block naming the axis-alignment trap, so the next vacuous uphill assertion is caught at authoring time.
Do NOT patch the small-offset abort, add per-frame sliding clearing, or
special-case walkable planes in validate_walkable — all three are on the
#137 DO-NOT-RETRY list and all three would be deliberate retail divergences.
If the one-frame deletion is ever judged unacceptable, the lever is the
provenance of the sliding normal, and it needs its own brainstorm.
#330 — The headless host registers no live-entity collision at all: a bot walks through every NPC and every server-spawned object
Status: OPEN — SCOPE MAPPED 2026-08-07 by a dual Opus review of a withheld implementation; the builder hoist landed, the wiring did not.
An overnight implementation attempt wired spawn-time registration into the no-window route. Both review lenses failed it, converging (two reviewers converging = near-proof, per the project's own rule), and the findings ARE the issue's true scope — recorded here so the next attempt starts from the map instead of rediscovering it:
- A headless remote-motion tick does not exist.
RuntimeRemotePhysicsUpdateris Runtime-HOMED but App-DRIVEN (constructed only atRemotePhysicsUpdater.cs:46, ticked only fromLiveEntityAnimationScheduler.cs:351), andGetOrCreateRemoteMotionhas one production caller, in App. A shadow registered at spawn therefore FREEZES at the unsettled wire pose: a walking NPC becomes a phantom obstacle at its spawn point while the real NPC still passes through the bot — strictly worse than the honest gap. This is the load-bearing prerequisite. - Live collision-asset publication does not exist headless-side. The
headless
PhysicsDataCacheholds only the per-landblock static closure; there is noIPreparedCollisionSourceon-demand pull (graphical:LiveCollisionAssetPublisher). Without it,_physicsBspBoundsis null for live-entity parts, BSP dispatch never fires, and doors/chests/statues/portals get the wrong shape or none. - Degrade resolution: the graphical route resolves collision part ids
through
GfxObjDegradeResolverslot-0 walking; rawsetup.Partsis the wrong id for every humanoid part. - Shadow lifetime hangs off FIVE edges, not two: wire delete, pickup
(
TryApplyPickupleaves a permanent invisible collider from a looted item), same-guid generation supersession (RetireCanonicalOnlypath has no unregister → duplicate phantom), GENERATION RESET (HeadlessGenerationResetHost.RetireEntityProjectionis an empty no-op andResetSessionPhysicsdoes not clearShadowObjects— registrations leak across reconnects), and hidden/withdrawn suspension. - The K-ledger cannot see any of this:
RuntimePhysicsOwnershipSnapshot.IsConvergedchecks retained shadow count only AFTER disposal, and disposal clears the registry. Extending the convergence oracle to pre-disposal retained counts is part of this issue's test work, or every leak above ships green. - Ordering + retry: registration must follow
ProjectSpawn(a throw afterApplyAcceptedSpawnleaves a committed-but-unprojected entity), and a spawn arriving before the world frame publishes needs a retry pump — the withheld code silently dropped it for the incarnation's lifetime. - The appearance route EXISTS (
OnAppearanceUpdated) and must rebuild collision, as the graphical binding does.
What DID land 2026-08-07: the builder hoist —
LiveEntityCollisionBuilder + LiveEntityDefaultPoseResolver moved to
AcDream.Runtime.Physics (internal + existing IVT), Build(...)'s
App-record parameter replaced by presentation-free primitives including the
FinalPhysicsState the contract had missed. Both reviewers passed the hoist
explicitly; the graphical host is diff-verified unchanged. The wiring
attempt itself is preserved in the review transcripts, not in the tree.
Original entry below.
Status (original): OPEN
Severity: HIGH for headless gameplay fidelity; zero impact on the graphical client.
Filed: 2026-08-06, from the AP-22 deletion's blast-radius survey (§5 of
docs/research/2026-08-06-ap22-contract.md).
ShadowShapeBuilder.FromSetup — the only producer of live-entity collision
shapes — has exactly one production caller,
src/AcDream.App/Physics/LiveEntityCollisionBuilder.cs, which lives in
AcDream.App. AcDream.Headless references AcDream.Runtime and
AcDream.Content, never AcDream.App, so no headless code path ever builds or
registers a collision shape for a server weenie. HeadlessSessionHost.cs:640
additionally pins the local player to
RuntimeLocalPlayerShadowDisposition.ProvenShapeless.
Consequence: a headless bot has landblock static collision (published
through LandblockPhysicsContentBuilder.PublishStaticCollision, which the
headless world projection does call) but no collision against creatures, NPCs,
players, or any other server-spawned object. It walks straight through all of
them. Retail collides against every cell-resident object with a shape.
This is a pre-existing gap, not introduced or widened by the AP-22 deletion — AP-22 removed a branch that was unreachable for all 5,935 installed Setups, so it changed no registered shape on either host. It is filed separately because the AP-22 survey is what established it and because nothing currently tracks it. Checked at filing time: #291 is a different thing (the headless 3x3 collision window wanting a divergence-register row), and no register row covers this.
Not a one-to-two-commit change: closing it means giving Runtime or Content ownership of live-entity shape construction (today an App concern), which overlaps the Slice-J ownership work. If it is ever accepted rather than fixed, it needs a divergence-register row; it is filed here as a defect because the intent is to fix it.
#325 — Gate A's teleport test is narrower than retail's: a ForcePosition carrying a NEWER teleport stamp is misrouted into a full Apply
Status: OPEN Severity: MEDIUM (no observed symptom; reachability against ACE is unmeasured — see below. The behaviour when reached is four simultaneous divergences, not one.) Filed: 2026-08-05 at the C5b closeout, with register row AP-148 Component: physics / inbound timestamp gate / local-player force position
Description. Retail's SmartBox::HandleReceivedPosition Gate A — the
local-player FORCE_POSITION self-echo shortcut — takes its shortcut iff the
wire TELEPORT_TS is not older than the stored one, i.e. equal or newer.
acdream requires exact equality
(PhysicsTimestampGate.TryAcceptPositionEvent:199,
teleport == _timestamps[Teleport]), so acdream's ForcePosition
disposition is a strict subset of retail's Gate A set.
The disassembly, the byte-level reasoning, and why two review rounds of
reading Binary Ninja pseudo-C recorded the term backwards are in AP-148 and
in docs/research/2026-08-05-c5b-contract.md §15.1. Short version:
0x0045402B–0x00454054 materialises the carry of a wrap-safe 16-bit compare
with sbb eax,eax / neg eax and skips Gate A on CF, where CF means "wire
strictly older"; Binary Ninja drops the flag test and renders the whole
thing as if (-((eax_7 - eax_7)) == 0), which is always true.
What the misroute does. The excluded packet falls through to Apply
with advancesTeleport true, which is four behaviour changes at once:
- the wire heading is applied instead of the body's being preserved
(
InboundPhysicsStateController.ApplyAcceptedPosition:846-856isForcePosition-gated); - the entity is unparented, and may have a placement frame installed
(
clearParent: !force,installPlacementFrame: !force && !hasAnimations); - local velocity is zeroed (
:882-885, theTeleportAdvancedarm); - TELEPORT_TS advances and
OfferTeleportDestinationis called, starting teleport/portal presentation for a packet retail never starts it for.
Retail's Gate A deliberately lets a force ride past a pending teleport
advance without consuming it — it returns @0x0045409D, before
newer_event(arg2, TELEPORT_TS, arg8) @0x00454158 — leaving the ordinary
Position channel to process that teleport.
This is NOT a one-line comparison swap, and the fix must not be attempted as one. Three things have to be decided together:
- The predicate exists twice. Besides
PhysicsTimestampGate.cs:199,RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthorityindependently requiresauthority.PreviousTeleportSequence == authority.AcceptedTeleportSequencefor aForcePosition— the same narrowing, encoded downstream. Widening one without the other turns the newly-admitted packets intoRejectedAuthorityroutes, which is a third behaviour, worse than either. - TELEPORT_TS's disposition on the Gate A path. acdream's Gate A branch
already returns without advancing TELEPORT_TS, which matches retail —
but it has never had to do so while the wire stamp was newer. After
widening,
AcceptedPhysicsTimestamps.PreviousTeleportand.Teleportwould be equal and STALE while the wire carried a newer value: a shape no consumer has seen.IsFreshTeleportStart, the drive controller'spreviousTeleportargument, and J6.3's F751/Position teleport correlation all read that pair. - Reachability has to be established before the fix, not assumed. ACE's
two
ObjectForcePositionbumps (Player.cs:1148PKLite re-placement,Player_Tick.cs:488z-hack correction) do not themselves bump the teleport sequence — butPositionPackserialises the current teleport sequence, so any client whose TELEPORT_TS lags ACE's is in the divergent window on its next force. Whether that lag is reachable in practice is unmeasured. A cdb trace or a wire capture answers it; guessing does not.
The correct predicate already exists verbatim one file away:
PhysicsTimestampGate.IsFreshTeleportStart:163 is
!IsNewer(teleport, _timestamps[Teleport]), which is exactly retail's Gate A
term.
Acceptance: both encodings widened together; the newly-admitted shape covered by a discriminating test at the disposition boundary AND at the classifier's authority validation; the three consumers in item 2 checked against a stale-but-equal teleport pair; AP-148 retired in the same commit.
#324 — The graphical and no-window hosts run parallel, non-shared inbound entity routes
Status: OPEN Severity: MEDIUM (no live symptom today; it is the structure that PRODUCED D1, and it will produce the next one) Filed: 2026-08-05, in the C5b architecture-review D1 fix commit, per that fix's brief ("if the correct answer is to unify the two session controllers, say so and file it rather than attempting it here") Component: runtime / session routing / host structure
Description. Two inbound entity routes exist and neither is derived from the other:
- graphical:
src/AcDream.App/Net/LiveEntitySessionController.cs→LiveEntityNetworkUpdateController.OnPosition(and siblings) - no-window:
src/AcDream.Runtime/Session/RuntimeLiveEntitySessionController.cs(OnPositionUpdatedand siblings), constructed only atsrc/AcDream.Headless/Hosting/HeadlessSessionHost.cs:682
They share the canonical Runtime owners underneath (Slice J's whole point) but NOT the routing decisions on top: which packet shapes reach which owner, in what order, under what gates. Every canonical rule expressed in the graphical route's control flow has to be re-derived by hand for the other, and nothing enforces that it was.
Why this is filed as its own issue rather than fixed inline. This is the
structural cause of defect D1 from the C5b architecture review (both reviewers
found it independently). C5b made the steady-state Position merge stop writing
residency — retail-correct — and moved the write to the OnPosition
prologue rebucket. That rebucket is graphical-only, so the no-window host
silently lost canonical cell tracking for every entity: remotes froze at their
placement cell for the whole session, and the local player lost one of
AP-146's three refresh edges. Nothing failed; the host just stopped being
right. The D1 fix gives the no-window route its own commit over a shared
Runtime value owner and files the residual duplication at AD-64 — it does not
remove the class.
What unification has to reconcile (why it is campaign-sized, not a slice):
- The graphical route performs presentation recovery the no-window route has
no analogue for (
RequiresSpatialProjectionRecovery, the equipped-childChildUnparentDispositionarm,LiveEntityHydrationController). - The graphical route performs remote contact routing, far-snap/teleport
placement arms, and projectile routing; the no-window route performs none
of them and returns early for
!isLocal. Unifying means deciding whether the no-window host GAINS those arms (a behaviour change with its own gate) or whether the shared route is parameterized over them. - Ordering constraints are load-bearing and already documented as
measurements, not intentions — AP-138's route-2 first-submit
CurrentCellIdobservation depends on the force drive submitting BEFORE the wire-cell commit, and AD-60/AP-147 on the merge publishing before the rebucket. A unified route must preserve each, per host. LiveEntityRuntime's spatial/presentation half and its canonical half are currently interleaved in one method (RebucketLiveEntity); the D1 fix split out the canonical value derivation, but the residence gate, the object-clock enter-world rebase, and the visibility publication are still entangled with the bucket move.
Acceptance: one route object owns the inbound decision set for both hosts, with presentation and routing supplied as collaborators; AD-64 is deleted in the same commit; the eight D1 sabotages still discriminate.
#320 — The local player's canonical cell does not track ordinary movement (follow-up from #319)
Status: OPEN Severity: LOW today (no observed symptom — see below); the correctness question is real and unresolved Filed: 2026-08-05, filed in the #319 fix commit per that contract's §2.2/§4/§9 Component: physics / entity lifetime / local player canonical cell
Description. Retail writes the local player's cell on EVERY physics tick
(CPhysicsObj::SetPositionInternal @0x00515330, unconditional). acdream's
canonical FullCellId for the LOCAL player is written only at three edges:
login activation (RuntimeSetPositionState.cs:2741-2745), the OnPosition
generic tail's prologue rebucket after an accepted inbound Position
(LiveEntityNetworkUpdateController → LiveEntityRuntime.RebucketLiveEntity
→ RuntimeEntityObjectLifetime.CommitRebucket), and a teleport/portal
placement commit
(RuntimeSetPositionState.cs:5001-5007; LocalPlayerTeleportController.cs:255).
Ordinary WASD movement passes a LANDBLOCK id, not an exact cell
(LocalPlayerProjectionController.Project, low 16 bits forced to 0xFFFF in
both branches), and LiveEntityRuntime.cs:935-938 explicitly PRESERVES the
prior canonical cell for that shape. So the player's canonical cell is coarse
and mostly-frozen between teleports — see the register row this issue's fix
commit files (AP-146) for the full citation and the argument that this is
currently safe for every EXISTING consumer.
Amended 2026-08-05 by C5b (#275). The second edge above used to be the
accepted-Position MERGE itself (RuntimeEntityDirectory.RefreshSnapshot →
RuntimeEntityRecord.cs:234). C5b made that merge withhold the wire cell
(AD-60), so the writer is now the OnPosition prologue rebucket's
CommitRebucket — one step later in the same call, same value. Nothing about
this issue's substance changes: the coarse landblock-preserve branch at
LiveEntityRuntime.cs:935-938 is unchanged and is still what makes the
player's cell mostly-frozen. One shape DID change and belongs to this issue's
survey: a local ForcePosition returns before that tail, so its residency
is now placement-receipt-authoritative — a refused or contended force writes
no cell at all (retail's own shape; AD-62).
Corrected 2026-08-05 by the C5b architecture review's D1 fix. The
three-edge enumeration above was written from the graphical host and silently
assumed both hosts shared it. They do not. AcDream.App and
AcDream.Headless run parallel, non-shared inbound routes
(LiveEntitySessionController → LiveEntityNetworkUpdateController.OnPosition
versus RuntimeLiveEntitySessionController.OnPositionUpdated), and C5b's
replacement writer lived only in the former — so the no-window host had only
TWO of the three edges, login activation and the teleport/portal commit, and
its remotes' cells were frozen from placement onward as well. That is fixed:
RuntimeLiveEntitySessionController.TryCommitAcceptedWireCell now commits the
same value through a shared Runtime owner,
RuntimeEntityObjectLifetime.CommitWireCellRebucket, which is also where the
landblock-preserve branch this issue's item 1 is about now lives (it moved
verbatim out of LiveEntityRuntime.cs:935-938; update that citation when
reading item 1). The reachability duplication the fix leaves behind is filed
at AD-64, and controller unification at #324.
What this changes for item 6, the unresolved first verification step. It
does not answer it, but it removes a strictly-worse case that was hiding
underneath it: before the fix, a no-window bot lacked the inbound-Position
edge entirely, so a bot running A→B without ever teleporting kept
FullCellId at A for the whole session — retiring A would park a body that
is physically in B, and retiring B would miss it. Both hosts now refresh at
ACE's 5-10 Hz Position cadence. The question item 6 actually asks — whether a
stale-cell landblock retirement can sweep a spatial-root local player — is
unchanged and still open.
Why this is not #319's blast radius. #319's fix makes a player-parented
equipped child inherit the parent's (the player's) canonical cell EXACTLY —
an equality invariant, not a freshness one. The child is stale-but-equal
wherever the player's own record already is; #319's fix does not touch the
player's own cell-writing paths at all. See
docs/research/2026-08-05-issue-319-contract.md
§2.2 for the full argument that folding this into #319 would have put that
slice at route-3 scale (~418 lines) and was the wrong bundling regardless.
What this issue must resolve before implementation, not after (§2.2's enumeration + §9 item 2):
- Making the player's canonical cell exact-track movement touches the
deliberate landblock-preserve contract at
LiveEntityRuntime.cs:935-938— a generic rebucket rule, not player-specific, so changing its input population changes it for the one caller that relies on it. Rebucketedentity-delta publication cadence: today the player NEVER publishes aRebucketeddelta during WASD (the preserve path early-outs atCommitRebucket'sprevious == fullCellId,RuntimeEntityObjectLifetime.cs:1965-1972); an exact-cell commit would publish per EnvCell crossing — audit every consumer before shipping.- The accepted-Position classification inputs for the LOCAL player (route 2
and the 4b-3
PreMergeCommittedCellIdmeasurement atTryApplyPosition:1801-1814): a fresh committed cell changes the pre-merge population on live correction paths AP-136/AP-138 spent four review rounds pinning. - The portal-space freeze interaction
(
LocalPlayerProjectionController.Project:100-103— the teleport owner alone projects the destination while the local controller deliberately retains its frozen source cell): a canonical exact-cell writer must not race the teleport owner. - The
isOrdinaryRootfamily (LiveEntityRuntime.cs:915-918,:3213,:3323) and the animation-scheduler local-player exclusion (LiveEntityAnimationScheduler.cs:183-227). - First verification step, unresolved by the #319 investigation or its
fix: whether the local player is a
RuntimePhysicsStatespatial root, and if so whether a stale-cell landblock retirement (a player WASD-ing beyond the streaming radius from its last teleport, with no intervening teleport or inbound Position) can sweep the player intoParkCollisionResidents. The connected routes exercised so far all teleport between stops, which refreshes the cell and may be masking this. Establish this BEFORE deciding whether exact-cell tracking is even optional — if the player can already be swept today, that is a separate, more urgent bug independent of this issue's scope.
Do not implement without a fresh retail-conformance argument — this is a design call (which of the two cells is the source of truth for a client-authoritative parent), not a bug fix, per the #319 contract's §2 verdict.
#318 — C4 route 3 §8 items 8/9/10 residual: no end-to-end composition test, no local-player shadow assertion, no T8 ordering
Status: OPEN Severity: LOW (does not block round-3 acceptance per both reviewers; carried into C5) Filed: 2026-08-05, C4 route 3 round-3 review (retail B5/A5, architecture B5), carried per both reviewers' explicit conditions Component: Runtime / portal placement / local-player presentation
Description: The retail review's round-1 §3.4 premise — that
TryApplyRuntimePlacementPlace does not write pose/rotation/ParentCellId or
rebucket — was WRONG; round 3 verified it DOES. That closed the original
blocking concern, but three narrower gaps remain and both reviewers agreed
they must be tracked rather than silently dropped:
- No end-to-end composition test exercises the full portal-arrival →
canonical commit → presentation-suffix →
PhysicsEngine.ShadowObjectschain for the LOCAL player specifically (existing tests cover pieces — the canonical commit, the presentation sink'sTryApply, the drive controller — but not the full composed path with a realRuntimePlacementPresentationSinkwired to a realPhysicsEngine). - No test asserts the local-player collision SHADOW lands at the
destination. The discriminating assertion for that future test:
PhysicsEngine.ShadowObjectsmust hold a row at the destination cell/ position, not justLocalPlayerShadowState's internal dedup cache — see the register row (AP-131 amendment, filed alongside this issue) for the asymmetry this exposes:LocalPlayerShadowState.Setupdates the dedup cache without publishing toShadowObjects, self-healing only on the local player's first subsequent movement tick. - No test proves T8's ordering — that the canonical commit's writes
(pose/rotation/
ParentCellId/rebucket) precede the presentation suffix's OWN redundant writes to the same fields, rather than racing or reversing.
Root cause / status: Not a defect — a coverage gap. The underlying
mechanism (RuntimePlacementPresentationSink.TryApply →
LiveEntityRuntime.TryApplyRuntimePlacementProjection →
TryPublishPlace → LocalPlayerShadowState.Set) is correct by code reading
and by the individual unit tests that DO exist; what's missing is the
COMPOSED, end-to-end proof plus the specific shadow-registry assertion.
Files: src/AcDream.App/World/RuntimePlacementPresentationSink.cs
(TryPublishPlace, LocalPlayerShadowState.Set call); src/AcDream.App/Physics/LocalPlayerShadowState.cs;
src/AcDream.Core/Physics/PhysicsEngine.cs (ShadowObjects);
src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs
(ReconcileAndAcknowledgePortal, the T8 probe log).
Research: docs/research/2026-08-04-c4-route-3-contract.md §3.4;
docs/research/2026-08-04-c4-route-3-retail-review-round2.md §D (B5/A5);
docs/research/2026-08-04-c4-route-3-architecture-review-round2.md B5.
Acceptance: A composition test drives a real portal arrival through the
canonical drive controller and the real RuntimePlacementPresentationSink
against a real PhysicsEngine, then asserts PhysicsEngine.ShadowObjects
holds the local player at the destination position/cell (not merely
LocalPlayerShadowState's cache) and that the write ordering matches T8 (a
probe or log-order assertion). Do not score the existing connected/manual
gate as covering this — it exercises the live path but does not assert the
shadow registry specifically.
#317 — TryCommitAuthoritativeVelocity's call site has no established retail basis
Status: OPEN Severity: LOW (tracking only; no known observable defect) Filed: 2026-08-04, C4 route 5 (projectile authoritative placement) round-2 review, MINOR (c) Component: physics / remote velocity
Description: LiveEntityNetworkUpdateController.cs's 4a-family remote
velocity commit (_liveEntities.TryCommitAuthoritativeVelocity(...), call
site around line 2444) carried a comment claiming
"MoveOrTeleport installs that exact vector with set_velocity." A
byte-level Capstone disassembly of the PDB-paired retail binary
(CPhysicsObj::MoveOrTeleport @0x00516330-@0x00516438, every branch,
performed as C4 route 5's mandatory Step 1 hard gate) shows
MoveOrTeleport never reads its velocity argument's stack slot at all, and
UnpackPositionEvent performs no set_velocity either — the only
set_velocity call in the whole accepted-Position chain zeroes the LOCAL
player (@0x004541B4), which is a different call site entirely. The comment
at the 4a call site has been corrected in place to state this plainly, but
the call itself was left in production unchanged (out of C4 route 5's
scope — the route's contract governs RuntimeSetPositionOperationKind
placement dispatch, not the pre-existing remote velocity commit) and
nothing currently tracks auditing or removing it.
Root cause: unverified assumption inherited from an earlier port pass; never checked against the named retail decomp until C4 route 5's Step 1 gate incidentally required decoding the neighboring function.
Files: src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs
(call site + corrected comment, ~line 2420); LiveEntityRuntime's
TryCommitAuthoritativeVelocity (the method itself, unaudited).
Acceptance: A dedicated retail audit of the ENTIRE accepted-Position
velocity chain (not just MoveOrTeleport) — likely UpdateObjectInternal,
update_object, and whatever actually feeds the remote's Velocity field
retail-side — determines whether this call has a retail basis at all, and
either finds the correct source function to cite or removes the call as an
acdream-only adaptation with a divergence-register row.
C4 route 6 — drops and split-recovery closure — 2026-08-04
#313 filed from the route 6 closure session (zero production lines; evidence +
coverage tests only). #314 was filed from the same session and CLOSED the same
day by daef7c98 — it was found by those coverage tests, in the exact
mechanism route 6's scoping cited as evidence that drops already converge, and
was split out into its own commit rather than carried. #315 also filed there,
carried over from the route 4b-3 round-2 reviews. Evidence:
2026-08-04-c4-route-6-contract.md,
2026-08-04-c4-routes-6-7-scoping.md.
#313 — DeclareValid's SetSelectedObject split-recovery is not ported
Status: OPEN Severity: LOW (selection UX, not placement) Filed: 2026-08-04 Component: UI / inventory / selection
Description: Retail's ACCWeenieObject::DeclareValid @0x0058E340 reads
the split marker recorded by UIAttemptSplitTo3D @0x0058D850 /
UIAttemptSplitToContainer @0x0058D7D0 (three fields: splitStackSize,
splitClassID, splitTime) and, on a matching WCID + stack-size within the
10-second window, runs ACCWeenieObject::SetSelectedObject(this->id, 0)
@0x0058E481 — a SELECTION transfer to the newly-materialized split result,
not effect suppression and nothing placement-related (verified against
acclient_2013_pseudo_c.txt; see
docs/research/2026-08-04-c4-route-6-contract.md). acdream's
PendingSplitToWorldProjection
(src/AcDream.App/World/InventoryWorldDropProjectionController.cs)
implements the 10-second recognition window (RetailRecognitionSeconds) but
has no selection dependency at all — the split result never becomes the
selected object after a ground split, and the container-split flavor
(UIAttemptSplitToContainer's equivalent) records no marker at all.
Root cause: InventoryWorldDropProjectionController's constructor takes
interaction / objects / runtime / hydration / clock and nothing selection-
related; PendingSplitToWorldProjection.TryResolve never calls anything
resembling SetSelectedObject.
Files: src/AcDream.App/World/InventoryWorldDropProjectionController.cs.
Whatever owns "currently selected object" client-side (search for
selectedObjectId — ItemInteractionController already takes one as a
Func<uint>, so the write side needs a matching setter/owner).
Acceptance: Split a partial stack to the ground; the newly-created pile becomes the selected object (matching retail's post-split selection behavior), with a 10-second recognition window identical to the existing recovery window. Out of C4 scope — do not implement as part of a placement- focused change; this is selection UX and mixing it into a placement closure makes the landing un-reviewable (per the route 6 contract).
#314 — Split recovery throws instead of recovering when the source's retained Movement/ServerControlledMove timestamps are nonzero
Status: CLOSED 2026-08-04 by daef7c98 — BuildSpawn's Timestamps
with block now resets Movement / ServerControlledMove to 0 alongside the
top-level MovementSequence / ServerControlSequence zeroing that was already
there. Zero is the honest value (a fresh split GUID has no movement history by
construction), NOT a loosening of
HasConsistentCreateIdentityAndParent — the predicate was correct and the
producer was wrong. The repro test is renamed
SplitSourceWithRetainedMovementTimestamps_StillRecovers and asserts the
channels are zero in BOTH projections, so it cannot pass against a
lenient-predicate workaround. Sabotage-verified in both directions.
Status when filed: OPEN Severity: MEDIUM (can turn a normal split-to-ground into a client exception instead of a placed item) Filed: 2026-08-04 Component: physics / inventory / entity lifetime
Description: Discovered while writing C4 route 6's "split stack" / "new
GUID recovery" coverage tests
(tests/AcDream.App.Tests/World/LiveEntityHydrationControllerTests.cs,
SplitSourceWithRetainedMovementTimestamps_ThrowsInsteadOfRecovering).
PendingSplitToWorldProjection.BuildSpawn
(src/AcDream.App/World/InventoryWorldDropProjectionController.cs:171-209)
resets the top-level MovementSequence / ServerControlSequence to 0
(:201-202) when constructing the synthetic spawn for the new split-result
GUID, but its Physics.Timestamps override list only touches Position /
Teleport / ForcePosition / Instance (:182-188) — it does NOT reset
Physics.Timestamps.Movement / .ServerControlledMove to match. Those two
fields instead retain the SOURCE item's original values verbatim.
RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent
(src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:2321-2327)
requires the flattened top-level sequence fields to agree exactly with the
embedded PhysicsSpawnData.Timestamps — by design, since they are two
projections of the same wire packet
(RegisterEntityCore throws "CreateObject 0x{guid} has inconsistent instance or parent projections." at :748-749 when they disagree). Retail's
per-object update_times timestamp channels are monotonic counters that do
NOT reset when an item re-enters a container, so any split source that ever
received a Movement or ServerControlledMove wire update during an earlier
stint with world presence (e.g. dropped once before, picked back up, split
again) carries nonzero values in exactly the two fields BuildSpawn forgets
to reset. The split recovery then throws InvalidOperationException instead
of completing the canonical create-placement transaction, inside
InventoryWorldDropProjectionController.TryRecoverUnknownPosition — an
unhandled exception on the ordinary network/UI event path.
Root cause: Asymmetric field reset in BuildSpawn's two with
expressions — the top-level projection and the embedded PhysicsSpawnData
projection of the same synthetic spawn are constructed independently and
fell out of sync.
Fix shape (not applied — C4 route 6 is a zero-production-line closure by
contract): either also reset Timestamps.Movement / .ServerControlledMove
to 0 in BuildSpawn's Timestamps with { ... } block, or don't reset the
top-level MovementSequence / ServerControlSequence at all and let them
inherit the source's values instead (whichever direction is retail-correct
needs a decompiled cross-check of what UIAttemptSplitTo3D's resulting
CreateObject actually carries for these two channels — not established by
this filing).
Files: src/AcDream.App/World/InventoryWorldDropProjectionController.cs:182-188,200-207;
consumed by src/AcDream.Runtime/Entities/RuntimeEntityObjectLifetime.cs:2321-2327.
Acceptance: Split a stack of an item whose weenie has previously been dropped to the ground and picked back up (so its retained Movement / ServerControlledMove timestamp channels are nonzero) a second time; the split result places normally instead of throwing.
#315 — runTeleportHook builds a Func<bool> closure per network packet
Status: CLOSED 2026-08-04 by aaf0811f — the OnPosition collapse
converged the three RunRemoteArmTail call sites into one, which is what
made caching worthwhile. The one remaining call site now passes two
delegates cached ONCE at construction (RemoteArmCallbacks, a small nested
type wrapping Func<bool> IsCurrentPositionOwner /
Func<bool> RunTeleportHook) instead of allocating a fresh closure per
packet; per-packet scratch state (canonical, remote, positionRecord,
positionAuthorityVersion, expectedEntity) moved from closure captures to
plain instance fields RunRemoteArmTail stamps immediately before use.
Deliberately NOT two bare Func<bool> fields directly on
LiveEntityNetworkUpdateController:
tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs's
ProductionFrameAdaptersRetainTypedOwnersWithoutWindowCallbacks asserts
every typed production owner carries zero Delegate-typed fields (the
GameWindow decomposition campaign's guard against a smuggled window
callback); wrapping both delegates in RemoteArmCallbacks respects that
invariant instead of tripping it.
Severity: LOW (real allocation regression, not correctness; not on the
per-frame resolve path Slice I's 0 B/resolve discipline governs)
Filed: 2026-08-04
Component: physics / networking
Description: Carried over from the C4 route 4b-3 round-2 architecture
reviews (both said defer, but flagged that route 5 will add a fourth call
site once it lands). Three RunRemoteArmTail call sites currently in
src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs each build a
Func<bool> delegate per inbound packet to pass into
ApplyRemoteContactRouting. This is a real allocation regression versus the
3e002993 baseline, on the 5-10 Hz network packet path — not the per-frame
physics resolve path Slice I's zero-allocation discipline covers, so it did
not show up in that gate.
Root cause: ApplyRemoteContactRouting's public signature takes a
Func<bool> parameter, and existing tests inject lambdas into it directly —
changing the signature to a non-allocating shape (a struct callback, a
cached delegate, or an explicit two-phase call) touches test call sites
across the file, which is why both round-2 reviews deferred it rather than
fixing it inline.
Files: src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs —
RunRemoteArmTail call sites feeding ApplyRemoteContactRouting.
Acceptance: The three (four, once route 5 lands) RunRemoteArmTail call
sites do not allocate a fresh delegate per packet; existing
ApplyRemoteContactRouting tests continue to pass, updated for whatever
non-allocating shape replaces the Func<bool> parameter.
C4 route 4b-1 review — park lifecycle — 2026-08-04
#309 and #310 filed from the route 4b-1 dual-review round; #311 filed from
the delta-review round on the same route's remediation. Evidence:
2026-08-04-c4-route-4b-1-review-findings.md.
#309 — Cancelled lost-cell park re-shows the entity where retail would keep it hidden
Status: DEFERRED as an ACCEPTED DIVERGENCE — user decision 2026-08-06. The standing record is register row AP-136, which already carries the retail mechanism, the exact divergence, and the observable; this issue is no longer a planned fix and does not block the placement-cutover campaign or C5c.
Decision and its reasoning, so a successor does not silently re-litigate it:
the retail-faithful end state is a park that SURVIVES cancellation, and that
was implemented and reverted this round. Landing it costs (a) reversing a
deliberate shipped invariant — NewerPositionPickupAndParentEachCancelExactLostOperation
asserts that a newer Position cancels the park — and (b) GameRuntime
teardown convergence (stage 10), where surviving parks never converge on
shutdown. Weighed against an observable that requires a remote to teleport
into a non-resident landblock and then stop moving (ACE stops
broadcasting for a stationary entity; the ordinary 5–10 Hz case is
superseded within ~150 ms), the cost is not currently worth paying. Revisit
if the teardown-convergence work is done for another reason, or if the
observable is reported in ordinary play.
This deferral does NOT cancel the six-step connected check. That check
validates the SHIPPED rollback behaviour (#312 / AP-136's restorableOnCancel
path, which sits in SubmitPreparedPlacementCore — the shared core behind
every production placement), not the deferred fix. It still needs running with
ACDREAM_PROBE_PARK=1, and therefore must run before the C5c probe strip
retires that flag.
Prior status, retained for context: largely superseded by #312 (closed
b1f914d5, 2026-08-04). #312 made a cancelled park restore the presentation
half as well as the Runtime half, which is the behaviour #309's connected check
was written to probe. What remained genuinely open was the narrower
retail-faithfulness question: retail's GotoLostCell keeps a lost-cell object
HIDDEN until reenter_visibility fires on cell arrival, whereas acdream
re-shows it on the cancel. Re-scope before running; the original six-step gate is now partly
redundant.
Severity: MEDIUM
Filed: 2026-08-04
Component: physics / placement
Description: When a DeferredCell park is cancelled by the accepted-Position
merge, we now roll the withdrawal back (InWorld, object clock, canonical
residency) so the entity is no longer stranded invisible-and-intangible. But the
entity becomes visible immediately at the committed destination pose, without
collision, whereas retail keeps it hidden and re-shows it only when the cell
loads.
Root cause / status: Retail's lost-cell mechanism has no cancel at all.
CPhysicsObj::SetPositionInternal @0x00515BD0 commits the destination pose via
store_position @0x00515CE2 and registers the object with
CObjectMaint::GotoLostCell @0x00515CF2 (@0x00508210). That registration is
removed by exactly one thing — CObjectMaint::InitObjCell @0x00508260, which
drains the lost list on cell load and calls CPhysicsObj::reenter_visibility
@0x00508296 (@0x00516250), re-placing at the pose store_position committed.
An update that performs no SetPosition leaves the registration untouched.
So the retail-faithful end state is a park that survives cancellation. That
was implemented and reverted this round because it reverses a shipped, tested
invariant — NewerPositionPickupAndParentEachCancelExactLostOperation
(tests/AcDream.Runtime.Tests/Physics/RuntimeSetPositionStateTests.cs, helper
VerifyPositionChannelCancellation) asserts Assert.False(IsDeferred(record)),
i.e. a newer Position cancels the park — and because surviving parks broke
GameRuntime teardown convergence (stage 10). Re-deciding that invariant plus
teardown convergence is the blocker.
Residual in practice: a remote at 5-10 Hz is superseded within ~150 ms. The case that bites is a remote that teleports into a non-resident landblock and then stops moving, because ACE stops broadcasting for a stationary entity.
Also unrestored: the ShadowObjectRegistry.Suspend applied by
WithdrawCanonical. Un-suspending requires a real placement dispatch
(ReplacePositionRows), so the entity rejoins the collision broadphase on its
next placement rather than at cancel time.
Files: src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs —
ParkDeferred's restorableOnCancel, Forget, RestoreParkWithdrawal.
Acceptance: Park survives a superseding no-placement Position and wakes via
InitObjCell-equivalent collision-generation arrival, with teardown converging
and the newer-Position invariant deliberately re-decided.
Connected check required — this is NOT a no-behaviour-change slice. The
route 4b-1 contract's "no connected gate" line does not apply to the park fix.
restorableOnCancel: true is set in SubmitPreparedPlacementCore, the shared
core behind EVERY production placement, and the merge-time
restoreCancelledPark: true is on the accepted-Position path every remote and
the local player traverse. So shipped behaviour changes for any entity whose
placement parks — including the local player, whose route-2 ForcePosition
corrections now roll a cancelled quiescence park back instead of leaving the
character withdrawn for the rest of the session.
Proposed connected check (two clients, local ACE). Scope widened 2026-08-04 at
C4 route 4b-2 round 3: the slice made SubmitPreparedPlacementCore's two
collision-prefix QUIESCENCE parks restorable as well, which the original steps
(the plain unplaceable-destination park only) never exercised, and made the
LOCAL PLAYER traverse the same restore rather than only remotes.
Run the whole gate with ACDREAM_PROBE_PARK=1 (added 2026-08-04, round 4).
Steps 4 and 5 both need a ForcePosition to land INSIDE a transient
collision-prefix quiescence window, which the tester cannot synchronise with a
teleport or portal arrival — so without a signal a clean teleport and a
correctly-parked one look identical and those steps pass while broken. The flag
emits one [park] line per park (guid, cause, the caller's pre-snap
resultCell, the POST-snap restoreCell the rollback would use, eligible =
the caller's half, captured = the final decision) and one [park-restore]
line per rollback (residency = whether canonical residency was re-taken).
Nothing is emitted for an ordinary committing placement, so an empty log means
the window was never entered — retry the step; do not record a pass.
- Walk the observed character to a landblock boundary so a remote sits in a
landblock the observer has not streamed, forcing a
DeferredCellpark. - Confirm the remote no longer vanishes permanently — the pre-fix symptom was invisible AND intangible for the rest of the session.
- Confirm it appears at the SERVER-authoritative destination pose, not at a stale pre-park pose, and that it becomes collidable once the landblock publishes.
- Quiescing swept NEIGHBOUR (new). Stand/run within about a metre of a
landblock seam while the neighbouring landblock across that seam is being
retired or republished by streaming (recentre by travelling, then provoke a
server ForcePosition — a
/teleport-class correction or a portal arrival — at the seam). The sweep footprint reaches the quiescing neighbour, so the placement parks even though neither the source nor the destination is quiescing. Confirm the LOCAL PLAYER is not left frozen/invisible after the next server Position: it must stay in the world, keep simulating, and stay collidable. Performed only when the log shows BOTH[park] … cause=quiescence:0x<neighbour-prefix> … eligible=True captured=Trueand a later[park-restore] … residency=Truefor the same guid. This is the shape round 3 measured as reachable; the "quiescing source landblock" shape is NOT reachable through either accepted-Position caller on a first submit, because both commit the accepted wire cell torecord.FullCellIdbefore submitting. - Quiescing DESTINATION (new). Provoke a ForcePosition into a landblock
that is mid-retirement. The park is deliberately NOT restored here (AP-136's
reason applies exactly): the character is left withdrawn — out of world,
object clock suspended, not a spatial root.
Corrected 2026-08-04 (round 4). The earlier text asked the tester to
confirm the player "recovers on the next server Position rather than staying
withdrawn indefinitely". It does not recover on that packet, and
QuiescingDestinationPrefix_ForcePositionParkIsNotRestoredpins the opposite: route 2 dispatches only forForcePosition, so the ordinaryApplythat follows merges behind the drive and cancels the park WITHOUT restoring it. Recovery needs a later packet that actually runs a placement and commits — another accepted ForcePosition (correction, teleport, or portal arrival) once that landblock's quiescence has released. Confirm exactly that, and confirm streaming's retirement of that landblock still COMPLETES rather than stalling; the retirement is the thing the non-restorable park exists to protect. Performed only when the log shows[park] … cause=quiescence:0x<destination-prefix> … eligible=True captured=False(eligible-but-declined is the decision under test) and no[park-restore]line for that guid until the recovering ForcePosition. - Confirm the local player's own ordinary ForcePosition corrections (route 2) still land unchanged — that path shares the same cancel — including the case where the correction lands in a landblock that is NOT quiescing, which must be indistinguishable from pre-slice behaviour. Steps 1-5 are the user-visible acceptance for AP-136's residual.
#310 — Retained preparation retry stalls landblock retirement with no bound
Status: OPEN Severity: HIGH Filed: 2026-08-04 Component: physics / streaming
Description: An entity holding a retained preparation retry keeps its
landblock prefix in placement debt, so
TryAcquireCollisionPrefixMutationPermission refuses on every poll and the
landblock never retires. There is no bound and no timeout.
Root cause / status: HasOldPrefixPlacementDebt refuses permission while any
affected root holds an operation, so LandblockRetirementStage.Physics never
completes and the retirement coordinator simply retries forever.
TickLostCellDeadlines — the only expiry that could break the cycle — has no
production caller, so its deadline never fires. The only thing that clears the
debt is an inbound packet for that same entity, which is exactly what a
RetrySetupUnavailable on an asset that never loads does not produce.
Pre-existing and independent of route 4b-1; 4b-1 does not bound it, it only
avoids widening it by declining to retain operations for destinations it cannot
service. Pinned by
RuntimeCollisionPrefixQuiescenceTests.RetainedPreparationRetryStallsPrefixRetirementIndefinitely
(1,000 consecutive refusals), which also shows retiring the operation is what
releases the prefix.
Note this is also why ParkCollisionResidents's overlap throw is unreachable:
permission is refused before ParkCollisionResidents is ever entered.
Files: src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs —
HasOldPrefixPlacementDebt, TryAcquireCollisionPrefixMutationPermission,
TickLostCellDeadlines (uncalled).
Acceptance: A retained preparation retry cannot block a landblock retirement indefinitely — either the deadline is driven in production or the retirement can proceed past stale placement debt.
#311 — RetryPendingProjections allocates a fresh array on every non-empty call
Status: OPEN Severity: LOW (perf, not correctness) Filed: 2026-08-04 Component: physics / headless
Description: RuntimeSetPositionState.RetryPendingProjections (reached via
RuntimePlacementProjectionChannel.RetryPending →
RuntimePlacementProjectionSubscription.RetryPending) snapshots the entire
pending-projection dictionary into a fresh array on every call —
_pendingProjection.Values.ToArray(). C4 route 4b-1's N3 fix
(HeadlessSessionEventRoute.RetryPending, wired from HeadlessSessionHost.Tick)
now reaches this path every headless host tick instead of once per session —
new per-tick pressure K4's 30-session resource envelope was not measured with.
Root cause / status: The empty-FIFO case is closed —
RuntimePlacementProjectionSubscription.HasPendingReceipts (added alongside
this issue) lets a host early-out before ever reaching
RetryPendingProjections when nothing is outstanding, which is the
overwhelming common case in steady state. The non-empty case still
allocates: every call that DOES have an outstanding receipt pays a fresh
.ToArray() copy. Closing it needs a non-allocating rewrite of
RetryPendingProjections itself (e.g. a reusable scratch buffer, mirroring
the _driveScratch pattern RuntimeRemotePlacementDriveController.Advance
and RuntimeFirstEntryDriveController already use) — deferred rather than
attempted in the C4 route 4b-1 delta-review session that filed this, whose
task scope held RuntimeSetPositionState.cs off-limits for that session
(a concurrent, separately-owned change was landing in the same file).
Files: src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs —
RetryPendingProjections. Call site:
src/AcDream.Headless/Hosting/HeadlessSessionEventRoute.cs — RetryPending.
Acceptance: A headless tick with N>0 outstanding placement receipts does not allocate a new array per tick.
#312 — Cancelled park restored Runtime state but never the presentation half
Status: CLOSED — fixed b1f914d5, user-confirmed 2026-08-04.
Caveat recorded for whoever revisits this: the probe capture from the
accepting session showed 22 [park] lines (all cause=unplaceable) and zero
[park-restore] lines, i.e. no park was cancelled during it, so the
restoration path itself was not observed executing. The user judged the
behaviour correct and closed it. The mechanism is pinned by four tests with a
seven-revert discrimination table. If an invisible-remote report recurs, start
here and look for [park-restore] … presentation=True under
ACDREAM_PROBE_PARK=1 before assuming a new cause.
Severity: HIGH (a remote player permanently invisible in world and radar)
Filed: 2026-08-04
Component: physics / placement / presentation
Description: A remote player that recalled into the observer's location was absent from both the 3-D world and the radar while remaining fully simulated — physics ticking, equipment attached, chat and spellcasting visible. It never recovered: not on remote movement, not on the observer walking away and back. Intermittent (it did not reproduce on the next recall).
Root cause: ParkDeferred publishes a Withdraw receipt with two halves.
Runtime owns the canonical half (InWorld, transient bits, object clock,
residency, spatial root) and RestoreParkWithdrawal rolls it back on cancel.
The PRESENTATION half — the graphical bucket, IsSpatiallyProjected /
IsSpatiallyVisible, the projection-visibility sinks (which drive
EntitySpawnAdapter.SetPresentationResident, i.e. the WB draw registry), plugin
world state and events, the effect-pose registry, the local-player shadow — is
performed by the host sink, and its only mirror image was a later Place
receipt. The per-packet prologue RebucketLiveEntity masked the hole for a
MOVING remote; a remote that parks on its FINAL accepted Position and then goes
idle never gets another packet, because ACE stops broadcasting for a stationary
entity. That is the intermittency and the "never recovers".
Introduced by 7f1c1f5a (C4 route 4b-2), the first commit that lets an ordinary
remote UpdatePosition open a canonical SetPosition and therefore reach a
restorable park. H1 (a latched PhysicsStateFlags.Hidden) was refuted: the
failing entity's 71 [remote-slide-tick] lines come from the ordinary remote
Tick, not the hidden-only loop.
Fix: a new RuntimePlacementProjectionKind.WithdrawalRestored receipt,
published by RestoreParkWithdrawal on the one ordered placement stream exactly
when the entity ends the rollback canonically whole. It is acknowledge-only in
Runtime (the parked operation is already retired), and the host sink maps it to
the exact inverse of its own withdrawal. Routing the restore's SetFullCell
through CommitCanonicalCell was considered and rejected on measurement: the
CellCommitted -> RebucketLiveEntity recovery it would fire never touches
plugin world state, the world-event stream, or the effect-pose registry, and it
cannot fire at all on the shipped remote path, where the prologue rebucket has
already recommitted a non-zero FullCellId before the merge cancels the park.
Files: src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
(RuntimePlacementProjectionKind, PublishWithdrawalRestoration,
RestoreParkWithdrawal, AcknowledgeProjection);
src/AcDream.App/World/RuntimePlacementPresentationSink.cs
(TryApplyWithdrawalRestoration); src/AcDream.App/World/LiveEntityRuntime.cs
(TryApplyRuntimePlacementProjection, TryApplyRuntimePlacementPlace's
commitPose); src/AcDream.Headless/Hosting/HeadlessRuntimePlacementProjectionSink.cs.
Research:
2026-08-04-invisible-recalled-remote-diagnosis.md.
Register: AP-136 amended, AD-63 filed (selection is not re-established).
Acceptance: WithdrawalRestored_ReinstatesEveryPresentationRegistrationTheWithdrawalRemoved
(App.Tests) and CancellingWakeableParkPublishesTheWithdrawalRestorationReceipt
(Runtime.Tests) both fail with the fix reverted. Live gate, folded into #309's
ACDREAM_PROBE_PARK=1 two-client run: recall a remote into the observer, let it
STAND STILL, and confirm it renders and blips; [park-restore] must report
presentation=True for that guid.
Recent-regression cleanup — 2026-08-03
Plan: 2026-08-03-recent-regression-cleanup.md.
All three were introduced by the 2026-08-02/03 stabilization batch, found while
reconciling #281's 43 test failures.
- #282 — DONE (2026-08-03) — live entities now write
EffectCellId, contradicting its documented contract, and 12ParentCellIdwriters cannot keep it in sync.WorldEntity.EffectCellId(src/AcDream.Core/World/WorldEntity.cs:95-102) documents itself as existing ONLY for outdoor dat stabs, which keep a null render parent while retail still gives their physics object an outdoor landcell forCObjCell::IsInViewparticle gating — "live/interior entities normally useParentCellIdinstead."f24532adbegan setting it on live entities at rebucket and placement projection (LiveEntityRuntime.cs:855,1101,1238). BecauseEntityEffectPoseRegistry.UpdateRoot:163resolvesEffectCellId ?? ParentCellId, the field now WINS for live entities, while 12+ production sites still writeParentCellIdalone. Any of those that changes cell without a matching rebucket strands particles and lights on the entity's previous cell: effects vanish behind a wall the object no longer occupies, or draw through one. Retail has exactly ONE cell per object (CPhysicsObj::set_cell_id@0x0050f4f0,change_cell@0x00513390, read byShouldDrawParticles@0x0050fe60); the two-field split is our adaptation for the null-render-parent stab case. Fix shape: one owner writes the entity's visibility cell and the effects path reads that owner. Caught in miniature byLiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell. Landed 2026-08-03 (S2). The audit found 14 cell writers: only 3 rebucket (and so repairedEffectCellIdby accident), while 11 do not — including the hottest paths,RemotePhysicsUpdater:239,294andLiveEntityOrdinaryPhysicsUpdater:107(every physics tick from the snapshot) andLocalPlayerProjectionController:79(the local player every frame). So a moving entity updated its cell constantly whileEffectCellIdstayed frozen. The consumers also disagreed:EntityEffectPoseRegistrypreferredEffectCellId, whileWbDrawDispatcher.TryGetEntityCelland the remote spawn seed preferredParentCellId. Fix:WorldEntity.VisibilityCellId(ParentCellId ?? EffectCellId) is the single accessor every consumer resolves through;LiveEntityRuntime's three live-entityEffectCellIdwrites are removed, restoring the field to its documented stab/building-shell purpose (LandblockLoader:80,97,LandblockBuildFactory:408). Register row AP-133 records the adaptation and the exact way it can regress.Refresh_FollowsCurrentTopLevelRootAndCellis back to moving the entity byParentCellIdalone — its original pre-f24532adform — and passes. Complete Release solution: 10,836 passed / 4 skipped / 0 failed. User-accepted 2026-08-03 in a connected Release session with the retail UI (ACDREAM_RETAIL_UI=1): effects stay attached across cell boundaries and lit statics are unchanged. The session's log corroborates it — 9 completed world reveals, 58 reveal events allfailures=0, zero unhandled exceptions, and a graceful exit. - #283 — DONE (2026-08-03, proven unreachable) — Runtime's world frame and App's render origin rebase at
different moments during a teleport.
670f307cgave Runtime its own world frame (RuntimePhysicsState.ObserveLocalWorldFrame), which rebases the instant an accepted Position carriesTeleportAdvanced. App'sLiveWorldOriginStaterebases only whenStreamingOriginRecenterCoordinator.AdvanceobservesIsOriginRecenterRetirementComplete— many frames later, after the old window has fully retired. Between those two edges the two owners disagree by the source-to-destination landblock delta, so a remote Create converted by Runtime lands a multiple of 192 m from the geometry App is building. Same failure family as the zero-offset bug670f307cfixed, with a wrong origin instead of a missing one. NOT yet proven reachable live — the portal reveal gate may or may not exclude Create during that window, and #280 says other work does continue arriving through it. Two owners of one fact; the campaign answer is that Runtime owns the frame and App projects it. Route-3 adjacent. Resolved 2026-08-03 as UNREACHABLE, not restructured. The plan's first step was to prove or disprove reachability before moving ownership.ACDREAM_PROBE_WORLD_FRAME=1(PhysicsDiagnostics.ProbeWorldFrameEnabled) compared both owners atDatLiveEntityProjectionMaterializer's landblock→world conversion — the App-side counterpart ofTryGetWorldFrameOffset. A connected Release session recorded zero disagreements across 11 completed reveals and six destination landblocks (0x0904,0x1134,0x3032,0x8763,0xA9B4,0xF682) spanning ~45 km — a gap of even one frame would have printed an offset in the tens of thousands of metres. Cause:BeginOriginRecenterdetaches EVERY resident landblock before the new origin is adopted, which serializes the two rebases so no conversion can observe the gap. Ownership is therefore left alone (restructuring on a disproven hypothesis would have been churn). InsteadLiveWorldOriginState.EnsureAgreesWithRuntimeFrameis a permanent terminal invariant at that conversion, turning a silent 192 m-multiple misplacement into a loud failure if a future change ever reopens the window; six focused tests pin it, including the cross-world portal case. The probe flag now emits a verbose per-conversion agreement trace for future investigation. - #284 — DONE (2026-08-03) — a placement that cannot resolve parks forever with no
diagnostic. A first-entry placement whose world frame is absent returns
RetrySetupUnavailable(RuntimeSetPositionState.PrepareMover:1535-1543) and is re-Advanced every pump indefinitely. Nothing counts it, names its reason, or distinguishes "waiting for something that will arrive" from "waiting for something that never can". This is why #281's 43 failures presented as four unrelated symptoms across App and Runtime instead of one cause. NOT a timeout or grace period — the fix is observability plus fail-fast on genuinely unresolvable states, matching the committed-invariant exception pattern established in01f4791e. Doing this FIRST makes #282, #283, and every C4 route cheaper to diagnose and lets the connected gates fail on nonzero parked entries. Landed 2026-08-03 (S1).RetryWorldFrameUnavailablesplits the world-frame park from the Setup park, so a missing frame stops reporting itself as an asset problem; call sites now testIsRetryable()instead of one reason, so a future reason cannot be silently demoted to a rejection. The operation retains itsRuntimeSetPositionParkReason, andRuntimeSetPositionOwnershipSnapshotreportsParkedAwaitingSetupCollisionCount/ParkedAwaitingWorldFrameCount/ParkedPlacementCount.ObserveLocalPlayerCreaterecords the accepted local-player Create even when it carries no landblock, andThrowIfWorldFrameUnreachablemakes that contradiction terminal instead of an infinite silent retry. Deliberately NOT folded intoIsConverged: #277 documents a far Create legitimately parking for the whole session, so a parked entry at teardown is not automatically a defect. The counts are exposed for gate assertions at stable checkpoints; wiring them into the connected gates'report.jsonis carried with #277's service-window conversion, where "legitimately parked" becomes precisely definable. User-accepted 2026-08-03 alongside #282 in the same connected Release session: ordinary play is unaffected, and the new terminal invariant never fired — the log shows zeroworld frame is unreachablefailures and zero parked placements across 9 completed reveals.
C4 route 2 — ForcePosition placement cutover — 2026-08-03
Plan: 2026-08-03-c4-route-2-implementation-plan.md;
contract: 2026-08-03-c4-route-2-contract.md.
- #285 — DONE (2026-08-03) — a ForcePosition on the local player wrote two
independent stores from one packet, and the outbound ack left before any
canonical commit existed.
LocalForcePositionTransaction.Apply(App)/HeadlessSessionWorldProjection.BlipLocalPlayer(headless) drovePlayerMovementController.BlipPosition— a rawPhysicsBody.SnapToCellwith no transition, no collision, no contact-plane resolve, noFullCellId/PlacementCommitVersionadvance — while the generic tail (LiveEntityNetworkUpdateController.cs) independently wrote the render-facingWorldEntityfrom the same wire frame; the App/no-window ack (LocalPlayerOutboundController.SendImmediatePosition) fired immediately after the blip, before either write's result was known. Same divergence class as the remote-placement bug670f307cfixed. Fix:RuntimeAcceptedPositionDriveController(src/AcDream.Runtime/Session/RuntimeAcceptedPositionDriveController.cs) is the single Runtime-owned accepted-Position execution seam for a ForcePosition on an already-live local player: it drives the SAMERuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement+TryPrepareAndSubmitAuthoredPlacementtransaction every other placement uses (retailCPhysicsObj::SetPositionSimple@0x005162B0, flags0x1012, called fromSmartBox::BlipPlayer@0x00453940), reconciles the controller's render-lerp/cell state (PlayerMovementController.CommitCanonicalForcePositionFrame, replacing the deletedBlipPosition), and fires the ack strictly AFTER that commit — never before it.LocalForcePositionTransaction.csandHeadlessSessionWorldProjection.BlipLocalPlayerare deleted outright, not adapted; the generic render-tail write is skipped for the local player's ForcePosition (App now projects the committed result through the existingRuntimePlacementPresentationSink, the same seam every other placement already uses). Two named behaviour changes, both retail-exact per this session's verification: (1) the outboundAutonomousPositionack is now an OUTPUT of the committed route, not a step alongside it — retail'scmdinterp->SendPositionEvent()@0x00454091 runs afterSmartBox::BlipPlayer@0x00454074 returns, and the deleted transaction's trailingisCurrent()recheck (which could only suppress the continuation, not an ack that had already left) is now structurally impossible; (2) the constraint leash is NOT re-armed on this route — everyCPhysicsObj::ConstrainTocall inHandleReceivedPosition(@0x00454272/0x0045418A/0x004541EC) is on a branch the FORCE_POSITION early return (@0x0045409D) never reaches.BlipPosition's leash re-arm (added at #167 Slice P5, commit7719d25b) was an unbacked deviation for this exact branch — it was correct for retail's "Player, normal" branch that Slice P5 was modeling in general, butSmartBox::BlipPlayeris not on that branch. #167's own historical write-up (below) is superseded for itsBlipPositionhalf by this entry. New behaviour (retail fidelity gain, not a regression): the ForcePosition now runs retail's REALSetPositioncollision resolve — a placement sphere or authored Setup, not a bare teleport-shaped snap — so a corrected Z can differ from the wire's literal Z by the placement sphere's own settle. R7 review correction (2026-08-03): the FIRST implementation pass wrote this paragraph against a fixture bug, not retail behavior — its headless test fixture's dummy Setup sphere had its centre AT the origin (offset == radius), which lifted a settled origin a FULL 0.48 m radius above the floor and was asserted as if that were the retail-correct answer. Retail'sBlipPlayerhas never lifted the origin by a sphere radius. The real dat human Setup0x02000001's foot sphere is(0,0,0.475) r=.48(Ts46SphereListConformanceTests.cs:35-39), whose bottom sits atorigin + 0.475 − 0.48 = origin − 0.005— so a settled origin lands within 5 mm of the floor it rests on, not a sphere radius above it. The headless fixture now uses the dat-exact sphere and asserts the measuredZ = 50.005f(HeadlessSessionHostTests.cs,WorldProjectionIgnoresNormalEchoButBlipsForcePosition). Runtime tests:tests/AcDream.Runtime.Tests/Session/RuntimeAcceptedPositionDriveControllerTests.cs(classification/NotApplicable guards, ack-strictly-after-commit, ack exactly once, the displaced-authority Contention case, the DeferredCell park→wake→commit sequence, heading preservation, leash NOT re-armed, and — added in the fix round — re-issue-from-canonical when a subsequent accepted Position's merge-timeForgetcancels a watched DeferredCell park). R8 review correction (2026-08-03): the DeferredCell test's name and assertions in the FIRST pass claimed a single-ack-after-wake sequence the fixture does not exercise — this fixture's cross-landblock resolve measuresInContact=false(no subsequent physics tick sweeps the body onto the terrain in this bare-Runtime harness), so no ack fires after the wake at all today. The test is renamedDeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakesand no longer pins the current zero-ack count with an assertion (a pinningAssert.Emptywould fail — and read as a regression — the day Contact correctly starts flipping); it captures the ack count once and asserts only that furtherAdvance()pumps never change it. The park→wake→commit→single-ack sequence remains unverified pending a harness that drives a real physics tick to establish ground contact. Fix-round corrections (2026-08-03), both dual reviews having FAILed the first pass — see2026-08-03-c4-route-2-review-findings.mdfor the full R1-R9 list: (R1, HIGH) the DeferredCell park could not survive in production —RuntimeEntityObjectLifetime.TryApplyPositionForgets the entity's in-flight SetPosition on EVERY accepted Position (any disposition), so a park outliving one ACE broadcast (~100-200 ms) was cancelled before its collision generation could ever commit it, silently losing the correction forever.RuntimeAcceptedPositionDriveController.Advancenow detects the dead watch (IsPlacementCompletionTracked) and re-issues the SAME route from the entity's current canonical snapshot rather than leaking_pending. (R2, HIGH) headless lostBlipLocalPlayer's collision- neighborhood re-centering; restored via the newIRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition. (R3, HIGH) the headless login-window ForcePosition fallback was dropped; restored. (R4, MEDIUM-HIGH) the force-ack was stealing aPlacereceipt the presentation sink had legitimately declined for its own retry contract; removed. (R5, MEDIUM-HIGH) corrected theRejectedstatus doc's false "no SetPosition ran" claim. (R6, MEDIUM)_pendingcould leak forever on a mid-session cancellation (now caught by the same R1 detection) andSubmitAndResolvecould silently overwrite a still-live pending (now guarded, throws on the invariant violation). (R9, LOW hygiene) a staleBlipPositiondoc cref,HeadlessSessionHost._currentSessionnever cleared on teardown, and the streaming observer/pose-dirty side effects firing for aRejected/Contentionstatus the route explicitly declined to place into. Complete Release solution after the fix round: 10,853 passed / 4 skipped / 0 failed (10,857 total) — App 4,058/3, Bake 15/0, Cli 4/0, Content 124/0, Core.Net 762/0, Core 4,247/1, Headless 79/0, Runtime 1,021/0, UI.Abstractions 543/0. Round 2 fix (2026-08-03), both delta reviews having FAILed the fix round — see the "ROUND 2" section of2026-08-03-c4-route-2-review-findings.md: round 1 bolted the re-issue onto ad-hoc per-branch_pendingbookkeeping, which had no single owner and no single lifecycle rule; that is the shared root cause of all three round-2 blockers. Replaced with ONE funnel:RuntimeAcceptedPositionDriveController.SettlePendingis the sole terminal writer of_pending,RetainPendingthe sole outstanding writer, andAbandonPendingthe sole teardown writer. Its ONE decision input is the terminal operation token'sPositionAuthorityVersion(RuntimeSetPositionState.cs:50) versus the live record's current value — equal ⇒ clear, no re-issue (fixes N1: one server correction now produces exactly one canonical placement and exactly one outboundAutonomousPosition, never two); advanced with the newest accepted event still a ForcePosition ⇒ re-issue it, re-classified from the current record via the newly recorded_newestForceobservation (fixes B1: a correction blocked by a woken park's retained completion is no longer lost); advanced with the newest accepted event now an ordinaryApply⇒ clear, no re-issue (fixes N2: the round-1 shape reused the stale force route and would have appliedTeleport|Slide+ an ack retail never sends to an ordinary pose, skipping that branch'sConstrainTo). Complete Release solution after round 2: 10,856 passed / 4 skipped / 0 failed — App 4,058/3, Bake 15/0, Cli 4/0, Content 124/0, Core.Net 762/0, Core 4,247/1, Headless 79/0, Runtime 1,024/0, UI.Abstractions 543/0. Round 3 closed the conformance reviewer's one remaining blocker: a terminal outcome that never committed now sends the packet's retail position event carrying the body's unchanged pose, becauseSmartBox::BlipPlayer@0x00453940 DISCARDSCPhysicsObj::SetPositionSimple's @0x005162B0enum SetPositionError(other retail callers test it,== OK_SPE@0x0055605D) and returnsvoid, after whichSmartBox::HandleReceivedPosition@0x00453FD0 runscmdinterp->SendPositionEvent()@0x00454091 unconditionally and returns @0x0045409D. Retail's rule is: attempt once, do not move on failure, acknowledge regardless, never retry — so route 2 acks exactly once per begun placement, from the commit path or from the settle, never both and never zero. Divergence register: AD-62 filed (round 2, rewritten round 3) — the deferred-placement adaptation means a ForcePosition that cannot commit when it arrives, and is then retired without commit, is not re-applied. As of round 3 its position-event ack IS still sent whenever the placement was begun AND that packet's descriptor reaches its own terminal settle. The ack is lost in two narrower groups: where no placement was ever begun (an externally-blockedContention; a re-issue marker that never begins), and — begun but displaced — where a newer force supersedes the packet before its settle, sinceSettlePendingnulls_pendingwithout reading it (AD-62 shape (v)). Replaying that one would emit a stale-sequence report carrying the newer packet's pose; the displacing packet always acks. Retail'sSmartBox::BlipPlayeris synchronous against a fully resident world and reaches none of these states. The route-2 cutover ITSELF still adds no deviation (it closes the duplicate-authority + premature-ack bug); AP-131 is explicitly NOT retired here (its named legacyTryApplyPositioncaller is route 4's job, not route 2's). - #286 — OPEN — headless never calls
RetryPendingon its placement projection subscription.RuntimePlacementProjectionSubscription.RetryPendinghas exactly one caller in the tree,GraphicalSessionEventRoute.cs:109,113;HeadlessSessionEventRouteconstructs the subscription (HeadlessSessionEventRoute.cs:22) but nothing pumps its retry. C4 route 2's R4 fix deliberately stopped the force-ack from consuming aPlacethe sink declined, on the contract that the subscription's OWN retry re-offers it — so on headless a declinedPlacewould sit at the FIFO head and wedge the ordered stream. Latent: not proven reachable today (the headless sink's decline conditions may be unreachable given its bounded collision window). Fix shape: give the headless host the same per-tick retry pump the graphical route has, or prove the decline unreachable and record why. Filed from the C4 route-2 round-2 review (N3). - #287 — OPEN —
RuntimeAcceptedPositionDriveController.Advancehas no reentrancy latch. Its templateRuntimeFirstEntryDriveController.DriveAllguards with_driving(RuntimeFirstEntryDriveController.cs:61,130,132,146); the accepted-position drive does not, even thoughAdvancecan now re-enterSubmitAndResolvethrough theSettlePendingre-issue. No live re-entrant path exists today (the funnel's recursion is bounded at one level and every host pumpsAdvancefrom a single synchronous cadence point). Hygiene, not a live defect. Filed from the C4 route-2 round-2 review (N4). - #288 — OPEN — two side effects were dropped when
HeadlessSessionWorldProjection.BlipLocalPlayerbecameCenterOnAcceptedForcePosition. (a) The deleted method also restoredcontroller.LocalEntityId = record.LocalEntityId ?? 0u; the replacement (HeadlessSessionWorldProjection.cs,CenterOnAcceptedForcePosition) does not. Inert today — nothing clears the id between publication and a ForcePosition — but it is an unreplaced deletion, not a decision. (b)_movementTruthDiagnostics.OnServerEchono longer fires for a local ForcePosition on the graphical host, because that route returns before the generic tail (LiveEntityNetworkUpdateController.cs). Diagnostic-only. Filed from the C4 route-2 round-2 review (N5). - #289 — OPEN — two doc comments still cite the deleted
PlayerMovementController.BlipPosition.src/AcDream.Core/Physics/Motion/ConstraintManager.cs:25andsrc/AcDream.Core/Physics/PhysicsBody.cs:442both name it inside<c>tags, so they are build-safe (unlike a<see cref="..."/>, which R9 already fixed) but false: C4 route 2 deleted the member. Left as-is they become the citation a future session trusts. Filed from the C4 route-2 round-2 review (R9 residue). - #290 — OPEN — route 1's classified
SendPositionImmediatelynever fires an ack. The C4 plan required confirming whether the outbound position ack fires while an initial-Create residence owns the record. It does not:RuntimeInitialCreateContinuationExecutorconsumesSendPositionImmediatelyonly as a trace fact (:717,:2576), never as an outbound send. Not a regression (route 1 predates route 2 and behaves exactly as before), but retail's FORCE_POSITION branch acks unconditionally afterBlipPlayer, so a ForcePosition admitted during the login residence window is one retail ack we do not send. Decide at route 4/C5 whether the residence tail should send it. Filed because the plan required filing it. - #291 — OPEN — the headless 3x3 collision window needs a divergence
register row. C4 route 2's R2 fix promoted it to a NAMED member of the
Runtime-facing contract (
IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition) with an explicit ordering requirement — re-center BEFORE the placement submits — that retail has no analogue for (retail has every landblock resident). No existing row covers it: AD-6 is retired and AD-2 is the graphical reveal barrier. Recommend one AD row naming the window, the ordering requirement, and the symptom if it breaks (aDeferredCellpark the window can never publish). Filed from the C4 route-2 round-2 review. - #292 — OPEN — C4 route 2 acceptance item 2 is source-pinned, not
proven. Recorded gap from round-2 finding B2 (the plan's own text is
corrected at
2026-08-02-placement-cutover.md). First half — "the generic tail no longer double-writes the local player" — is pinned only by a source-text regex, which a differently spelled second write would pass, and no test exercises the branch. Second half — "the committed projection is what moves the render entity" — is uncovered at any layer: no test drives a route-2 ForcePosition throughRuntimePlacementPresentationSink/TryApplyRuntimePlacementPlaceand asserts theWorldEntitymoved. Given R4, that is exactly the seam whose failure is silent (canonical body moves, render entity stays). Fix shape: an App-layer end-to-end test asserting the render entity's position/cell came from the committed placement receipt. - #293 — OPEN — the DeferredCell park still consumes
Withdrawreceipts the sink may have declined.RuntimeAcceptedPositionDriveController.SubmitAndResolve'sDeferredCellbranch drains the head of the placement FIFO while it is aWithdrawfor this entity and callsAcknowledgeProjectionon it (RuntimeAcceptedPositionDriveController.cs:709-716). That is the exact receipt-stealing shape R4 removed forPlace: the R4 fix's whole argument is thatRuntimePlacementProjectionSubscriptiondeliberately leaves a receipt the sink declined at the FIFO head for its own later retry, and that this route has no follow-up binding to cover it. TheWithdrawdrain was left unchanged in round 1 because it is copied verbatim fromRuntimeFirstEntryDriveController.TryCompleteContinuationPlacement, but the same asymmetry applies — that controller's residence guarantees the decline, this one's does not. Fix shape: decide whether a declinedWithdrawis reachable for this route and either drop the drain (letting the subscription's retry own it, asPlacenow does) or record why the decline cannot happen here. Filed from the C4 route-2 round-3 adversarial review. - #294 — OPEN — the deferred wake reconciles and acks BEFORE the funnel's
currency guard.
RuntimeAcceptedPositionDriveController.Advanceconsumes the acknowledged placement and immediately callsReconcileAndAcknowledge(:452), and only then entersSettlePending, which is where the entity-still-active / still-the-local-player / still-the-same-incarnation checks live (TryGetActive,ServerGuid,PhysicsBody,key != terminalToken.Entity). So a wake that lands after the entity departed the world, stopped being the local player, or released its incarnation still runs the controller-local reconcile and can still send an outboundAutonomousPosition.ReconcileAndAcknowledge's ownrecord.ServerGuid != _localPlayerServerGuid()test uses the RETAINED record, not a re-resolved active one, so it does not cover the departed case. Ordering, not a workaround: the currency guard belongs before the reconcile. Filed from the C4 route-2 round-3 adversarial review. - #295 — OPEN — the re-issue retry marker inflates
AcceptedPositionDrivePendingCount. WhenSettlePending's re-issue cannot begin, it parks the terminal descriptor as a retry marker in_pending. That marker is not an in-flight placement — its token is dead by construction — butPendingCount(:259) and the ownership ledger registered at:255-256both report it as one. Any convergence reader (CaptureOwnership,GameWindowLifetime.DisposeGameRuntime's non-convergence throw) therefore sees a placement that does not exist, and a marker that outlives its usefulness reads as a wedged operation rather than as "a re-issue is owed". Fix shape: count in-flight placements and owed re-issues separately, or give the marker its own field. Filed from the C4 route-2 round-3 adversarial review. - #296 — OPEN — a retryable prepare is reported to hosts as
Contention.SubmitAndResolvereturnsRuntimeAcceptedPositionExecutionStatus.Contentionfor a retryable preparation status (RetrySetupUnavailable/RetryWorldFrameUnavailable,:661), reusing the status whose documented meaning is "begin failed; the entity already owns an operation". The two are materially different: the retryable case DID begin, IS retained in_pending, and WILL be re-driven by the nextAdvancepump, while trueContentionmay have recorded nothing at all (register row AD-62 shape (iv)). Hosts cannot distinguish them —LiveEntityNetworkUpdateControllerbranches on the status — and neither can a future reader of the enum doc. Fix shape: a distinct status (or a documented union) so the retained-and-pumping case is not conflated with the dropped case. Filed from the C4 route-2 round-3 adversarial review.
PK Lite gaps exposed by @pklite — 2026-08-03
All three found live by the user minutes after 69ba9486 made PK Lite
reachable for the first time. None is a C4 route 2 regression — verified by
diff: 9966b531 touched none of CollisionExemption, CombatTargetPolicy,
SelectedObjectHealthPolicy, EntityCollisionFlags, ObjectTableWiring or
CreateObject, and all three gates predate it (3361a8d7, 2644d1d5,
0f2d98c5). @pklite made pre-existing behaviour reachable; it did not create
it. Do #297 FIRST — #298 depends on it.
-
#297 — DONE
9b1e6fc6(2026-08-03), USER-ACCEPTED live 2026-08-03 — PublicWeenieBitfield is frozen at CreateObject, so a PK status change never reaches the client. HIGH. User symptom: after@pklite, the local player walks straight through other PKLite players. ACE's only PK-change message isGameMessagePublicUpdatePropertyInt(0x02CE) carryingPropertyInt.PlayerKillerStatus(134) =PKLite(0x40) (Player.cs:1153->Player_Properties.cs:1122-1132->WorldObject_Networking.cs:1413-1442). The PWDObjectDescriptionFlagbits are recomputed only inside serialization, andEnqueueBroadcastUpdateObject(WorldObject.cs:662-665) has zero live callers — so no PWD re-send ever happens and a client CANNOT learn PK status from the bitfield after login. We parse and store the property (ObjectTableWiring.cs:41-47, landing inProperties.Ints[134]) but never translate it:ClientObject.PublicWeenieBitfieldhas exactly one writer (ClientObjectTable.cs:891) fed solely from the 0xF745 CreateObject parse (CreateObject.cs:827). Both sides of the collision test then read that frozen value — mover viaEntityCollisionFlags.cs:133-139/LiveSessionEventRouter.cs:419-442, target viaLiveEntityCollisionBuilder.cs:151-153— soCollisionExemption.cs:117-122("4c. both PKLite -> collide") never fires and:125exempts. Retail is the exact port we are missing:ACCWeenieObject::OnStatUpdated@0x0058DF20case 0x86:callsPublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0, which rewritespwd._bitfieldin place — PK|0x20, PKLite|0x2000000, Free|0x200000, mutually exclusive, else clear all three.IsPKLite@0x0058C8A0 reads(_bitfield >> 0x19) & 1. Entry points areHandle_Qualities__PrivateUpdateInt@0x00558FD0 (0x02CD, self) andHandle_Qualities__UpdateInt@0x00558D60 (0x02CE, remote). Fix shape: portSetPlayerKillerStatusas a bitfield rewrite on PropertyInt 134, applied from BOTH routes.RecomputePvpStatusalready reacts toObjectUpdatedso the mover side follows for free; the TARGET side needs a second edge because shadow-registryEntityCollisionFlagsare frozen at registration. Cheap falsification test: a relog fixes it for the local player only, since a fresh CreateObject carries the real bit. Unaudited: whether making the bitfield mutable disturbs its item-shaped readers (ToolbarController.cs:633,ItemInteractionController.cs:1272,AppraisalUiController.cs:543,ItemAppraisalTextFormatter.cs:949,:1110). Note the vivid target indicator (WorldSelectionQuery.cs:271-298) reads the spawn PWD bits directly and is stale by the same mechanism. -
#298 — DONE
bc0077a5(2026-08-03), USER-ACCEPTED live 2026-08-03 ("melee and bow works, all good") — melee/missile attack admission excludes players by construction. MEDIUM-HIGH. Blocked on #297. User symptom: selecting a PKLite player and attacking retargets to the nearest monster (auto-target on) or does nothing (auto-target off, loggingcombat: attack ignored; no creature target found,LiveCombatAttackOperations.cs:187).CombatTargetPolicy.IsHostileMonster:31-33rejects any candidate carryingSelectedObjectHealthPolicy.BfPlayerbefore reachingObjectIsAttackable, so the PKLite pool match atSelectedObjectHealthPolicy.cs:70-71is unreachable for player targets. Retail has ONE predicate for monsters and players, with no player exclusion:ClientCombatSystem::ExecuteAttack@0x0056BB70 gates unconditionally onObjectIsAttackable@0x0056A600, which checks creature type, the0x200000bits, thenIsPlayer(): (bothPK) || (bothPKLite), elseBF_ATTACKABLEwith pets excluded. We already have that predicate ported verbatim and correctly atSelectedObjectHealthPolicy.cs:41-78— it is simply unreachable. DO NOT fix by relaxingIsHostileMonster's automatic-acquisition scope.IsHostileMonsteralso backs auto-target ACQUISITION (CombatAttackTargetSource.cs:80,WorldSelectionQuery.cs:280), and relaxing it would violate register row IA-19, explicit product direction that auto-target must never select NPCs, players or pets. Retail's own auto-target DOES admit players (@0x0056C040 pc:377318-377327), so retail and IA-19 genuinely disagree here — for acquisition only. The combat camera is NOT an IA-19 concern, despite an earlier draft of this note claiming otherwise. RetailClientCombatSystem:: UpdateTargetTracking@0x0056A950 (pc:375691-375696) gatesCameraSet::TrackTargeton the SAMEObjectIsAttackablepredicate asExecuteAttack@0x0056BB98, not the narrow monster-only policy. The camera performs no acquisition of its own — it only tracks whatever the player already selected — soWorldSelectionQuery.GetCombatCameraTargetPointmust route through the wide predicate exactly like explicit-target admission. (Landed:GetCombatCameraTargetPointnow callsIsAttackableTarget.) Fix shape: SPLIT explicit-target admission AND the combat camera (->ObjectIsAttackable/IsAttackableTarget, retail-exact) from auto-acquisition (-> keepIsHostileMonster, IA-19 intact). IA-19's own text already promises "manual player-selection commands remain available"; that promise was unimplemented before this fix. Not affected: the health bar (SelectedObjectHealthPolicy.cs:32already admitsBfPlayer) and the vivid target indicator. Correct model to copy: spells already work on PKLite players becauseRetailSpellTargetPolicy.cs:40-46treatsBF_PLAYERas an ACCEPT and never callsObjectIsAttackable— the client checks target-TYPE compatibility and lets the server arbitrate PK legality (retailClientMagicSystem::ObjectCompatibleWithSpellTargetType@0x00567230). -
#299 — DONE
88348f67(2026-08-03) — CollisionExemption misses retail's mover-side IsImpenetrable branch, and its doc comment asserts the opposite. LOW.CollisionExemption.cs:103checks only the TARGET'sIsImpenetrable, and the class doc at:33-39claims "retail's pseudo-C only checks the target'sIsImpenetrable(); acdream follows retail." The pseudo-C at pc:276824-276827 has TWO short-circuit branches — moverstate & IS_IMPENETRABLE (0x80)OR targetIsImpenetrable()— either alone exempting. We are missing the mover branch, and the comment blames ACE (PhysicsObj.cs:403-405) for an addition that is actually retail-faithful. Found during the #297/#298 investigation; not symptom-causing. Fix the code and the comment together.
Follow-ups from the #297 fix and its review — 2026-08-03
-
#300 — OPEN —
Properties.Ints[134]andPublicWeenieBitfieldcan disagree inside oneClientObject. LOW.ClientObjectTable.UpdateIntProperty:792-796is the only entry point that mirrors PropertyInt 134 (PlayerKillerStatus) into the PWD bitfield.UpsertProperties:750-767(PlayerDescription 0x0013) andUpdateProperties:728-741(IdentifyObjectResponse) writeProperties.Ints[134]without the mirror. An assess/appraisal bundle on a player carrying PlayerKillerStatus would leave the raw int saying PKLite while the bitfield still reads NPK — andLiveSessionEventRouter.RecomputePvpStatus:426-429reads the raw int for the jump-stamina PK timer while everything else reads the bitfield, so one row would drive two different answers. Benign today (CreateObject's PWD is authoritative at login and ACE's assess bundles for players are unlikely to carry 134) and it does not affect the #297 collision path. Fix shape: a shared mirror helper called from all three appliers. Filed from the #297 delta review; see register row AP-134. -
#301 — OPEN — retail's OnStatUpdated also rewrites radar blip colour and radar behaviour; acdream ignores both. LOW.
ACCWeenieObject::OnStatUpdated@0x0058DF20 rewritespwd._blipColoroncase 0x5f(95 = RadarBlipColor) andpwd._radar_enumoncase 0x85(133 = RadarBehavior), verified atacclient_2013_pseudo_c.txt:408381-408391. acdream handles neither, andRadarSnapshotProvider.cs:85,134reads the frozen CreateObject spawn — so a server-side radar-appearance change never reaches the radar. This is #297 for the radar, same defect class, same fix shape (mirror the property into the bitfield/snapshot at its source). Filed from the #297 delta review. -
#336 — OPEN —
RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocateis a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381assertsGC.GetAllocatedBytesForCurrentThread()is EXACTLY 0 across a warmed 10,000-iteration steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solutiondotnet test AcDream.slnx -c Release -m:1run (measured 2,944 bytes), then passing on the immediate full-suite retry, on both of two isolatedAcDream.Runtime.Testsproject runs, and on a filtered single-test run. Filed rather than absorbed, and deliberately NOT conflated with the other three. It shares #302's MECHANISM (an exactGC.GetAllocatedBytesForCurrentThread()assertion, sensitive to JIT tiering and background GC on the measuring thread) but it is a different test in a different assembly — #302 isAcDream.App.Tests, this isAcDream.Runtime.Tests— so “the known allocation flake” would hide whichever of the two is real on any given run. #308 is a wall-clock deadline inAcDream.Core.Net.Tests; #321 is a concurrent-decode dedup in the sound cache. Four mechanisms, four rows. Not caused by #334's cell-membership port, which is the change in flight when it was seen: the measured loop calls onlyRuntimeCollisionReportingStatehandling, registers no shadow inside the measurement, and touches none ofCellTransit/ShadowObjectRegistry/ShadowShape. Fix shape, same as #302: warm the path before measuring, or assert a bounded range rather than an exact zero — matching how the other allocation gates in the repo are written. Do not delete the assertion; the 0 B/resolve budget it guards is a real Slice I1 invariant. -
#302 — OPEN —
PortalProjectionTests.ClipToRegion_FrameOwnedStore_ReusesExactResultArrayis flaky. LOW. Measured 1 failure in 6 consecutive isolated runs ofAcDream.App.Testsat88348f67, and once in a full-suite run that passed on two immediate retries. The test asserts onGC.GetAllocatedBytesForCurrentThread()(tests/AcDream.App.Tests/Rendering/PortalProjectionTests.cs:532), which is sensitive to JIT tiering and background GC regardless of the code under test. Unrelated to the PK/collision work it surfaced during. Do not treat a green suite as proof this is gone — it passes ~5 times in 6. Fix shape: warm the path before measuring, or assert a bounded range rather than an exact allocation count, matching how the other allocation gates in the repo are written. Found while independently verifying the #297 gate. -
#308 — OPEN —
NakEmissionTests.LossSoak_TwoPercentBidirectional_ZeroMessageLoss_LedgersConvergeis a SECOND, load-sensitive flake — distinct from #302. LOW.tests/AcDream.Core.Net.Tests/Transport/— a wall-clock-driven randomized packet-loss soak with aDateTime.UtcNow < deadlineloop. Observed failing twice on 2026-08-03/04, both times only inside a full-solution run, and 0 failures in 4 consecutive isolated runs ofAcDream.Core.Net.Testsalone. That profile points at CPU contention starving the deadline loop under the full suite, not at transport logic. Filed because it was twice misattributed to #302 before being written down. They are different tests in different assemblies with different mechanisms: #302 is aGC.GetAllocatedBytesForCurrentThread()assertion inAcDream.App.Testssensitive to JIT tiering; this one is a wall-clock deadline inAcDream.Core.Net.Testssensitive to machine load. Conflating them hides one of the two, and an agent instructed to "ignore the known flake" will wave through a real transport regression. Fix shape: drive the soak from a virtual/injected clock or an iteration count rather than wall-clock, matching how the deterministic transport suites are written. Do not simply widen the deadline — that hides load regressions instead of removing the dependency. Note Campaign N's transport work is the SSOT here; readclaude-memory/project_network_transport_digest.mdbefore touching it. -
#303 — OPEN —
LiveEntityPvpBitfieldSynclives in App but touches only Runtime-owned state. INFO/shape.src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.csreadsRuntimeEntityObjectLifetime.Objectsand writesRuntimePhysicsState.Engine.ShadowObjects— both Runtime-owned since J5.5. Moving it besideRuntimeEntityPvpBitfieldSnapshotSyncwould leave one subscriber and one owner. No coverage gap today (headless has noLiveEntityCollisionBuilderand therefore no live-entity target shadows), so this is shape rather than a defect. Filed from the #297 delta review.
Follow-ups from the #298 fix and its review — 2026-08-03
-
#304 — OPEN —
SelectionInteractionController.GetSelectedOrClosestCombatTargethas no production caller. LOW/shape. Grep confirms only tests reach it (GetSelectedOrClosestCombatTarget:114-121); noGameplayInputActionRouteror other App wiring calls it. The #298 fix widened it correctly (explicit selection now checksIWorldSelectionQuery.IsAttackableTargetinstead ofIsHostileMonster), matchingCombatAttackTargetSource's live path defensively, but it currently exists only to keep the (also unused-in- production)IsAttackableTargetmember exercised by fourIWorldSelectionQueryfakes. Fix shape: delete the dead method (and, if nothing else callsIsAttackableTargetthrough this interface after that, the interface member and its fake stubs too) — or find the caller that was supposed to exist and wire it. Filed from the #298 review. -
#305 — OPEN —
HeadlessGameplayOperations.GetSelectedOrClosestTargethas the same player-exclusion bug #298 fixed for graphical hosts. MEDIUM.src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:235-244checks explicit selection viaRuntimeHostileTargetQuery.IsHostile(src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs:73-101), which is the monster-onlyCombatTargetPolicy.IsHostileMonstergate — structurally identical to the bug #298 fixed inCombatAttackTargetSource/SelectionInteractionController. A headless bot that explicitly selects a compatible-PK player and attacks will fall through toSelectClosestTarget()(sinceAutoTargetis hardcodedtrueat:128) instead of attacking the selected player. Pre-existing (not introduced by #298 — confirmed by the same9966b531/3361a8d7/2644d1d5/0f2d98c5diff boundary #297 used), but the graphical/headless behavioral divergence is new as of the #298 commit, and Slice K makes headless a first-class host, so the gap is now live for bot PvP. Fix shape: same split as #298 — add anObjectIsAttackable-backed explicit-admission query toRuntimeHostileTargetQuery(or a sibling) and routeGetSelectedOrClosestTarget's explicit branch through it, leavingFindClosest/auto-acquisition on the narrow policy. Filed from the #298 review.
WeenieError message-mapping coverage — 2026-08-03
-
#306 — OPEN —
WeenieErrorMessagesshould map every code retail'sHandleFailureEventswitch displays, not accumulate a bigger partial table. MEDIUM–LARGE (string-table port + two structural gaps). Filed after fixing #504 (YouAreNonPKAgain, "WeenieError 0x0504" shown on a PK Lite status reversion) by hand-recovering four strings; that session also mapped the retail switch's true shape, which changes the scope of "finish this" considerably from what a quick read suggests.The goal is complete coverage, not a bigger partial map. Today
src/AcDream.Core/Chat/WeenieErrorMessages.csmaps 60 of 378WeenieErrorenum values (src/AcDream.Core/Physics/WeenieError.cs); the rawWeenieError 0xNNNN[: param]fallback is the norm for any code a player actually triggers outside chat/channel/tell/allegiance chatter. Target state:WeenieErrorMessageshandles every code retail's switch handles, and the raw fallback is a genuine last resort for codes retail itself never displays — not a "we haven't gotten to it yet" placeholder.Authoritative source + extraction method. The complete display switch is
ClientCommunicationSystem::HandleFailureEvent@0x00571990, decompiled atdocs/research/named-retail/acclient_2013_pseudo_c.txt:382616+. Each case constructs a literal UTF-16 string and callsClientSystem::AddTextToScroll. The pseudo-C dump truncates long string literals at their declared array bound mid-sentence (e.g.data_7d32c0's declaredwchar16 const [0x5f]stops at "...protection of the Lig", but the real null-terminated string in the binary is 139 chars, not 95). The exact text must be recovered from the PDB-paired binary atC:\Users\erikn\Downloads\acclient.exe(imagebase 0x400000, verify pairing withtools/pdb-extract/check_exe_pdb.pyfirst) via VA → RVA → file-offset mapping — the technique is written up inclaude-memory/reference_pe_byte_decode.md. Guessing a string ending is not acceptable; an unrecoverable string must stay unmapped rather than be approximated — a wrong message is worse than a raw hex code, because it reads as authoritative when it isn't. (Some strings are NOT truncated — the dump appends, 0right after the closing quote when the declared array bound exactly equals string length + 1; those can be transcribed directly without touching the binary. Truncation is only a risk when there's no trailing, 0.)Scope the switch precisely — it is not one contiguous band. Binary Ninja renders the compiled switch as (at least) six separate
switch (arg2) { ... }blocks, all reported at the same decompiler block address0x00571dc5— a decompiler/codegen artifact from the compiler lowering one sparse switch into several contiguous range-checked jump tables chained together (each guarded by its ownif (arg2 > X) { if ((arg2 - Y) <= Z) switch(arg2) {...} }). One example, confirmed by dumping its jump table (acclient_2013_pseudo_c.txt:385851-385971):arg2 > 0x4e8then(arg2 - 0x4e9) <= 0xaaselects a 171-entry table covering codes0x4e9–0x593. That is only the highest of the six sub-switches — do not mistake it for the whole thing. A mechanical scan of the full function body (grep -oE "case 0x[0-9a-f]+"over lines 382616–385971) found 339 distinct case values, spanning0x17–0x593in 41 contiguous runs with gaps between them. Codes that appear in none of the six sub-switches (and are not one of the fiveAbortAutomaticAttack-only codes below) are the legitimate fallback set — retail truly does not display them via this path. This 339-code figure was obtained by grep, not exhaustive verification against our enum's numeric values (see verification note below) — treat it as a strong estimate, not a final count.Three structural gaps mean this is not a pure string table:
- Per-case colour. Every case calls
ClientSystem::AddTextToScroll(this, &str, <colour>, 1, 0)with a colour argument — a scan of the whole function found exactly three distinct values in use:0(162 call sites),0x1a(113 call sites),7(59 call sites).WeenieErrorMessages.Formathas no colour concept today;ChatLog.OnWeenieErroremits a plainChatEntrywithKind: ChatKind.Systemand no colour field. Before porting the full table, check whetherChatEntry/ChatVMcan express a per-entry colour at all — if not, that's a prerequisite sub-task, not a detail to skip. AbortAutomaticAttackside effect — verified true.HandleFailureEventopens with:
(if (arg2 == 0x43 || arg2 == 0x3f7) goto label_5719de; if (arg2 == 0x3e || arg2 == 0x23 || arg2 == 0x36) goto label_5719de; label_5719de: if (ClientCombatSystem::GetCombatSystem() != 0 && ClientCombatSystem::RepeatAttackInProgress(...) != 0) ClientCombatSystem::AbortAutomaticAttack(...);acclient_2013_pseudo_c.txt:382625-382630, block0x005719b1-0x005719fe). Confirmed against our enum:0x0043=NoMtableData,0x03F7=YouAreTooFatiguedToAttack,0x003E=YouAreTooTiredToDoThat,0x0023=MotionFailure,0x0036=ActionCancelled— all combat-failure codes, so the behaviour is coherent (stop swinging on a swing-failure error). Also confirmed: this check runs before, and independently of, the display switches —0x43has no case label anywhere in the 339-value scan (abort-only, no scroll text), while the other four also have their own display cases further down (so they both show text and aborted an in-progress auto-attack). acdream has no equivalent hook — an auto-attack that should stop on any of these five errors currently keeps swinging. This is a real gameplay gap, not cosmetics, and should land as its own sub-task (find acdream's repeat-attack/auto-attack state — likelyRuntimeActionState's combat-attack owner per J5.3 — and the equivalent abort call) rather than be bundled silently into the string-table commit.%s-parameterised (WithString) cases. A meaningful fraction of the 339 cases build their string viaPStringBase<unsigned short>::sprintf(..., u"...%s...")usingarg3->m_charbuffer(the event's string parameter) rather than a bare literal — e.g. case0x4e9/0x4ea(acclient_2013_pseudo_c.txt:382636-382653). These map toWeenieErrorWithStringon our side (WeenieErrorMessages.WithStringTemplates,_-placeholder convention). When porting, classify each case as plain-literal (→NoParamTemplates) or%s-templated (→WithStringTemplates) — do not assume the split matchesWeenieErrorvsWeenieErrorWithStringenum membership 1:1 without checking, since ACE's wire dispatch and retail's switch are independently authored.
Verification shape (378 hand-transcribed strings is exactly where silent typos live). Recommended before merging the full port:
- A test that iterates every code
WeenieErrorMessagesclaims to map and assertsFormat(code, ...)never returns theWeenieError 0x...fallback form (catches accidental no-ops/typo'd dictionary keys). - A mechanical cross-check against the extracted data rather than
eyeballing: e.g. a one-off script that walks the PE bytes for every
data_7dXXXXaddress referenced by acasein the six sub-switches and asserts the transcribed C# literal matches the recovered bytes exactly (reusing the VA→offset routine from the #504 fix). Treat any mismatch as a hard stop, not a judgment call.
Prior art from this session:
src/AcDream.Core/Chat/WeenieErrorMessages.cs(0x0504/0x0505/0x04EC/0x04ED, each cited with its case address anddata_7dXXXXsymbol) andtests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs. - Per-case colour. Every case calls
C4 accepted-position authority — 2026-08-03
-
#307 — DONE (2026-08-03) —
AcceptedPhysicsTimestamps.PreviousTeleportwas always 0 on the live Position path, silently dropping every local-player ForcePosition correction after the character's first teleport.InboundPhysicsStateController.TryApplyPositioncalled its privateCurrent(gate, teleportAdvanced: …)helper without thepreviousTeleportargument, which defaulted to a literal0. The only site that populated it was the deferred initial-create pathTryAcceptDeferredPosition, which is whyRuntimeInitialCreateContinuationExecutorwas correct and every newer consumer was not.Live blast radius. Shipped in C4 route 2 (
9966b531).LiveEntityNetworkUpdateController.csandRuntimeLiveEntitySessionController.csfeed the value intoRuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition, whereRuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthorityrequiresPreviousTeleportSequence == AcceptedTeleportSequencefor aForcePositiondisposition — which is exactly what retail's FORCE_POSITION branch guarantees (SmartBox::HandleReceivedPosition@0x00453FD0 fires only when the packet's teleport stamp equals the live one, and never advances it). WithPreviouspinned to 0, any player whose TELEPORT_TS had advanced — i.e. anyone who had portalled or recalled that session — had the authority rejected and the server's force correction dropped. Route 2's user acceptance was genuine but narrow: the acceptance character had never teleported, so the stamp was still 0. A second latent consequence: with an accepted stamp ≥ 0x8000 the wrap-safeTeleportRegressedcheck would also have fired against the 0, rejecting ordinaryApplypositions too.Fix. Capture
previousTeleport = gate.TeleportTimestampBEFORETryAcceptPositionEventmutates it (the exact shapeTryAcceptDeferredPositionalready used) and pass it through. The zero default onCurrentis removed outright — the parameter is nowushort?defaulting to the gate's own live stamp, so the channels that cannot move TELEPORT_TS get "previous == current" by omission instead of a silent 0 that is indistinguishable from a real "never teleported".Regression tests:
InboundPhysicsStateControllerTests.TryApplyPosition_ReportsThePreEventTeleportStampand…LocalPlayerForcePositionAfterATeleport_ClassifiesAsAnAcceptedForceCorrection(the second drives the real classifier and fails withRejectedAuthorityagainst the pre-fix behaviour).
C3c placement cutover — 2026-08-02
-
#276 — OPEN — SpawnPlacementSettler discards the settle's resolved cell.
SpawnPlacementSettler.TrySettle(src/AcDream.Core/Physics/SpawnPlacementSettler.cs:61) commitssettle.Positionbut never readssettle.CellId: a compressed first-gravity-frame settle whose few-cm sweep crosses a cell boundary (outdoor/EnvCell seam, stacked EnvCells) leaves the body's cell at the placement cell until the next resolve corrects it. Inherited #270 semantics — shared by the remote spawn seed and the C3c local first-entry settle (register row AD-61). Fix shape: commit the settle's resolved cell through the same body/cell channel the per-tick resolve writeback uses; needs a conformance test placing a body above a floor whose containing cell differs from the wire cell. Found by C3c review round 1 (retail minor M2). -
#277 — OPEN — route-1 far-Create relies on a practical radius bound, not an invariant. A graphical-host wire Create for a landblock the streaming window never reaches keeps its residence + one drive-pending entry for the session (re-Advanced per frame). Bounded today because the collision-publication window (5×5, two-tier N₁=4) is strictly larger than ACE's Create-broadcast group, so the far set is empty; if C4 changes either radius, route 1 needs the F7-style service-window/celless conversion (
HeadlessSessionWorldProjection.cs:557-570is the template). Wake and despawn-reap paths are verified correct (C3c adversarial delta review). Related narrowing: a remote whose landblock leaves the headless service window between ProjectSpawn and placement commit still parks (route 8, rarer than the pre-F7 leak). -
#278 — NARROWED 2026-08-03 — post-C3c user-session triage bundle. Resolved and user-verified: (a) the persistent login purple haze no longer races raw PlayerCreate receipt (
175ad6b0); (c)/lsworks; (d) remote monsters no longer pop in behind the player or place/attack in a different coordinate frame (670f307c); (e) the retirement-receipt replay loop that stalled streaming and portal convergence is gone (01f4791e); and (f) materialization/effect presentation is bound after canonical placement (f24532ad,175ad6b0). The remaining item is (b): explicitly compare lateral glide against impassable slopes before closing this bundle (the original wording said "with open #269", but #269 was already DONE 2026-07-31 — the comparison itself is what survives). Far terrain that can visibly continue building after portal reveal is tracked separately as #280. -
#279 — DONE (2026-08-03, user-verified) — one-shot spell/effect scripts arriving during the suppressed-until-receipt window were lost.
EntityEffectControllernow retains the mixed F754/F755 FIFO behind an exact-incarnation initial-presentation barrier and replays it only after canonical placement has bound the mesh, pose owner, and particle visibility resources. Live rebuckets also keep the effect cell synchronized with the entity cell. Spell buffs, recalls, arrows, and combat spell projectiles were verified in the connected client; focused effect, projectile, and cell-transition tests cover the race. Landed atf24532ad. -
#280 — CLOSED 2026-08-06, user-accepted at the C5c connected gate — portal reveal could expose an incompletely streamed distant landscape. Gate result: "now portal space takes longer but terrain is complete when I exit" — both halves of the specified criteria (a measurably longer hold, and a complete destination on reveal). Probe evidence in
c5c-gate-after2.log: three Portal reveals plus a Login reveal, every one atradius=12where pre-fix it was a hardcoded1, each portal hold raising the wait cue at ~5.0 s before completing. An accidental but genuine A/B was obtained in the same session: an earlier run setACDREAM_PROBE_REVEAL_RADIUS=1— that variable is a radius value, not an on/off flag — which forced the pre-fix window, and the user observed the original defect under it and not underradius=12. Fixed across3aab05b0(derivation),73cdb95c(D-1, an unrecoverable portal hang found by review),bcb66ccd(the atlas-tier seam its fix depends on). Residual AP-149 stays open: our outer ring accepts terrain-only readiness where retail'sPreFetchCellsalso requires each landblock'sLandBlockInfoand every building's EnvCells — so distant scenery may still fill in after reveal even though terrain does not. Deliberately not folded in; it costs further hold time and is a game-feel call. Historical description follows. User-observed 2026-08-03: after some recalls, the nearby destination is playable but terrain near the far end of the view continues visibly building after portal space exits. Premise correction (the original text named a setting that does not exist): acdream has no "configured view distance" —grep -rniE "viewdistance|view_distance|LandscapeDrawDistance|DrawDistance"oversrc/returns nothing. The correct premise is the configured streaming/fog window,QualitySettings.FarRadius: at the shippedHighpreset the user sees terrain out to the fog end (FarRadius * 192 m * 0.95≈ 2,189 m) inside a 2,304 m Far window, while the outdoor reveal gate opened at a hardcoded radius-1 3×3 neighbourhood (≈192 m) — an 11.4:1 ratio where retail's is 1:1 by construction, because retail's prefetched, loaded and drawn squares are literally the same array (LScape::mid_radius;LScape::PreFetchCells@0x00505660). The conclusion in the original text was right; the premise was not. Fix correction: raising the constant alone could not work, twice over.StreamingController.IsRenderNeighborhoodResidentdemandedIsNearTierfor every ring member, so any radius aboveNearRadiuswas unsatisfiable and would have held the reveal forever; andRuntimeWorldTransitState.AcknowledgeDestinationReadinessre-derived and assertedindoor ? 0 : 1, so a changed radius failedinvalid-readiness-shapeand the reveal never opened. The landed change is a radius derivation and a tier-aware predicate and an invariant loosening, plus the forced allocation fix inPhysicsEngine.IsNeighborhoodTerrainResident. Contract:docs/research/2026-08-05-280-contract.md. Residual filed as register row AP-149 (the outer ring accepts terrain-only publication where retail requires LandBlockInfo and every building EnvCell). -
#326 — OPEN — acdream has no Viewing Distance option. Retail exposes one user-facing landscape-extent preference,
Render.LandscapeDrawDistance— a six-position enum (Render_LandscapeDrawDistance_Values@0x007CA988 = 3/5/8/11/15/25, labels VeryLow/…/Extreme, default 8, both byte-verified), registered atUserPreferences::RegisterPreference@0x0054ECBE and pushed intoSmartBox::set_mid_radius@0x00453180. acdream's structural analogue is the quality preset'sNearRadius/FarRadiuspair, which is not separately user-controllable. Split out of #280 deliberately (§3/§14 of that contract): #280 derives its reveal window from whatever feeds the streaming radii, so this feature lands by changing what feeds them and #280's derivation keeps working untouched. -
#327 — OPEN — acdream has no analogue of retail's DDD prefetch progress readout. While
CellManager::blocking_for_cellsis latched, retail reportsECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total)@0x00692870 intogmPowerbarUI::RecvNotice_RuntimeDDDStatus@0x004DA5C0, which drives a powerbar progress bar with an "N of M" cell count (string idID_Powerbar_DDDModeText). acdream shows only the centered "In Portal Space - Please Wait..." cue. #280 makes reveal holds longer and more frequent, which makes the missing readout more noticeable. -
#328 — OPEN — the camera far plane is a hardcoded 5000 f in four camera classes.
RetailChaseCamera.cs,ChaseCamera.cs,FlyCamera.cs,OrbitCamera.cseach hardcode it with no config path. Retail'sRender::zfaris statically initialised to 4000.0 (byte-verified at0x0081EC88), and the only writers areGameSky::Draw@0x00507055 / @0x005070EE, which temporarily multiply by 4 for the skybox and restore. Independent of #280 — in both clients the landscape horizon is the landblock window, not the frustum — but it is an uncited divergence. -
#281 — DONE (2026-08-03) — the stabilization commits left the automated suites red, and the world-frame contract they introduced had no coverage. The 2026-08-03 handoff recorded "six selected fixture failures". A measured baseline found 43: the App suite was fully green at
01f4791eand670f307cbroke 28 tests in one commit (f24532adadded 2 more), while the Runtime suite lost 13 — 12 of them inRuntimeRemoteFirstEntryStateTests, the exact conductor670f307cgated. Both commits were gated on focused runs only. Root cause A (670f307c): remote first-entry placement now resolves its landblock-local Create origin through Runtime's world frame (RuntimeSetPositionState.PrepareMover) and returnsRetrySetupUnavailableuntil that frame exists, and ONLY the accepted local-player Create publishes it (RuntimeEntityObjectLifetime.RegisterEntityCore->RuntimePhysicsState.ObserveLocalWorldFrame). Fixtures that drove remote conductors in a player-less world — a state production never occupies — parked forever, so residences never retired. The production gate is correct and matches App's ownLiveWorldOriginState(initialized from the player spawn;Recentercalled only fromStreamingOriginRecenterCoordinator.Advanceat a teleport boundary), so the fixtures were stale, not the assertions: every one was repaired by supplying the missing precondition without altering a single expected value. Root cause B (f24532ad):EffectCellIdis now populated at materialization and wins overParentCellIdinEntityEffectPoseRegistry.UpdateRoot, and projectile classification reads the canonical body's ownCellPosition.ObjCellIdinstead of deriving it from the sidecar. Two rendering fixtures still modelled the pre-change shape. NewRuntimeWorldFrameTestspins the previously untested contract, including the load-bearing rule that ordinary movement across a landblock boundary must NOT rebase the frame while an accepted teleport must. Complete Release solution: 10,831 passed / 4 skipped / 0 failed.Retail oracle:
CellManager::PreFetchCells @ 0x00455820setsblocking_for_cellsuntilLScape::PreFetchCells @ 0x00505660has walked the configuredmid_radiussquare and each requiredCLandBlock::PreFetchCells/CLandBlockInfo::PreFetchCellsbuilding and connected EnvCell dependency is available. While blocked,SmartBox::UseTime @ 0x00455410checks prefetch status but does not advance ordinary object maintenance, physics, landscape, game time, or ambient audio; the portal viewport and UI remain live and may show retail's centered "In Portal Space - Please Wait..." notice. Once the destination is ready, retail resumes it behind the portal viewport duringTAS_TUNNEL_CONTINUEbefore the later tunnel-to-world reveal.Fix shape: replace the hard-coded radius-one reveal requirement with a retail-derived, quality-configured destination prefetch window and keep one generation-scoped reservation across terrain, statics/buildings, EnvCells, render publication, composite textures, and collision until that complete visible window is ready. Preserve bounded asynchronous preparation and the existing wait cue; never reveal early merely to meet a timeout. Do not wait for an unknowable "all dynamic server objects delivered" condition—ACE has no such terminal marker and some object delivery follows LoginComplete.
Acceptance: at every quality/view-distance setting, repeated login,
/ls, spell recall, and portal routes reveal no constructing terrain, buildings, statics, interiors, missing composite textures, or nearby collision; slow destinations remain in the authored portal presentation with responsive UI until ready, then receive the existing hidden settling interval before the world viewport appears. Dynamic monsters/items may continue to arrive authoritatively after reveal.
Current queue — 2026-07-27
- Structural handoff: all eight
GameWindowdecomposition slices and the automated closeout are complete. The 1,622-line native shell has typed startup, frame, callback, and shutdown owners; 293 focused tests, the 7,835/5 Release suite, the lifecycle/reconnect route, two canonical nine-stop soaks, and the Slice-7 framebuffer comparison pass. The user's connected visual matrix passed 2026-07-23, so the campaign is complete indocs/architecture/code-structure.md. - Active M4 prelude: resume
plans/2026-07-23-world-interaction-completion.md. Slices 1–4, including equipped-child picking, are user-accepted. Vendor browsing and authoritative transactions remain Slices 5–6. - Separate rendering gate:
#225, lifestone/particle alpha ordering. Its connected performance, lifetime, and unattended portal routes pass. - Carried behavior debt:
#116slide response,#273tight-gap collision clearance, and deferred restricted-house gate#274;#153closed 2026-07-30 on the AD-30 hold + arrival StopCompletely + canonical outbound + reveal-barrier evidence chain). TS-50/TS-51/TS-53 are tracked in the divergence register. - Deferred visual fidelity:
#226retail landscape detail overlay. - Deferred frame-pacing fidelity:
#235, capped/RDP jump presentation aliases the retail 30 Hz object clock; uncapped Release presentation is smooth and physics, collision, and wire state remain correct. - Build hygiene:
#228records 17 clean-Release test-project warnings; production compilation and all tests pass. - Modern Runtime/performance program: Slices A–K are complete. Prepared
world/collision packages, typed residency, bounded publication/retirement,
retained render-scene deltas, flat-authoritative collision, and bounded
frame/network owners are production paths. Slice J closed at
a9a822f2with one presentation-independentGameRuntimeshared by graphical and no-window hosts. Slice K closed through776482dawith the Windows/Linux multi-session host, shared immutable content, deterministic bot API, 1/5/10/30-session and two-hour simulated gates, connected portal/movement parity, resource ceilings, and graceful zero-debt teardown. - Active rendering campaign: Campaign V, OpenGL → Vulkan, executes from
plans/2026-07-27-vulkan-campaign.md. V0 pinned the RHI contract, V1 landed the GL backend, V2 migrated the shaders and CPU batch data to texture-table indices, and V3 audited clip space and sRGB.#248came out of the V3 audit. - Deferred Linux graphical track: L0 completed at
66f114b2; L1's backend selection/capability implementation checkpoint landed at11501d52. Native Windows passes and WSLg correctly rejects its missing mandatory bindless capability. Physical-Linux validation and L2–L6 are parked by user direction; resume at the supported AMD/NVIDIA L1 gate.
The documentation map defines how this tactical ledger relates to the milestone, roadmap, architecture, divergence register, research, and memory.
Template
Copy this block when adding a new issue:
## #NN — Short title
**Status:** OPEN
**Severity:** HIGH | MEDIUM | LOW
**Filed:** YYYY-MM-DD
**Component:** e.g. sky, physics, net, ui
**Description:** One paragraph — what's wrong or what's missing.
**Root cause / status:** What we know so far. Empty if unknown.
**Files:** Path references with approximate line numbers.
**Research:** Links to `docs/research/*.md` if applicable.
**Acceptance:** How we'll know it's fixed.
#275 — Unify the legacy Position wire path onto the executor's route classifier
Status: CLOSED 2026-08-05 (C5b) — behaviour unified; the remaining structural item is tracked below, not by this issue Severity: LOW (internal refactor debt; not a retail divergence) Component: Runtime / inbound Position
Description: the legacy InboundPhysicsStateController.TryApplyPosition
(today's only production Position wire caller) has no route-classification or
contact concept; the continuation executor's ApplyPositionAction runs
RuntimeAuthoritativePositionRouteClassifier with the wire's own IsGrounded
bit and threads the classified installPlacementFrame/clearParent flags
(register rows AP-131 documents the legacy caller's unconditional flags, AD-60
the cell-semantics difference). When the production cutover wires the executor
in, unify the legacy caller onto the same classifier (or delete it with the
route) and retire AP-131/AD-60's legacy halves. See
InboundPhysicsStateController.TryApplyPosition remarks.
Resolution (C5b, 2026-08-05, docs/research/2026-08-05-c5b-contract.md).
The steady-state merge was CORRECTED rather than deleted — it is still the only
production Position wire caller, and the issue's alternative branch ("or delete
it with the route") was not taken. Two behaviour changes landed atomically:
TryApplyPosition now computes installPlacementFrame/clearParent pre-merge
from (disposition, hasAnimations(old)), which is exactly the classifier's own
two rows because retail decides both writes ahead of MoveOrTeleport; and the
merge stops deriving FullCellId from bare wire acceptance
(refreshPosition: false). AP-131 retired, AD-60's legacy half retired and
its row rewritten to name the surviving wire-cell channels (W2 the prologue
rebucket, W3 the post-routing adopt).
What deliberately remains, and is NOT this issue. The two computations are
still separate small pure expressions in two places rather than one shared code
path — they are pinned equal by
InboundPhysicsStateControllerTests.MergedPrePlacementFieldsMatchTheClassifiedRouteFlags,
which uses the production classifier as the oracle. Wiring the continuation
executor into the steady-state path is a separate structural decision that no
longer has any behavioural motivation behind it.
Successor filed 2026-08-05 at the C5b review (finding S1): #322. This
closure originally left "the remaining structural item is tracked below" with
no ID, and the production comment on
InboundPhysicsStateController.TryApplyPosition pointed at docs/ISSUES.md
for a follow-up that did not exist. Both now cite #322.
#274 — Restricted/barred-house entry needs a connected retail comparison
Status: OPEN — explicitly deferred by the user on 2026-07-31 Severity: LOW (validation debt; no confirmed failure) Filed: 2026-07-31 Component: physics / EnvCell entry restrictions
Description: Campaign P ported retail
CObjCell::check_entry_restrictions and retired AP-71, but the final
connected barred-house scenario was not run. The user requested that this
gate be deferred and retained as an issue rather than block current work.
Acceptance: at a known restricted house, use equivalent characters in retail and acdream. Both must reject an unauthorized character at the same threshold, while an owner/guest enters normally. Record the house/cell, character access state, and result before closing.
#273 — ACDream can squeeze through tight world gaps that block retail
Status: OPEN — live mismatch confirmed 2026-07-31; exact location/capture still required Severity: MEDIUM (world traversal differs from retail) Filed: 2026-07-31 Component: physics / player collision shape and cell collision
Symptom: in some tight world-geometry passages, acdream can pass through a gap that blocks the retail client. The Campaign P wall/corner slide, crowd, remote movement, door, portal, and general collision checks otherwise passed.
Scope: this is not folded into #116, which tracks a specific near-perpendicular slide-response/fixture family. The new symptom is under-blocking or clearance divergence and may involve the active player sphere list, scale/pose, candidate-cell collision set, or a missing world-collision primitive.
Next evidence: record one exact reproducible location, heading, movement input, character scale/equipment, and cell ID in both clients. Capture the acdream transition path and active Setup-derived sphere list before changing collision math.
Acceptance: the captured tight gap blocks or permits traversal at the same clearance as retail without regressing normal doorways, stairs, wall grazing, or crowd movement.
#272 — Strength enchantments do not invalidate burden
Status: DONE — 2026-07-31 (implementation, automated gates, and user live buff/death gate) Severity: HIGH (movement state and retained HUD disagree with retail) Filed: 2026-07-31 Component: player qualities / enchantments / burden
Symptom: while overburdened, casting a Strength spell did not reduce the burden state until the base Strength attribute changed. Dying purged the Strength spell but did not restore the overburdened state.
Root cause: LiveSessionEventRouter.RecomputeBurden read raw
AttributeValue.Current and subscribed only to base Strength/object-table
changes. The indicator bar and inventory meter had the same raw-Strength
composition, and the inventory meter did not observe enchantment changes.
Retail CACQualities::InqLoad @ 0x0058F130 calls
CACQualities::InqAttribute @ 0x00591A00, which applies
CACQualities::EnchantAttribute @ 0x00594570; every load query therefore uses
effective Strength.
Fix: all three consumers now read
LocalPlayerState.GetEffectiveAttribute(Strength). The Runtime burden owner,
indicator bar, and inventory meter subscribe to the canonical
Spellbook.EnchantmentsChanged edge, covering add, remove, expiration,
dispel, and death purge through one path. Regression tests pin both
buff-to-unburdened and purge-to-overburdened transitions without any base
attribute update.
Acceptance: overload a character, cast a Strength spell, and observe the burden icon/meter plus movement update immediately. Die while the spell is active and observe the spell purge restore the overburdened icon/meter and movement immediately. Passed live 2026-07-31: the user confirmed the burden state now updates correctly.
#271 — Stair-side collision reverses uphill movement and rapidly slides the player down
Status: DONE — 2026-07-31 (implementation + user live gate)
Severity: MEDIUM (movement feel and navigation)
Component: physics / edge_slide / precipice_slide
Symptom: while running diagonally up an outdoor staircase and pressing against its side, the character could suddenly move backward and slide rapidly to the bottom.
Root cause: a 677-quantum live trace caught the first bad frame. A valid
X side-wall collision turned a requested +0.88377 uphill Y displacement into
-0.34059, followed three frames later by a 1.52 m snap to terrain.
EdgeSlideAfterStepDownFailed promoted ACDream's separately retained
LastWalkable polygon into the current SPHEREPATH::walkable slot. At the
stair side this could be the preceding tread, so PrecipiceSlide projected
against stale geometry and reversed the tangent.
Named-retail CTransition::edge_slide @ 0x0050B3D0 never substitutes an older
polygon: when current walkable is null it back-probes at the current sphere
center, restores the failed candidate, and only then invokes
SPHEREPATH::precipice_slide @ 0x0050CC80. Both stale-history substitutions
are removed. The exact captured frame is pinned against the installed stair
fixture: pre-fix output moved downhill to Y 75.346481, while the retail-flow
result advances uphill to Y 76.078186 and Z 60.016247. Core Release passes
4,108 tests / 2 skips; the complete Release solution passes 10,062 tests /
5 skips. The user then repeatedly climbed the same stairs while pressing into
their sides and confirmed the rapid downhill slide was gone. Evidence:
docs/research/2026-07-31-271-stair-side-slide-capture.md.
#270 — Stuck spell animations + intermittently missing monster attack animations
Status: CLOSED 2026-07-31 — both symptoms user-verified fixed (stuck casts: exhaustion-edge gate a46c8e65; missing monster attack animations: spawn settle placement 21b3a3f3 + lost-cell retry 807fdb5f). Final settle-session log: 14/15 spawn settles grounded; Falling-refusal spam 2,954 → 15 transient pre-settle lines. All #270 probes stripped.
attack-animation misses under investigation with the [remote-edge] probe
Severity: HIGH (combat/casting presentation)
Component: motion re-dispatch cadence / remote action animations
Local stuck casts — root cause CONFIRMED and fixed: Campaign P P1 wired
ApplyMovementStats to call MotionInterpreter.ReportExhaustion() on EVERY
movement-stats application — i.e. every stamina regen/drain tick. Each call
re-dispatches the current movement state through the animation sink,
truncating any in-flight action animation; the diagnostic session log shows
490 spurious casting-stance re-queues while the user was in magic mode.
Retail fires CPhysicsObj::report_exhaustion from exactly ONE site —
CommandInterpreter::HandleExhaustion (0x006b3c70), a notification handler
invoked on the stamina-EXHAUSTION EVENT. Fix: the re-apply now fires only
when the exhausted state (stamina == 0) transitions; skills/burden/stamina
still reach PlayerWeenie immediately (the next natural dispatch picks up
rate changes, exactly retail).
Monster attack misses — ROOT CAUSE FOUND AND FIXED (2026-07-30, third
session): the [MT-FAIL] probe caught combat-stance monsters constantly
failing to dispatch motion 0x40000015 = FALLING — their bodies were
airborne-FLAGGED while standing on the ground. contact_allows_move
(0x00528dd0) requires Contact+OnWalkable on the body and silently refuses
every action animation for an "airborne" mover — a spawned-standing
monster's attack swings never played until it first moved (movement →
resolve → floor touch → contact). Retail never has this state: CreateObject
spawns run the placement transition (CPhysicsObj::SetPosition →
SetPositionInternal 0x00515330), which establishes contact at spawn; our
remote creation seeded a raw position with no placement. Fix:
SeedRemoteSpawnPlacement runs the engine placement resolve + the
verbatim CommitSetPositionTransition at BOTH RemoteMotion creation sites
(UM-triggered and first-UP), mirroring RemoteTeleportPlacement. Earlier
theories eliminated en route: remote edge-drains (zero edges fired), the
legacy stop-detector (dead code), cycle hard-swap (the funnel uses the full
motion-table link machinery), 0x00D3 misread (= CastSpell, casters animate
fine), motion-table port (offline sweep: all 27 "failures" are non-caster
tables never sent CastSpell). Probes [UM-ACT]/[MT-FAIL]/[remote-edge]
remain in place (ride ACDREAM_DUMP_MOTION=1) until the user gate passes.
#269 — Slope-stop slide runs too far (post-bounce-rework residual)
Status: DONE — 2026-07-31 (implementation + user live gate) Severity: LOW-MEDIUM (feel residual; bounce family otherwise accepted) Component: physics / landing slide decay
Symptom: after the #265 landing-bounce rework (accepted: downhill bounce chain, flat pop, uphill clean landing), the user reports the character SOMETIMES slides too far when stopping on slopes vs retail.
Byte-verified NOT the cause (all decoded from the PDB-paired binary this
session — do not re-audit): calc_friction 0x0050ee70 is byte-identical
to our port in BOTH branches (0.25 dot gate, into-plane removal,
(1-friction)^dt decay, DEFAULT_FRICTION 0.95, sled bands 1.5625/6.25 +
cos(10°) with base 0.2); the jump chain end-to-end (GetJumpHeight
0x006b09b0 exact incl. 1300/22.2/0.05/0.35, InqJumpVelocity 0x00592980
vz=sqrt(h*19.6), powerbar charge 1.0 s / 0.8 s dual-wield) — the user's
"we jump too high" hypothesis is REFUTED, jump height is retail-parity
(the former five-point effective-skill gap was closed by #268). Retail has no
Sledding auto-toggle (P2 finding re-confirmed; no state |= 0x800000
writer exists).
Resolution (2026-07-31): a 2,184-quantum live capture isolated the
first divergence. The landing tick produced retail's correct 5% reflect,
but the following quanta repeatedly restored
LastKnownContactPlane while retaining the reflected velocity. The body
therefore remained Contact + OnWalkable with v·n > 0.25, where retail
calc_friction intentionally does no work, and slid at full speed.
Named-retail CTransition::validate_transition @ 0x0050AA70 revealed the
omission: its non-OK remembered-plane recovery calls
OBJECTINFO::kill_velocity @ 0x0050CFE0 before the proximity test and
plane restore (0x0050AAED–0x0050AB42). It also consumes the remembered
plane only in that non-OK branch and overwrites last-known validity from
the final contact plane at 0x0050ACFF. ACDream now follows that exact
ordering. Focused collision-recovery and clean-advance pins pass; full
Core (4,107/2 skips) and Runtime (439/0) suites pass; the user repeated the
slope-jump test and accepted the result (“Perfect! Works great!”).
Capture and decode:
docs/research/2026-07-31-269-slope-stop-capture.md.
#268 — Character panel: vitae color, buff coloring, and augmentation bonuses
Status: DONE — 2026-07-31 (implementation + user visual/live gate). Severity: MEDIUM (presentation parity) Component: retained UI / character window
Resolution: the shared Core PlayerSkillMath now ports
CACQualities::InqSkill @ 0x00592660 in retail order for both the
character panel and Runtime movement: intrinsic skill, positive
LumAugAllSkills (0x16D), the authored +10 melee/missile/magic category
augmentation, EnchantSkill, then +5 Jack of All Trades (0x146) and
2 × LumAugSkilledSpec (0x158) for specialized skills. Live player
PropertyInt updates recompute the Runtime snapshot, so the display and
run/jump prediction cannot drift.
AttributeInfoRegion::Update @ 0x004F1910,
Attribute2ndInfoRegion::Update @ 0x004F19E0, and
SkillInfoRegion::Update @ 0x004F1AE0 now drive exact value coloring:
green/red compare the non-vitae residual against the base, so a pure vitae
penalty remains white. The selected-skill footer uses one shared inline-run
text primitive matching retail AppendTextWithFont; its vitae fragment uses
the authored LayoutDesc 0x2100002E / FooterTitle 0x1000024E palette index 3
(#7FFFFF), while positive/negative buff fragments use palette indices 1/2
(#00FF00/#FF0000). Attributes, secondary attributes, and skills share those
exact colors. AP-127 and TS-8 are retired by the same stat-chain package.
The user confirmed the live buff values, footer coloring, and immediate skill
row refresh after the final retained-UI invalidation correction.
#267 — Vitae does not update the character panel's skills/attributes display
Status: IMPLEMENTED 2026-07-30 (cf2605fa, merged) — closure pends
the user visual check. Retail finding: primary attributes are
VITAE-IMMUNE (EnchantAttribute 0x00594570 never references the vitae
singleton) — only skills and vitals take the penalty. Panel now shows
effective values; skill footer shows the vitae parenthetical (e.g.
"(-100)", SkillInfoRegion::GetVitaeModifier 0x004f0fa0) plus a
separate buff residual; refresh fires on EnchantmentsChanged.
Severity: MEDIUM (matrix live gate 2026-07-30)
Component: retained UI / character window / vitae
Symptom (user report): with 5% vitae active, the skills and attributes values in the character window do not change; retail shows the CURRENT (vitae-reduced) value and, on click/detail, the current level with the vitae reduction in parentheses (e.g. "(-100)"). The P1 movement chain consumes vitae correctly (EnchantSkill for run/jump); the character window presentation does not.
Scoping (2026-07-30): confirmed — CharacterSheetProvider/
CharacterStatController contain zero vitae/enchantment references; the
panel renders base property values only. Fix shape: thread the P1
Spellbook accessors (vitae + GetSkillMod, extended to the attribute
namespace as needed) into the sheet's value computation, and port the
retail detail formatting (current level with the parenthetical
reduction) from the gmCharacterUI text-building decomp — grep-named
first, don't guess the format string.
#266 — Local player faster than a comparable retail character
Status: CLOSED 2026-07-30 — root cause: ACE-inherited >= 800 misread
of retail's exact-equality run-rate sentinel
Severity: HIGH (matrix live gate 2026-07-30; core speed parity)
Component: movement / MovementSystem.GetRunRate
Root cause: retail MovementSystem::GetRunRate (0x006b0950) returns
18/4 = 4.5 ONLY when runSkill == 800 EXACTLY (byte-decoded fcom [800f]; test ah, 0x44; jp — the C2/C3 parity equality idiom; <, >, and unordered
all take the general formula). ACE misread the same x87 mush as
>= 800 ("max run speed?") and our P1 port inherited it via the
ACE cross-reference. Every maxed character therefore ran a flat 4.5
(retail-true ~3.70) — ~21% too fast and completely vitae-independent,
because both the vitae-reduced and unreduced skill sat above 800. The
controlled comparison (33%-vitae +Acdream vs 5%-vitae +Je, both maxed)
showed acdream faster while retail runs them within ~0.4% — exactly the
formula's prediction. The vitae/enchantment chain itself was verified
intact end-to-end via [stat-chain] live capture (vitae 0.67 installed →
eff run 10200 → applied to controller).
Fix: == restores the general formula for all non-800 skills;
golden tests pin 799/800/801 straddle + the maxed-skill vitae
differential; docs/research/2026-07-30-stat-coupled-movement-pseudocode.md
§6 corrected with the full byte decode and an explicit "do not re-import
ACE's >= reading" warning.
#265 — Steep-slope response set: uphill-jump bounce, roof slides lost, edge wedge (TS-4 removal fallout — REVERTED)
Status: IMPLEMENTED 2026-07-30 (landing-bounce rework: retail check_contact seed + SetPositionInternal commit + live 5% elasticity reflect; docs/research/2026-07-30-landing-bounce-family.md) — pending user live gate (downhill bounce chain, flat pop, uphill clean landing)
user's visual-gate acceptance. The named culprit for symptoms (b) and (c)
was capture-bisected to a THIRD, pre-existing (frozen-phase, predates
Campaign P by ten days) mechanism — neither the S1 nor S2 suspects named
below — and is now ported. Symptom (a) is confirmed a SEPARATE,
pre-existing, already-closed retail-faithful mechanism (AD-25); see the
"as-fixed" addendum for the full trace.
Severity: HIGH (matrix live gate 2026-07-30, scenarios 4/5)
Component: physics — grounded residual-velocity ownership
(PlayerMovementController.cs), not BSPQuery/Path-6 (see below)
Symptoms (user report, on the shortcut-removed build): (a) bouncing
when jumping INTO an uphill slope — retail does not; (b) house-roof
slides no longer happen ("as I used to"); (c) occasionally stuck sliding
on an edge — the historical wedge, live, refuting the oracle plan's
"pure-vertical degenerate only" convergence claim. The fixture-gated
TS-4 removal under-modeled real trajectories, but a full capture-driven
bisect (docs/research/2026-07-30-265-capture-bisect.md) cleared BOTH
of the two named Campaign-P suspects for the two concrete mined freeze
events:
- S1 (
db2889af, BSPQuery Path-6hasSphere1) — reverting it locally produced byte-identical replay output; its site is provably unreached by either mined trajectory (hit1never true across an 80-tick replay). Real, narrow, retail-faithful — NOT reverted. - S2 (
calc_friction's 0.25 threshold, AP-7) — proven inert by static analysis before this session (zero production call sites at the time).
Root cause (this session, capture-bisect + fix):
PlayerMovementController.cs's per-tick grounded block (the R6
"animation-root-motion-owned grounded movement" architecture, landed
f961d700, 2026-07-20 — ten days before Campaign P, so not a Campaign-P
regression) hand-zeroed Velocity.X/Y to EXACTLY zero every single tick
once OnWalkable, whenever animation root motion drives the walk (the
production graphical local-player path). This discarded any residual
horizontal momentum a fall/landing left on the body BEFORE
calc_friction (AP-7, already correctly ported) or
PhysicsBody.UpdatePhysicsInternal's Euler integrator ever got a chance
to act on it — a mover that landed on a walkable roof/slope with residual
velocity had that velocity vanish the very next tick and never moved
again. A second, previously-unwired gap compounded this: PhysicsBody. GroundNormal (what calc_friction dots the velocity against) had ZERO
production writers anywhere — it silently defaulted to Vector3.UnitZ
forever, so even without the zero, friction would have treated every
slope as flat ground.
Fix: (1) src/AcDream.Core/Physics/PhysicsEngine.cs now syncs
body.GroundNormal from the committed ContactPlane.Normal at the same
commit point that already publishes ContactPlane itself (Core-level,
so player/remote/ordinary/projectile all benefit uniformly — "the
mechanism is general," not roof-specific). (2)
src/AcDream.Runtime/Gameplay/PlayerMovementController.cs's grounded
block no longer reconstructs Velocity at all for the animation-root-
motion case (only the headless/test-controller get_state_velocity
fallback still does, unchanged — that model has no separate root-motion
channel to compose with). Root motion still fully owns commanded
locomotion; this only stops DESTROYING whatever residual Velocity
already holds, letting it compose with root motion through the SAME
ResolveWithTransition sweep exactly as retail's
CPhysicsObj::UpdatePositionInternal composes both channels.
Symptom (a) — NOT addressed, confirmed separate: the "uphill bounce"
traces to PhysicsObjUpdate.HandleAllCollisions's shouldReflect
gate (!(prevOnWalkable && nowOnWalkable && !sledding)), re-verified
BYTE-EXACT against the raw retail decomp (handle_all_collisions,
pc:282647-282760) this session. For any FRESH landing from airborne
(prevOnWalkable=false), retail itself reflects whenever the collision
normal shows "moving into the surface" (dot < 0), regardless of
whether the destination is walkable — this is the SAME mechanism AD-25
closed (2026-07-30, Campaign P Slice P3, docs/ISSUES.md #166) for both
local and remote movers. Per CLAUDE.md's "do not fix code that matches
retail" rule, this is out of scope for a fix. A synthetic 30°-uphill test
(UphillLanding_Synthetic_ReflectionDecisionUnaffectedByResidualVelocityFix,
Issue265SteepSlopeCaptureBisectTests.cs) confirms the residual-velocity
fix above changes NOTHING about this reflection decision (same input,
same output, with or without the fix) — it is orthogonal, not
introduced or worsened. If the user's live repro still shows an
unwanted bounce after this fix lands, it needs its own dedicated
capture + brainstorm against HandleAllCollisions/BSPQuery, not a
reopening of this root cause.
Evidence: docs/research/2026-07-30-265-capture-bisect.md's
as-fixed addendum; Issue265SteepSlopeCaptureBisectTests.cs's new
ComposedRoofLanding_* fixtures (freeze reproduced under the old model,
survives+advances under the new one, exponential decay demonstrated in a
synthetic dot<0.25 case); PlayerMovementControllerTests.cs's new
Update_AnimationRootMotion_WalkSpeedUnaffectedByResidualVelocityFix
(ordinary walking is a no-op under the fix) and
Update_RunningJumpLandsOnFlatGround_ResidualVelocitySurvivesAndDecays_NotFrozen
(a real running jump's residual velocity survives landing and decays on
the actual production PlayerMovementController, not just the Core-level
model).
#263 — Drudge Scrying Orb still occludes its particles after the composite-translucency fix
Status: OPEN (deferred by user 2026-07-29) Severity: LOW (single known item; "very subtle" per user) Component: rendering / world translucency / particles
Symptom (user report): wielded items subtly hid particle effects, "missing
some translucent texture." The general fix landed as 16ed6e7c (authored
Surface.Translucency was baked into texture alpha only on the shared-atlas
path; the per-instance composite paths — palette/texture overrides, i.e. most
wielded loot — never applied it, so translucent parts painted alpha=1 and
erased particles composited behind them). User confirms other items now render
correctly; the Drudge Scrying Orb still shows traces of the occlusion.
Root cause / status: the override-driven translucency loss is fixed and verified on other items. The orb residual matches the remaining ranked hypotheses from the 2026-07-29 investigation (transcript-level, not yet a research doc):
- H3 — ClipMap-only shell:
IsOpaquecountsBase1ClipMapas opaque (WbDrawDispatcher.csIsOpaque, pipeline writes depth), so a ClipMap-authored orb shell would depth-reject particles behind/inside it. Only a divergence if retail composites that same surface blended. - H2 — scope-split ordering: interior roots draw unattached scene
particles (
AttachedObjectId == 0) BEFOREFlushLandscapeAlpha(RetailPViewRenderer.cs~:774-780), so an unattached emitter composites in a strictly earlier scope than every dynamic's mesh regardless of distance. - Orb-specific authoring (interior particle system inside a glass sphere) is the classic shape for both.
Discriminating probes (no new code needed): ACDREAM_WB_DIAG=1 (a
translucent-looking orb part appearing in the opaque draw count = H3),
ACDREAM_PROBE_OUTSTAGE=1 (owner-id in the late-landscape particle set vs
dynamics set = H2), ACDREAM_DUMP_ENTITY=<orb setup id>,
ACDREAM_HIDE_PART=<index> to confirm the occluding part by construction.
Files: src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs (IsOpaque /
ClassifyBatches / DeferTransparentGroups), src/AcDream.App/Rendering/TextureCache.cs
(DecodeFromDats bake), src/AcDream.Core/Textures/SurfaceDecoder.cs
(ApplyAuthoredTranslucency), src/AcDream.App/Rendering/ParticleRenderer.cs
(scope deferral). Related: #225 (RetailAlphaQueue, final visual gate pending),
register rows AP-34/AP-21/AD-19.
Acceptance: the Drudge Scrying Orb's particle effect reads through its shell like retail, wielded and on the ground, indoors and out, without regressing the #225 lifestone/candle compositing.
#264 — Water semantics: WATER_CONTACT_TS consumer + two unverified swim behaviors
Status: OPEN (filed 2026-07-30, Campaign P Slice P4 AP-10 closeout) Severity: LOW (no confirmed divergence; research/verification follow-up) Component: physics / terrain / water
Context: AP-10 (dry-corner water sink-in) is retired and WATER_CONTACT_TS
(TransientStateFlags.WaterContact) is now produced (mirrored alongside
Contact by PhysicsObjUpdate.ApplySetPositionContact,
CommitSetPositionTransition, and PhysicsEngine's per-resolve body-state
commit). Three items from
docs/research/2026-07-29-remote-and-world-specials-pseudocode.md §5.3-5.4
remain genuinely unresolved — none block the AP-10 port, but none are
silently absorbed either:
- No confirmed retail CONSUMER of
WATER_CONTACT_TSwas found. The write site (CPhysicsObj::SetPositionInternal, pc:283459-283483) is confirmed; a full xref scan for READS of bit 0x8 ontransient_statewas not attempted (bitmask reads are not text-greppable across the 1.4M-line pseudo-C dump without high false-positive noise against unrelated 0x8 masks). Is it a pure reporting/query bit (e.g. an "is swimming" query for animation/sound/UI with no gameplay-feel consequence), or does something in the movement/friction/step chain branch on it? Next step: Ghidra MCP/function_xrefs?name=CPhysicsObj:: SetPositionInternalonce reachable, or a live cdb capture. CLandCell::find_env_collisions's ENTIRELY_WATER early-exit (pc:317091:if (block_water_type == ENTIRELY_WATER && !ethereal && !(state&0x40)) return;— a swimming/ethereal exemption from terrain collision entirely) was not cross-checked against acdream's handling of this exact condition. Flagged as unverified, not asserted-divergent or asserted-matching.- Jump-in-water and movement-effects-in-water (reduced jump height, swim
animation triggers) were not investigated — out of the P4 physics/
collision scope; would need a
MovementSystem/animation-side read.
Files: src/AcDream.Core/Physics/PhysicsBody.cs (TransientStateFlags .WaterContact, IsWaterContact); src/AcDream.Core/Physics/PhysicsObjUpdate.cs;
src/AcDream.Core/Physics/TransitionTypes.cs (outdoor FindEnvCollisions
terrain branch — the ENTIRELY_WATER exemption's acdream-side home, if it
exists at all).
Acceptance: either (a) a confirmed consumer of WATER_CONTACT_TS is
found and ported (or confirmed absent, closing this cleanly), and (b) the
ENTIRELY_WATER early-exit is cross-checked and either confirmed matching or
filed as its own register row; or (c) this issue is re-scoped/split once one
sub-item resolves.
#262 — Run-on-the-spot at first login: no displacement until a recall reset
Status: OPEN Severity: MEDIUM (self-heals via any teleport; first-login only so far) Filed: 2026-07-29 (Campaign N acceptance run 1 on Coldeve) Component: login flow / local movement / physics readiness
Symptom (user report): on the first login of the acceptance session the character animated running but did not move ("ran on the spot"); casting a recall spell — i.e. the first server teleport/position reset — fixed it, and movement was normal for the rest of a 20-teleport session. The second login (same binary, minutes later) did not reproduce.
Evidence: artifacts/coldeve-acceptance-20260729/ acceptance-run1-20teleports-1recovery.log (local). The login reveal was
clean — generation 1 reached collision=True before world-visible — so the
reveal gate is not the culprit. The log shows a large first-position
recenter jump (169,180)→(135,102)@0x87660009. Not yet attributed.
Hypotheses to test (in order): (a) server-side position rejection until the first ForcePosition — our AutonomousPosition stream may have been ignored by ACE until a position reset (check whether early 0xF61C sends flowed and what ACE echoed); (b) client physics landed before the walkable was resident at the exact spawn cell (the run-on-spot presentation is what a missing contact plane looks like); (c) a Campaign-N interaction is NOT suspected (the transport ledger was clean at login) but rule it out by checking the first minute's [net-out]/[net-tick] against the movement input timeline.
2026-07-30 triage (Campaign P P6, log-only — no repro yet): read the
full run-on-spot window (log lines ~91-245, world-visible → first
teleport). FACTS: (1) outbound movement flowed the whole time — 0xF61C on
every input edge, periodic 0xF753, combat toggle/attack/select actions all
sent; transport healthy (one isolated resend+nak blip). (2) The reveal's
collision=True is attested by
PhysicsEngine.IsNeighborhoodTerrainResident — the SAME _landblocks
dictionary the per-tick resolve walks — so the 3×3 spawn neighborhood WAS
physics-resident before world-visible; "terrain never arrived" is dead.
(3) The "unattributed" recenter jump attributes cleanly: the pre-login
world view is Holtburg-centered by default (loading world view centered on 0xA9B4FFFF = lb (169,180)) and the first real position recentered to
(135,102). DEDUCTION: hypothesis (a) is DEMOTED — the symptom is the
user's OWN client's body (local display is client-authoritative), so
server-side rejection cannot pin the local body; the defect is local:
every resolve returned ~zero advance while the animation ran. REFINED
HYPOTHESES, probe-decidable: (e1) stale-offset survivors of the login
recenter — the 2026-06-20 #145 fix removes only the old CENTER
landblock; if the login first-position recenter does not route through
Slice E's generation-scoped full-window/recenter retirement the way
teleports do, Holtburg-frame NEIGHBOR blocks stay resident overlapping
the new frame → the resolve grounds/collides against phantom geometry →
zero advance until the recall's full arrival pipeline cleans up (also
explains the once-only timing and the teleport self-heal); (e2) CellGraph
terrain-origin registry inconsistent with _landblocks after the login
recenter (membership hold). (e1) REFUTED same day: the log shows ZERO
landblock loads between the world-view declaration and the recenter — the
#192 StreamingReadinessGate held (StreamingController. InitializeKnownLoginCenter documents the worker stays stopped until the
real spawn center), so no stale Holtburg-frame blocks ever existed. The
default-center log line is declarative only. Remaining candidates, one
probe run apart: (f) login seed race — the player body missed or
raced its SnapToCell login seed, so every per-tick resolve takes the
NO-LANDBLOCK verbatim branch (zero advance = run-on-spot exactly), until
the first teleport re-seeds; (e2) above; (g) root-motion Frame not
reaching the transition (animation advances, body write rejected).
Round 3 (code): (f)'s strongest form is refuted — outbound 0xF61C
requires the PUBLISHED movement controller, so EnterPlayerModeNow's
transaction completed and SetPositionCore/SnapToCell seeded the body;
what remains of (f)/(e2) is a seeded (cell, pos) pair the resolver
cannot operate on (e.g. Resolve's XY landblock scan missing at mode
entry, IsOnGround:false verbatim seed, or offsets skewed vs
_landblocks). ALSO: the #111 [snap] apparatus is currently DEAD in
production — PhysicsEngine.DiagnosticLog has NO assignment anywhere in
src/, so its absence from the Coldeve log says nothing. Next (probe
run decides): wire PhysicsEngine.DiagnosticLog to the diagnostic
sink (it was designed low-volume/permanent), then fresh logins with
ACDREAM_PROBE_RESOLVE=1 + ACDREAM_PROBE_CELL=1 + net probes.
Discriminator: [snap] shows the mode-entry branch and seeded cell; no
[resolve] lines at all → upstream seed/mode; [resolve] firing with
zero advance → the responsible-entity/plane names geometry-vs-(e2);
[resolve] advancing while the render stands still → (g) projection. Do
NOT add a workaround (no auto-recall, no synthetic position kick).
2026-07-30 P6 apparatus shipped + local probe batch: aa07baed wires
PhysicsEngine.DiagnosticLog at session composition — the [snap] line
is now permanently live in production (one line per login/teleport entry
snap, no env var). Three probe-instrumented fresh logins against local
ACE (artifacts/262-probe/login-{1..3}.log) were all clean: [snap]
OUTDOOR branch committed the server Z, ~1,700 [resolve] lines each,
recenter (169,180)→(9,4) healthy — no reproduction (consistent with the
once-in-two-logins Coldeve rarity; local latency may also matter).
Remaining path to closure: the next natural recurrence now
self-diagnoses via the always-on [snap] + the campaign visual matrix's
scenario 11 (20 fresh logins) provides the structured re-test.
#261 — Wire LinkStatusSnapshot.PacketLossPercentage from retail's formula
Status: OPEN Severity: LOW Filed: 2026-07-29 Component: net (link-status presentation)
Description: LinkStatusSnapshot.PacketLossPercentage
(src/AcDream.Core.Net/LinkStatusSnapshot.cs:11) is a permanent default 0 —
the indicator UI renders it but nothing computes it. Campaign N Slice N5's
TransportStats now counts every input a loss figure could want (resends,
NAKs both directions, duplicate drops, parked words), but locate retail's
CLinkStatusAverages loss formula (see
LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) before wiring
PacketLossPercentage; inventing a ratio is forbidden — the displayed number
must be retail's windowed average, not an acdream-invented counter quotient.
Acceptance: the ported formula cites the named-retail symbol + address,
and the indicator shows non-zero loss under tools/run-connected-loss-gate.ps1.
#260 — Portal-network wedge: outbound actions die + native/GPU memory climbs
Status: CLOSED 2026-07-29 — Campaign N complete and user-accepted.
Slices N0–N6 all shipped and reviewed (outbound resend 43e60a69, inbound
sequence-aligned ISAAC 46d209d0, retail ack cadence 0265cc42, NAK
emission + reclaim 852a59e3, the permanent loss gate 4e290f00,
handshake/assembler hardening f9c5e47e). Acceptance: the user's Coldeve
session ran 20 portal transits with zero wedges, and the log captured a
REAL wire loss recovering live (resend/s=1 nak-in=1 mid-session;
[net-final] resends=1 nak-in=1 … cksum-fail=0 … nakset=0 — evidence at
artifacts/coldeve-acceptance-20260729/, local). The exact event that
permanently killed sessions two days earlier is now a sub-second
non-event. One unrelated first-login anomaly filed as #262. Original
root-cause record below.
(docs/plans/2026-07-29-network-transport-campaign.md).
The wedge is missing packet-loss recovery in BOTH directions: (a) no outbound
retransmission — ACE's RequestRetransmit lists are parsed and consumed
nowhere, no sent-packet cache exists, so one lost C2S datagram permanently
stalls ACE's ordered processing of everything we send (actions void, position
updates void → ACE stops streaming new areas → the #256 invisible portals);
(b) the inbound ISAAC keystream is burned in ARRIVAL order
(PacketCodec.TryDecodeBorrowed consumes before comparing), so one lost S2C
datagram permanently desyncs the inbound cipher and silences inbound. Local
ACE is loopback (zero loss) — every historical gate was structurally blind.
The memory half is CLOSED as benign: WS +2.5 GB vs private +0.7 GB over a
session = mapped-pak page residency (27.85 GB mmap) + GPU caches filling to
designed ceilings and plateauing; no leak.
Severity: HIGH (renders the client unplayable after sustained portal use)
Filed: 2026-07-29 (instrumented Coldeve session, Vulkan backend)
Component: Core.Net reliable transport (both directions)
Supersedes framing of: #256 (invisible portal-network objects) and #257
(~1.5 GB working set) — both are facets of this.
Reproduction (the first solid one): on Coldeve (play.coldeve.ac), character
Barris, after several portal-network runs returning to the town portal network,
the client wedged: portals unusable, combat mode won't toggle, portals/signs
missing — while movement still works.
Evidence captured (artifacts/coldeve-repro-20260729/, gitignored, local):
gcdump taken in the broken state (15 MB), full session log with
[cell-transit]/[input]/[use-target]/equipment traces, the
dotnet-counters CSV, and stderr.
Root 1 — the wedge (cause still open, field narrowed). The as-filed
hypothesis (J5.2 use gate latched by an un-acked UseWithTarget) is refuted:
the log shows every UseWithTarget received its matching [use-done], all
reveal generations 2→17 reached event=complete cancelled=False failures=0,
and RuntimeCombatModeState.Toggle()
(src/AcDream.Runtime/Gameplay/RuntimeCombatModeState.cs:49-93) never touches
that gate — it gates only on IsInWorld. Remaining ranked hypotheses:
(1) frame-thread saturation — inbound drains from an unbounded
_inboundQueue on a ~4 ms frame budget (WorldSession.cs:990-1016), collapsed
by Root 2's memory pressure; (2) outbound ISAAC/sequence desync — a send off
the frame thread desyncs the cipher (NextGameActionSequence() is a non-atomic
++, WorldSession.cs:1618) and the server silently drops every subsequent
packet, which uniquely explains hard-zero effect while client-predicted
movement survives.
Root 2 — the memory climb is NATIVE/GPU, not managed. The as-filed "LOH
leak" is wrong: LOH is a bounded sawtooth (318→829→318 MiB; churn, not
retention) and live managed heap at wedge was only 333 MB. The monotonic climb
is working set 1,295→3,261 MiB (~180 MB/min); at peak, WS 3,261 vs managed
committed 1,015 MiB leaves ~2.25 GB unaccounted native/GPU memory. Prime
suspect: Vulkan device resources minted per equipment re-attach (185–187
equipment: attached … RightHandCombat lines vs 1 CreateObject) and per vfx
setup, never released.
Why the scripted gates missed it: the V11 portal-churn soak used /teleloc,
which enters the transit state machine by a different door than a walked portal
transit — exactly the gap the walked-play session was designed to probe.
Next (authorized): (a) instrument the WorldSession outbound boundary —
opcode+sequence per send, swallowed exceptions, IsInWorld, per-frame dt,
inbound-queue depth — and reproduce with walked portal play; at a dead combat
toggle, whether the send reaches the wire and whether the sequence still
advances distinguishes the remaining hypotheses. (b) audit the
attach→GPU-resource path for create-without-release. Do NOT patch the symptom
(a gate timeout or retry loop is the classic forbidden workaround).
#259 — Win32 Vulkan surface creation fails machine-wide (ERROR_UNKNOWN)
Status: OPEN — environment fault, not a product defect; recorded so the next reader does not bisect the tree for it Severity: HIGH while it lasts (the client cannot start at all) Filed: 2026-07-29 Component: host machine / AMD driver / Win32 WSI
Symptom: every AcDream.App launch dies during startup with
VulkanCallException: vkGetPhysicalDeviceSurfaceCapabilitiesKHR returned ErrorUnknown
at VulkanSwapchain.QuerySurface()
at VulkanGraphicsContext.SelectDeviceAndGate()
It is not our code. Observed first during the V11 gate battery and bisected:
the failure reproduces identically at V11 HEAD, at both V11 implementation
commits, and at db4426d5 — the pre-V11 commit whose Vulkan soak had
completed 91 checkpoints three hours earlier on the same machine.
VulkanSwapchain.cs and VulkanGraphicsContext.cs were not modified by V11.
The one-line diagnosis. Run the Khronos tool, which shares no code with us:
> vulkaninfo --summary
ERROR while creating surface for extension VK_KHR_win32_surface : failed with ERROR_UNKNOWN
If vulkaninfo fails there too, the fault is the machine's, not acdream's, and
no amount of bisecting the tree will find it. Check this first whenever the
client will not open a window.
State when observed: session not locked (LogonUI absent), desktop present
at 2560x1440, both adapters (RX 9070 XT driver 32.0.31021.5001, and the
integrated Radeon) reporting Status = OK and enumerating with Vulkan 1.4.
Vulkan instance and device creation succeed; only Win32 surface creation
fails. It followed several hours of continuous GPU-heavy soak runs.
Expected remedy: restart the display driver or reboot. Not reproduced from a
cold boot. If it recurs after a reboot, it stops being an environment note and
becomes a real investigation — capture vulkaninfo --summary and the driver
version at that point.
Blocked by this: V11's runtime gate battery (offline pixel gate, both
connected routes, validation run, working-set re-measure). The pre-deletion
pixel baseline is already captured at artifacts/v11-pre, so the
self-differential is still available once a window can be created.
#258 — Developer panels have no host after V11 deleted ImGui
Status: OPEN Severity: MEDIUM (developer capability regression; no player-facing effect) Filed: 2026-07-29 Component: developer tooling / retained UI
Description: Campaign V slice V11 deleted src/AcDream.UI.ImGui/ and the
UI Studio tree, because ImGui was a GL-only frontend and porting it to Vulkan
was never in the campaign's scope. The consequence, recorded here so it is a
decision rather than an accident: ACDREAM_DEVTOOLS=1 no longer produces any
developer UI. The variable survives — it still selects Vulkan's debug-utils
instance extensions, and the client logs one line saying so — but the panel
overlay, the menu bar, the debug panel and the UI Studio previewer are gone.
What was lost: the IPanel/IPanelRenderer overlay and every panel written
against it, the ui-studio CLI verb (layout preview, --layout, --dump,
--mockup, --screenshot), and the ImGui-hosted Settings/Diagnostics
surfaces. AcDream.UI.Abstractions itself survives intact — the panel
contract, ViewModels and commands were always backend-agnostic, which is the
whole reason Code Structure Rule 3 exists. Only the ImGui backend went.
What this issue is for: deciding what replaces it. The retained
UiHost/UiRoot tree is already a working, Vulkan-capable retained-UI engine
with its own layout, input routing and rendering — so the cheapest credible
answer is to host the developer panels as retained-UI windows rather than
reintroduce an immediate-mode dependency. That keeps one UI stack instead of
two, which is a simplification the campaign paid for.
Not urgent, and deliberately unscheduled. Nothing in M4's remaining work
needs a dev overlay, and the diagnostic env-var family
(ACDREAM_PROBE_*, ACDREAM_DUMP_*) still works without one. Sequence this
when a debugging task actually wants a panel, not before.
Files: src/AcDream.UI.Abstractions/** (the surviving contract),
src/AcDream.App/UI/** (the retained engine that would host it). The deleted
tree is recoverable from git history at 844cf092^.
#255 — Two RetailDatLoader concurrency tests measured the thread pool, not the loader
Status: DONE — 2026-07-30 (Campaign P adjacent; flake hunt). The
sleep-race class was root-caused and fixed for good: the two
RetailDatLoaderTests concurrency proofs raced a fixed
ReadDelayMilliseconds=40 window against thread-pool injection latency —
under full-solution CPU contention (9 concurrent VSTest hosts) the second
Task.Run can miss the window, observe MaxConcurrentReads==1, and fail
(the Slice-P3 one-in-three full-suite failure). Production coalescing
(ConcurrentDictionary<K, Lazy<T>>.GetOrAdd) verified NOT racy. Fix:
deterministic RawDatabase.ArmConcurrencyGate(n) (a Barrier rendezvous
inside TryGetFileBytes) replaces the sleep race — the same pattern
DecodedTextureCacheTests already uses. Proof: 3x full solution suite
clean + 20x isolated filtered runs clean. Hunt attempt matrix: ~72
Content.Tests executions across four contention strategies never caught
the original failure live; root cause established by static analysis of
the only wall-clock-dependent file in the project. (An earlier
worktree-based writeup of this fix was drafted against a stale base as a
new issue; reconciled into this entry — no separate issue number.)
guarantee; AnimationCache_Coalesces… still fails under full-suite load on
Windows. Reopened independently by two sessions on the same day; both evidence
sets are kept at the end of this issue.
Severity: LOW (test infrastructure only; no production defect)
Filed: 2026-07-28
Component: tests / xUnit parallelism, content loaders
Description: AcDream.Content.Tests.Vfx.RetailDatLoaderTests.AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel
and .PhysicsScriptLoader_AllowsConcurrentFirstReads failed on both legs of
portable-headless in run 30392764012. They were previously invisible: the
ubuntu leg died in Core.Net (#254) before reaching AcDream.Content.Tests, and
the windows leg died at an even earlier step (the misplaced sudo apt-get), and
the workflow's test loop exits on the first failing project.
Root cause: both assert on RawDatabase.MaxConcurrentReads after issuing two
Task.Run reads, each of which blocks 40 ms in Thread.Sleep. A pair of pool
work items does not guarantee two workers are ever in flight: on a low-core or
saturated pool the second queues behind the first, the two reads run back to
back, MaxConcurrentReads stays 1, and the assertion fails for a reason that has
nothing to do with the loader. Same family as #252 and #254 — a test asserting a
real-time/parallel property under an unbounded-parallelism test host.
Evidence: clean on Windows locally (6/6 full-suite runs, 124/124 each).
Reproduced by pinning the suite to two CPUs on Ubuntu 24.04 (taskset -c 0,1):
5 failures in 6 runs, the same two tests every time.
Fix: a private InParallel helper starts both callbacks with
TaskCreationOptions.LongRunning on TaskScheduler.Default, which asks for a
thread each, so the concurrency the assertions measure is actually offered. No
assertion changed — they still fail if the loader serialises. The two
coalescing tests were moved onto the same helper deliberately: two callers
genuinely in flight is the situation coalescing exists for, where a sequential
pair only ever exercised a cache hit. 10/10 clean under the same two-CPU pin
afterwards.
Files: tests/AcDream.Content.Tests/Vfx/RetailDatLoaderTests.cs.
Reopened 2026-07-29 — two independent sessions, one diagnosis
Two overnight sessions hit this on the same day, on different trees, and reached the same conclusion without knowing about each other. Both evidence sets are recorded below because they probe different pressure regimes and agree, which is what makes the diagnosis solid rather than anecdotal.
Evidence set A — the V11 gate (post-deletion tree)
AnimationCache_CoalescesSameDidAndAllowsUnrelatedReadsInParallel failed again
on Windows, at RetailDatLoaderTests.cs:311 (Assert.True(portal.MaxConcurrentReads >= 2)),
in 2 of 5 complete-solution Release runs on an otherwise green V11 tree —
and passed 124/124 in isolation and in the other 3 whole-suite runs. V11 does
not touch the subject: its only change under src/AcDream.Content/ is
UploadFormats.cs, which is pixel-format enum documentation.
Why the fix did not hold. TaskCreationOptions.LongRunning asks the
scheduler for a dedicated thread; it does not promise the two callbacks overlap
in time. With nine test assemblies running concurrently, the first 40 ms
Thread.Sleep read can finish before the second callback is scheduled at all,
so MaxConcurrentReads never reaches 2 — the same failure mode #255 originally
described, merely made rarer. The evidence in the section above (10/10 under a
two-CPU pin) tested a narrower pool, not a contended one, which is why it
looked closed.
What a real fix needs: the two callbacks must be synchronised against each
other rather than against the scheduler — e.g. a Barrier or two-party
SemaphoreSlim rendezvous inside the read stub, so each read cannot complete
until both have entered. That makes the assertion measure the loader's
coalescing, which is what it is for, and makes it independent of how many
threads the host happens to offer. Do not weaken the assertion or add a
retry — the assertion is correct; the harness around it is what is wrong.
Evidence set B — the wire-stack audit session (pre-deletion tree)
Observed independently on b70b9832, a tree that already contains the
LongRunning fix (c7861020) in its base lineage: 2 failures in 4 full-suite
runs, with the project passing 124/124 every time it is run alone. Both
failures were --no-build runs, which start the projects faster and so apply
more parallel pressure — consistent with the original diagnosis rather than a
new cause.
PhysicsScriptLoader_AllowsConcurrentFirstReads was not observed failing in
that session, but it shares the helper and should be treated as the same risk.
That session changed nothing under AcDream.Content; its diff was confined to
src/AcDream.Core.Net, that project's tests, and docs/. So neither reopening
is explained by the tree it was observed on — set A's V11 diff touches
AcDream.Content only in pixel-format enum documentation, and set B's touches
it not at all.
What the two sets agree on
Four full-suite failures across nine runs on two different trees, against
124/124 in isolation every time. The fix direction is the same from both
sides: synchronise the two callbacks against each other — a Barrier or
two-party SemaphoreSlim rendezvous inside the read stub — so neither read
can complete until both have entered. Then MaxConcurrentReads == 2 is a
property of the loader rather than of the scheduler, and the test cannot flake
no matter how loaded the box is.
#254 — Logout confirmation wait overran its timeout on a starved thread pool
Status: DONE — 2026-07-28; monotonic deadline added to the synchronous drain Severity: LOW (shutdown-path timing; no user-visible defect observed) Filed: 2026-07-28 Component: net / session shutdown
Description: AcDream.Core.Net.Tests.WorldSessionShutdownTests.WaitForConfirmation_TimeoutWinsDuringContinuousUnrelatedDrain
failed on the portable-headless (ubuntu-latest) leg of the Headless
portability workflow in three CI runs out of three (30259857711 on main,
30386410215 and 30389334662 on the campaign branch), 599/600 each time, while
the windows-latest leg never saw it. The assertion that fell was
Assert.True(processed < 100): the method drained all 100 queued items — about
200 ms of work behind a 25 ms timeout — before noticing the deadline.
Root cause: WorldSession.WaitForCharacterLogOffConfirmation expressed its
deadline only as CancellationTokenSource(TimeSpan) and polled
IsCancellationRequested. A CTS timeout is published from a thread-pool timer
callback, so when the pool is saturated the token stays unsignalled well past
the deadline while the drain loop keeps consuming already-queued items — exactly
the case the method exists to bound. This is a real (if small) product defect,
not a test artifact: on a loaded machine the client's logout wait could overrun
its own timeout by the length of whatever is already sitting in the inbound
queue.
Evidence: not reproducible on Windows (5/5 suite runs clean) nor on native
Linux with 16 cores (8/8 clean). Reproduced by pinning the suite to two CPUs on
Ubuntu 24.04 — taskset -c 0,1 — which failed 2 runs out of 6; the same pin at
four CPUs was clean 6/6. GitHub-hosted ubuntu-latest is 4-core and slower than
the local pin, which is why CI saw it every time.
Fix: the drain loop now also compares Stopwatch.GetElapsedTime against the
requested timeout, so the deadline is read off the monotonic clock rather than
only off a thread-pool callback. The CTS is kept — it still bounds the
asynchronous WaitToReadAsync. A negative timeout keeps its framework meaning
of "infinite". Ten of ten suite runs clean under the same two-CPU pin
afterwards. The test was not modified.
Files: src/AcDream.Core.Net/WorldSession.cs (the internal generic
WaitForCharacterLogOffConfirmation), asserted by
tests/AcDream.Core.Net.Tests/WorldSessionShutdownTests.cs:203.
#256 — Server-spawned objects go invisible after repeated portal runs
Status: OPEN Severity: HIGH (world objects invisible but interactive; live-server observed) Filed: 2026-07-28 Component: live-entity render publication / streaming (backend attribution pending)
Description: During the first live-server session (Coldeve, Vulkan backend, ~long session with repeated town-portal-network runs), the user observed that some server-generated objects — signs and portals in the portal network — became visually missing after repeated transits. The portal remained usable while invisible: interaction worked, so the entity was alive in the object table and only its render projection was gone.
Class candidates: render-publication retirement across reveal generations dropping statics without re-publish on revisit; texture-slot or composite churn per transit exhausting/leaking table entries (see #249's GL-side residency finding); the streaming residence race class (entities pending during recenter — see the #168/#169 memory note). "Invisible but interactive" pins it to the presentation half, not the wire or object table.
Discriminator to run BEFORE V11 deletes GL: the same repeated portal-network route on the GL backend, same session length. Vulkan-arm-only → the leak/retirement bug is in the new arm's resource lifecycle; both arms → a pre-existing publication bug the campaign merely witnessed. After V11 this question costs far more to answer.
Acceptance: repeated portal-network transits leave every server-spawned sign/portal visible; a soak instrument counts published-vs-live entities per transit and holds at zero drift.
Discriminator result (2026-07-28, V11 step 0) — NEGATIVE ON BOTH ARMS
The discriminator this issue demanded before V11 was built and run:
tools/run-portal-churn-soak.ps1, 30 cycles x 3 portal-bearing stops
(Holtburg town / Facility Hub interior / Aerlinthe island, all taken from the
existing connected routes, every teleloc carrying the identity quaternion so the
heading repeats), 90 transits per arm, once on gl and once on vulkan
from the same binary at 122fe8a7. Both arms: 91 checkpoints, zero error
lines, graceful exit, desktop witness RENDERED.
The pixel half is a within-arm comparison — the Holtburg capture at cycle 1 against cycles 10, 20 and 30 from the same process at a pinned viewpoint with MSAA off and all four determinism levers forced. An object that stopped being drawn would appear as a contiguous blob in the difference map.
| Arm | c01 vs c10 | c01 vs c20 | c01 vs c30 |
|---|---|---|---|
| gl | 12,366 px | 14,492 px | 17,082 px |
| vulkan | 12,774 px | 15,234 px | 16,183 px |
The two arms are the same to within noise, and the difference maps show
nothing missing. Every building, the portal cone, the statue, the distant
treeline, the scenery and the NPC render identically at cycle 30 and cycle 1 on
both backends. What differs is: the player avatar's animation phase, the
portal's own scrolling texture, one corpse, the stamina readout — plus, on GL
only, a scatter of single-pixel alpha fringes across the ground that Vulkan does
not produce. Publication counters corroborate: worldEntities is 10,382 at
every one of the 30 cycles on both arms, ownedCompositeTextures is 471,
trackedGpuTextures is 437 (GL) / 372 (VK) — all three constant from warmup to
the last transit, with zero drift.
Verdict: the "Vulkan-arm-only lifecycle bug" hypothesis is refuted for this workload, so V11 is not blocked by this issue. But neither arm reproduced the symptom, so this does not identify the pre-existing bug either — it rules out the one outcome that would have made deleting GL wrong.
This issue stays OPEN. The next attempt must differ from this route the way
the user's session did: real portal use (walk into a portal, F751 wormhole,
materialization) rather than /teleloc, and Coldeve's town portal network
rather than these three stops. /teleloc and a walked portal do not take the
same path into the transit state machine, and that difference is now the leading
suspect. Artifacts: artifacts/v11-churn/ (per-checkpoint CSVs, memory sample
CSVs, difference maps, churn-soak.json).
#257 — Working set balloons to ~1.5 GB over a live portal-churn session
Status: OPEN Severity: HIGH (memory; live-server observed) Filed: 2026-07-28 Component: GPU/streaming resource lifetime (backend attribution pending)
Description: Same session as #256: the process working set grew to ~1,500 MB, versus 877 MiB (VK) / 944 MiB (GL) measured by V8's stationary vehicles and the ~930 MiB private-set neighborhood of the campaign baselines. V8's vehicles were stationary or a single 9-stop circuit; a live session with heavy repeated portal transits is a different lifetime profile, and the growth suggests a per-transit accumulation (texture table entries, composite arrays, mesh-arena ranges, or per-generation buffers) that eviction never returns.
Likely coupled to #256 — if per-transit churn leaks presentation resources, exhaustion (invisible objects) and growth (working set) are two symptoms of one lifecycle bug.
Discriminator: same as #256, same reason, same before-V11 urgency. The
existing lifetime/uncapped soak machinery plus GpuMemoryTracker accounting per
transit is the instrument; the campaign's typed-residency ledgers (Slice D/E)
are the model to hold this to.
Acceptance: a repeated-portal soak (≥30 transits) holds working set flat after warmup, with per-category GPU accounting drift at zero.
Discriminator result (2026-07-28, V11 step 0) — NEGATIVE ON BOTH ARMS
Same run as #256's: tools/run-portal-churn-soak.ps1, 30 cycles x 3 stops =
90 transits per arm, gl then vulkan from one binary at 122fe8a7, with
the OS sampling working set and private bytes every 2 s (547 / 548 samples per
arm) and each checkpoint joined to its nearest sample by timestamp.
Holtburg, every fifth cycle:
| Cycle | GL WS | GL priv | GL GPU | VK WS | VK priv | VK GPU |
|---|---|---|---|---|---|---|
| 1 | 1878.1 | 1949.6 | 321.5 | 1893.3 | 1848.7 | 58.6 |
| 5 | 1728.6 | 1783.3 | 332.3 | 1905.7 | 1843.7 | 58.6 |
| 10 | 1902.7 | 1963.6 | 332.3 | 1928.5 | 1867.6 | 58.6 |
| 15 | 1911.7 | 1972.9 | 332.3 | 1933.8 | 1870.6 | 58.6 |
| 20 | 1976.1 | 2042.5 | 332.3 | 1757.8 | 1693.7 | 58.6 |
| 25 | 1874.3 | 1940.7 | 332.3 | 1875.4 | 1812.0 | 58.6 |
| 30 | 1920.4 | 1994.5 | 332.3 | 1774.5 | 1709.1 | 58.6 |
(MiB. The GL/VK GPU-byte levels are not comparable to each other — the two
backends' GpuMemoryTracker coverage differs — but each is comparable to
itself, and each is exactly constant: GL pins at 332.3 MiB from cycle 2
onward and never moves again; Vulkan sits at 58.6 MiB for all 30 cycles.)
Whole-run statistics over every OS sample:
| Arm | min | mean | max | peak private | warm 1st-half mean | warm 2nd-half mean | drift |
|---|---|---|---|---|---|---|---|
| gl | 1713 | 1864 | 2210 | 2282 | 1927 | 1880 | -48.0 |
| vulkan | 1734 | 1863 | 2173 | 2112 | 1894 | 1867 | -27.0 |
Neither arm grows. The warm-half drift is negative on both, the two means agree to 1 MiB, and handle counts are flat (GL +1, VK -4 over 30 cycles).
Verdict: not reproduced, on either backend, so V11 is not blocked. The ~1.9 GB band is a level, not a leak: this route holds three very different landblocks' content live, which is a different residency profile from V8's stationary 877 MiB (VK) / 944 MiB (GL) vehicles. The 1.5 GB the user saw is inside this band, so the reported number may simply be what a multi-region session costs — but that is a hypothesis this run cannot confirm, because it did not reproduce unbounded growth to explain.
This issue stays OPEN, coupled to #256 and blocked on the same follow-up:
re-run with walked portal transits rather than /teleloc. Artifacts:
artifacts/v11-churn/.
#253 — Attribute/skill icons: not centered in their cells, and fully opaque
Status: OPEN Severity: LOW (visual fidelity; user-observed) Filed: 2026-07-28 Component: retail UI / character sheet (D.2b)
Description: During the Campaign V mid-campaign visual session (on the Vulkan backend), the user observed that the attribute and skill icons in the character sheet are the right size but sit misaligned in their cells — they should be centered — and are fully opaque, where retail renders them with translucency comparable to the vitae window.
Suspected pre-existing, not a Vulkan regression: the V7 differential holds
the retained UI identical between backends (static 2-D content compares inside
3.94e-04 at tolerance 2), so whatever the character sheet draws on Vulkan, GL
draws the same. The panel itself was not opened in any automated capture, so
this needs one GL-side look to confirm — after which it is a D.2b retail-UI
fidelity item, not campaign scope. Verify against retail (LayoutDesc
0x2100002E import path, CharacterSheet/CharacterSheetProvider cell
placement and the icon draw's alpha) rather than guessing the intended offsets.
Acceptance: icons centered per the authored layout; translucency matching retail's; confirmed identical on both backends (or the GL/VK difference, if one exists after all, attributed).
#252 — App test classes raced on process-global camera/render statics
Status: DONE — 2026-07-28; serialized via CameraDiagnosticsCollection
Severity: LOW (test-infrastructure only; no production defect)
Filed: 2026-07-28
Component: tests / xUnit parallelism
Description: A full Release AcDream.App.Tests run failed once at
Issue181WallPressEquilibriumTests.Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability.
The test passed in isolation and did not recur across five further whole-suite
runs. The diff under test touched only the world texture-creation stack —
nothing in camera, visibility or physics — so a regression was never a
plausible mechanism.
Root cause: Ten App test classes share three process-global mutable statics. xUnit runs distinct test classes in parallel by default, so one class's mutation window is observable by another class mid-test:
AcDream.Core.Rendering.CameraDiagnostics—AlignToSlope,CollideCamera,TranslationStiffness,RotationStiffness,UseRetailChaseCamera.RetailChaseCameraTestssetsAlignToSlopeandCollideCameratofalse, and three classes setUseRetailChaseCameratofalse— all away from their defaults. Read byRetailChaseCamera.Update,CameraController.Active,CameraFrameController,WorldRenderFrameBuilderandMouseLookController.AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled— written byCornerFloodReplayTestsandIssue181WallPressEquilibriumTests.System.Console.Out— redirected viaConsole.SetOutby those same two classes to capture probe output.
Every class saved and restored in try/finally, which is correct within a
class but not sufficient across classes. A finally bounds the mutation in
time along its own thread; it cannot stop a concurrent class from reading the
static inside that window. Two overlapping save/restore pairs can also
interleave so the second restore writes back the first one's temporary
value, leaving the global wrong for the remainder of the run. The
Console.Out case is the sharpest: an interleaved restore can install a
disposed StringWriter as the process-wide Console.Out, which then
throws in unrelated tests.
Fix: tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs
adds a marker [CollectionDefinition] (no fixture — each member still needs
its own per-test values, several as [Theory] cases, so a fixture cannot own
the save/restore) applied to the ten sharing classes. Follows the existing
WorldEnvironmentControllerCollection precedent. No production code changed
and no assertion was weakened. Membership is deliberately narrow: classes that
merely construct a CameraController without a retail chase camera are not
members, because their reads are insensitive.
Verification: Base commit f6275f45 measured empirically at 3,763 passed /
3 skipped. Post-fix, 136 whole-suite Release runs; every failure observed was
in the pre-existing zero-allocation family (see #250) and none was in any
collection member. A matched 55-run baseline at f6275f45 reproduced the same
zero-allocation family. Serialization cost is within run-to-run noise — the
suite is ~3 s of a ~4.5 s wall-clock dotnet test.
Files: tests/AcDream.App.Tests/Rendering/CameraDiagnosticsCollection.cs
(new); [Collection] applied to Issue181WallPressEquilibriumTests,
Issue181CameraParkStabilityTests, Issue177StairDescentCameraFloodTests,
RetailChaseCameraTests, CameraControllerTests,
WorldRenderFrameBuilderTests, CornerFloodReplayTests,
HouseExitWalkReplayTests, CameraFrameControllerTests,
MouseLookControllerTests.
Follow-up (not fixed here): CameraDiagnostics and RenderingDiagnostics
are static by design (the PhysicsDiagnostics runtime-toggle pattern). The
test-side collection is the correct fix for the observed race; making the
camera knobs an injectable instance carried by the camera would remove the
class of defect entirely, but that is production surgery and out of scope.
AcDream.Core.Tests has the same shape in a separate assembly (hence a
separate process, so it does not race with App): CameraDiagnosticsTests is
its only CameraDiagnostics writer, but four classes there call
Console.SetOut — CellarLipWedgeTests, CameraCornerSealReplayTests,
Issue137CorridorSeamReplayTests, RenderingDiagnosticsVisibilityTests.
#251 — glClientWaitSync returned 0 and crashed the render loop, once in nine connected runs
Status: OPEN Severity: MEDIUM (one observed occurrence; kills the process when it fires) Filed: 2026-07-28 Component: rendering / GL frame-flight fences
Description: During the Campaign V slice V4t connected gate, one run died
with an unhandled InvalidOperationException in OnRender:
OpenGL returned unexpected fence wait status NoError (0x0).
at GpuFrameFlightController.RetireFence(Int32 slot)
at RenderFrameOrchestrator.Render(RenderFrameInput input)
at GameWindow.OnRender(Double deltaSeconds)
glClientWaitSync is specified to return ALREADY_SIGNALED,
TIMEOUT_EXPIRED, CONDITION_SATISFIED or WAIT_FAILED. It returned 0, which
is none of those, so SilkGpuFenceApi.Wait's exhaustive switch threw — the
switch is correct and the throw is the right behaviour; the anomaly is the
driver's return value. The run had already reached world-visible and
complete and had logged a graceful logout; the crash landed while it was
still rendering, before the probe's screenshot, so the gate recorded
NO-CAPTURE. Shutdown then reported
status=AbandonedIncomplete, blocked=submitted GPU work — the same fence, hit
a second time from WaitForSubmittedWork — followed by Silk.NET's
"You cannot call Reset inside of the render loop!" from the native fallback.
Both are consequences, not separate defects.
Root cause / status: Unknown, and NOT attributed to V4t. It occurred once
in nine connected runs on 2026-07-28: once in three at the V4t-1 tree, then
zero in three more at that same tree and zero in three interleaved runs at
cb2a70b8. The V4t-1 diff creates, deletes and waits on no fence and adds no
retirement registration that executes on that path. A sync object whose handle
stops being valid mid-session, on a clean glGetError, is the same
below-the-API failure family the campaign documented four instances of on this
exact driver (AMD 26.6.4, RX 9070 XT) in plan §5.5.1–§5.5.3 — a deadlocking
glGetQueryObject read, a never-executed GL_QUERY_BUFFER write, a
multisampled glReadPixels, and a capture that could not see the presented
surface. That is a hypothesis, not a finding: nothing here rules out a real
double-delete or a lifetime bug in our own fence bookkeeping.
Files:
src/AcDream.App/Rendering/GpuFrameFlightController.cs:274RetireFencesrc/AcDream.App/Rendering/GpuFrameFlightController.cs:474SilkGpuFenceApi.Waitsrc/AcDream.App/Rendering/GameWindowLifetime.cs:419shutdown'sframe flight drain
Acceptance: Either a reproduction that pins the invalidation to our own
bookkeeping and a fix for it, or — if the driver is confirmed — a decision
recorded here about whether a 0 return should be treated as WAIT_FAILED and
retried rather than thrown. Do not silently widen the switch to swallow it: an
unexpected status is exactly the signal §5.5 spent three days wishing it had.
#250 — Zero-allocation tests fail intermittently, roughly 1 run in 3
Status: OPEN Severity: MEDIUM (undermines every "tests green" gate) Filed: 2026-07-27 Component: tests / allocation assertions
Description: Two tests fail non-deterministically on an otherwise unchanged tree:
AcDream.App.Tests.UI.UiDatFontTests.InstanceMeasureWidth_ReusesGlyphTableWithoutAllocatingAcDream.App.Tests.Rendering.RenderFrameProductTests.WarmProductBuildAndBorrowAllocateNothingAcDream.App.Tests.Rendering.CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing(added 2026-07-28 during Campaign V slice V6f: one full run reported 2,752 bytes against an expected 0, on a diff that touches only GLSL and the terrain renderer's uniform plumbing. It passed in isolation and in four other full runs of the same binary, so it is the same class rather than a new defect.)AcDream.App.Tests.Rendering.ArchRenderSceneTests.TransformUpdateBatch_ReusesRetainedStorage(added 2026-07-28 during Campaign V slice V7, on a docs-only tree. Two of six consecutive whole-suite Release runs failed on it and nothing else; the class passes 12/12 in isolation. Its assertion is the same shape as the others —GC.GetAllocatedBytesForCurrentThread()delta acrossscene.Apply(updates)asserted equal to zero — so it is a fourth member of this family, not a new defect. Recorded so the next reader does not re-investigate it.)
Measured over six consecutive Release runs of the App suite on an unmodified tree: four passed 3,846/3, and two failed with exactly one failure — a different one of the pair each time. So the observed rate is about one run in three, and it is not specific to either test.
Both assert that a warmed code path allocates zero managed bytes. That measurement is inherently sensitive to anything else the runtime does on the thread — tiered JIT recompilation and background GC bookkeeping can both attribute bytes to the measured window.
Why it matters now: Campaign V's acceptance criteria include 0 B/frame steady-state managed allocation, and these are the tests that guard it. A gate that fails a third of the time for unrelated reasons trains everyone to re-run until green, which is exactly how a real regression gets waved through — see the V4a revert, where a failing gate was rationalised rather than investigated.
Provenance: first observed during Campaign V slice V2 and dismissed as unrelated flakiness; seen again independently by the V4c and V4d agents; then reproduced deliberately here. Not caused by the campaign.
Fix direction: warm the path harder before measuring (force tiered
promotion), take the best of N samples rather than a single one, or measure with
GC.TryStartNoGCRegion. Whichever is chosen, the assertion should stay strict —
the goal is to remove the measurement noise, not to loosen the bound.
Acceptance: twenty consecutive Release runs of the App suite with zero failures.
Fixed at the measurement — 2026-07-29
The four members share one root: the measured window was never the warmed path. Reading them side by side makes it obvious, and it is not "allocation measurement is inherently noisy" — it is two concrete, fixable mistakes.
| Test | Warmup | Measured window |
|---|---|---|
UiDatFontTests |
1 call | a 10,000-iteration loop written inline |
RenderFrameProductTests |
8 calls | a 1,000-iteration loop written inline |
CurrentRenderSceneOracleTests |
1 call | 1 call (body is a 1,000-iteration loop) |
ArchRenderSceneTests |
Apply(registrations) |
Apply(**updates**) — a different switch arm |
Two mechanisms follow:
- On-stack replacement inside the window. A test method is JIT-compiled at tier 0 like any other method, and a long-running loop in tier-0 code is replaced mid-flight by OSR. OSR compiles on the thread running the loop — the measuring thread — so its bookkeeping is charged to the window. Both inline-loop tests measured exactly the shape that triggers it.
- First-call cost inside the window.
ArchRenderSceneTestswarmed theApplyRegisterarm and measured theApplyUpdatearm, so the measured call was the first ever into that code: its tier-0 JIT, type loads and static initialisation all landed inside.RenderFrameProductTestswarmed 8 times, below the tier-0 call-counting threshold of 30, so promotion was still pending when measurement began.
That also explains the signature — clean alone, failing about one full run in three. Alone, the process is quiet and the runtime has finished before the assertion arrives. Alongside eight other test assemblies, tier-0 compilation never stops, the call-counting delay is re-armed continually, and the work slides into the window.
Fix: tests/AcDream.App.Tests/ZeroAllocationProbe.cs. It invokes the step
many times before measuring anything, then measures windows that run the same
already-warmed loop over the same already-taken path. Each window is a batch
of 32 invocations and the probe reports the minimum across 4 such batches.
The minimum excludes one-time costs; the batch is what keeps the assertion as
strong as the loops it replaced, since minimising over single invocations
would report zero for a path that allocates every tenth call. The bound stays
exactly zero — no tolerance, no retry, no assertion weakened.
ZeroAllocationProbeTests guards the apparatus in both directions: a step that
allocates every call is reported above zero and does throw; a first-invocation
cost reads as zero; a cost every tenth call is caught; and the stated limit —
the batch must cover the period — is pinned rather than left as prose. Without
those, a later edit could quietly make the whole family unfailable.
The family was larger than four. The first attempt converted only the four
members the issue named and left the rest, on the grounds that none had been
observed failing. A 20-run complete-solution baseline immediately disproved
that: LiveEntityRuntimeTests.AnimationView_HotSpatialTraversalDoesNot
AllocateAfterWarmup failed in run 2 and
StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization_Reuses
RetainedJournalStorage in runs 14 and 18 — both the same shape, neither
previously recorded. "Not observed failing" only ever meant "not yet observed".
So every strict-zero site in the assembly is now on the probe — ten tests:
the four named members plus LiveEntityRuntimeTests,
StaticRenderProjectionJournalTests, RenderFrameRouteOwnerSelectorTests,
GpuWorldStateRenderTraversalTests, UiTextLayoutCacheTests,
RetailInboundEventDispatcherTests, PackedProjectionClassificationCacheTests
and EquippedChildProjectionWithdrawalTests.
Two of those got stricter rather than merely steadier.
StaticRenderProjectionJournalTests turned out to be measuring a synchronise
whose journal does not coalesce — repeating it grew the journal by 1,000
entries per call, so the steady state it claimed to test did not exist. Its step
is now the whole frame cycle, synchronise and drain, which puts DrainTo
inside the measured window for the first time.
RetailInboundEventDispatcherTests now counts its own dispatches and pins the
callback count against them, where before it asserted a hard-coded 1,001.
Deliberately not converted: the four sites that assert a tolerance rather
than zero — CellViewDedupTests (two, <= 256) and PortalProjectionTests
(two, <= 1_024 and <= 4_096). Their ceilings already absorb the noise this
issue is about, none has been observed failing, and touching their bounds in
either direction is a separate decision. PortalProjectionTests is worth
revisiting: its ceiling exists explicitly to tolerate "a tiered-JIT/ArrayPool
bookkeeping transition ... to the first measured batch", which is precisely what
the probe removes, so it could likely be tightened to zero on the probe now.
#249 — Bindless handles stay resident after their table slot is released
Status: OPEN Severity: MEDIUM Filed: 2026-07-27 Component: rendering / GPU resource lifetime
Description: GlGpuDevice.ReleaseTextureSlot zeroes the table entry and
returns the index to the free list, but never calls
BindlessSupport.MakeNonResident. Two consequences:
- Callers go on to
glDeleteTexturea texture whose bindless handle is still resident, whichGL_ARB_bindless_textureleaves undefined. Six other caches in the tree (TextureCache,CompositeTextureArrayCache,StandaloneBindlessTextureCache,TerrainAtlas) do callMakeNonResidentfirst, so the omission is inconsistent as well as unsafe. - Every released slot leaks a resident handle for the process lifetime, even though the index itself is recycled.
Provenance: found by the Campaign V slice V4a audits. Introduced in V1
(4f94ad7d), not in either V4a attempt; present on the current tree.
Also in scope for this issue (same audits, same class of "no gate would have caught it"):
- No test covers the
Multisampledimension ofGlRenderStateCache. Mistyping that comparison would leave the whole suite green — that is exactly the regression that forced the V4a revert (9aaf97e7). Add a positive test plusAssert.False(changes.Multisample)in the existing negative cases. - Add an encoding guard: an
.editorconfigwithcharset = utf-8and a.gitattributestext rule, plus a check that rejects newly introduced BOMs or mojibake. The first V4a attempt re-encoded 259 files and corrupted non-ASCII characters in 116 of them, and no existing gate noticed.
Acceptance: released slots make their handle non-resident before the texture
is deleted; a test proves the Multisample dimension is diffed; an encoding
guard fails on a reintroduced BOM.
#248 — FrustumCuller extracts the near plane with the GL-convention formula
Status: DONE — 2026-07-29; near = Normalize(col3), pinned by a theory that
asserts the extracted near distance equals the camera's near value across four
near/far pairs. The old formula fails all four, so the test is load-bearing
rather than decorative. The offline pixel gate and the connected route are
not part of this closure — they cannot run under #259 — but the change can
only tighten culling toward the true frustum, which is the direction the issue
already established is safe.
Severity: LOW (correctness hygiene; not currently exploitable)
Filed: 2026-07-27
Component: rendering / culling
Description: FrustumCuller.FromViewProjection
(src/AcDream.App/Rendering/FrustumCuller.cs:34-55) extracts the near plane as
Normalize(col4 + col3) — the classic Gribb-Hartmann formula for OpenGL's
[-1,1] NDC z range. Every acdream projection is built by
Matrix4x4.CreatePerspectiveFieldOfView, whose NDC z range is [0,1], for which
the correct near-plane extraction is col3 alone.
Why it is not currently a bug: the mismatched formula places the effective
near threshold at -n·f/(2f-n) — roughly 0.5 m instead of 1.0 m for the retail
chase camera — which makes the AABB test strictly more permissive near the eye.
It can keep something the true frustum would drop, never the reverse, so it
produces no missing geometry. The far plane (col4 - col3) is identical under
both conventions and is unaffected.
Provenance: found by the Campaign V slice V3 clip-space audit. This is the
same class of mistake as the PortalProjection near-test bug documented at
src/AcDream.App/Rendering/PortalProjection.cs:12-19, which was user-visible
(it clipped a doorway the camera stood close to, culling the cell behind it).
Filed rather than fixed inline because it is pure CPU math, untouched by the
OpenGL → Vulkan migration, and therefore outside Campaign V's scope.
Fix: extract near = Normalize(col3).
Acceptance: culling behaviour unchanged in the offline pixel gate and the connected route; a unit test pinning the extracted near-plane distance to the camera's actual near value.
#247 — Loot ordering and local dropped-item projection regressed
Status: DONE — 2026-07-26; connected loot/drop/selection gate passed Severity: HIGH Filed: 2026-07-26 Component: inventory / live-entity projection
Description: Double-click loot transferred successfully but settled at an unrelated backpack position. Locally dropped items were authoritative and visible to retail observers, yet remained invisible in acdream.
Root cause / status: The compact container projection sorted a completed
server move by stale slot state instead of performing retail's separate
remove-then-IDList::AddAtNum insertion. Whole-object drops then exposed two
projection errors. Inventory-only objects retained canonical CreateObject and
timestamp state without an App projection sidecar; the Position authority gate
committed their server position and then rejected the presentation tail
because it required a pre-existing LiveEntityRecord. Accepted Position now
returns the exact canonical incarnation, and the network presenter crosses
the normal spatial-hydration boundary before continuing. Objects which already
retain a sidecar recover whenever IsSpatiallyProjected is false.
EquippedChildRenderController also no longer interprets every final None
equip mask as a detach; it requires the actual equipped-to-unequipped edge.
Partial-stack drops have an additional ACE-specific wire omission.
HandleActionStackableSplitTo3D creates a new GUID and sends Position updates,
but does not send that new object's CreateObject to the initiating session.
Retail records the source WCID, selected count, and request time for ten
seconds so the created object can be recognized. acdream now retains that same
single pending identity; the one matching unknown ACE Position hydrates a
canonical clone of the source appearance through the normal live-entity path.
Reconnect evidence proved all prior drops were persisted and received as
ordinary CreateObjects. Focused ordering, projection, and pending-identity
tests pass, including stale-authority rejection, first projection construction,
and no duplicate resource/ready publication.
The follow-up stale marker was a separate projection-lifetime ordering edge.
Retail keeps canonical selection but suppresses its vivid world marker while
the object is player-owned or IN_CONTAINER. acdream additionally must not
mistake a retained leave-world WorldEntity pose for a current ground pose:
ACE may publish the drop placement before the new Position. Marker resolution
now requires both world placement and a current top-level spatial projection.
The inverse packet order is also safe: a fresh Position remains suppressed by
ownership until the placement arrives. This uses no timer and does not impose
camera/PVS visibility, preserving off-screen and through-wall indicators.
Research:
research/2026-07-26-retail-inventory-placement-and-world-drop-pseudocode.md.
Acceptance: Looted items occupy the retail-selected backpack slot in server order; locally and remotely dropped items appear once in the world; pickup removes the ground marker without clearing inventory selection; re-drop shows the marker only at the new authoritative pose. User accepted all four connected checks on 2026-07-26.
#246 — Prepared indoor cells stopped traversing their portals
Status: DONE — 2026-07-26; connected house/dungeon gate passed Severity: HIGH Filed: 2026-07-26 Component: physics / cell membership / portal visibility
Description: Houses and dungeons could collide with empty space, expose the background through walls, retain the wrong room, and fail on stairs after the parsed collision graph was removed.
Root cause / status: The prepared package retained exact portal topology,
planes, and BSPs, but CellTransit.FindTransitCellsSphere still returned
immediately when the old parsed portal-polygon dictionary was absent. It now
consumes the prepared topology's direct polygon index, preserving retail's
portal traversal and failing loudly on corrupt topology. Synthetic graph-free
tests and the exact connected Holtburg cottage camera path pass, along with
1,778 physics tests / 1 skip.
Research:
research/2026-07-26-prepared-indoor-transit-regression.md.
Acceptance: PASSED — the user confirmed house/dungeon collision, doorway entry, stairs, and interior rendering work in the corrected connected build.
#245 — Distant Use stayed busy without turning or walking
Status: DONE — 2026-07-25; connected gate passed Severity: HIGH Filed: 2026-07-25 Component: interaction transport / local motion completion ownership
Root cause / resolution: Retail sends Use before any approach and ACE
authoritatively returns the required MoveTo chain. After restoring that order,
the live trace showed ACE's MoveTo target resolved correctly but remained
behind one orphan login Ready node. LiveEntityAnimationPresenter had guarded
MotionDoneTarget installation by the temporary spatial projection, even
though the exact logical live-record animation runtime still owned the
PartArray. The PartArray consumed Ready without popping the
MotionInterpreter. Completion is now bound to the logical animation owner,
and player-controller construction drains any pre-controller PartArray queue
before publishing its interpreter. Focused tests cover withdrawn spatial
projection, replacement owners, immediate one-shot Use, and busy transfer to
UseDone. The connected distant-NPC gate turns, walks, uses, and releases busy.
Research: docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md
#234 — Monster assessment held busy and examination used the shared panel
Status: DONE — 2026-07-23; corrected creature presentation visual gate pending Severity: HIGH Filed: 2026-07-23 Component: appraisal protocol / retained examination UI / item writing
Description: Assessing a monster appeared to hang the UI with the busy cursor. Examination also replaced Inventory/Skills in the shared primary panel, while retail opens its own floaty window. Assessed item inscriptions were display-only.
Root cause / status: The local IdentifyResponse enum did not match
ACE/retail: creature profile bit 0x0100 was labeled WeaponProfile. The strict
parser consequently rejected normal creature responses before the appraisal
owner could release its balanced busy reference. The examination LayoutDesc
had also been incorrectly registered as synthetic main-panel id
0x80000001; retail's concrete root is gmFloatyExaminationUI. Exact response
bits/order are now pinned by literal packet tests, the floaty is registered as
an independent top-level window, and the imported multiline field sends
retail's CP-1252 SetInscription (0x00BF) under its ownership/authorship rules.
The follow-up creature page now uses the authored row template and exact retail
stat order/formatting, creature-type EnumMapper, level property, fixed-heading
animated clone, bounding-box camera, and private-viewport light.
Its correction ports the second authored damage/critical/resistance rating
list, composites row chrome behind the animated preview and row text in front,
adds balanced text inset, and automatically reassesses each new selection
while the floaty remains visible. The next correction restores the authored
310 x 400 profile size, insets only foreground row text, lays generated item
text out from the top, and replaces the sparse property dump with retail's
ordered item-report branches for common weapons, armor, magic and DAT spell
descriptions, requirements, capacities, cooldown/special properties, uses,
crafting, ratings, rare state, and prose. AP-110 records the remaining
specialized/player-dependent/DAT-name/creature-font/object-preview branches
explicitly.
The item-format conformance correction first preserved
PublicWeenieDesc.HookItemTypes and HookType, stopped falling back from an
incomplete appraisal to public Value/Burden, and ported hook-profile capacity
suppression, lock wording, page-count order, AddItemInfo paragraph
boundaries, and the authored white/green/red font list. The connected
Black Phyntos Hive gate then exposed a separate wire-width error: WCID 28249
is an NPC-lookalike Creature, not a hook, and ACE sends its -1/-1
capacity sentinels as FF/FF. Retail PublicWeenieDesc::UnPack @ 0x005AD470
uses MOVSX at 0x005AD530 and 0x005AD545; acdream had zero-extended those
bytes into 255/255. CreateObject now sign-extends both capacity bytes, so
Appraisal_ShowCapacity's existing positive tests naturally suppress the
sentinels without a name/type workaround.
The subsequent exhaustive item-report pass ports the remaining EoR formatter branches: literal equipment-set names, positive-only ratings, salvage workmanship, clothing coverage, failed weapon unknowns, exact item-XP curves, activation/healer/rare/magic prose, and decorated lifespan/material/gem/portal descriptions. Installed DAT maps now supply material and creature/slayer names. AP-110 retains only item preview, live player/localization projections, character detail, and creature FontInfo state; the connected item visual is the remaining acceptance gate.
The next connected boots comparison exposed two final shared-path omissions.
CreateObject walked past PublicWeenieDesc.MaterialType, so
ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) could not decorate the
authored base name, and the retained report builder discarded empty strings
even though retail AddItemInfo still appends their separator. Material type
now survives CreateObject -> EntitySpawn -> WeenieData -> ClientObject and the
DAT resolver prefixes it exactly once. Empty append fragments now preserve
retail's intentional blank section rows, including the blank after
Workmanship and the literal leading newline before Armor Level.
The adjacent world-input follow-up also completes retail right-click
assessment. SelectRight is now a release-completed configurable click with
the shared three-pixel drag threshold, then pulses, selects, and appraises the
picked world object through the same owners as the magnifying glass and E key.
RMB camera drags and empty-world releases do not issue appraisal requests.
The separate retained ItemList path now does the same for occupied backpack,
side-bag, loot-container, paperdoll-slot, and physical shortcut cells; it
selects the item before entering the shared appraisal owner, and RMB movement
neither appraises nor lifts the item.
The adjacent input-latency correction ports
UIElement_ListBox::MouseDown @ 0x0046E3A0: physical retained items now update
canonical selection and their green frame on left-button down, before the
three-pixel drag threshold. Target mode is intercepted at that same press.
Bag opening, item use/equip, and shortcut activation remain completed
click/double-click actions, so a drag or target-consumed press cannot also
activate the item.
The corresponding favorite-spell branch is now ported without conflating a
spell ID with a physical object GUID. Favorite selection occurs on left press;
right-click opens the authored local Spell examination subview with
name, school, mana, duration, range, description, and the current appropriate
component formula. It sends no Appraise request and takes no busy reference,
so the status-bar magnifier remains object-only. Switching the shared floaty
from a pending object appraisal to a spell releases exactly that appraisal
transaction before showing the SpellPanel.
The formula-media correction now passes the component's DAT icon DID to
GetSpellComponentIcon instead of its WCID, so the authored cells render
their actual scarab/taper images. Each image is installed as the authored
template root UIRegion's own image, matching retail's ClearImage/SetImage
path instead of sinking a synthetic child behind the window content. ACE
characters with component enforcement disabled show the modern
scarab/prismatic formula rather than an inactive legacy recipe (IA-21). A
per-window authored-geometry revision resets the obsolete saved 545-pixel
examination height once to LayoutDesc's 310 x 400 extent, preserving its
position and all future retail-style user resizing.
Files: src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs;
src/AcDream.App/UI/RetailUiRuntime.cs;
src/AcDream.App/UI/Layout/AppraisalUiController.cs;
src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs;
src/AcDream.App/UI/UiField.cs;
src/AcDream.Core.Net/Messages/InventoryActions.cs.
Research:
research/2026-07-23-retail-appraisal-ui-pseudocode.md.
Acceptance: Monster/NPC/player assessment opens and clears busy; the
examination window coexists with the shared primary panel; an owned
inscribable weapon can be edited/cleared and reassessed; foreign/nonowned/
noninscribable cases show retail's exact permission messages. Monster pages
show the animated target, mapped creature type, level, and nine ordered
retail stat rows; rated creatures show retail's paired rating rows; the
animated target remains above row chrome but below text; changing selection
updates the open window without another magnifier click. Item text begins at
the top and assessed melee/missile/armor/magic items show their retail-ordered
stats and full DAT spell descriptions.
Right-clicking a visible world object opens the same examination window;
right-dragging the camera does not.
Right-clicking an occupied retained item cell selects and examines it without
using, equipping, looting, or dragging it.
Left-pressing one selects it before the button is released; holding and
dragging preserves that selection without activating the item.
Left-pressing a favorite spell likewise changes its selection immediately.
Right-clicking it opens the spell examination subview; selecting it and using
the status-bar magnifier does not examine the spell.
The formula contains only its power scarab(s) and prismatic taper(s) in the
component-disabled ACE test profile, every component cell shows its DAT icon,
and the first corrected open is 310 x 400 even when the old profile saved a
545-pixel height.
The Black Phyntos Hive specifically reads Value: ???, Burden: Unknown,
then its description after one retail paragraph break, without the bogus
255-item/255-container line.
#235 — Capped/RDP jump presentation aliases the 30 Hz object clock
Status: OPEN Severity: LOW Filed: 2026-07-23 Component: local animation / render interpolation / frame pacing
Description: At the capped RDP presentation rate (about 31–32 FPS), the player's upward and downward jump animation can look stepped. The same build and route are visually smooth when uncapped.
Root cause / status: The evidence points to cadence aliasing rather than a physics or protocol error. Retail advances object work on a fixed 30 Hz clock, and R6 correctly preserves that clock for motion, animation hooks, collision, and outbound truth. The existing render interpolation smooths the root position, but the animated part pose remains quantized to the last admitted object tick. When capped rendering lands close to the object-tick boundary, some visual frames repeat the same part pose. Do not change the retail object clock to fix this presentation-only symptom.
Files: src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs;
src/AcDream.App/Rendering/LiveAnimationPresentationContext.cs;
the local-player render interpolation path.
Research:
docs/research/2026-05-06-issue-38-render-interp-pseudocode.md;
docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md.
Acceptance: At capped and uncapped presentation rates, a jump renders a smooth root and animated part pose while object-clock admission, animation hooks, collision, landing, and movement-wire cadence remain unchanged.
#236 — UiText re-runs full word-wrap shaping every visible frame
Status: DONE — 2026-07-25, Modern Runtime Slice H-a1 Severity: MEDIUM Filed: 2026-07-24 Component: ui (retained)
Description: UiText.LinesProvider is polled unconditionally on every
draw, and the appraisal / character-info / effects wire-ups re-run full
word-wrap from scratch per poll (string split + per-glyph measure walks +
concatenation), including re-shaping fixed captured strings
(AppraisalUiController.cs:750, 814) and rebuilding the entire character
report per frame (CharacterController.cs:99). Real CPU + GC cost on the
most commonly opened panels, multiplying with uncapped FPS.
Root cause / status: Found by the 2026-07-24 audit review (missed by the
audit itself). The correct pattern already exists in-tree:
ChatWindowController.GetTranscriptLines caches on revision + wrap width +
font identity. Apply it to the appraisal/indicator/character paths. Mapped
to plan Slice H-a.
Files: src/AcDream.App/UI/UiText.cs:39,454;
src/AcDream.App/UI/Layout/IndicatorDetailText.cs:14-36;
src/AcDream.App/UI/Layout/ItemAppraisalReport.cs:137-183;
src/AcDream.App/UI/Layout/CharacterStatController.cs:297,306.
Resolution: UiTextLayoutCache<T> now retains shaped lines across stable
draws and invalidates on semantic content, width, padding, color, palette, or
font changes. Appraisal and effect controllers publish content through that
cache; character information is invalidated by the current character's
object/local-state events and panel-show lifecycle. Stable provider polling is
covered by a zero-managed-allocation test. Evidence:
docs/research/2026-07-25-slice-h-a1-ui-text-cache.md.
#237 — PhysicsEngine allocates a fresh Transition graph per resolve call
Status: DONE — 2026-07-25, Modern Runtime Slice I1 Severity: MEDIUM Filed: 2026-07-24 Component: physics
Description: Every ResolveWithTransition call allocates
new Transition() → 7+ heap objects (ObjectInfo, SpherePath, CollisionInfo,
three Sphere[] pairs). Callers: local player per quantum, every moving
NPC/remote, projectiles, and the camera probe — per-tick GC pressure that
scales with active-mover count (combat/crowds), a scenario the audit's
dwell-heavy route under-measured.
Resolution: Each PhysicsEngine now owns retail-shaped ten-deep,
same-thread, LIFO transition scratch. Every stored member is reset and
reflection-poisoned in tests; exact-length walkable, candidate-cell, collision
GUID, and reentrant neighbor-order storage retains identity without exposing
stale state. Fresh-vs-reused engines are bit-identical across hostile
player/remote/projectile/camera/placement/failure/step/slide cases. Release
allocation fell from 2,512–6,848 B/resolve to 0 B/resolve for all measured
mover profiles. Evidence:
docs/research/2026-07-25-slice-i1-transition-scratch.md.
Files: src/AcDream.Core/Physics/TransitionScratchArena.cs;
src/AcDream.Core/Physics/PhysicsEngine.cs;
src/AcDream.Core/Physics/TransitionTypes.cs;
tests/AcDream.Core.Tests/Physics/TransitionScratchResetTests.cs;
tests/AcDream.Core.Tests/Physics/TransitionScratchDifferentialTests.cs.
#238 — EquippedChildRenderController ticks twice per frame with six ToArray snapshots
Status: DONE — 2026-07-25, Modern Runtime Slice H-a3 Severity: MEDIUM Filed: 2026-07-24 Component: render / live entities
Description: Tick() runs twice per host frame
(LiveObjectFrameController.cs:204 pre-network and :241 post-network via
the spatial reconciler), and each run's RetryPendingProjectionTransitions
takes up to six .ToArray() dictionary snapshots for iteration safety —
up to 12 short-lived arrays per frame while any equip transition is in
flight, plus the full parent-first pose-composition walk running twice for
every character with a visible equipped item.
Root cause / status: 2026-07-24 audit review finding. The class already
has the allocation-free pattern (AttachmentUpdateOrder,
_pendingProjectionChildrenScratch). The double-tick needs its own look:
if the second tick exists for post-network reconciliation ordering, keep
the ordering and skip the redundant recomposition instead. Plan Slice H-a.
Files: src/AcDream.App/Rendering/EquippedChildRenderController.cs:265-310,1421-1429;
src/AcDream.App/Update/LiveObjectFrameController.cs:204,241.
Resolution: Both required authority boundaries remain. The first is still a
complete parent-first pose pass; the post-network boundary now compares the
exact parent's pose version, identity, visibility, and cell and recomposes only
changed branches. Publishing a changed child dirties its descendants naturally
within the same parent-first walk. Per-heartbeat pending dictionaries use
retained typed snapshots instead of arrays. Evidence:
docs/research/2026-07-25-slice-h-a3-attached-pose-reconcile.md.
#239 — Inbound net path allocates 3-4 arrays/objects per packet
Status: DONE — 2026-07-25, Modern Runtime Slice H-c Severity: LOW Filed: 2026-07-24 Component: net
Description: Every inbound datagram allocates: the UdpClient.Receive
byte[], a second full copy into Packet.BodyBytes
(PacketCodec.cs:58,64), per-packet Packet + List<MessageFragment> +
PacketHeaderOptional objects, and a third per-FRAGMENT copy in
MessageFragment.TryParse (MessageFragment.cs:41) — scaling with
fragment count, which dominates during active play. The audit named only
the outbound ToArray() copy.
Resolution: One cancellable pooled receive owner now preserves kernel
arrival order while packet headers, optionals, checksum verification, and
single-fragment dispatch borrow the queued datagram. Only multi-fragment state
whose lifetime crosses datagrams is copied. Warm production decode allocates
zero bytes; the 500,000-packet differential benchmark improved throughput
8.666x and removed approximately 400 bytes per packet. Direct recurring sends
also frame into caller storage with zero warmed allocation. Connected
login/portal/dungeon/reconnect and exact graceful disconnect pass. Evidence:
docs/research/2026-07-25-slice-h-closeout.md.
Files: src/AcDream.Core.Net/NetClient.cs:63-78;
src/AcDream.Core.Net/Packets/PacketCodec.cs:58,64;
src/AcDream.Core.Net/Packets/MessageFragment.cs:41.
#240 — RetailAnimationLoader caches every parsed Animation forever
Status: DONE — 2026-07-24, Modern Runtime Slice D3 Severity: MEDIUM Filed: 2026-07-24 Component: content / animation
Description: RetailAnimationLoader._cache
(ConcurrentDictionary<uint, Lazy<Animation?>>) has no bound or eviction;
each entry holds per-part per-keyframe transform + hook data (tens of KB).
A long-uptime session (or 30-bot fleet) that encounters progressively more
creature/object motion tables grows this monotonically — same class as the
audio caches bounded on 2026-07-24, and structurally incapable of the
plateau behavior the audit measured for entities/particles.
Fix: RetailAnimationLoader now has one concurrent in-flight gate plus a
64 MiB / 512-entry LRU (startup-configurable through
ResidencyBudgetOptions). Duplicate requests coalesce, unrelated reads remain
parallel, null and oversize results cannot grow the cache, and eviction only
drops the cache reference. Live sequencers therefore keep their immutable
Animation object valid. Deterministic count, byte-pressure, oversize, LRU,
and concurrent-load tests cover the policy; the unified residency snapshot
reports decoded bytes, budget, and evictions.
Files: src/AcDream.Content/Vfx/RetailAnimationLoader.cs:19-53.
#241 — InteriorEntityPartition never uses its per-landblock AABBs to cull
Status: OPEN Severity: MEDIUM Filed: 2026-07-24 Component: render
Description: Partition receives per-landblock AabbMin/AabbMax but
walks every near-tier landblock's full entity list every frame without a
landblock-level frustum test — while WorldSceneDiagnosticsController
computes exactly that AABB-vs-frustum answer every frame one call away and
uses it only for a diagnostics/checkpoint counter. Reusing it as a pre-cull
would skip most of the dense-town entity walk for landblocks outside the
frustum.
Root cause / status: 2026-07-24 audit review finding (the audit flagged
the whole-world walk as P2; the free cull input was the missed half). Keep
the retail dynamics contract: out-of-flood dynamics are dropped by the
per-dynamic viewcone CULL in DrawDynamicsLast, not by set membership —
the landblock pre-cull must sit upstream of, not replace, that. Plan
Slice H-a / G.
Files: src/AcDream.App/Rendering/InteriorEntityPartition.cs:108-144;
src/AcDream.App/Rendering/WorldSceneDiagnosticsController.cs:169-201.
#242 — Static publication rebuilds a third dictionary and re-sorts per completion attempt
Status: OPEN Severity: LOW Filed: 2026-07-24 Component: streaming
Description: Beyond the two dictionaries + hashset + two sorts the audit
counted per landblock publication, CompletePublication also rebuilds
_activeByLandblock[canonical] wholesale via ToDictionary, and its
OrderBy(...).ToArray() re-runs on every completion ATTEMPT while the
plugin cursor persists across retries — per-retry amplification during
portal bursts.
Root cause / status: 2026-07-24 audit review finding. Fold into the plan Slice C/E cursor-receipt work: sort once at publication construction, store the ordered array on the publication object.
Files: src/AcDream.App/Streaming/LandblockStaticPresentationPublisher.cs:261-296.
#243 — ObjectMeshManager._boundsCache grows unbounded
Status: DONE — 2026-07-24, stale audit finding closed by Slice D3 Severity: LOW Filed: 2026-07-24 Component: render
Description: _boundsCache is an unbounded ConcurrentDictionary
memoizing GetBounds results for the session's lifetime — small per entry,
but the one unbounded container in a class where every other cache is
deliberately budgeted, and another no-plateau curve for long-uptime
sessions.
Resolution: The named field and insertion site do not exist in the current
tree: a repository-wide search finds no _boundsCache and
ObjectMeshManager has no bounds memoization owner. The review appears to
have described an older or different tree. Slice D deliberately does not add
a cache merely to bound it; current mesh/prepared/staging/arena owners are all
reported through the residency ledger.
Files: src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs (_boundsCache,
insert site ~1757).
#244 — SequencerFactory probes Setup without a DID guard or catch; [dat-miss] logs under the database lock
Status: DONE — 2026-07-24, Modern Runtime Slice C Severity: MEDIUM Filed: 2026-07-24 Component: composition / content
Description: Two residuals adjacent to the audit's ResolveActivation
finding: (1) SequencerFactory runs the identical unguarded
Get<Setup>(SourceGfxObjOrSetupId) probe with NO try/catch — a live entity
carrying a GfxObj-typed source id would crash the spawn path outright
instead of degrading (live weenies conventionally carry Setup ids, which is
why it has never fired); a dead duplicate exists in
RenderBootstrap.cs:179-206 (ui-studio only). (2) Non-throwing failed
probes hit the dated TEMP [dat-miss] Console.WriteLine
(DatCollectionAdapter.cs:196-206, "strip with fix, 2026-06-09") —
synchronous console I/O while holding _databaseLock, serializing all
readers of that dat.
Root cause / status: Slice C's prepared TOC presence index now gates both
SequencerFactory and ResolveActivation, so GfxObj IDs do not enter the
Setup parser. Known malformed DAT failures are diagnosed once; cancellation
and unexpected failures propagate. The remaining [dat-miss] anomaly report
now captures its condition while holding the serialized DAT lock, performs
console I/O only after releasing it, and is limited to one report per database.
Connected capped, uncapped, and dense routes recorded zero invalid Setup-probe
exceptions.
Files: src/AcDream.App/Composition/LivePresentationComposition.cs:200-225;
src/AcDream.Content/DatCollectionAdapter.cs:196-206;
src/AcDream.App/Rendering/RenderBootstrap.cs:179-206.
#233 — Live skill-credit resolver used an ACE shortcut, not retail's formula
Status: DONE (2026-07-22, GameWindow Slice 8 Checkpoint C) Severity: MEDIUM Filed: 2026-07-22 Component: live character state / DAT skill formulas
Description: The live skill-update route calculated a skill's primary-
attribute contribution with an ACE-oriented shortcut. It rejected every
formula whose first multiplier was zero, truncated division, and added W
after division. Custom DAT formulas—and any stock boundary landing on a half—
could therefore display the wrong skill value.
Root cause / status: The binding cited ACE without first checking named
retail. SkillFormula::Calculate @ 0x00591960 treats all four arithmetic words
as unsigned, fails only for Z == 0, forms the wrapping numerator
X*a + Y*b + W, and rounds numerator/Z half-up. Checkpoint C moved that
algorithm into RetailSkillFormula, added a named LiveSkillCreditResolver,
and pinned zero-X, W placement, half rounding, unsigned reinterpretation, and
32-bit wrap with conformance tests.
Files: src/AcDream.App/Net/RetailSkillFormula.cs;
tests/AcDream.App.Tests/Net/RetailSkillFormulaTests.cs.
Research:
docs/research/skill_formula_calculate_pseudocode.md.
Acceptance: Live skill updates resolve the DAT entry and reproduce the named retail function for all unsigned field values; missing skills or attributes remain safely projected as zero.
#232 — Nine-stop soak process-memory gate lacks canonical owner snapshots
Status: DONE (2026-07-22, bca41487 + 6c5e0604)
Severity: LOW
Filed: 2026-07-22
Component: connected automation / resource diagnostics
Description: Identical-binary nine-stop runs can cross the same-location working/private-process-memory tolerance even when all deterministic lifecycle, entity, animation, allocation-rate, update-cost, movement, and graceful-close checks pass. At Slice 7 closeout, two runs reported Caul-plateau working-set growth of +240.9 MiB and +497.4 MiB; a third unchanged run passed at +85.7 MiB (private +59.3 MiB). Earlier Slice 6 development history also contains both a coarse-memory failure and subsequent clean runs.
Root cause / status: The old soak sampled only process working set/private bytes plus coarse world/title counters. Those totals combine live .NET objects, committed-but-free GC segments, native allocations, mapped pages, and GPU driver residency, so a failed total could not identify the owner that grew. Deferred acknowledged render-frame checkpoints now record the exact frame outcome and canonical managed/GPU/cache/VFX/pending-work owners. Two fresh 403-second routes pass all nine ordered checkpoints, zero pending/staged/warmup work, no unconfounded owner growth, graceful close, and the unchanged process and performance limits. Legitimate owner retirement is allowed; growth becomes a hard failure when workload is stable and a named warning when authoritative or visible workload changed.
Files: tools/run-connected-r6-soak.ps1;
src/AcDream.App/Diagnostics/WorldLifecycleAutomationController.cs;
src/AcDream.App/Rendering/RenderFrameDiagnosticsController.cs.
Acceptance: Each named soak checkpoint records the existing canonical world-lifecycle resource snapshot (managed used/committed, tracked GPU buffers and textures, mesh/atlas caches, composite/particle texture ownership, VFX and pending teardown counts). Same-location assertions identify which owner grew; process residency remains a secondary guard. Repeated clean runs pass without loosening the canonical-owner leak limits.
#231 — F-key pickup omits retail pending destination-slot presentation
Status: DONE (2026-07-21, 52dbb574 + 5acc3f01)
Severity: MEDIUM
Filed: 2026-07-21
Component: selection / inventory / retained UI
Description: World double-click loot reserves its destination cell with the
gray pending mesh before the request, but SelectionPickUp queues SendPickUp
directly and skips that presentation. The item can therefore settle into the
backpack without the same immediate first-slot reservation retail shows.
Root cause / status: Retail CPlayerSystem::PlaceInBackpack @ 0x0055D8C0
always sends CM_Item::SendNotice_ShowPendingInPlayer before
AttemptToPlaceInContainer, including the F-key action from
CPlayerSystem::OnAction @ 0x00561964. acdream's double-click path enters
ItemInteractionController and raises PendingBackpackPlacementRequested;
the F-key switch case bypasses that owner.
Files: src/AcDream.App/UI/ItemInteractionController.cs;
src/AcDream.App/Interaction/SelectionInteractionController.cs;
src/AcDream.App/UI/Layout/InventoryController.cs.
Research:
docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md.
Acceptance: F-key pickup validates the target, publishes the exact destination/placement pending reservation, then sends one authoritative request. Server confirmation settles it; server failure removes it without speculative ownership mutation.
Resolution: ItemInteractionController.PlaceWorldItemInBackpack now owns
the exact-token pending destination projection. The F-key route reserves it
before immediate or deferred MoveTo transport, promotes that same reservation
only when the packet is actually sent, and withdraws it on cancel/lifetime
loss. The review correction also ported the single global
ACCWeenieObject::prevRequest owner across inventory, external-container,
toolbar, paperdoll, give, merge, split, and drop routes. Optimistic placement,
server rollback, and authoritative response notifications are distinct; a
response clears global and local state atomically before reentrant observers.
#230 — Selection hits and deferred actions can cross live GUID incarnations
Status: DONE (2026-07-21, 047a4c83 + 5acc3f01)
Severity: HIGH
Filed: 2026-07-21
Component: selection / live entity lifetime / interaction
Description: The completed render-selection frame and a pending close-range Use/PickUp store only the server GUID. If the object hides, is deleted, or is replaced with the same GUID before the next pick/completion, old presentation or movement state can resolve to the replacement incarnation. Session reset also leaves item target mode and the render-selection pulse/frame outside one atomic interaction reset.
Root cause / status: RetailSelectionPart/RetailSelectionHit, click
lighting, and PendingPostArrivalAction lack the canonical local-entity/record
identity owned by LiveEntityRuntime. Teardown's GUID-only replacement guard
correctly protects new selection, but necessarily skips clearing a GUID-only
pending action. CombatTargetController also treats SessionReset like an
ordinary clear and can re-acquire before live records drain.
Files: src/AcDream.Core/Selection/RetailSelectionMesh.cs;
src/AcDream.App/Interaction/WorldSelectionQuery.cs;
src/AcDream.App/Interaction/SelectionInteractionController.cs;
src/AcDream.App/Input/OutboundInteractionQueue.cs.
Research:
docs/plans/2026-07-21-gamewindow-slice-1-selection-interaction.md.
Acceptance: Hidden/pending/deleted/replaced objects reject stale render hits; old click lighting never colors a replacement; natural MoveTo completion sends only for the captured current visible incarnation; session reset clears the complete interaction lifetime and never auto-targets.
Resolution: Render parts/hits and SmartBox lighting now carry the local
WorldEntity.Id; LiveEntityRuntime revalidates that identity against the
current interaction-visible record. Deferred actions capture the same identity
and teardown clears the captured action even after GUID replacement. Session
reset now clears published selection geometry, lighting, ItemHolder target and
throttle state, and cannot trigger combat auto-target acquisition. Queued
input also captures the exact ClientObject reference, deferred movement is a
cancel-before-arm transaction, and session queue epochs prevent pre-reset work
from crossing into a new world.
#228 — Clean Release build emits 17 test-project warnings
Status: OPEN Severity: LOW Filed: 2026-07-20 Component: tests / build hygiene
Description: A fresh dotnet build AcDream.slnx -c Release --no-restore
succeeds with zero errors but emits 17 warnings from
tests/AcDream.Core.Tests. Earlier current-state prose claimed “zero warnings”
after an incremental gate in which those projects did not recompile.
Root cause / status: The warnings are existing nullable-flow (CS8600,
CS8602, CS8625), never-assigned test fixture fields (CS0649), and xUnit
analyzer findings (xUnit1025, xUnit1031, xUnit2013, xUnit2017). One
duplicate InlineData also causes xUnit to skip a duplicate test-case ID during
discovery; the intended unique cases still pass. This is test hygiene, not a
production runtime failure.
Files: tests/AcDream.Core.Tests/Conformance/DatConcurrencyStressTests.cs;
tests/AcDream.Core.Tests/Physics/Motion/RemoteChaseEndToEndHarnessTests.cs;
tests/AcDream.Core.Tests/Physics/CellGraphMembershipTests.cs;
tests/AcDream.Core.Tests/Physics/DoorSetupGfxObjInspectionTests.cs;
tests/AcDream.Core.Tests/Physics/MotionInterpreterDoMotionFamilyTests.cs;
tests/AcDream.Core.Tests/Rendering/Wb/AcSurfaceMetadataTableTests.cs;
tests/AcDream.Core.Tests/Physics/MotionInterpreterTests.cs;
tests/AcDream.Core.Tests/Physics/CellArrayTests.cs;
tests/AcDream.Core.Tests/Streaming/StreamingControllerTwoTierTests.cs.
Acceptance: A non-incremental Release solution build reports zero warnings and zero errors without suppressing analyzers or weakening nullable checking; the full 6,558-pass / 5-skip suite remains green.
#226 — Retail landscape detail-texture overlay is not rendered
Status: OPEN — deferred visual fidelity; the user-visible tiling regression in #155 is fixed Severity: LOW Filed: 2026-07-20 Component: rendering / terrain material
Description: Retail can overlay a high-frequency landscape detail texture,
faded by viewer distance and gated by the Environment Detail Textures setting.
acdream now repeats every base/overlay/road surface at its authored
TerrainTex.TexTiling, which fixed the stretched/blurry symptom in #155, but
does not yet render this separate optional detail layer.
Root cause / status: The earlier #155 investigation conflated two retail
mechanisms. bb5acab9 ported the behavior that produced the observed mismatch:
TexMerge::CopyAndTile/Merge pass each source's authored base tiling into the
terrain composition. The still-missing detail pass is a distinct
LScape::GenerateDetailSurfaces/ACRender::landPolyDraw path. The first
experimental detail-array implementation sampled the wrong neutral/data
contract and was reverted rather than shipping a darkened ground. TS-52 records
the current divergence.
Files: src/AcDream.App/Rendering/TerrainAtlas.cs;
src/AcDream.App/Rendering/TerrainModernRenderer.cs;
src/AcDream.App/Rendering/Shaders/terrain_modern.frag.
Research: docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md
covers the now-shipped base contract. The detail symbols cited above must be
distilled into a dedicated pseudocode note as the first #226 implementation
step; the reverted experiment remains available in git history.
Acceptance: With retail Environment Detail Textures enabled, close ground shows the same high-frequency detail and distance fade without changing base color/brightness. Disabling it produces the already-accepted authored base tiling.
#225 — Scene particles overpaint translucent world objects
Status: IN-PROGRESS — implementation/reviews and connected stress gate pass; final visual gate pending Severity: MEDIUM Filed: 2026-07-18 Component: rendering / world translucency / particles
Description: Smoke, candle flames, and other scene particles behind a translucent lifestone crystal remained fully bright and visible through it. The crystal itself had the correct DAT translucency, but particles composited as though no transparent object were in front of them.
Root cause / status: WbDrawDispatcher finished its transparent object
pass before ParticleRenderer began its independent scene-particle pass.
Both paths depth-tested but disabled depth writes, so a later particle behind
the crystal still passed the opaque depth buffer and overpainted the crystal.
Retail makes each emitted particle a CPhysicsPart, orders it with ordinary
parts by the transformed GfxObj sort_center, and appends delayed surfaces to
one alpha list. acdream now has one frame-scoped RetailAlphaQueue shared by
world Wb entities and scene particles, with retail's landscape and final-world
flush boundaries. It preserves DAT blend/cull/lighting state and batches only
adjacent compatible submissions so renderer grouping cannot change the
compositing order.
The first connected build exposed a modern-backend performance regression:
distance sorting naturally interleaves particle textures, while the old
billboard shader required one texture bind/draw for every texture run. After
several portals, the dense 0xC95B scene reached roughly 21,000 visible
entities and fell to 6 FPS / 171 ms. This was not a retained queue or streaming
leak. Billboard instances now carry their resident bindless texture handle in
the vertex-instance ABI; different textures therefore remain in exact sorted
order inside one instanced draw, with only DAT blend-mode changes splitting a
run. The identical location recovered to about 153 FPS / 6.6 ms in the
connected Release client.
A second, genuinely cumulative portal regression was first isolated: ACE retains
KnownObjects across normal teleports, while acdream retained every old
LiveEntityRecord indefinitely. Each destination therefore left animation,
effect, and render owners active; after enough trips the render thread blocked
inside the GL driver and C95B fell persistently to 3–12 FPS. The client now
ports retail's 25-second leave-visibility destruction queue, using current
spatial residency plus holtburger's conservative 384-unit ACE envelope until
exact ObjCell PVS is available. Expiry uses the normal generation-safe F747
teardown. The modern GlobalMeshBuffer also allocates one vertex range per
mesh, coalesces released ranges, and reuses them on cache eviction instead of
duplicating vertices per material and growing forever. A connected five-region
round trip returned live/animation ownership to baseline, recreated C95B on
revisit, and held its normal 60–80 FPS.
That shorter route did not close the process-lifetime problem. A subsequent multi-recall run still climbed to about 3.0 GiB working set / 3.5 GiB private memory and reproduced the 5–12 FPS collapse, with effect emitters, composite textures, decoded DAT objects, texture atlases, and physical GL stores remaining resident after their owners left. The final integration therefore makes the whole chain owner-scoped and bounded: exact-incarnation appearance replacement, retryable live/landblock/UI/portal teardown, emitter retirement indexes, bounded DAT/decoded/standalone/composite caches, reclaimable mesh/atlas storage, incremental arena migration, and three-frame GPU-fenced physical reuse. It also reuses per-frame scratch storage without clearing a legitimate large working set. No draw distance, texture resolution, particle range, or visual effect was reduced.
The final connected route (Caul → Sawato → Rynthid → Aerlinthe → Sawato → Holtburg → Caul, with 25–30 second destination dwells and 60 seconds after the return) passed without an exception, WER report, or AMD display-driver reset. Peak working set fell from 2,954.5 MiB to 1,493.4 MiB and peak private memory from 3,502.3 MiB to 1,969.5 MiB versus the failing build. Returned Caul settled at 1,030.6 MiB working set / 1,638.2 MiB private / 831.6 MiB local GPU; the final local-display dwell held 125–153 FPS (141.8 average). Emitter/binding ownership balanced at 1,715/1,715 and composite physical residency remained below its 128 MiB ceiling. The older 32 FPS comparison run was RDP-refresh-capped and is used only for memory comparison. The lifestone/particle visual gate remains.
The 141.8 FPS result above is one destination-specific dwell, not a universal
throughput claim. A fixed dense-Caul capture with about 21,024 visible entities
later isolated a render-thread bottleneck: CPU frame time was roughly 9–12 ms
while the GPU needed only 3.8–3.9 ms. The follow-up preserves exact alpha order
and every quality/range setting while replacing comparison sorting with a
stable radix, avoiding a second immediate-buffer pack for deferred transparent
instances, packing per-instance light sets, caching static picking descriptors,
and using checked direct group handles. Historical material groups now retire
after one complete unused frame (not after one Draw, because landscape slices
and paperdoll share the dispatcher) and release their list capacity. Before the
final group-retirement review correction, that fixed dense sample reached
5.3–6.2 ms CPU p50, 7.1–8.3 ms CPU p95, and 3.2–3.6 ms GPU p50.
The earlier software limiter also used coarse Thread.Sleep, whose nominal
one-millisecond waits measured about 15.98 ms on this host. A reusable Windows
high-resolution waitable timer now paces to the monitor deadline and is reported
as pace by the frame profiler. Scripted UI automation no longer enables the
allocation-heavy GPU-stream dump implicitly, and graceful shutdown now wakes
all persistent mesh workers without resetting their shared wake signal. The UI
automation clock now uses command-local compensated accumulation, retaining
sub-millisecond frame deltas instead of rounding every uncapped frame up to one
millisecond. Exact sleep and strict timeout boundaries therefore remain stable
across high-refresh frames and long scripts.
The post-review connected rerun passed on 2026-07-19. It used the complete Caul → Sawato → Rynthid → Aerlinthe → Sawato → Holtburg → Caul route, waited for each actual materialization, allowed at least five seconds of destination streaming, synchronized to the next profiler boundary, and then held the retail turn-right action through one dedicated interval (4.93–5.07 seconds, more than one full visibility sweep) at every stop. Each turn window and its following stationary window were captured on separate profiler boundaries. The uncapped Release client completed all seven portals and seven turns and shut down gracefully, with no exception, WER report, or AMD driver reset observed in the process logs, Windows event logs, or crash-dump directory. Returned dense Caul contained about 21,024 visible entities and measured 3.8 ms median / 5.0 ms p95 CPU and 2.6 ms median / 3.2 ms p95 GPU while stationary (241 FPS in the title sample). Its complete turning window measured 3.0 ms median / 4.9 ms p95 CPU and 2.0 ms median / 3.4 ms p95 GPU.
Across all stationary destinations, CPU p50/p95 ranged from 1.1–4.0 / 1.6–5.1 ms and GPU p50/p95 from 0.7–2.8 / 1.0–3.4 ms. Across the turn windows, those ranges were 1.0–3.1 / 1.8–4.9 ms CPU and 0.7–2.0 / 1.2–3.4 ms GPU. Working/private memory was 939.8/1,438.3 MiB on the first Caul dwell; the highest stationary sample was 1,065.0/1,660.6 MiB in Holtburg, followed by 1,034.8/1,630.5 MiB on the Caul return. The samples were non-monotonic, but one circuit is not claimed as a new lifetime plateau proof. Startup reached a transient 240.2 ms maximum, and the largest later destination-load window reached 81.9 ms CPU; all isolated turn and stationary windows recovered to low-millisecond frame times, so this run is not described as stall-free.
The run was performed over RDP, but software pacing was explicitly disabled and
pace remained zero, so these are engine-throughput measurements rather than
remote-display refresh measurements. The lifestone/particle visual gate remains.
The 2026-07-20 unattended R6 rebaseline repeated the seven-destination route
without an interactive desktop and exercised semantic turn, forward, charged
jump, and combat input through the production dispatcher. All seven portal
materializations, outbound movement gates, same-location memory/update checks,
fatal-log scan, and graceful WM_CLOSE teardown passed. Caul update p95 was
1.2 ms on the first visit and 1.1 ms on return; Sawato was 0.2/0.3 ms. This is
additional lifetime/correctness evidence, not a replacement for the translucent
lifestone visual check. See
docs/research/2026-07-20-connected-r6-soak.md.
Files: src/AcDream.App/Rendering/RetailAlphaQueue.cs;
src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs;
src/AcDream.App/Rendering/ParticleRenderer.cs;
src/AcDream.App/Rendering/Shaders/particle.vert;
src/AcDream.App/Rendering/Shaders/particle.frag;
src/AcDream.App/Rendering/RetailPViewRenderer.cs;
src/AcDream.App/World/LiveEntityLivenessController.cs;
src/AcDream.App/Rendering/Wb/GlobalMeshBuffer.cs;
src/AcDream.App/Rendering/GpuFrameFlightController.cs;
src/AcDream.App/Rendering/CompositeTextureArrayCache.cs;
src/AcDream.App/Rendering/StandaloneBindlessTextureCache.cs;
src/AcDream.Content/BoundedDatObjectCache.cs;
src/AcDream.Content/DecodedTextureCache.cs;
src/AcDream.App/Rendering/FramePacingController.cs;
src/AcDream.App/Rendering/WindowsHighResolutionFramePacingWaiter.cs;
src/AcDream.App/Diagnostics/FrameProfiler.cs;
src/AcDream.App/Rendering/GameWindow.cs;
src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs;
src/AcDream.App/Rendering/Wb/CachedBatch.cs;
src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs;
src/AcDream.App/UI/Testing/RetailUiAutomationScriptRunner.cs.
Research:
docs/research/2026-07-18-retail-shared-alpha-list-pseudocode.md;
docs/research/2026-07-18-retail-object-liveness-and-mesh-reclamation-pseudocode.md;
docs/research/2026-07-18-retail-texture-resource-lifetime-pseudocode.md.
Acceptance: At a translucent lifestone, smoke or flame behind the crystal is attenuated by it while an effect in front remains bright. The lifestone's own transparency, nearby candle/portal particles, indoor/outdoor PView transitions, paperdoll rendering, and portal-space rendering do not regress. Repeated distant portal trips do not accumulate live/animation owners or degrade FPS, and revisiting an expired region restores its objects.
#224 — Gameplay indicator bar only implemented effect icons
Status: DONE — 2026-07-17, user visually confirmed after detail-panel polish Severity: MEDIUM Filed: 2026-07-17 Component: retained UI / gameplay indicators / session telemetry
Description: Retail LayoutDesc 0x21000071 is one seven-control strip for
link quality, helpful effects, harmful effects, vitae, burden/character
information, mini-game state, and end-character-session. acdream only promoted
the two effects controls to behavioral buttons; the other custom classes fell
back to inert generic DAT elements.
Root cause / status: DatWidgetFactory recognized only
gmUIElement_EffectsIndicator. The retained controller consequently had no
live bindings for session packet age, Vitae, or the player load ratio and did
not dispatch Character Information or the shared logout confirmation path.
The strip and its actions are now ported. Character Information (panel 3), Link
Status (8), both effect pages (4/5), Vitae (15), and the authored Mini Game
shell (9) are registered as children of the same retained main-panel owner as
Inventory/Character/Magic, so switching pages preserves one canonical size and
position. Helpful/Harmful use retail's authored 300x32 icon/name/time rows,
selection states, SELECT A SPELL prompt, and selected spell description.
The burden icon routes to Character Information rather than Attributes/Skills.
Link ping RTT and Vitae
recovery XP are live; transport packet-loss averaging and Mini Game gameplay
remain the explicitly narrowed AP-110 residual.
The 2026-07-17 retail-conformance follow-up completes the visible detail data: Vitae now includes all three localized paragraphs; Character Information now emits retail's birth/playtime/deaths, resistance grades, innates, chess/fishing, earned mastery/augmentation, and burden report instead of an invented character-sheet summary. Character/Vitae authored center surfaces are no longer composited twice, and the end-session button enters its Normal DAT state before hover.
The polish follow-up restores gmEffectsUI's lower information viewport:
selected spell name/description now reflows to the authored width and its own
0x10000127 scrollbar works independently of the upper effect-list scrollbar.
Vitae recovery XP also uses retail's grouped integer presentation.
Resolution: 52c529be, a96767ba, d1d60310, 16c21e29, and
82789eea ported the strip, shared pages, retail Vitae/Character Information,
button state, description wrapping, independent lower scrolling, and grouped
XP. The user accepted the resulting pages. Transport packet-loss averaging and
Mini Game gameplay remain explicitly tracked by AP-110 rather than keeping this
visible UI issue open.
Files: src/AcDream.App/UI/Layout/IndicatorBarController.cs;
src/AcDream.App/UI/Layout/DatWidgetFactory.cs;
src/AcDream.Core.Net/WorldSession.cs; src/AcDream.App/UI/RetailUiRuntime.cs.
Research:
docs/research/2026-07-17-retail-indicator-bar-pseudocode.md.
docs/research/2026-07-17-retail-vitae-character-info-pseudocode.md.
Acceptance: All seven authored controls render their retail sprites. Link quality follows the 5/20/40-second thresholds and bad-link flash cadence; helpful, harmful, Vitae, and burden react to live state; the burden icon opens Character Information; effects open their matching panels; crossed swords use the shared confirmation dialog and close gracefully only after acceptance. Every non-ghosted detail icon opens its authored page in the shared main-panel geometry; closing a restore-previous page returns to the deferred ordinary page.
#223 — Primary panel resize stopped before the screen edge
Status: DONE — 2026-07-17 (connected visual gate accepted) Severity: MEDIUM Filed: 2026-07-17 Component: retained UI / shared main panel / resize constraints
Description: The Inventory, Character/Skills, and Spellbook/Components panel could not be extended vertically to the bottom of the screen. Magic was not resizable at all, Character carried a fixed 760-pixel maximum, and Inventory calculated one maximum from its startup position that became stale after the shared panel moved.
Root cause / status: Retail LayoutDesc 0x2100006E owns one 310x372
gmPanelUI host. Its primary children are all authored into the same 300x362
content region with bottom anchoring, while the host exposes a bottom Type-9
vertical Resizebar. acdream imports those children as separate wrappers, so
their mount policies must preserve that one host contract. All three primary
wrappers now expose bottom-only vertical resizing and have no ad-hoc maximum.
The shared mount seam carries a dynamic parent-extent constraint, so pointer
and restored/programmatic sizes use the panel's current position and stop at
the current screen edge.
Files: src/AcDream.App/UI/RetailUiRuntime.cs;
src/AcDream.App/UI/Layout/RetailWindowFrame.cs;
src/AcDream.App/UI/UiElement.cs; src/AcDream.App/UI/UiRoot.cs;
src/AcDream.App/UI/RetailWindowManager.cs.
Research:
docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md.
Acceptance: Move the shared primary panel to more than one vertical position. In Inventory, Character/Skills, and Spellbook/Components, drag the bottom resize edge to the bottom of the client. Every panel must reach that edge without crossing it, and its anchored content must use the added height.
#222 — Window move and resize cursors were missing
Status: DONE — 2026-07-17 (connected visual gate accepted) Severity: MEDIUM Filed: 2026-07-17 Component: retained UI / cursor feedback / window controls
Description: Hovering or operating the retained-window move and resize regions left the ordinary retail pointer visible. Retail shows a four-way move cursor and direction-specific horizontal, vertical, and diagonal resize cursors.
Root cause / status: UiRoot and CursorFeedbackController already
identified the exact active/hovered window-control intent, but
RetailCursorManager consumes cursor media rather than the semantic kind. No
media was attached to the semantic result for synthetic wrapper edges or the
IA-12 whole-window drag region. RetailCursorCatalog now maps those five
intents to the exact direct DAT cursor surfaces and (16,16) hotspots authored
on retail Type-2 Dragbar and Type-9 Resizebar controls. Authored widget media
keeps retail captured/hovered precedence; an active synthetic control holds its
cursor until release.
Files: src/AcDream.App/UI/CursorFeedbackController.cs;
src/AcDream.App/UI/RetailCursorCatalog.cs;
tests/AcDream.App.Tests/UI/CursorFeedbackControllerTests.cs;
tests/AcDream.App.Tests/UI/RetailCursorCatalogTests.cs.
Research:
docs/research/2026-07-17-retail-window-control-cursor-pseudocode.md.
Acceptance: Hover every enabled window edge/corner and an empty move region; the correct retail cursor must appear before pressing and remain stable while dragging outside the original control. Locked or non-resizable windows must not advertise unavailable operations.
#221 — Primary UI panels retained independent window positions
Status: DONE — 2026-07-17 Severity: MEDIUM Filed: 2026-07-17 Component: retained UI / panel lifecycle / persistence
Description: Moving Inventory and then opening Character/Skills or Magic opened the replacement panel at its own stale position. Retail presents these as different child contents of one persistent main panel.
Root cause / status: RetailPanelUiController already ported
gmPanelUI's one-active-child and restore-previous behavior, but the imported
LayoutDesc children were mounted as independently positioned retained window
frames. The controller now owns one canonical placement for Inventory (7),
Character (11), and Magic (13). Moving any primary child synchronizes its
hidden siblings through RetailWindowHandle.MoveTo, so ordinary per-character
layout persistence observes the same position. Switching and closing capture
that canonical placement; Helpful/Harmful effect panels retain their separate
placement and restore-previous behavior.
Files: src/AcDream.App/UI/Layout/RetailPanelUiController.cs;
src/AcDream.App/UI/RetailUiRuntime.cs;
tests/AcDream.App.Tests/UI/Layout/RetailPanelUiControllerTests.cs;
tests/AcDream.App.Tests/UI/RetailWindowLayoutPersistenceTests.cs.
Research:
docs/research/2026-07-17-retail-shared-main-panel-pseudocode.md.
Acceptance: Connected normal-Release visual gate passed 2026-07-17: Inventory, Character/Skills, and Spellbook/Components retain one position across switching, dragging, closing, and reopening.
#220 — Local player glides before locomotion animation starts
Status: DONE 2026-07-17 — user-confirmed connected visual gate Severity: HIGH Filed: 2026-07-17 Component: local movement / animation / physics
Description: A very short W tap moved the acdream character before the Ready→Walk/Run animation visibly started, producing a brief foot-slide.
Root cause / status: The grounded local controller called
CMotionInterp::get_state_velocity and installed the full 3.12–4.0 m/s body
velocity on the input edge. Retail instead advances CPartArray first and
uses the exact root displacement authored by the current transition/cycle;
PositionManager::adjust_offset then composes into that same Frame before the
collision sweep. The local player now advances its owning AnimationSequencer
after input dispatch, accumulates the emitted root displacement to the 30 Hz
physics quantum, and passes it through the existing PositionManager/collision
path. The prepared part pose is reused by the presentation pass so animation
time and hooks advance exactly once.
Files: src/AcDream.App/Input/PlayerMovementController.cs;
src/AcDream.App/Rendering/GameWindow.cs;
tests/AcDream.Core.Tests/Input/PlayerMovementControllerTests.cs;
tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs.
Research:
docs/research/2026-07-17-local-player-root-motion-start-pseudocode.md.
Acceptance: From rest, tap W briefly several times. The body must not move before the Ready→Walk/Run transition produces movement. Holding W must still reach normal continuous speed; release, backward/strafe, and jump remain correct. Passed 2026-07-17: “Good! works.”
#219 — Outbound movement dropped the player's combat stance
Status: DONE 2026-07-17 — user-confirmed from a retail observer Severity: HIGH Filed: 2026-07-17 Component: movement wire / combat stance / remote animation
Description: acdream looked correct locally while moving in combat mode, but a retail observer saw the character leave combat stance and locomote in NonCombat.
Root cause / status: Retail CommandInterpreter::SendMovementEvent passes
the canonical MovementManager::RawMotionState directly into
MoveToStatePack. acdream instead projected the raw axes through
MovementResult and rebuilt a packet state without CurrentStyle, leaving its
retail default 0x8000003D NonCombat. RawMotionStatePacker therefore omitted
the style bit; ACE relayed no combat stance, and retail's unpack correctly
defaulted the observer to NonCombat. MovementResult now carries the canonical
MotionInterpreter.RawState.CurrentStyle, and the outbound builder writes it
without inferring from CombatMode or equipment.
Files: src/AcDream.App/Input/PlayerMovementController.cs;
src/AcDream.App/Input/LocalPlayerOutboundController.cs;
tests/AcDream.App.Tests/Input/LocalPlayerOutboundCombatStyleTests.cs.
Research:
docs/research/2026-07-17-combat-movement-outbound-style-pseudocode.md.
Acceptance: With a melee, bow/crossbow, or magic stance active, move the acdream player while observing from retail. The observer retains the same combat stance and uses its combat locomotion instead of switching to peace. Passed 2026-07-17: “Yes works.”
#218 — Portal silhouette pose, destination reveal, and indoor observer snap
Status: DONE — 2026-07-25, /ls and spell-recall visual re-gate passed
Severity: HIGH
Filed: 2026-07-16
Component: portal VFX / streaming / physics / outbound movement
Description: The purple player silhouette in portal space sampled the tail of the recall action instead of the finished Ready pose. Some outdoor exits briefly revealed grey/unloaded landblocks during the view-plane zoom. A retail observer watching acdream enter a dungeon saw movement while commands were active, but stopping snapped acdream back to the dungeon entrance.
Root cause / status: These were three lifecycle truths hidden by the same
portal gate. Hidden entities skipped part-pose composition entirely, so
HandleEnterWorld changed the sequence cursor to Ready without publishing that
pose before the zero-time Hidden PES emitted the silhouette. Portal readiness
checked collision residency but not actual mesh/texture render readiness; the
captured destination additionally failed whole landblock builds when dense
procedural scenery exceeded the obsolete 256-id namespace. Finally,
PhysicsBody.CellPosition advanced only outdoors: indoor resolves updated the
controller cell but left the canonical outbound frame at the portal entrance.
The implementation now samples Hidden sequence poses without advancing time,
joins render and collision readiness before world reveal, uses a collision-free
0x8XXYYIII 4,096-entry scenery namespace, and commits every successful indoor
transition's full cell/frame before movement serialization. Render readiness
tracks both static GfxObjs and EnvCell shell geometry through completed WB
uploads and requires the complete priority ring to be Near-tier. Stale worker
completions carry a hard-recenter generation, so even overlapping old loads and
unloads cannot overwrite the replacement window. A Near load demoted before
its first publication is converted to the equivalent terrain-only Far payload
instead of leaving a hole. Mesh upload no longer manufactures a second logical
owner; static and synthetic EnvCell geometry pins release symmetrically, while
zero-owner late uploads stay evictable. EnvCell publication replays its
schema-aware preparation request after those pins are installed, closing an
eviction race without routing synthetic ids through generic GfxObj decode. The
bounded CPU cache retains texture payloads and re-stages evicted meshes exactly
once instead of returning a never-uploaded or blank cache hit. A self-contained
promotion that supersedes a queued Far load now publishes directly as a real
Near landblock, so readiness cannot wait forever for a base the streamer
intentionally cancelled; later Far completion cannot overwrite that Near tier.
Near-to-Far now tears down the full Near-only App/Core layer while preserving
terrain, and repeated current-generation Near completions are ignored before
they can duplicate statics, scripts, pins, or callbacks. Static collision is
removed by owner before prefix cleanup, including footprints flooded across a
landblock seam, while server-live/dynamic registrations remain refloodable.
Full unload uses the same owner rule; surviving owners seeded across the seam
track withdrawn prefixes so a later reload restores their missing collision
rows. Destination reveal now also keeps sky and landscape on the same active
SmartBox projection, matching SmartBox::RenderNormalMode/GameSky::Draw;
the old fixed 60-degree sky camera exposed the clear/fog background during the
nearly 180-degree exit warp.
A later retained-runtime regression left one half of destination placement
uncommitted: the controller, mesh transform, and authoritative FullCellId
advanced, but the local player's live projection remained in its old/pending
GPU bucket. Since retail CPhysicsObj::update_object @ 0x00515D10 advances
scripts only with a non-null cell, acdream correctly paused the queued Hidden
PES—but for the wrong reason and until after viewport reveal. Connected traces
showed 0x33000331 firing only after the normal world became visible, directly
causing the opaque pop and late purple/action tail. LocalPlayerTeleportPlacement
now rebuckets the same retained entity to the resolved destination cell before
spatial reconciliation and before destination simulation resumes. Legitimate
unloaded-cell script pausing remains unchanged.
Files: src/AcDream.Core/Physics/AnimationSequencer.cs;
src/AcDream.App/Rendering/GameWindow.cs;
src/AcDream.App/Streaming/StreamingController.cs;
src/AcDream.App/Streaming/GpuWorldState.cs;
src/AcDream.App/Streaming/LocalPlayerTeleportController.cs;
src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs;
src/AcDream.App/Rendering/Wb/ObjectMeshManager.cs;
src/AcDream.App/Rendering/Sky/SkyProjection.cs;
src/AcDream.Core/World/ProceduralSceneryIdAllocator.cs;
src/AcDream.Core/Physics/PhysicsBody.cs;
src/AcDream.App/Input/PlayerMovementController.cs.
Research: docs/research/2026-07-16-portal-completion-pseudocode.md.
Acceptance: Recall/portal Hidden particles outline the finished standing pose; outdoor exits reveal only fully rendered terrain/scenery; and a retail observer sees acdream run and stop repeatedly inside a dungeon without any snap back to the entrance.
Gate: Passed 2026-07-21. The user confirmed the final two-client
portal-out/materialization and indoor observer comparison on the R6 baseline.
The 2026-07-25 retained-runtime regression re-gate also passed: both /ls and
spell recall briefly show the purple materialization silhouette without an
opaque character pop, late purple effect, or source-action animation tail.
#217 — Character windows did not receive live 64-bit experience updates
Status: DONE — 2026-07-13, connected gate user-confirmed Severity: HIGH Filed: 2026-07-13 Component: retained UI / character sheet / net / player state
Description: The Attributes window always displayed Total Experience as 0 and an empty level-progress meter after the player earned XP. The Skills footer also displayed Unassigned Experience as 0, preventing an accurate raise-cost decision. The Total Experience number was centered instead of right-aligned.
Root cause / status: acdream parsed the neighboring private/public Int32
quality messages (0x02CD/0x02CE) but omitted retail
PrivateUpdatePropertyInt64 (0x02CF). Both TotalExperience (quality 1) and
AvailableExperience (quality 2) use that message. The exact parser/event/state
path is now ported, both local-player projections are synchronized in one
wiring owner, the existing retail XP-band formula receives live data, and only
the value element 0x10000235 is explicitly right-aligned.
Files: src/AcDream.Core.Net/Messages/PrivateUpdatePropertyInt64.cs;
src/AcDream.Core.Net/WorldSession.cs;
src/AcDream.Core.Net/ObjectTableWiring.cs;
src/AcDream.Core/Player/LocalPlayerState.cs;
src/AcDream.App/UI/Layout/CharacterStatController.cs.
Research: docs/research/2026-07-13-retail-experience-update-pseudocode.md;
CM_Qualities::DispatchUI_PrivateUpdateInt64 @ 0x006AEAD0;
ClientObjMaintSystem::Handle_Qualities__PrivateUpdateInt64 @ 0x00559000;
gmStatManagementUI::UpdateExperience @ 0x004F0A70.
Acceptance: Earn XP while logged in. Attributes immediately shows the new right-aligned total and red progress fill; Skills immediately shows the same server-authoritative unassigned XP available for raises.
Gate: Passed in the starter-dungeon item-giving session: earned XP, right-aligned Total Experience/progress, and Unassigned Experience all updated.
#213 — Retail client commands were sent to ACE as chat text
Status: IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate Severity: MEDIUM Component: retained UI / chat commands / net
Description: Retail chat-bar commands such as /ls were treated as unknown
ACE commands. The client rewrote /ls to @ls and sent it through Talk, so ACE
reported an unknown command instead of recalling the character.
Root cause: The shared chat router had only two outcomes: local
presentation commands and SendChatCmd. It therefore conflated retail commands
that send typed game actions with ACE administrator commands that are consumed
as Talk text.
Resolution: Added an immutable named-retail client-command catalog and
three explicit routes: typed retail client action, ACE server command, and
ordinary chat. The catalog now owns recall/house/PK travel, age/birth,
framerate/lock/version/location/corpse/die, clear and named/automatic UI
layouts, AFK/consent, emotes, friends, squelch/filter/message types, and
fill-components. ClientCommandController keeps the panel layer independent
of App/network services; WorldSession sends the exact game-action or control
message for server-backed families. Friends and squelch databases are parsed
into authoritative Core state, confirmation requests use the imported retail
dialog catalog through the ported context/queue/callback factory, and unknown
/ commands still publish SendServerCommandCmd in
canonical @ form so ACE administrator commands continue to work. Both chat
backends use the same router.
Corrective pass (2026-07-13): The first connected gate passed all other
representative commands and exposed two presentation gaps. Suicide success
arrives as WeenieError 0x004A; it now resolves to retail's informational
"Ack! You killed yourself!" instead of the raw numeric fallback. /framerate
now mounts and toggles SmartBox LayoutDesc 0x2100000F element 0x10000047,
showing the live FPS and DEG values with the authored font and two-decimal
format. /framrate remains unknown because retail registers no misspelled
alias.
The reusable-dialog follow-up ports DialogFactory @ 0x004773C0..0x00478470
and type-1 ConfirmationDialog rather than sharing one mutable widget. Every
display gets a fresh LayoutDesc 0x2100003C root; /die, server confirmation
tuples (including CharacterConfirmationDone aborts), and PK/NPK/volatile-rare
item use now share its property-backed result and lifecycle.
Research:
docs/research/2026-07-13-retail-client-command-routing-pseudocode.md;
docs/research/2026-07-13-retail-client-command-families-pseudocode.md;
docs/research/2026-07-13-retail-dialog-factory-pseudocode.md;
ClientCommunicationSystem::OnChatCommand @ 0x00581320;
ClientCommunicationSystem::DoLifestone @ 0x0056FC70;
CM_Character::Event_TeleToLifestone @ 0x006A1B90.
Acceptance: In the connected Release client, representative local and
server-backed commands execute without appearing as speech: /loc,
/framerate, /saveui test, /loadui test, /age, /friends, /afk on,
and /marketplace. /ls now shows local usage and sends nothing. An ACE
command such as /ci 629 1 still works. /die ends with the retail success
text rather than a raw error code, and exact /framerate toggles the two-line
in-world SmartBox readout.
#212 — Toolbar shortcut numbers turn gray in physical combat
Status: IN-PROGRESS — implementation complete 2026-07-13, pending user gate Severity: LOW Component: retained UI / toolbar
Description: Entering melee or missile combat changed the numbered overlay on occupied toolbar shortcuts from the gold mesh to the gray mesh.
Root cause: The port interpreted UIElement_UIItem::SetShortcutNum's Boolean
as peace versus war. Retail names the stored value m_bShortcutGhosted, and
gmToolbarUI::RecvNotice_SetCombatMode passes true only for Magic combat mode.
The false interpretation therefore ghosted every occupied shortcut in every
combat stance.
Resolution: Shortcut state and digit arrays now use retail's regular/ghosted
terminology. NonCombat, Melee, and Missile select property 0x10000042; Magic
alone selects ghosted property 0x10000043. Empty slots continue to use the
stance-independent 0x1000005E array.
Research: docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
§2.4; UIElement_UIItem::SetShortcutNum @ 0x004E1590;
gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610.
Acceptance: Compare an occupied numbered toolbar slot in peace, melee, and missile modes: the mesh remains gold/yellow. Enter Magic mode: physical-item shortcuts use the gray ghosted mesh.
#211 — Login-equipped missile weapon incorrectly requests melee combat
Status: DONE — 2026-07-13, user confirmed combat entry works (ab98cda2)
Severity: HIGH
Component: inventory projection / combat mode
Description: After relaunching with a bow or crossbow already equipped, the combat button and grave key briefly selected Melee before ACE restored NonCombat, so the character could not enter combat.
Root cause: PlayerDescription recorded equipped entries with their equip mask but no player ownership/container index. The combat-mode lookup therefore missed login equipment, defaulted to Melee, and sent a mode incompatible with ACE's equipped missile weapon. ACE correctly returned authoritative NonCombat.
Resolution: PlayerDescription preserves login equipment ownership and the
production combat planner selects Missile for the login-equipped crossbow. A
2026-07-14 retail-conformance refinement also corrected live WieldObject: its
payload is exactly (itemGuid, equipLocation), and confirmation produces
ContainerId=0, WielderId=player. Equipment consumers now use
GetEquippedBy, which unifies that authoritative state with the temporary
optimistic pre-confirm projection instead of treating equipment as backpack
contents. The connected gate completed NonCombat → Missile → NonCombat →
Missile, including a Jump press, without a server rejection.
2026-07-17 active-switch follow-up: Connected primary-weapon replacement
traces exposed ACE's queued old-stance transition and trailing NonCombat.
AutoWieldController now retains the desired ready mode through every
confirmed blocker move and, only for a transaction that began in active
combat, arms settlement on authoritative WieldObject. It recognizes both a
pre-wield transition followed by ready -> NonCombat and a wholly post-wield
NonCombat -> ready -> NonCombat tail, then sends the normal mode request from
the trailing notice after the queued callback that caused it. An explicit user
combat request cancels settlement. This keeps the correct missile or magic bar present while retaining
ACE ownership of every state and motion; peace-mode switching is unchanged.
The narrow ACE compatibility difference is registered as AP-118.
Research: docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md
Acceptance: Relaunch with the crossbow already equipped. The combat button and grave key enter Missile mode, the combat bar appears, returning to peace hides it, and Jump followed by combat still works without a crash.
#210 — Mouse-up crashes when a UI callback changes pointer capture
Status: DONE — 2026-07-13, user confirmed the crash no longer occurs (e74efc5c)
Severity: HIGH
Component: retained UI / input lifecycle
Description: Jumping while changing combat mode could terminate the client
with a NullReferenceException in UiRoot.OnMouseUp. The crash was exposed by
the new jump/combat visibility transitions, but affected any click callback that
hid its captured window or transferred capture.
Root cause: OnMouseUp repeatedly dereferenced the mutable global
Captured property while dispatching MouseUp, Click, and DoubleClick. Those
callbacks may synchronously clear or replace capture, so one physical release
could change target halfway through its own event transaction. The unconditional
final ReleaseCapture could also discard a replacement capture.
Resolution: Mouse-up now snapshots the original mouse-down target and double-click classification before dispatching callbacks, uses that target for the complete event transaction, and releases capture only if the original target still owns it. Regression tests cover hiding the captured window during the second click and transferring capture to a newly opened element.
Files: src/AcDream.App/UI/UiRoot.cs,
tests/AcDream.App.Tests/UI/UiRootInputTests.cs
Acceptance: Hold/release Jump while repeatedly toggling combat and peace, including rapid double-clicks. The client remains running, the combat bar follows combat mode, and the jump bar still fills and hides normally.
#209 — Retail jump power bar missing
Status: IN-PROGRESS — implementation complete 2026-07-13, pending live visual gate Severity: MEDIUM Component: retained UI / movement
Description: Holding the jump key charged the movement system correctly, but the separate retail floating power bar did not appear or show jump power.
Root cause: The production retained runtime had not mounted
gmFloatyPowerBarUI LayoutDesc 0x21000072. The generic meter importer also
mistook its stateful fill child plus hidden Recklessness child for a vitals-style
three-slice meter, leaving all slice ids empty.
Resolution: The importer now recognizes the stateful single-image meter
shape: the authored direct image is the empty track and the selected numeric
child state supplies the fill. JumpPowerbarController selects JumpMode, reads
the movement controller's existing retail-timed charge snapshot, shows the
state-managed window on charge begin, fills left-to-right, and resets/hides it
on release. Geometry and resize limits come from the authored DAT layout.
Research: docs/research/2026-07-13-retail-jump-powerbar-pseudocode.md
Acceptance: In the connected Release client, hold Jump: the 610×25 retail bar appears and fills smoothly from its left edge to its right edge in about one second. Keep holding at full, then release: the bar resets and disappears as the jump starts. Repeat after moving/resizing the bar and after relogging.
#208 — Combat bar appears after logging in peacefully
Status: IN-PROGRESS — fix shipped 2026-07-12, pending live gate Severity: MEDIUM Component: retained UI / combat lifecycle
Description: The basic attack bar appeared immediately after login even though the character always enters the world in peace mode.
Root cause: The combat controller mounted the bar hidden and correctly
followed CombatState, but the later per-character layout restore reapplied a
previously saved Visible=true bit. Window persistence therefore overruled the
mode-owned visibility from retail gmCombatUI::RecvNotice_SetCombatMode.
Resolution: Retained-window persistence now distinguishes state-managed
visibility from user-managed visibility. The combat window still restores its
position, but ignores and no longer saves show/hide transitions. Character
session entry also resets CombatState to retail's initial NonCombat mode.
Research: docs/research/2026-07-11-retail-combat-bar-pseudocode.md
Acceptance: Log in while in peace mode: no attack bar is visible. Enter melee or missile combat: it appears. Return to peace: it disappears.
#207 — Repeat attack continues after movement begins
Status: DONE — 2026-07-12, user confirmed movement stops repeat attacks Severity: HIGH Component: combat / input
Description: With Repeat Attacks enabled, starting to move did not stop the attack loop, making combat difficult to disengage from.
Root cause: The attack controller had no port of
ClientCombatSystem::AbortAutomaticAttack, and semantic movement input was
never forwarded to combat as retail's ACCmdInterp::HandleNewForwardMovement
does.
Resolution: Forward, backward, autorun, and jump press transitions now send the retail cancel-attack event, clear repeat mode, and hide an active combat power build. Turns and sidesteps remain independent, matching the retail command-list split. Controller tests prove movement sends one cancel and that a later AttackDone cannot launch another repeat.
Research: docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md
Acceptance: Enable Repeat Attacks, start attacking, then press forward. The attack loop stops immediately and movement proceeds normally.
#206 — Relogging into a monster leaves the player stuck
Status: DONE — 2026-07-12, user confirmed occupied-position relog works Severity: HIGH Component: physics / login lifecycle
Description: If a monster moved onto the character's saved position while the user was logged out, logging back in placed both bodies together and the character could not escape.
Root cause: Login used the cell/floor-only Resolve snap and omitted retail
CTransition::find_placement_pos, the radius-aware nearest-clear-position
search. The local player was also omitted from the shadow registry, so remote
creatures could not collide/de-overlap against it as retail physics objects do.
Resolution: The retail four-metre concentric compass-ring placement search is ported into Core and runs after login's existing AdjustPosition/floor snap. The local player now registers a normal multipart collision shadow, skips that shadow in its own transition, and republishes the resolved body position through the same shared synchronizer as remotes. A flat-world conformance test begins inside a monster and proves the selected position clears both bodies.
Research: docs/research/2026-07-12-login-placement-and-repeat-cancel-pseudocode.md
Acceptance: Log out beside a monster, let it occupy the saved position, and log back in. The character is seated at the nearest clear point and can move.
#205 — Auto Target clears the toolbar and includes friendly creatures
Status: DONE — 2026-07-12, user confirmed the replacement target appears correctly Severity: HIGH Component: combat / selection / retained toolbar
Description: After a selected monster died, Auto Target correctly selected and marked a replacement on the radar, but the selected-object strip remained empty. The closest-target scan could also select friendly NPCs because it treated every live creature as a combat candidate.
Root cause: CombatTargetController subscribes before the retained toolbar.
On the death-driven clear it selected a replacement reentrantly; the toolbar
processed that nested selection, then processed the outer captured Clear
transition and erased itself. Retail selection notices carry no object id, so
gmToolbarUI::HandleSelectionChanged reads the current global selection and
cannot consume that stale payload. Separately, the scan used ItemType.Creature
instead of retail's ObjectIsAttackable combat gate.
Resolution: The toolbar notice consumer now reads the canonical live
SelectionState, matching retail's payload-free notice. Automatic acquisition
uses a pure Core policy built on the ported ObjectIsAttackable predicate and
rejects friendly NPCs, pets, players, corpses, and non-creatures. The deliberate
player exclusion is recorded as IA-19 because retail can include attackable PK
players through its broader SELECTION_TYPE_COMPASS_ITEM fallback.
Research: docs/research/2026-07-12-death-and-auto-target-pseudocode.md
Acceptance: With Auto Target enabled, kill several monsters near a friendly NPC. Every replacement target appears both on radar and in the toolbar; only a living hostile monster is acquired.
#204 — Replacement corpses replay the death transition and stand back up
Status: DONE — 2026-07-12, user visually confirmed multiple corpses remain fallen Severity: HIGH Component: live entities / animation / CreateObject lifecycle
Description: A killed monster played its death animation correctly, but when ACE replaced the victim object with its lootable corpse, the body visibly returned to the standing pose and replayed the fall.
Root cause: Six-object live trace proved ACE sent every corpse CreateObject
with the correct NonCombat + Dead motion. A follow-up trace after the first
correction showed the replacement corpse nevertheless completed Ready: its
persistent Dead rest pose is static, so it bypassed the normal multi-frame
spawn branch and entered the broader #187 reactive branch. That branch had
been generalized from doors by its gate but still hard-coded Door On/Off from
PhysicsState; for a corpse those commands did not select a cycle, leaving the
motion-table default Ready and producing the hard upright pop.
Resolution: Both live-spawn branches now use one data-driven initializer.
It installs the motion-table default, applies the authoritative MovementData
stance/command, and then calls MotionTableManager.HandleEnterWorld() exactly
like retail's description-then-enter-world lifecycle. No corpse or door type
switch remains. App tests pin Dead and Door On/Off wire-state resolution; a
Core conformance test pins removal of the detached Ready→Dead link before the
first rendered tick.
Research: docs/research/2026-07-12-death-and-auto-target-pseudocode.md
Acceptance: Kill several monsters. Each falls once, is replaced by a lootable corpse in the same fallen pose, and never visibly stands between the victim deletion and corpse display.
#203 — Unequipping armor freezes character and world animations — FIXED
Status: DONE — 2026-07-11 (this change) Severity: HIGH Component: live entities / animation / appearance updates
Description: After armor was removed, the local character stopped animating and animation-dependent world interactions such as doors appeared frozen.
Root cause: 0xF625 ObjDescEvent was incorrectly handled as a full
despawn/respawn. That discarded the live WorldEntity, animation manager,
physics host, and collision registration while the player movement controller
still referenced the discarded sequencer. Named retail instead applies
DoObjDescChangesFromDefault to the existing object.
Resolution: Appearance hydration now mutates only the existing entity's meshes, palette, part overrides, and bounds. Entity identity, animation playback, movement, physics, collision, selection, and dead-reckoning state survive. The classification cache is invalidated in place. A regression test pins entity and sequencer identity across the update. User verified the exact equip/unequip flow in the Release client.
#191 — Tapping W (brief forward press) glides forward without playing the step animation
#194 — WbDrawDispatcher._groups is never pruned (minor)
Status: OPEN — filed 2026-07-10. post-M2, LOW priority. Surfaced during the #193 heap analysis.
Severity: LOW — bounded, not a crash risk (unlike #193 which was the real OOM).
Component: render — WbDrawDispatcher._groups (Dictionary<GroupKey, InstanceGroup>, WbDrawDispatcher.cs:236).
_groups accumulates one InstanceGroup per distinct GroupKey (mesh + texture + translucency + cull combo) ever seen, and is never pruned when the entities/landblocks that created a group unload. After the #193 fix, groups for unloaded content are reset to empty each frame (cheap: the object + five empty List<T> ≈ a couple hundred bytes) but the dictionary entry lingers. Bounded by the number of distinct render-group combos in the world (hundreds–low thousands), so total cost is sub-MB — plateaus, does not grow like a leak. Fix if convenient: drop groups whose instance count stayed 0 for N frames, or clear entries on landblock unload. Not urgent.
#193 — Client OOMs after extended play (~50 min) — FIXED
Status: ✅ FIXED + measurement-verified 2026-07-10 (119a2326). Root cause: WbDrawDispatcher.InstanceGroup.Opacities (a List<float> added by #188) was appended one float per drawn instance per frame but never cleared — the per-frame reset loop (WbDrawDispatcher.cs:959) cleared its four sibling parallel lists but not Opacities. List<float> capacity-doubling → ~128 MB/512 MB LOH float[] → ~1 GB/min → OOM after ~50 min. Fix: extract the reset into InstanceGroup.ClearPerInstanceData() clearing ALL FIVE parallel lists (so a future 6th can't drift out); TDD InstanceGroupClearTests. Before/after (same 6-min churny roam, dotnet-counters): working set 1.6→7.6 GB (leaked) vs 0.75→1.36 GB (fixed); LOH 1.1→6.1 GB (climbing) vs 0.24→0.64 GB then FLAT (240→607→641→641→641). No crash on the fixed build. (Move to Recently closed on next tidy.)
Severity: was MEDIUM→HIGH (crashed after extended sessions). RESOLVED.
Component: render — WbDrawDispatcher per-frame instance-group reset.
Observed 2026-07-10 (two crashes in one evening, both Release build):
- Crash 1:
dotnet : Out of memory.after an extended dev-UI session; smoke pluginsaw 37085 entities totalat teardown. - Crash 2:
Out of memory.after ~50 min of retail-UI play (started 09:12:07, crashed 10:01:49);saw 58051 entities total.
IMPORTANT — do NOT mis-read the entity number as the cause. SmokePlugin.OnEntitySpawned => _entitiesSeen++ is a MONOTONIC cumulative count of every spawn EVENT over the session (never decremented on despawn). 37K/58K over a long roam is normal streaming churn (landblocks re-spawn their entities on every revisit), NOT a resident count and NOT proof of an entity leak. An earlier "entity leak" framing this session was retracted for exactly this reason (see claude-memory/feedback_phantom_regression_runtime_state.md).
How to investigate (capture-first, do NOT guess): measure ACTUAL memory growth over time — managed heap (GC.GetTotalMemory / dotnet-counters) AND unmanaged/GPU (native heap, GL texture/buffer/mesh allocations, the WB mesh caches, TextureCache, GlobalMeshBuffer). Prime suspects to rule in/out with real measurements: GPU resource accumulation at the High preset (aniso16x/MSAA4x/25×25 far window), per-revisit landblock/mesh/texture cache growth, or a genuine managed leak in the entity/streaming path. Snapshot the working set every N minutes of a roam and diff. Possible contributor to tonight's phantom-door-regression episode (memory pressure → GC thrash → dropped inbound motion packets) — see feedback_phantom_regression_runtime_state.md.
Status: OPEN Severity: MEDIUM (visible every time a player taps instead of holds a movement key — likely a common input pattern) Filed: 2026-07-09 Component: physics / animation — movement input, motion sequencing
Description: User-reported (live testing, unrelated to tonight's A7 lighting work): tapping W briefly translates the player forward smoothly ("glide") without the walk/run step animation playing. In retail, a brief tap produces a single visible step (a short animated motion), not a silent slide. Not investigated this session — filed to keep A7 lighting/particle work from being interrupted by an unrelated subsystem (movement/animation, not rendering).
Root cause / status: UNKNOWN. Likely candidates for a future session (grep named retail decomp FIRST, per the mandatory workflow — do not guess):
- The R5 movement-manager arc shipped 2026-07-05 (
docs/research/2026-07-03-r5-managers/) — check whether this is a regression from that work or a pre-existing gap it didn't cover. - Retail likely distinguishes a brief key tap (press+release before some
threshold) from a held key at the INPUT/COMMAND layer, producing a distinct
one-shot "step" motion command rather than the continuous Walk/RunForward
cycle — search
docs/research/named-retail/acclient_2013_pseudo_c.txtfor howCMotionInterp/the movement command dispatch handles a short-duration ForwardCommand before it's promoted to a full walk/run cycle. - Candidate files:
src/AcDream.App/Input/PlayerMovementController.cs(local input → command translation),src/AcDream.Core/Physics/MotionInterpreter.cs/AnimationSequencer.cs(cycle selection/dispatch). - Cross-reference
references/holtburger/crates/holtburger-core/src/client/movement/for what a real client sends on a brief tap vs a hold.
Files: Not yet identified beyond the candidates above.
Acceptance: A brief W tap produces a single retail-faithful step animation (not a silent glide); holding W still transitions normally into the walk/run cycle.
#187 — [DONE 2026-07-08] Non-hinged doors (sliding doors, gates) don't play their open animation
Status: CLOSED 2026-07-08 — user-confirmed live gate: "sliding doors now work."
Severity: MEDIUM (visual correctness; collision + interaction already work — #137)
Filed: 2026-07-08
Component: render — entity animation registration (GameWindow.cs live-spawn dispatch)
Root cause (CONFIRMED via retail decomp + a live weenie-data survey):
GameWindow.cs:3897 registered the reactive-motion-table rescue sequencer only when
spawn.Name == "Door" (an exact display-name string match). Retail's own client-side
motion dispatch chain (ACCObjectMaint::CreateObject → CPhysicsObj::set_description →
SetMotionTableID → CPartArray::SetMotionTableID 0x005186e0 → MotionTableManager:: PerformMovement) is unconditionally data-driven — the only gate anywhere in that chain
is "does this object have a non-zero MotionTableId" (if (ebx != 0)); there is no
CDoor class and no name/type check. Production weenie data confirms Sliding Door /
Portcullis / Gate / "Magic Wall" all carry the identical WeenieType=Door +
non-zero-MotionTableId shape as a plain "Door", differing only in display name — so the
name-string gate silently dropped every door-like object not literally named "Door".
Fix: GameWindow.cs:3897 — dropped the name check; the branch's existing
mtableId != 0 test (already computed one line later) is now the entire gate, matching
retail exactly. IsDoorSpawn deleted (dead code); IsDoorName kept only for an
unrelated diagnostic log-label filter. Full regression green (App 741 / Core 2631).
Live-verified: sliding doors now animate open/closed correctly.
Scope note: the investigation also surfaced a SEPARATE, deeper gap — some door-family objects (confirmed: "Pedestal Weak Spot", a fading-wall secret passage) don't use ordinary part-transform motion at all; their open cycle is a translucency-fade
- ethereal-toggle effect that acdream has no rendering sink for. That is NOT a registration problem (this fix's dispatch reaches it correctly) — filed separately as #188.
#188 — "Fading wall" secret passages don't visually fade (missing TransparentHook/EtherealHook render sink)
Status: CLOSED 2026-07-09 (3284dd0a) — user-confirmed live gate: fading-wall
doors fade out and hold; sliding doors hold open. Fix = TranslucencyHookSink →
TranslucencyFadeManager → per-instance alpha SSBO (binding 7) → mesh_modern.frag
(FragColor.a *= vOpacityMultiplier), register AP-89. The commit ALSO fixed a
door "flip-back" (a settled-open door/wall reverted to the Tier-1 static cache's
rest pose + opacity 1.0) by reverting an uncommitted IsEntityCurrentlyMoving
cache-bypass narrowing — every Sequencer entity stays on the per-frame path. That
narrowing chased a Debug-build FPS artifact; Release is GPU-bound (~200 fps Sawato).
Severity: MEDIUM (a real but narrow class of dungeon secret-door objects; collision
already correct via a separate wire channel)
Filed: 2026-07-08 (surfaced during #187's live gate)
Component: render — animation hook dispatch (IAnimationHookSink / render-state sinks)
Description (user): a "fading wall" style secret-passage door ("Pedestal Weak Spot") can be used and passed through, but never visibly changes — no fade, no motion, nothing. Confirmed distinct from #187 (registration/dispatch already reaches this entity correctly).
Root cause (CONFIRMED via a live dat decode of the actual entity, not inference):
loaded the real MotionTable (0x090000F9) for the live-identified "Pedestal Weak Spot"
(guid 0x7C95B03B) and dumped its open cycle's animation hooks directly — the cycle
(anim 0x03000919, 10 frames) carries EtherealHook, TransparentPartHook,
SoundTableHook — i.e. this door type's "open" isn't skeletal motion at all, it's a
per-part translucency fade + a collision-passthrough toggle + a sound cue, fired as
keyframe hooks during the animation.
src/AcDream.Core/Physics/IAnimationHookSink.cs's own doc comment documents
TransparentHook/NoDrawHook/ScaleHook/ReplaceObjectHook/etc. as intended to route
to "GfxObjMesh / renderer state mutations on the target entity" — but only three sinks
are ever registered (_particleSink, _lightingSink, _audioSink; GameWindow.cs
~1414-1441). No sink anywhere in the codebase pattern-matches TransparentHook /
TransparentPartHook / EtherealHook / NoDrawHook / ScaleHook /
ReplaceObjectHook (verified by a full-repo grep) — the sequencer correctly parses and
fires these hooks every tick, the router fans them out, and all three registered sinks
silently ignore them. Nothing crashes (the router swallows exceptions per-sink); nothing
renders differently either.
Confirmed NOT a collision bug: the object's ethereal/collision-passthrough state is
applied correctly via a SEPARATE server-authoritative wire message (SetState →
OnLiveStateUpdated, GameWindow.cs:5517) — independent of the animation-hook
mechanism. EtherealHook firing client-side during the animation is very likely a
redundant/cosmetic signal in retail's own design; the real remaining gap is purely
TransparentPartHook → no visible fade.
Scope (not yet designed): implementing this touches the render pipeline (a per-part
runtime alpha under the mandatory N.5 bindless/MDI pipeline — see
memory/reference_modern_rendering_pipeline.md for the existing SSBO layout
constraints) — this is feature-shaped work, not a one-line fix. Needs its own design
pass (grep retail's TransparentHook::Execute/SetTranslucency2/SetPartTranslucency
decomp for the exact interpolation semantics) before implementation.
Apparatus (kept): tests/AcDream.Core.Tests/Physics/Issue187FadingDoorMotionTableInspectionTests.cs
— reflects the real DatReaderWriter hook-type shapes + decodes a live MotionTable's
hook contents directly (no guessing). Reusable for any future "why doesn't this animate"
question — just swap the MotionTableId.
Acceptance: the Pedestal Weak Spot (and similar fading-wall objects) visibly fades out/in when triggered, matching retail. No regression to #187's fix or any other door type.
#186 — [DONE 2026-07-08 · 8257b9ba] Indoor→indoor GREY flap at a connecting room (top floor, new house type)
Status: CLOSED 2026-07-08 — live gate PASSED (no grey at any camera angle) + probe
(216 root=0118 frames, 0 still grey; 0118->0116 now TRV, vis=4).
Severity: MEDIUM · Filed: 2026-07-08 · Component: render / indoor visibility (portal side-cull)
Description: Top floor of a new house type; a thin connecting cell between two rooms. Passing through → brief GREY flap; stopping at the spot → whole screen grey and stays; turning the camera clears it, turning back → grey again (camera-direction dependent, player stationary).
Root cause (CONFIRMED via live retail cdb trace + dat diagnostic — the report-only "narrowed" note
was the right FAMILY but the WRONG mechanism): the render portal side-cull reconstructed each
doorway's interior side (PortalClipPlane.InsideSide) from the cell's AABB centroid. For the thin
connector 0xF6820118 (5 render polys) the bounding-box center falls on the WRONG side of the
0118->0116 doorway → the eye read as a back-portal → the forward room 0116 was culled → the
aperture showed the fog clear color = grey. Retail's PView::InitCell (0x005a4b70) AND acdream's own
PHYSICS path (CellTransit.cs:190) read the explicit dat PortalSide bit ((Flags&2)==0); the
render path was the only one guessing from geometry.
Fix (8257b9ba): GameWindow.BuildLoadedCell derives InsideSide from the dat PortalSide bit,
matching retail + physics. Surgical — the dat diagnostic
(Issue186…PortalSide_CentroidVsDatBit_AtGreyEye) shows the bit agrees with the old centroid on every
portal of these cells EXCEPT the one #186 breaks; the CornerFlood/Issue113 dat helpers updated to the
same bit keep every real Holtburg/tower/hall flood identical (App 741 / Core 2631 green). Touches neither
PortalSideEpsilon nor the deleted EyeInsidePortalOpening rescue.
The retail trace OVERTURNED both prior hypotheses: NOT PICK (both clients root at 0118 at the pose,
eye ≈ identical) and NOT the handoff's FLOOD-epsilon framing — retail draws 0116 from the 0118 root
because its dat side bit admits the portal; acdream's centroid guess culled it. Apparatus (kept):
tools/cdb/issue186-connector-decider.cdb (viewer_cell + cell_draw_list decider) + the offline geometry
test. Handoff (now historical): docs/research/2026-07-08-186-connector-grey-flap-handoff.md.
#185 — [DONE 2026-07-08 · 07c5b832] LOCAL player jams half-way up outdoor stairs (house on stilts); a jump clears it
Status: DONE (live gate PASSED — "OK works"). Root cause was NOT the collision response. Severity: MEDIUM · Filed: 2026-07-08 · Component: physics / collision-registration (landblock shadow objects)
Description: Running up the outside stairs of a house-on-stilts you hit an invisible wall
"in the middle of the stairs" (steps look unbroken) — you shuffle sideways, never advance; a
jump clears it. Landblock 0xf682, jam ≈ world (132, 77.9, 61.5), cell 0xF682002C.
Root cause (REAL — a uint32 overflow in the shadow-registry part-id, 07c5b832):
GameWindow.cs registered each landblock BSP part with a synthetic id entity.Id * 256u + partIndex. That << 8 overflows uint32 for class-prefixed landblock ids
(0x40/0x80/0xC0…) and drops the prefix byte, so different-class entities sharing the low
24 bits collide on one shadow part-id; Register's deregister-then-insert silently
overwrites one entity's collision (0xF6822100 ← {0x40F68221, 0xC0F68221} — 23 such collisions
in landblock 0xF682 alone). Three mid-staircase steps therefore rendered but had NO
collision; the player floats into the hole and the (retail-faithful) PrecipiceSlide wedge
fires at the walkable edge = the "invisible wall." The wedge was a symptom, not the cause.
The two earlier theories in this session — the handoff's "convex-tread-edge synthetic normal"
and design-v1's "grounding-retention at a coplanar seam" — were both SUPERSEDED by the live
[entity-source]/[bsp-test] capture (#3) that mapped the collision hole + the 23 id
collisions.
Fix (Option A, retail-faithful): register each multi-part landblock entity via
ShadowObjectRegistry.RegisterMultiPart under its unique 32-bit entity.Id (retail
CPhysicsObj::add_shadows_to_cells → CPartArray::AddPartsShadow — one object, a part array;
no synthetic per-part id). New builder ShadowShapeBuilder.FromLandblockBspParts. Setup
cyl/sphere path unchanged (runs only when entityBsp==0, retail BSP-xor-cyl). Despawn is
landblock-scoped so the id change is safe. Does NOT touch the frozen collision internals.
Files: src/AcDream.Core/Physics/ShadowShapeBuilder.cs (FromLandblockBspParts),
src/AcDream.App/Rendering/GameWindow.cs (~7898 registration block).
Tests: ShadowRegistrationOverflowTests (overflow arithmetic; old scheme drops one;
RegisterMultiPart keeps both; builder), Issue185OutdoorStairsSeamReplayTests (dat-free
clean-climb pin). Core 2629 / App 741 green. Design: docs/superpowers/specs/2026-07-08-185-outdoor-stairs-fix-design.md (v2).
Fallout note: shared registration path → this was silently dropping collision on OTHER landblock objects too (23 id collisions in one landblock); likely fixed a class of "walked-through-a-thing-that's-clearly-there" bugs, not just these stairs.
#184 — [DONE 2026-07-08 · 37a94e1f+f51c1dff+e1ac56cc+ddb5a967] Remote monsters overlap (arms interpenetrating) in a crowd; retail barely overlaps
Status: CLOSED 2026-07-08 — all four slices shipped + both visual gates passed. Slices 1+3
(37a94e1f+f51c1dff) fixed the reported monster-overlap symptom (gate passed). Slice 2 shipped
this session: 2a (e1ac56cc) extracted the ~690-line remote DR tick into a testable
RemotePhysicsUpdater (Code Structure Rule 1) — byte-exact, behaviour-neutral. 2b (ddb5a967)
collapsed the player/NPC fork so EVERY remote runs the same catch-up + sweep + shadow-follows-resolved.
A 3-lens adversarial review (workflow wf_b163315b-14f, 10 agents) corrected the design's player gate:
retail lets two non-PK players WALK THROUGH each other (PvP exemption — the remote-player mover now
carries IsPlayer|EdgeSlide like the local player), so 2b's player win is that players now collide with
monsters + terrain + walls (they skipped all collision before) while still passing through each other;
the review also caught + fixed a UM-first placement-snap gap (invisible-player risk). Gate PASSED
(user, 2026-07-08): "Looks good." #40 "remotes skip the transition" premise retired. Full plan + the
review corrections: docs/research/2026-07-07-184-slice2-unify-extract-handoff.md + the physics digest
banner. Register: TS-41 retired, TS-44 narrowed, TS-23 extended, AP-86/87/88.
Severity: MEDIUM
Filed: 2026-07-07
Component: physics / remote dead-reckoning
Description: Side-by-side vs retail on the SAME ACE, monsters packed around the player interpenetrate (arms) in acdream where retail keeps them barely overlapping — the "no room to slide out" feel. This is the REMOTE-creature thread — distinct from #182 (the LOCAL-player wedge).
Root cause / status: Retail de-overlaps remotes CLIENT-side: it runs the collision sweep on
every remote every tick against neighbours' LIVE resolved positions (the shadow == the resolved
m_position, re-registered every moved step), with the server pos a GENTLE catch-up target
(MoveOrTeleport 0x00516330), not a hard-snap. A first attempt (commit 9c0849dd) GATE-FAILED
(invisible monsters + player stuck on them) and was REVERTED — it (a) replaced the NPC UP
hard-snap with enqueue-everything, losing the body's PLACEMENT authority (an unplaced body blipped
over a huge distance into the sweep → garbage pos → invisible), and (b) left the shadow at the raw
server pos, so neighbours de-overlapped against overlapping shadows and the player collided with an
offset shadow. REDO (this session): (1) NPC UP MoveOrTeleport with a PLACEMENT-SNAP (snap when
the body isn't already near the target — first UP / no-Sequencer / far / >4 m; enqueue only near);
(2) grounded movement = interp catch-up feeding the kept sweep; (3) shadow-follows-resolved —
the shadow is re-registered at the resolved body every moving tick (SyncRemoteShadowToBody,
movement-gated; :5669 raw sync now players-only). Retires TS-41, narrows TS-44, adds AP-86/AP-87.
Mechanism proven in Core (RemoteDeOverlapMechanismTests: with-sync 0.86 m stable / without <0.40 m;
the real-interp loop absorbs the stall-blip). 2-lens Opus review CONCERNS → all addressed
(movement-gate for the town-FPS risk; players-only :5669; the blip-absorption test). Core 2620 /
App 741 green.
Known residual: the de-overlap sweep uses the fixed human sphere (R 0.48) for the mover, so large packed creatures de-overlap at human radii (TS-46; Setup-derived dims = Slice 3).
Files: src/AcDream.App/Rendering/GameWindow.cs (NPC OnLivePositionUpdated ~:5960 MoveOrTeleport
- shadow sync; Path B tick catch-up/sweep/shadow-sync;
SyncRemoteShadowToBody).
Research: design docs/superpowers/specs/2026-07-07-remote-creature-deoverlap-design.md; handoff
docs/research/2026-07-07-remote-creature-deoverlap-handoff.md; digest claude-memory/project_physics_collision_digest.md (2026-07-07 top).
Acceptance: side-by-side vs retail — packed monsters spread to retail spacing (arms no longer
interpenetrating), monsters VISIBLE (not stuck-on-nothing), no ~3 Hz jitter in a pack, no town-FPS
drop; remotes don't rubber-band/desync; sticky #171 facing unbroken; walk/run/jump/land unchanged.
Then Slice 2 (unify Path A + RemotePhysicsUpdater extraction) and Slice 3 (Setup-derived sphere).
#183 — Floating distant scenery: trees from another biome hover in the distance
Status: OPEN Severity: LOW Filed: 2026-07-07 Component: rendering / scenery / streaming
Description: Observed during #182 crowd testing (Holtburg area, +Acdream): trees
that appear to belong to a different biome render floating in the distance —
detached from the ground, at the wrong Z and/or wrong placement. Distant/far-radius
scenery only; unrelated to the collision work.
Root cause / status: Unknown. Candidates: far-tier streaming placing scenery before its terrain Z is resolved (procedural scenery Z uses the terrain height at the cell — a stale/zero height would float it), a biome/terrain-type mismatch selecting the wrong scene set, or a far-LOD placement offset. Not yet investigated.
Files: likely src/AcDream.App/Rendering/Wb/ scenery pipeline
(SceneryRenderManager / SceneryHelpers) + the two-tier streamer's far tier.
Acceptance: distant scenery sits on the ground with correct biome/placement.
#182 — Player wedges in a packed monster crowd, can't wiggle free (hand-rolled SphereCollision)
Status: VELOCITY-MODEL REBUILD SHIPPED (Slices 1+2, 8bb8b204→54d56229) —
awaiting the user visual gate (crowd glide/land + normal-locomotion regression pass).
Severity: MEDIUM
Filed: 2026-07-07
Component: physics / collision
REBUILD SHIPPED (2026-07-07): The CPhysicsObj::UpdateObjectInternal velocity chain
was ported verbatim, fixing the airborne "stuck in the falling animation" regression the
CSphere port had exposed. Refinement of the design's framing: the airborne-stuck
bleed is the frames_stationary_fall counter (ValidateTransition increments it when a
gravity mover can't advance; handle_all_collisions zeros the velocity at fsf>1 → gravity
resumes → glide/fall), NOT the cached_velocity field (a separate retail reporting/DR
value). Slice 1 = the fsf round-trip in the kept transition internals (retires TS-3) +
ungated small-velocity-zero; Slice 2 = PhysicsObjUpdate.HandleAllCollisions wired into
PlayerMovementController, gated on candidateMoved (retail pc:283657), replacing the
ad-hoc airborne-only reflect + Velocity.Z snap (AD-25 narrowed to the remote-DR sweep;
AD-39/40/41 added). Tests: FramesStationaryFallTests, HandleAllCollisionsTests,
Issue182CrowdJumpTests (a blocked jump bleeds to ~0 and grounds instead of hanging with
+12). Core 2617 / App 741 green. Plan:
docs/superpowers/plans/2026-07-07-player-physics-update-verbatim-rebuild.md.
A/B instrument: ACDREAM_CAPTURE_RESOLVE → tools/analyze_resolve_capture.py (before =
52.8% OK / 22.1% stuck / 107 airborne-stuck; retail target ~78% OK / 0 airborne-stuck).
Residual (design §7 Q3, measure after the gate): the general ground-jam (22% stuck vs
retail 13%) may need a second divergence in the #137 sliding-normal-provenance family
(TS-4) — the fsf ladder only climbs when the sweep runs, and a purely-horizontal push has
its offset absorbed by the sliding normal. #182 keeps the CSphere port as the base.
Description: In a large group of monsters packed around the player it was too easy to get stuck — the player couldn't shuffle/slide out. Retail leaves room to wiggle free.
Root cause: humanoid creatures/players collide as body Spheres
(ShadowShapeBuilder.FromSetup emits ShadowCollisionType.Sphere for a Setup with
Spheres + no CylSpheres), so the crowd contact ran through Transition.SphereCollision
— a hand-rolled 3-D wall-slide (register TS-45), NOT a port of retail
CSphere::intersects_sphere. It shaved no ε, force-pushed each contact radially
to a fixed combinedR + 1 cm shell, ignored the head sphere, always returned Slid,
and leaked SetSlidingNormal. In a crowd the opposing radial de-penetration pushes
from neighbours fight each other → wedge (the "until an oblique input clears it" feel
TS-45 predicted).
Fix: ported the full CSphere::intersects_sphere family verbatim (dispatcher
0x00537A80 + step_sphere_up/slide_sphere/land_on_sphere/collide_with_point/
step_sphere_down), the direct analog of the 2026-07-05 CCylSphere port (#172). The
grounded slide now routes through the shared crease SlideSphere (0x00537440) →
tangential shuffle along the contact toward gaps, retail-faithful. TS-45 retired,
AP-84 added (PerfectClip TOI dead in M1.5). Verified retail-faithful: retail's
validate_transition (0x0050aa70) reverts curr_pos on any non-clean-OK step, so a
deep-mutual-overlap start wedges in retail too — the realistic crowd-edge graze
slides free.
Files: src/AcDream.Core/Physics/TransitionTypes.cs (SphereCollision + the
six new Sphere* siblings + FindSphereTimeOfCollision; caller at
FindObjCollisionsInCell threads isCreature).
Research: docs/research/2026-07-07-csphere-collision-family-pseudocode.md.
Acceptance: user shuffles free of a spawned monster pack the same way retail does.
Conformance: SphereCollisionFamilyTests (slide-around trajectory, head-on block,
ethereal passable) + ShadowShapeBuilderShapeSourceTests (body-spheres → Sphere-type).
Core 2603/0, App 741/0.
#181 — Facility Hub flicker at pressed camera (residual after the full 2026-07-06 fix ladder) — PARKED
⏸️ PARKED 2026-07-06 (user decision after the static-curve gate: "flickering is still
there — file it, document it, move on to the stairs"). The session shipped SEVEN
fixes/ports, every one individually verified, and the user-visible flicker at their
pressed-camera pose SURVIVED all of them. Shipped: 48aaab81 (stateful camera sought),
f10fe4e9 (retail adjust_to_plane — the dead ACE port), 87cddce2 (static light
re-apply stacking), 3f34bca0 (retail viewer step subdivision + viewer-exempt abort),
233b469b (A7 fix #2 — stationary weenie fixtures on retail's STATIC light curve,
Ghidra-verified 0x0059c8b0). ELIMINATED with evidence: camera eye strobe (post-fix logs:
zero >2 cm jumps), display tearing (VSync-on 30 fps still shows it; windowed+DWM never
tears anyway), light-pool membership + applied per-cell sets ([seam-cell]: frozen across
107k parked frames incl. every vis-flap frame), the light-stacking leak (fresh-session
flicker), the flood-root flap (stable at the user's standing pose), the sliver-cell's
own geometry (zero-area region). STILL-OPEN leads for whoever resumes: (1) the parked
clean captures at MY pose show a pixel-static scene — the flicker has never been
captured in a frame pair at THE USER'S pose with an unobstructed window: get the user to
hold the pose, capture ≥8 frames, diff (the apparatus is in the scratchpad scripts —
capture-still.ps1/imgdiff.ps1/imgdiffmap.ps1); (2) the deep-cell vis 31↔32 flap
(0x8A020181 sliver admission on the wall-press mm wobble — both real, both measured,
consequence unproven; Issue181VisFlapReplayTests + Issue181WallPressEquilibriumTests are
the pins); (3) cdb-trace retail's wall-pressed viewer to compare equilibria (toolchain
proven, needs a live retail session); (4) the static-curve fix may have changed the
artifact's LOOK (brightness) without killing the motion-flicker — re-characterize
before assuming tonight's captures still describe it.
Original filing (mechanism evidence trail below remains valid): Status: OPEN — mechanism PINNED from live evidence; the knife-edge test not yet identified Severity: HIGH (THE user-visible #176 flicker that survived the #180 camera fixes: washed regions with hard screen-space rectangle boundaries pulsing at a parked camera) Filed: 2026-07-06 (split from #176 after the #180 fixes exonerated the camera) Component: render — portal flood / clip-slot scissor degrade / light-visibility scoping
Evidence (launch-176-leakfix.log, root 0x8A020142, camera parked all session):
[flap] vis count flips 31↔32 every ~100–200 frames (≈10×/s at ~1500 fps) for the
ENTIRE 517k-frame session; the root's own portal products are stable except 2-dp
print-rounding wobble (eye 49.15↔49.14, p3 D −0.81↔−0.82) — the swept eye carries
micron-scale float-roundtrip noise ([flap-sweep] in/out differ ~7 µm; retail's parked
viewer is bit-exact via the UpdateCamera dead-band return viewer). One cell deeper in
the flood rides a knife-edge include/exclude test on that noise. Visible impact is
double: the cell's lights are visibility-scoped (GameWindow light registration
cellId: scoping), so a washed region's LIGHTING strobes, and the flapping cell's
portal extends the union-AABB clip/scissor rect (AD-17 degrade path) — captured as a
dashed axis-aligned rectangle boundary resizing frame-to-frame (scratchpad
flip-1/flip-4 crops, t0b stills). Frame-pair pixel diffs: 2.7–5% of the game area
changes per 150 ms at a fully parked, fresh session.
Confound note: every pre-#180 isolation (LIGHT_DEBUG=3, CLIP_DEBUG=1, etc.) ran WHILE the #180 camera strobe was live, so none of them cleanly cleared the render side — and CLIP_DEBUG=1 forced gl_ClipDistance slots only; the AABB-scissor degrade sibling may never have been disabled.
ROOT CAUSE CHAIN (completed 2026-07-06 night, all headless/live-probed):
- The flapping cell is 0x8A020181 (
Issue181VisFlapReplayTests: ±0.5 mm eye perturbation flips its admission across several gazes — at yaw 15/pitch −20 it flips for EVERY perturbation direction). - The excitation is the WALL-PRESS wobble, not µm noise: at the parked spot the camera
is pressed into a wall (
[resolve]hit=yes n=(0,−1,0) every frame; the player physics position is BIT-FROZEN — in=tgt=out). The sought steps α·gap ≈ 4 mm into the wall per frame and the sweep clips it back withadjust_to_plane's parametric 0.02 termination window (retail's own constant) → the published eye slides ~1 mm/frame along the wall.Issue181CameraParkStabilityTests: with static inputs and no wall the camera parks BIT-EXACT — the loop is healthy; a wall-press wobble of this scale is retail-class. - The AMPLIFIER is ours and is the actual defect: the A7 adaptation scopes light
APPLICATION to the camera flood's per-frame admission (light registration
cellId:scoping). Retail computes a light's reach through the portal graph ONCE at registration (Render::add_static_light→CObjCell::add_lights), camera- independent — a sliver cell flapping costs retail a few pixels; it costs us whole lit regions (the washed-region strobe).
⚠️ RETRACTION + EXONERATION (same night, the seam-diff run): the "per-frame
resizing dashed scissor rectangle" in the t0b captures was the USER'S SNIPPING-TOOL
MARQUEE (they were taking screenshots during the capture burst — the bright-inside/
dim-outside rectangle is the snip overlay, and my frame-pair diffs were contaminated
by it + the chat window). Do not trust the t0b/still-* pixel-diff numbers. AND the
parked seam-diff run (launch-181-seamdiff.log, [seam-cell] per-cell applied sets +
[flap] vis) proves the applied light sets are FROZEN across 107k parked frames
INCLUDING all 1,394 vis=32 frames — the vis 31↔32 flap has ZERO lighting consequence
(the only 7 set-change events were startup hydration). Light-set path EXONERATED.
What stands: the flicker is real (user eyes + their clean screenshots: the pink wash
patches + a brick-textured stripe across the floor), the vis flap is real but
consequence-free, and camera/pool/applied-sets/sliver-geometry are all clean. Next
instrument: a capture session with the game window UNOBSTRUCTED (no chat overlay, no
snip tool) while the user confirms the flicker is visibly active, + the user's
description of exactly WHAT changes (wash extent? the stripe? brightness?).
🎯 ARTIFACT CHARACTERIZED (2026-07-06 late night — the VSync test + clean live
captures): VSync ON (30 fps) and the stripes REMAIN ⇒ not tearing (and the client is
windowed — DWM never tears windowed apps; the tear theory was structurally wrong).
Clean captures at the user's spot with frame-pair diffs across 1 s: the scene is
pixel-STATIC except the idle animation — the "stripes/triangles" are STATIC RENDERED
CONTENT: the corridor wall's angled brace geometry silhouetted as dark triangles
against a BLOWN-OUT saturated magenta glow (zoom: scratchpad live-band.png). Retail
shows the same geometry against a dim wall; ours zebra-stripes because the pink
fixtures are ~10× too hot. ROOT = the A7 fix-#2 item: server-weenie stationary lamps
take the DYNAMIC light path (isDynamic: true at the GameWindow registration, 1/d,
range×1.5) instead of retail's static curve — now Ghidra-verified at 0x0059c8b0:
f = (1 − d/range)·intensity·wrap/d³ beyond 1 m, range = falloff×1.3, per-channel
clamped to the light's own colour (see the a7 pseudocode doc §1.6). The "flicker"
in motion = the high-contrast pattern's edge crawl (+ the wall-press mm wobble),
secondary to the brightness. NEXT: implement fix #2 (static curve for stationary
lights; isDynamic decided by whether the light MOVES, not by weenie-vs-dat origin),
then the combined #176/#180/#181 re-gate. retail's viewer step subdivision ported (3f34bca0 — radius-anchored
steps, remainder final step, viewer-exempt small-offset abort; calc_num_steps
0x0050a0b0 / find_transitional_position 0x0050bdf0 via Ghidra, pseudocode
docs/research/2026-07-06-viewer-step-subdivision-pseudocode.md). The wall-press
wander is UNCHANGED by it: a bit-exact 12-frame limit cycle (~130 µm/frame inward creep
×11, one 2.6 mm snap; Issue181WallPressEquilibriumTests orbit dump). With
adjust_to_plane + adjust_sphere_to_poly + the stepping now ALL Ghidra-verified
faithful, the residual mm cycle is most plausibly retail-class plateau physics —
invisible at retail's 60 fps vsync, tear-interleaved into visible stripes at our
~1500 fps unsynced (VSync defaults OFF, GameWindow.cs:1096). DECISIVE USER TEST:
VSync ON (F11 → Display), camera pressed at the wall/opening — stripes gone ⇒ the
artifact = mm wobble × unsynced tearing (fix = default display mode decision, no
physics change); stripes remain ⇒ cdb-trace retail's wall-pressed viewer per frame
to measure whether retail truly holds sub-mm (then the divergence is structural and
the trace names it).
REFINEMENT (same night, after the a7-pseudocode CORRECTION-2 re-read): the pool is
ALREADY resident-scoped + player-anchored (d8984e87 deleted the flood-scoped slice-1),
so "flood-scoped lights" is NOT the amplifier as first framed. Measured: 0x0181's
admitted view region at the flap pose is ONE zero-NDC-area sliver triangle
(Diagnostic_FlappingCellViewRegion_SliverOrLarge), and ClipPlaneSet.From handles
degenerates correctly (area < 1e-7 → Empty; sliver planes otherwise) — so the cell's own
gated geometry costs ~zero pixels either way. The amplifier is downstream of ADMISSION
but not the slice gate: 0x0181 joining/leaving changes the DRAWN-CELL LIST, and some
per-drawn-cell state keyed by list position/rebuild — prime suspects: the per-cell
light-set SSBO slots (SelectForCell, d8984e87) shifting so a DIFFERENT cell reads the
wrong 8-light set on minority frames, or the seal/punch assembly — flips a whole cell's
lighting. That is cell-sized, matches the captures (washed region with hard
boundaries), and is directly instrumentable: run parked with ACDREAM_PROBE_SEAMDRAW=1
(+FLAP) and diff the [seam-blk] applied-set lines of the WASHED cell between vis=31
and vis=32 frames — if its applied set (or slot index) changes with 0x0181's admission,
the indexing/rebuild site is the defect. NO band-aids on the flood admission itself
(the sliver flap is retail-class).
Acceptance: parked pressed camera in the washed spot → lit regions steady (the
[flap] vis 31↔32 flap may legitimately persist at sub-pixel visual cost); frame-pair
pixel diffs show no region-shaped changes; the user sees no flicker; #176 re-gate
(steady purple wedge) passes.
#180 — Camera-collision sweep bistable at a compressed boom → per-frame eye strobe (the #176 "stripes")
Status: 🟡 BOTH FIXES SHIPPED 2026-07-06 + LOG-VERIFIED; user gate pends on #181
(the residual visible flicker turned out to be render-side — see #181). Fix 1
48aaab81: stateful sought-position per CameraManager::UpdateCamera 0x00456660
(pseudocode docs/research/2026-07-06-camera-sought-position-pseudocode.md; register
AD-37/AD-38). Fix 2 f10fe4e9: BSPQuery.AdjustToPlane/AdjustSphereToPoly rewritten
per retail 0x00539bf0/0x00538170 — the ACE-inherited port was structurally dead
(always-false), so every PathClipped camera stop reverted to a whole transition-step
boundary; the original strobe's pulledIn 0.27↔0.53 was 1-step-vs-2-step quantization
(pseudocode docs/research/2026-07-06-adjust-to-plane-pseudocode.md, replay pin
Issue180CorridorSweepHysteresisReplayTests). Post-fix logs: zero eye jumps >2 cm
across 76k turn-only sweeps pressed into walls (was 0.27–0.29 m every ~5–90 frames);
wall stops resolve to the constant surface-contact point.
Severity: HIGH (the visible flicker/stripe artifact the #176 gate keeps failing on; corridor camera constantly rides walls)
Filed: 2026-07-06
Component: camera / physics (NOT render — every render suspect eliminated by isolation)
Description (user, Facility Hub): "geometrical patterns, triangles, that get
weird stripes… especially when I push the camera to the wall or the openings of
the corridor. If I zoom out and the camera does not touch the walls I get no
pattern." Reproduced autonomously (synthetic back-into-wall + GDI window
captures, launch-176-cameye.log).
Root cause (probe-pinned): while the compressed chase boom moves along/near
a wall, the SweepEye first-contact solution is BISTABLE: consecutive
[flap-sweep] records show the sweep INPUT moving ~1.4 mm (the player's glide)
while the OUTPUT flips 0.27 m along the boom (pulledIn 0.27 ↔ 0.53, eye
(78.500,−38.633,−3.845) ↔ (78.669,−38.815,−3.938)), re-flipping every ~5–10
frames — a knife-edge graze on the corridor's double-faced slabs (the #137
threshold family, camera edition; all 368k sweeps returned ok=True — this is
NOT the fallback path). Each flip hard-cuts the whole view matrix; at ~1700 fps
with no vsync every monitor refresh tear-interleaves BOTH views → fine
stripe/hatch patterns over surfaces (worst near seams where the 0.27 m parallax
is largest), flickering with movement. Confirmed present in the pure light
field (ACDREAM_LIGHT_DEBUG=3 still shows it → not texture) and with the
shell clip trim disabled (ACDREAM_CLIP_DEBUG=1 → not the clip gate).
Explains the residual #176 flicker reports post-lighting-fix: one of the two
alternating eyes sees the purple under-room parallax, the other doesn't.
Retail anchor (the fix): retail's sought position is STATEFUL —
SmartBox (0x00452d75) calls CameraManager::UpdateCamera(mgr, &ret, &this->viewer) with the CURRENT swept viewer and assigns the RETURN to
viewer_sought_position (0x00452d84): the next frame's target derives from
the collided position (converges; re-extends gradually), so mm-jitter never
re-tests the full-length knife-edge ray per frame. acdream's
RetailChaseCamera recomputes the full-length desired boom from scratch every
frame. Fix = read CameraManager::UpdateCamera (0x00456660) + the sought
derivation and port the stateful shape — NO damping band-aid on the swept
result without that reading (workaround rule).
Files: src/AcDream.App/Rendering/RetailChaseCamera.cs (boom/desired-eye),
src/AcDream.App/Rendering/PhysicsCameraCollisionProbe.cs (SweepEye — port
verbatim per update_viewer 0x00453ce0, exonerated), [flap-sweep] probe
(ACDREAM_PROBE_FLAP) is the apparatus.
Acceptance: camera pressed into walls/openings while moving → no
stripe/hatch patterns, no per-frame view jumps (consecutive-frame eye deltas
stay continuous in [flap-sweep]); camera glides along walls like retail.
#178 — Retire the A8 double-sided cell-shell stopgap (CullMode.Landblock → None)
Status: OPEN Severity: LOW-MEDIUM (correctness/perf debt; 2× shell fragment load) Filed: 2026-07-06 Component: render — EnvCellRenderer MDI draw
Description: EnvCellRenderer.RenderModernMDIInternal still carries the
Phase A8 visual-gate stopgap: if (cullMode == CullMode.Landblock) cullMode = CullMode.None; — "render cell polys double-sided while the architectural
cause is isolated." Every cell shell draws two-sided to this day. Retail
draws cell polygons single-sided (the drawing BSP + winding decide facing).
The "architectural cause" (winding convention vs the frame-global CW
front-face) was never isolated; the stopgap outlived its gate. Retiring it
needs the winding audit (which side do CellStruct polys wind under our
extraction?) + a visual gate — walls/floors must not vanish.
Acceptance: cell shells draw with proper backface culling, no missing
surfaces at the Holtburg + Facility Hub gates. Found during the #176/#177
investigation (docs/research/2026-07-06-176-177-render-pair-investigation.md).
2026-07-09 triage: investigated, verdict STILL_OPEN — the CullMode.Landblock -> CullMode.None double-sided stopgap is still present verbatim at EnvCellRenderer.cs:1394-1399 with no follow-up commit or roadmap reference retiring it.
#177 — Dungeon stairs pop in/out across levels (invisible until entering the room; last step vanishes running down)
🅿️ PARKED 2026-07-07 (user decision, after retail cdb disproved the portal-flood theory —
see session-3b below). NOT a blocker (cosmetic indoor pop). Ruled out with evidence: lighting,
membership, camera coherence, the collision sweep, the 0178/0182/0183 handoff cells, edge-on
eye-in-opening (fix#1 shipped→gate-failed→REVERTED), and the portal flood itself (retail's flood
collapses identically). Freshest un-chased lead: the steps are STATIC objects (GfxObj
0x010000DE ×6/cell) drawn via the separate viewcone cull, NOT cell shell — probe acdream's
step-static draw vs retail's. Characterization pins + the reusable retail-cdb capture toolchain
(tools/cdb/pview-spiral2.cdb, eye + cell_draw_list dump, clean top-level qd detach) are
committed. Resume from the session-3b block.
⚠️ UPDATE 2026-07-07 (session 3b) — cdb-traced RETAIL; the PORTAL-FLOOD theory is
DISPROVEN by retail's own ground truth. Attached cdb to live retail (PDB MATCH),
broke on PView::DrawCells (0x005a4840), dumped cell_draw_num + cell_draw_list cell
ids (.data[i]->m_DID.id) + the eye (Render::FrameCurrent->viewer.viewpoint) while the
user descended the spiral (retail-spiral2.log, 767 samples). Retail's flood is dynamic
and IDENTICAL in character to ours — from the spiral cells it swings num 3→27 with gaze
and COLLAPSES to 3 cells at many poses (e.g. cam=015d eye(60.05,-28.51,-3.66) → just
{015d 015e 015f}; cam=014b → {014b 014c 014a}). Our flood does the same (3→43, headless
FloodDepthFrom015E_VsRetail26 = max 43 vs retail 26). So retail does NOT keep the spiral
where we drop it — the flood is EXONERATED as the cause. The vanish must be DOWNSTREAM of
the flood (unexplored): the steps in these spiral cells are STATIC objects (GfxObj
0x010000DE ×6/cell), drawn via the separate viewcone cull (ViewconeCuller.SphereVisibleInCell
/ DrawCellObjectLists), NOT the cell shell — acdream may cull those step statics where
retail doesn't. OR a per-pose clip difference at the exact descending gaze (would need
retail's gaze — not yet captured — to confirm, but the matching num RANGES argue against it).
cdb watchout CONFIRMED THE HARD WAY: qd in a CONDITIONAL bp action does NOT fire (stuck
cdb → had to kill it → took retail down). Reliable detach = bp action falls through to BREAK
after N (no gc), then top-level qd (tools/cdb/pview-spiral2.cdb). NEXT: probe acdream's
step-static (0x010000DE) draw/viewcone in the spiral vs the cell shell.
⚠️ UPDATE 2026-07-07 (session 3) — fix#1 (edge-on eye-in-opening rescue) FAILED the
visual gate + REVERTED; [flood-collapse framing superseded by 3b — retail's flood collapses
the same way, so the flood is not the differentiator]. The mechanism is a GRAZING/SLIVER
FLOOD COLLAPSE in the tight SPIRAL staircase, NOT the edge-on-in-plane case. Live [stair-clip]+[flap] capture
(launch-177-stairclip.log, PROBE_FLAP): the user's staircase is the 0x8A02 SPIRAL
01C0→01C1→…→01C8→0210→… (cells stacked z 0..6, joined by floor/ceiling portals + turning
via alternating ±Y wall portals; the recurring 0x010000DE×6 statics are the steps). At the
vanish, root=01C8, vis collapses 12→3: the DOWN floor-portal →0210 goes OFF-SCREEN
(clip=0, D=3.75 — NOT edge-on) and the wall portals →01C4/01C9 project to SLIVERS
(clip=3), so the flood barely spreads and 9 cells (the spiral) drop. Turning slightly
re-admits (portals swing on-screen); zoom-out gives a top-down shaft view (aligned floor
portals) → floods down → visible. The camera is NOT collision-jammed — [flap-sweep]
pulledIn=0.00 across all 103k frames, full boom, collNormValid=False — so it is purely
the flood/clip, from a NATURAL chase pose. This is the #119/#181 grazing/sliver-clip class.
The retail contradiction (hit 3×): retail draws only the flood (PView::DrawCells
cell_draw_list) and polyClipFinish also rejects <3-vert slivers — so retail's flood
should collapse the same way, yet the user's retail shows the spiral. Something about
retail's viewpoint or flood in a spiral differs in a way NOT derivable from static analysis.
NEXT: cdb-trace retail descending this spiral (capture PView::ConstructView →
cell_draw_list contents + Render::FrameCurrent->viewer.viewpoint) to see how retail keeps
the spiral flooded — the CLAUDE.md tool for "guessing failed twice." Characterization pins +
the [stair-clip] probe (reverted from the shipped path) live in
Issue177StairDescentCameraFloodTests. DO-NOT-RETRY: the edge-on eye-in-opening rescue
(fix#1) — the vanish D is never near 0; a camera-press fix — the sweep is never pulled in.
⚠️ UPDATE 2026-07-06 (session 2) — [superseded by session 3; the edge-on sub-case below
is real but is NOT the production vanish]. ROOT CAUSE RE-DIAGNOSED with production-faithful
evidence; the two prior attributions below (lighting; then "flood-admission miss at
0178/0182/0183") are BOTH SUPERSEDED. Findings, all evidence-backed (headless real-camera
harness Issue177StairDescentCameraFloodTests + production [flap]/[flap-cam]/[vis]/[cell-transit]
capture launch-177-flapcell2.log):
- NOT membership, NOT camera coherence, NOT the collision sweep. Across the whole
capture:
eyeInRoot=Yon every frame (0 anomalies), the camera sweep never failed (0 failures). The render root always contains the eye. My headless harness driving the REAL chase camera + REAL sweep down the stairs =collapses=0, incoherentFrames=0. - The real staircase the user tests is
0x8A02015E/015F/01C1(stacked vertically, joined by floor/ceiling portals N=(0,0,±1), 6–7 statics each) — NOT0178/0182/0183(those have no statics, no floor portals). The handoff mis-identified the staircase. - The steps are cell SHELL geometry (drawn whole/unconditionally when the cell is admitted), so the vanish ⇒ the cell drops from the flood.
- MECHANISM: you can flood DOWN a staircase but not UP it.
[vis]sets: root01C1(top) ⊇ {015F,015E}; root015Fdrops01C1; root015Edrops both015FAND01C1. The up-cell dies at the CLIP, never the side test (root015F→01C1: CULL=0, edge-on clip≤0 = 1508/1782). The chase-camera eye on a staircase sits at/near the cell boundary = the floor/ceiling portal PLANE (local eye-z ≈ 6.0 = the ceiling,D≈0), so the vertical portal projects EDGE-ON (collinear, <3 verts) →ClipPortalAgainstViewrejects it → the stacked cell above never floods in → the whole staircase above the camera vanishes. Descending puts the camera below the stairs (vanish); looking back from the lower cell (vanish); zoom-out lifts the camera to a higher root that floods DOWN (floor portals admit) → visible. It's the #119/#181 edge-on-clip class, on VERTICAL (floor/ceiling) portals viewed from a boundary-height eye. - Retail complication (why the fix is NOT a naive clip relax):
PView::DrawCells(0x005a4840) drawscell_draw_list(the portal flood), andpolyClipFinishALSO rejects <3 survivors (docs/research/2026-06-11-polyclipfinish-w0-clip-pseudocode.md lines 39,59). So retail's flood would drop an edge-on portal too — meaning retail's eye must NOT sit edge-on with floor/ceiling portals (leading hypothesis: retail's flood viewpoint is mid-cell / the player-eye at ~1.7 m above the floor, never in a floor portal's plane; our chase-cam eye rides up to the ceiling portal). NEXT: confirm retail's stair-camera eye height (cdb) OR the flood-seed viewpoint, then fix retail-faithfully (camera-side keep-eye-off-portal-planes vs a narrow boundary-eye flood rule). DO NOT relaxClipToRegion's <3 gate (diverges from retail + risks #119/#181).
⚠️ UPDATE 2026-07-06 (visual gate) — this is NOT lighting. [SUPERSEDED by session-2 block above.] The A7 visible-cell light-scoping fix shipped + was probe-validated, but the user's gate showed the stairs STILL not visible looking back from the corridor (zoom-out changes the last-step case). Eye-position/flood behavior ⇒ a portal-VISIBILITY miss at the stair cells (0178/0182/0183), NOT the "its LIGHTS went dark" attribution recorded below. Re-diagnose as visibility. See the render digest banner.
Status: OPEN Severity: MEDIUM (visible geometry churn in the M1.5 dungeon) Filed: 2026-07-06 Component: render — indoor portal-flood visibility (dungeon multi-level)
Description (user, Facility Hub, 2026-07-06 gate session): a staircase connecting two levels (a) disappears on roughly the last step when running DOWN it, (b) is not visible when looking into the stair room from the corridor, and (c) pops into existence on entering the room. Classic portal-visibility miss: the stair geometry's cell is not reached by the portal flood from the viewer's cell until the viewer crosses into it.
Status: OPEN — root cause CONFIRMED; fix DEFERRED to the A7
dungeon-lighting arc (the cap-raise fix was live-tested and REVERTED,
see below).
Root cause (confirmed via the probe launches): the geometry never
vanishes — its LIGHTS do. BuildPointLightSnapshot keeps only the
MaxGlobalLights=128 point lights nearest THE CAMERA; the Facility Hub
registers 366 fixtures, so 238 are evicted per frame by camera distance.
A room whose torches all rank past the cap renders at bare 0.2 ambient
(near-black in a dungeon = "not visible"); approaching re-admits them
("pops into existence"); the eviction boundary sweeping with the camera
drops the ramp's lights mid-descent ("disappears on the last step").
Retail's minimize_object_lighting (0x0054d480) has no global
camera-nearest cap.
Why the fix is deferred: raising the cap to 1024 (commit 4d25e04d)
made the pops stop but exposed three unported retail lighting semantics
that DOMINATE the frame with the full pool active: (a) lights reach
through solid floors/walls — retail registers lights per-CELL
(insert_light 0x0054d1b0) so the under-room portal light never touches
the corridor above; our flat sphere-overlap has no reach notion; (b)
stationary weenie fixtures ride the DYNAMIC 1/d falloff (~9× retail's
static 1/d³ at 3 m — the #143 isDynamic misassignment for ACE-served
fixtures); (c) an unexplained striped z-fight-like artifact on lit floor
regions (user screenshot). Reverted to 128 (AP-85 documents the
stopgap; the desired-end-state pin is Skip'd in LightManagerTests).
A7 fix shape: per-cell light registration (insert_light port) +
static curve for stationary fixtures + the stripe hunt, THEN uncap.
Full investigation ledger:
docs/research/2026-07-06-176-177-render-pair-investigation.md.
Acceptance: the staircase renders whenever its room is visible through the connecting opening, and stays rendered through the full descent.
#176 — Purple flashing on dungeon floors at cell seams, camera-angle dependent
🟡 FIX SHIPPED 2026-07-06 (pending user visual gate) — the flicker was the LIGHT POOL
tracking the CAMERA, not a draw z-fight. The z-fight framing was refuted by the in-engine
[seam-*] probe (RenderDoc is infeasible on this pipeline — it hides
GL_ARB_bindless_texture and our mandatory-modern startup gate throws; AMD GPU rules out
Nsight): exactly ONE shell instance per seam cell at the lifted z (−5.98), NO
floor-coincident entity (portal entities sit at z=−12.05, six meters down), ZERO portal
depth fans (sealed dungeon). What the probe DID catch: the corridor floor's applied light
set flipping wholesale with flood composition. Root cause (named-decomp-verified): the
c500912b scoping port glossed retail's CEnvCell::visible_cell_table as "the portal-flood
visible set" — it is the RESIDENT-cell registry (add_visible_cell 0x0052de40
dat-loads absent cells; populated from each activated cell + its dat visible-cell list;
gaze can never remove entries), and Render::insert_light (0x0054d1b0) caps the pool
nearest Render::player_pos, not the camera. Our flood-scoped pool dropped/admitted
the six intensity-100 under-room portal purples as the camera turn changed the flood
(probe: flood churned 8→41 cells over a full turn) → the wedge blinked. Fix:
BuildPointLightSnapshot(playerWorldPos) — resident collection (all registered lit
lights), dynamics-first player-nearest cap; the RebuildScopedLights callback deleted.
Verified live: full-circle turn sweep, flood churning 8→41, the floor's set held the SAME
8 identities on every post-spawn frame. Pins: PointSnapshot_HubScale…CameraInvariant (rewritten),
PointSnapshot_OverCap_DynamicsNeverEvictedByNearerStatics, PointSnapshot_ResidentCollection_CellTagDoesNotFilter.
Register AP-85 rewritten; correction banner in
docs/research/2026-07-06-a7-per-cell-lighting-pseudocode.md. Residual parity note for the
gate: our single 128 pool admits 7 purples + viewer (8 dynamics, all 8 slots) where retail's
7-dynamic/40-static dual pool showed 4 purples + viewer + fixture slots — if the wedge reads
"too purple," the A7-arc dual-pool cap is the faithful trim. [seam-*] probes stay until the
gate passes, then strip.
Gate: stand in the 0x8A020164 corridor, turn back-and-forth across the seam — the
purple wedge must hold steady (its faceted SHAPE is retail-correct and stays).
⚠️ GATE 2026-07-06 (evening): FAILED on a RESIDUAL that turned out to be a
SEPARATE mechanism — flickering stripe/triangle patterns when the camera is
pushed into walls/openings. Isolation toggles killed every render suspect
(texture via ACDREAM_LIGHT_DEBUG=3, shell clip trim via ACDREAM_CLIP_DEBUG=1,
coplanar dat pairs, double-draws, seals); the artifact is the CAMERA-COLLISION
sweep strobing the eye 0.27 m per few frames at a compressed boom → tear-
interleaved views = stripes. Split to #180 (camera/physics). The lighting half
of #176 (the pool tracking the camera via flood scoping) remains FIXED and
live-verified; re-gate #176 after #180 lands. Also filed from this arc: the
site-A weenie light-registration LEAK (a portal's I100 light stacked ×2→×4
over one session as CreateObject re-sends re-registered it under fresh
entity ids — [seam-ent] L= showed 01D4:I100 four times) — fold into the
A7 arc or fix with #180's gate.
Status: 🟡 lighting fix SHIPPED + verified; #180 (camera strobe) BOTH halves
FIXED + log-verified 2026-07-06 (48aaab81 + f10fe4e9); the site-A static
light-stacking re-apply hole CLOSED (87cddce2 — idempotent re-registration;
whether it fully accounts for the observed [seam-ent] ×2→×4 growth needs a
probe re-run, the live weenie path's guid-dedup reads sealed). The residual
user-visible flicker survives all of these at a parked camera and is now
#181 — the portal-flood vis 31↔32 flap on micron eye noise driving
visibility-scoped lights + the AABB scissor rect. Re-gate #176 after #181.
Severity: MEDIUM (visible artifact along every corridor seam in the M1.5 dungeon)
Filed: 2026-07-06
Component: render — floor-portal polygons / portal surface state
Description (user, Facility Hub, 2026-07-06 gate sessions): the floor flashes with a purple overlay at cell seams, at certain camera angles only. Initially suspected to be the #137 physics oscillation exposed by the render; the physics fix landed (seam shake gone, user-gated) and the flash REMAINS — so it is a render-side issue in its own right, correlated with camera angle.
Status: OPEN — root cause CONFIRMED; fix DEFERRED to the A7
dungeon-lighting arc (see #177 for the revert story — same mechanism,
same deferral).
Root cause (confirmed via the probe launches): per-cell LIGHTING pops,
not a draw failure. The probe run reproduced the flash while the ambient
branch ([light] — stable 0.2 grey) and the portal flood ([pv-input] —
zero drops in 54k frames) were provably healthy, which eliminated the
last CPU-side theories and left the one channel the probes cannot see:
per-cell 8-light SET COMPOSITION. The camera-capped snapshot (128 of the
Hub's 366 fixtures, nearest-to-camera) evicts in-range lights of visible
cells; the flipping unit is a CELL, so the discontinuities sit at exactly
cell-seam granularity, swing with the camera position (the chase boom),
and the dominant flipping light is the under-room PORTALS' purple —
hence purple flashes on the floor. Twelve other mechanisms were refuted
first — ledger in
docs/research/2026-07-06-176-177-render-pair-investigation.md.
Deferral: the uncapped pool (live-tested 4d25e04d, reverted)
stabilizes the pops but floods rooms with through-floor portal light
(no per-cell reach semantics), over-strong dynamic-curve fixture light,
and a striped floor artifact — the A7 arc owns the real fix (per-cell
insert_light registration + static fixture curve + stripe hunt, then
uncap). Register row AP-85.
Acceptance: no purple/placeholder flashes on dungeon floors from any camera angle at the corridor seams.
#175 — Door collision registers the Setup PLACEMENT pose, not the motion-table CLOSED pose (phantom slab behind the visual door)
Status: 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (Facility Hub double door: closed blocks AT the visual panels from both sides, no embed, no phantom wall; Holtburg cottage door unregressed).
FIX: ShadowShapeBuilder.FromSetup gains a partPoseOverride (BSP part
shapes only; CylSphere/Sphere unchanged); RegisterServerEntityCollision
derives it via GameWindow.MotionTableDefaultPose — the wire MotionTableId's
default style, first cycle, LowFrame part frames (the closed/idle pose retail's
live CPhysicsPart holds). Null / short poses fall back per-part to placement
frames (table-less entities + landblock statics unchanged). Register row
AP-84 (one-shot registration snapshot vs retail's per-frame live pose —
equivalent for the door lifecycle since open = ETHEREAL). Pins: the three
FromSetup_* tests in Issue175HubDoorPoseInspectionTests.
Severity: MEDIUM-HIGH (embed into doors from one side; phantom wall on the other — can push the player out of use radius)
Filed: 2026-07-05
Component: physics — server-entity collision registration (door part poses)
2026-07-30 gate reconciliation (Campaign P P7): this issue's "pending user visual gate" status (2026-07-05/17) is superseded — its confirmation is formally folded into the Campaign P visual matrix scenario 8 (docs/plans/2026-07-30-physics-parity-visual-matrix.md) as the one consolidated gate. Automated backing accumulated since the fix shipped: the R6 rebaseline acceptance, every nine-stop soak (latest PASS 2026-07-30, logs/connected-r6-soak-20260730-131141), and the P3 sphere-list/response-swap conformance suites exercise these exact paths. The matrix result closes or reopens this issue.
Description (user, Facility Hub door guid 0x78A020C7 / Setup 0x02000C9D): running at the door embeds the player INTO the visual panel (deep enough to camera-clip to the other side); the actual blocking plane sits displaced to the FAR side, and approaching from that side there's a phantom wall in front of the visual door — far enough that the door can be out of use range.
Mechanism (dat-confirmed, 2026-07-05): the hub door is a DOUBLE door —
Setup 0x02000C9D has 3 parts; panels part[0]/part[1] (GfxObj 0x01002936,
physics slab 1.66×0.29×2.95 m) pose in the Setup's Default PLACEMENT
frames at yaw −150° / −30° with origin (±0.88, −0.44, 1.37) — an
AJAR pose displaced 0.44 m behind the doorway plane. The RENDERED door poses
its panels from the motion table's default (closed) state via the sequencer
(the setup itself has no DefaultMotionTable; the wire spawn supplies it).
Collision registers via ShadowShapeBuilder.FromSetup, which reads the
PLACEMENT frames (Resting|Default|first) — so the physical slabs sit at
the ajar placement pose while the visuals show closed panels: the exact
offset the user walked into. Retail tests each part's LIVE pose
(CPhysicsPart — see the #150 notes: for a CLOSED door the live pose IS
the motion-table closed pose; the open swing never matters because ETHEREAL
bypasses collision entirely).
Fix shape (retail-faithful, next session): the BSP shadow shapes for
server entities with a sequencer must use the SEQUENCER's part transforms
(the motion-table default/closed pose) instead of the raw placement frames —
either sample at registration (the sequencer exists by then — verify spawn
wiring order) or re-register via ShadowObjectRegistry.UpdatePosition-style
refresh after the sequencer's first advance. Parts without animation data
keep the placement-frame fallback. Watch: entScale composition, multi-part
dedup (feedback_dedup_keys_after_cardinality_change), and the Holtburg
single-door apparatus must stay green (its placement pose ≈ closed pose,
which is why #99/#150 never surfaced this).
Files: src/AcDream.Core/Physics/ShadowShapeBuilder.cs (placement-frame
read), src/AcDream.App/Rendering/GameWindow.cs
(RegisterServerEntityCollision ~4130), inspection
tests/AcDream.Core.Tests/Physics/Issue175HubDoorPoseInspectionTests.cs.
Acceptance: at the Facility Hub double door: closed door blocks AT the visual panels (no embed, no phantom wall on either side); open door fully passable; use radius reachable from both sides. Holtburg cottage door unregressed (door apparatus green).
#174 — Door Use dies after the first jump: the RemoveLinkAnimations seam stripped animations without retail's queue drain
Status: 🟡 FIX SHIPPED 2026-07-05 (same session) — pending user gate (jump around, then use the Facility Hub door from close AND from ~3 m).
FIX: the MotionInterpreter.RemoveLinkAnimations seam is retail
CPhysicsObj::RemoveLinkAnimations 0x0050fe20 — a tailcall to
CPartArray::HandleEnterWorld 0x00517d70 → MotionTableManager:: HandleEnterWorld 0x0051bdd0: strip the sequence's link animations AND drain
pending_animations completely (each pop relays MotionDone → the interp pops
its pending_motions node in lockstep). acdream bound the seam to the BARE
sequence strip (RemoveAllLinkAnimations), so every jump's LeaveGround
removed the animations that queued manager nodes were counting down on —
orphaning them and permanently damming BOTH queues; MotionsPending() then
starved every armed moveto (the far-range walk-to-door and the close-range
use turn — both door faces below). Rebound at both production sites
(GameWindow remote bindings + the player's EnterPlayerModeNow block) to
Manager.HandleEnterWorld(); harness mirrors updated; pins
Issue174LinkStripDrainTests (seam drains both queues; new motions queue +
complete after). The UseDone (0x01C7) display gap stays open below.
Severity: HIGH (can't open doors reliably → blocks the #137 door acceptance + normal play)
Filed: 2026-07-05
Component: interaction — B.4b Use pipeline / AP-23 speculative moveto deferral (R5-V5 facade)
2026-07-30 gate reconciliation (Campaign P P7): this issue's "pending user visual gate" status (2026-07-05/17) is superseded — its confirmation is formally folded into the Campaign P visual matrix scenario 8 (docs/plans/2026-07-30-physics-parity-visual-matrix.md) as the one consolidated gate. Automated backing accumulated since the fix shipped: the R6 rebaseline acceptance, every nine-stop soak (latest PASS 2026-07-30, logs/connected-r6-soak-20260730-131141), and the P3 sphere-list/response-swap conformance suites exercise these exact paths. The matrix result closes or reopens this issue.
Description (user, Facility Hub 0x8A02, door guid 0x78A020C7 Setup 0x02000C9D useRadius=0.50): double-clicking / R-using the door does nothing. The same door opens fine from the retail client on the same ACE, and acdream renders the observed swing correctly (inbound path healthy).
Evidence (3 wire captures + app log, 2026-07-05):
- First attempt (log):
use-deferred seq=624fired (the close-range deferral COMPLETED once — arrival callback ran), then 625 + 642–647 sent far-range. Door never opened. ACE's replies to those weren't captured. - Retail control (capture
door-use3.pcapng): retail sent[0xF7B1][seq][0x36][guid]×5 — ACE responded EVERY time with door0xF74CUpdateMotion +0xF74BSetState (ETHEREAL toggling 0x1001C ↔ 0x10018), broadcast to BOTH clients. Message format identical to ours. - acdream re-try (capture
door-use4.pcapng+ log): 2× DoubleClick + 4× R — picks land, but ZERO[B.4b] uselines and ZERO door-guid packets on the wire. The Use is swallowed CLIENT-SIDE before the send.
Mechanism (code trace): SendUse close-range branch (≤2 m by the AP-23
bucket — this door's flags → 2.0 m, overriding its real 0.5 m) parks the
action in _pendingPostArrivalAction and fires it ONLY from
MoveToComplete(WeenieError.None) (natural completion of the speculative
TurnToObject installed through the R5-V5 facade). A cancel (user input) or a
never-starting turn silently eats the use — no toast, no log (the deferral
prints are probe-gated). Candidates for "never completes": (a) the
BeginTurnToHeading MotionsPending() early-return starving the turn (the
#170 class, local-player edition — the log shows a steady
MOTIONDONE pending=True stream); (b) every attempt instantly cancelled by
concurrent user movement input (retail-faithful per-attempt, but the user
also clicked while standing still). Also noted: GameEventType.UseDone
(0x01C7) is parsed nowhere (not registered in GameEventWiring) — ACE
rejection reasons are invisible; and the first attempt's 7 sent uses never
opened the door either (suspect: concurrent outbound movement cancelling
ACE's MoveToChain server-side — unproven, replies not captured).
Files: src/AcDream.App/Rendering/GameWindow.cs (SendUse ~12607,
OnAutoWalkArrivedSendDeferredAction ~12761, InstallSpeculativeTurnToTarget
~12842, MoveToFactory callback wiring ~13530);
src/AcDream.Core/Physics/Motion/MoveToManager.cs (BeginNextNode /
BeginTurnToHeading gates). Captures in the session scratchpad
(door-use3.pcapng, door-use4.pcapng).
ROOT MECHANISM FOUND (2026-07-05 evening, probe round launch-174-autowalk.log):
the local player's pending-motion queue drains at ~1 node/sec and backs up
minutes deep during active play — MotionsPending() then starves every
manager-driven movement. Chain, all evidence in the log:
- Fresh session, standing at the door: every Use completes same-tick
(
[autowalk-end] err=None×6, seqs 11–16 sent, door opens — "now it works??"). Queue shallow ⇒ pipeline healthy. - After the jump/run sequence: the LAST player
pending=Falsecompletion is at the firstMovementJump Press(log line 371); from there to the end (line 939) every player MOTIONDONE reportspending=True— INCLUDING at rest, with old jump-family motions (0x6500000D/0F) still completing minutes later. That is a slow-draining BACKLOG, not one immortal node. - With MotionsPending() true,
BeginTurnToHeading/BeginMoveForward(retail 0x00529b90if (motions_pending) return) never start:- far range (wire-proven, seqs 98–101): Use SENT, ACE replies mt-6
MoveToObject (objDist=0.50 — ACE is healthy),
[autowalk-begin] mt=0x06arms, body NEVER walks ([autowalk-up] position frozen) → ACE waits forever → door never opens. Same as the original session's 642–647. - close range (round-3 silence): TurnToObject armed, never completes →
_pendingPostArrivalActionnever fires → use eaten with zero feedback.
- far range (wire-proven, seqs 98–101): Use SENT, ACE replies mt-6
MoveToObject (objDist=0.50 — ACE is healthy),
- Retail contrast: the #170 live cdb drain trace showed retail's queue
stays SHALLOW (add_to_queue == MotionDone, drained same-tick); our
CheckForCompletedMotionscompletes ~one node per animation cycle, so adds outpace drains during any active play. This is the #170 pending_motions-flood family — LOCAL-player drain-rate edition (the remote fix427332acremoved the flood's feeder; the local queue's DRAIN semantics are the divergence here).
Next (fix session): oracle-first on the drain: decomp
MotionTableManager::CheckForCompletedMotions (0x0051bfd0) +
AnimationDone/MotionDone pop semantics — which queued nodes retail
completes per tick (superseded/non-playing nodes must flush immediately,
not serialize behind animations). Add a queue-dump probe (node ids + ages)
before changing anything. Then re-verify the door BOTH branches + re-check
UseDone (0x01C7) wiring so ACE rejections become visible.
DO NOT band-aid: no MotionsPending bypass in BeginTurnToHeading (the gate
is verbatim retail), no deferral-skipping (turn-to-face is retail).
#173 — Observed character jumping into a ceiling hovers at the roof until the arc decays (no collision-velocity response on remotes)
Status: 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (watch a second client jump into the 0x0007 dungeon roof; it should bounce down immediately like the local player).
Severity: MEDIUM (remote-motion fidelity indoors; lands visibly late)
Filed: 2026-07-05
Component: physics — remote dead-reckoning collision response
2026-07-30 gate reconciliation (Campaign P P7): this issue's "pending user visual gate" status (2026-07-05/17) is superseded — its confirmation is formally folded into the Campaign P visual matrix scenario 8 (docs/plans/2026-07-30-physics-parity-visual-matrix.md) as the one consolidated gate. Automated backing accumulated since the fix shipped: the R6 rebaseline acceptance, every nine-stop soak (latest PASS 2026-07-30, logs/connected-r6-soak-20260730-131141), and the P3 sphere-list/response-swap conformance suites exercise these exact paths. The matrix result closes or reopens this issue.
Description (user, 0x0007 dungeon): watching another character jump into the dungeon roof, the observed char sticks to the ceiling until the jump arc would naturally have come down — "like we are calculating the entire jump instead of actually checking the collision" — and lands later than retail, with the animation pinned at the roof. The LOCAL player's own jump bounces off the roof immediately.
Root cause (code-confirmed): the remote DR tick integrates the
VectorUpdate launch ballistically and DOES sweep collision
(ResolveWithTransition, GameWindow remote block) — the sweep pins the
POSITION at the ceiling — but the retail post-transition velocity response
(CPhysicsObj::handle_all_collisions, pc:282699-282715: reflect
v −= (1+elasticity)·dot(v,n)·n) was only ever ported for the LOCAL player
(L.3a, PlayerMovementController ~:940). The remote body kept its +Z launch
velocity, re-integrated it into the roof every tick, and only descended once
gravity burned the arc off. Retail runs handle_all_collisions after every
SetPositionInternal for every physics object — remotes included.
Fix (this commit): mirror the local L.3a reflection block in the remote sweep's post-resolve path (same formula, same AD-25 airborne-before-AND-after suppression so corridor slides and landings don't reflect, same Inelastic zero-out). Register AD-25 extended to cover both sites.
Files: src/AcDream.App/Rendering/GameWindow.cs (remote sweep, #173
block after rm.Body.Position = resolveResult.Position).
Acceptance: from acdream, watch a second client jump into a dungeon ceiling: the observed char deflects off the roof immediately and lands at retail timing; grounded remote movement (corridor wall slides, NPC chases) unchanged.
#172 — Town-network portal platform blocks instead of stepping up (CCylSphere family was never ported)
Status: 🟡 FIX SHIPPED 2026-07-05 (this commit) — pending user visual gate (walk up onto the Holtburg portal platform, then the 0x0007 dungeon run).
Severity: HIGH (blocks dungeon access — gates the whole #137 repro)
Filed: 2026-07-05
Component: physics — CylSphere object collision response
2026-07-30 gate reconciliation (Campaign P P7): this issue's "pending user visual gate" status (2026-07-05/17) is superseded — its confirmation is formally folded into the Campaign P visual matrix scenario 8 (docs/plans/2026-07-30-physics-parity-visual-matrix.md) as the one consolidated gate. Automated backing accumulated since the fix shipped: the R6 rebaseline acceptance, every nine-stop soak (latest PASS 2026-07-30, logs/connected-r6-soak-20260730-131141), and the P3 sphere-list/response-swap conformance suites exercise these exact paths. The matrix result closes or reopens this issue.
Description (user): the Holtburg town-network portal sits on a stone
platform the player collides with instead of stepping up onto it (retail just
walks up). Entity 0xC0A9B465 = landblock stab #0x65, Setup 0x020019E3,
one CylSphere r=2.597 m, h=0.256 m — a 26 cm disc, trivially steppable in
retail. Surfaced the moment #149 (4cf6eeb) started registering BSP-less
stab CylSpheres (before that fix the platform had NO collision at all, so the
player clipped through it — the collision shape was the #149 fix; the
collision response was never retail).
Root cause (probe-confirmed, launch-137-repro.log): the pre-port
CylinderCollision was a hand-rolled approximation (AP-6): step-up gate +
radial wall-slide only. Every contact returned Slid with a horizontal rim
normal ([cyl-test] … result=Slid, [resolve] … n=(0.99,-0.11,0.00)) and
the player orbited the rim forever. The step-up gate passed (clearance
0.256 ≤ 0.6) but DoStepUp's internal step-down probe could never validate a
landing ON the cylinder top — a cylinder has no polygons, and the port had no
step_sphere_down cap-landing (top-disc contact plane). Airborne landings on
tops (land_on_cylinder + the Collide-flag exact-TOI branch) were missing
too.
Fix (this commit): verbatim port of the full retail CCylSphere family —
dispatcher intersects_sphere 0x0053b440, collides_with_sphere 0x0053a880,
normal_of_collision 0x0053ab50, collide_with_point 0x0053acb0,
slide_sphere 0x0053b2a0, step_sphere_up 0x0053b310, land_on_cylinder
0x0053b3d0, step_sphere_down 0x0053a9b0. Pseudocode + settled BN
ambiguities + two ACE-bug findings:
docs/research/2026-07-05-ccylsphere-collision-family-pseudocode.md.
Register: AP-6 retired, AP-83 added (PerfectClip TOI tail per ACE, dead code
in M1.5). Conformance: CylSphereFamilyTests (grounded step-up-onto-top on
the exact platform shape, tall-cylinder block, airborne top landing, ethereal
Layer-2 guard); the #42 self-shadow control assertion updated to the retail
observable (denied movement, not the old artifact radial push).
Files: src/AcDream.Core/Physics/TransitionTypes.cs (CylinderCollision
Cyl*family),tests/AcDream.Core.Tests/Physics/CylSphereFamilyTests.cs.
Acceptance: walk straight onto the Holtburg town-network portal platform (no rim slide); jumping onto it also lands. Doors/torches/NPC cylinders unregressed (suites green; #150 open-door behavior unchanged). Likely also advances #137's door-foot half — re-check in the dungeon repro.
#171 — Group melee: monsters interpenetrate + facing drifts (sticky melee unbound, arrival radii = 0)
Status: DONE (2026-07-04) — user visual gate PASSED ("Looks good, ship it")
after three slices: 5bd2b8bc (R5-V3 sticky binding + real radii) + 7a823176
(NPC UP-snap suppression while stuck — TS-44) + 69966950 (sticky deep-overlap
back-off sign pin — AP-82; ACE's literal decode steered INTO the target when the
overlap exceeded one tick's step; 1661-tick probe capture refuted it against the
retail oracle). ACDREAM_PROBE_STICKY=1 stays as permanent gated diagnostics
(PhysicsDiagnostics family). (Move to Recently closed on next ISSUES tidy.)
History below. Original landing note: StickyManager/
PositionManager (ported R5-V1) now BOUND: StickTo/Unstick/UnstickFromObject
seams → the host's PositionManager (remote + player), AdjustOffset composed
at the retail UpdatePositionInternal slot before the collision sweep,
UseTime (1 s lease watchdog) at the UpdateObjectInternal tail, real setup
cylsphere radii threaded (own via EnsureRemoteMotionBindings/player wiring,
target via RouteServerMoveTo), the SERVERVEL leg now also yields to a stuck
entity (TS-41), exit-world/teleport teardown wired (remote teleport gap filed
TS-43). TS-39 retired. Harness: 2 new sticky scenarios in
RemoteChaseEndToEndHarnessTests (arrive→stick→strafe-track→lease-expiry;
unstick-on-rearm→re-stick).
Severity: MEDIUM (visual — group combat feel vs retail)
Filed: 2026-07-04 (user report during the #170 visual gate: "some monsters were
partly inside other monsters while hitting me… some orientation of the monsters
are a bit off")
Component: remote entity, MoveTo/PositionManager, R5-V3
Description: in a pack melee, acdream attackers end up partly inside each other with slightly stale facings vs retail on the same ACE. Two code-grounded causes (both already-tracked R5-V3 scope), plus one server-side caveat:
- Sticky melee is a no-op (register TS-39). ACE arms every melee chase with
Sticky|UseFinalHeading|MoveAway|FailWalk(references/ACE/…/Monster_Navigation.cs:416). Retail's sticky arrival hands off toPositionManager::StickTo→StickyManager::adjust_offset(0x00555430): per-tick 0.3 m edge-gap + facing tracking against the moving target. OurMoveToManager.BeginNextNodeinvokes theStickToseam — unbound — so attackers complete-and-freeze at stale arrival poses until the next wire re-arm. - Arrival radii are zero. ACE/retail arrive edge-to-edge
(
cylinder_distance, own + target radii from the PartArray/setup —ACE PhysicsObj.MoveToObjectreadsGetRadius()/GetHeight()from the TARGET). acdream bindsgetOwnRadius: () => 0f(GameWindowEnsureRemoteMotionBindings, the explicit R5-V3 pin) ANDRouteServerMoveTonever setsMovementStruct.Radius/Height→ both radii 0 → every attacker closes ~one body-radius deeper than retail → dogpile. - Caveat: ACE has no server-side monster-vs-monster avoidance — some overlap is server-authoritative and shows on retail too. Acceptance = retail parity, not zero overlap.
Ruled out: the between-snap collision sweep (remotes run full
ResolveWithTransition; CollisionExemption keeps creature cylinders collidable
for non-viewer movers).
Approved fix (R5-V3): port StickyManager + bind PositionManager
StickTo/UnStick seams + thread setup-derived cylsphere radii through the
MoveTo distance math. SSOT: docs/research/2026-07-04-171-sticky-melee-handoff.md
- pickup
docs/research/2026-07-04-171-pickup-prompt.md.
Acceptance: side-by-side vs retail in a scamp pack: attackers hold separation
- face the player while it strafes; user visual gate.
Gate 1 result (2026-07-04): "in general it is better" but three residuals:
(1) monsters sometimes pushed INTO the player; (2) monsters sometimes attack
while facing the wrong way; (3) "flashing/flapping instead of gliding". All
three = ONE mechanism: the legacy NPC UP handler hard-snaps position (5678) +
orientation (5714) + velocity/cycle UNCONDITIONALLY, fighting the armed sticky
per tick (ACE's authoritative rest pose sits ~0.6 m out + lags the strafing
target's bearing; sticky pulls to 0.3 m + live facing → oscillation at UP
cadence). Retail is immune BY ARCHITECTURE: UPs flow through the
InterpolationManager into the same adjust_offset chain where sticky OVERWRITES
them while armed. Residual fix: suppress the NPC UP snaps while stuck
(register TS-44 — the retail chain semantics translated to the snap path) +
ACDREAM_PROBE_STICKY=1 apparatus ([sticky] lifecycle/steer lines +
[sticky-snap-skip], all per-guid). Awaiting gate 2.
#170 — Remote creature chase+attack renders wrong vs retail (glide, over-frequency, uniform attack anims)
Status: DONE (2026-07-04) — user visual gate PASSED ("looks good, as close
to retail now as I can see"). Fixes: 427332ac (per-frame re-dispatch flood) +
d2ccc80e (velocity refresh) + 1051fc83 (armed moveto always ticks UseTime — the
SERVERVEL starvation) + probe strip. Gate telemetry (launch-170-gate2.log): run
installs ≈ 1:1 with arms for every chasing creature (was 16:1), zero
armed-moveto UseTime skips, queue depth 1. (Move to Recently closed on next
ISSUES tidy.) History below. Fix #1 (427332ac): the per-frame
apply_current_movement re-dispatch flooded pending_motions to ~1.3M → deleted;
flood 1.3M→~1, "stuck attack" gone (user-confirmed), run installs 1→10. Fix #2 (this
session): the "slow Ready drain" framing was WRONG — a full-stack offline harness
(RemoteChaseEndToEndHarnessTests, real MoveToManager + MotionInterpreter +
AnimationSequencer + omega integration in GameWindow's exact tick order) proved the
Core drain/turn/run pipeline healthy (turn completes <1 s, run sustains, add==done).
Corrected per-guid log attribution (launch-drainq.log) showed the REAL funnel: 16 arms
→ 11 turns dispatched → 1 run install, because the per-tick branch arbitration
routed any UP-receiving NPC to the SERVERVEL leg (HasServerVelocity synthesized from
position deltas) which skips MoveToManager.UseTime — the armed moveto was starved
for exactly the duration of the server-side chase ([npc-tick] branch=SERVERVEL (skips UseTime) mtState=MoveToObject), legs stayed Ready while the body glided on synthesized
velocity; the manager only woke in UP-silent gaps and was interrupted by the next UM.
Retail runs MovementManager::UseTime UNCONDITIONALLY per tick
(CPhysicsObj::UpdateObjectInternal 0x005156b0 @0x00515998) and has no wire-velocity
leg-driver. FIX: an armed moveto (MovementTypeState != Invalid) always takes the
MOVETO leg; SERVERVEL remains only for non-moveto entities (register TS-41; drain-order
one-frame divergence also pinned + filed as TS-42). SSOT + pickup:
docs/research/2026-07-04-170-creature-run-handoff.md +
docs/research/2026-07-04-170-pickup-prompt.md (residual sections superseded by this
entry). SUPERSEDES the earlier MovementManager-coexistence hypothesis (eb423fb7,
wrong shape — but the starvation IS a coexistence bug at the tick-arbitration altitude)
and keeps the d2ccc80e velocity fix. #159 was a red herring here. NEXT: user visual
gate (retail side-by-side, chase a fleeing player) → then strip the #170 probes
(s_mvtoDiag, s_drainDiag, [npc-tick], UM ↳ actions) and close.
Severity: MEDIUM (visual — remote combat / aggro)
Filed: 2026-07-03 (user retail side-by-side during the R5-V2 visual gate)
Component: animation, remote entity, combat
Description: A monster chasing + attacking the player (aggro) renders wrong vs the retail client running against the SAME local ACE. User side-by-side: retail plays proper animations, positions the monster better, plays DIFFERENT attack animations (variety), and the attack-animation FREQUENCY is correct; acdream shows the creature "stuck in the attack animation, gliding after me." Because retail gets the identical ACE motion stream and renders it correctly, the divergence is CLIENT-side (acdream's UM/animation handling), not ACE.
Three distinct sub-divergences:
- Wrong/uniform attack animations → #159 is DONE (
2de5a011) but was a RED HERRING for this creature. The Mite Scamp's attacks are wire0x62/0x63/0x64=AttackHigh1/Med1/Low1, which live in the ALREADY-CORRECT low block — they were never misnumbered, andCombatAnimationPlannerisn't even wired into the runtime dispatch (the live path isAnimationCommandRouter→MotionInterpreter/AnimationSequencer). #159 fixed a genuine latent bug in the late block (Offhand*/Attack4-6/Punch*) but does not touch this symptom. The "uniform/same animation" is therefore NOT a classification-numbering bug — it is sub-bug 2 below (the same attack replays becausepending_motionscompletes+re-queues) and/or the sequencer not selecting per-command frames. - Over-frequency / stuck attack — motion trace: ACE streams attack UMs
(
mt=0x00 cmd=0x62/0x63/0x64 spd=0.97) on top of themt=0x06chase; acdream'spending_motionscompletes+re-queues in a tight MOTIONDONE loop (pending=Truespam) → over-plays. R3/R4 pending_motions/MotionDone area. - Glide — the creature's position moves (mt-6 moveto + UP dead-reckon) but no locomotion legs play (attacks override) → smooth slide. AP-80 / #160 dead-reckon-vs-animation family.
Evidence: ACDREAM_DUMP_MOTION trace for guid 0x80000244 — mt-0 stance 0x3C
→ mt-6 chase spd 2.03 → stream of mt-0 attacks 0x62/63/64 spd 0.97;
MOTIONDONE loop between 0x8000003C (stance) and the dispatched motion.
NOT V2/R5: the voyeur target-tracking correctly makes the creature chase
(the mt-6 is proof); this is the animation/position layer.
Where: MotionInterpreter/AnimationSequencer attack-motion dispatch +
pending_motions; CombatAnimationPlanner (#159); ServerControlledLocomotion
- remote dead-reckoning (glide).
Acceptance: side-by-side with retail — a chasing+attacking Mite Scamp shows correct attack-animation variety, correct frequency, and steps/runs (no glide).
Investigation 2026-07-04 (code-grounded mechanism, HYPOTHESIS — pending live
confirmation): traced the live remote path end-to-end. The chase (mt-6
MoveToObject) is owned by the entity's verbatim MoveToManager
(GameWindow.RouteServerMoveTo, ~4899) — this is what moves the legs. The
attacks arrive as SEPARATE mt-0 InterpretedMotionState UMs and flow through
MotionInterpreter.MoveToInterpretedState (~4985). Two seams make the creature
glide + over-play:
- Every inbound UM fires the
unpack_movementhead interrupt (remoteMot.Motion.InterruptCurrentMovement, GameWindow ~4893) which is bound toMoveTo.CancelMoveTo(ActionCancelled)(GameWindow ~4319). So eachmt-0attack UM cancels the active chase MoveTo. MoveToInterpretedState→ApplyInterpretedMovement(cancelMoveTo:true)installs the UM'sForwardCommand, which for an attack-only UM defaults to Ready/idle (ims.ForwardCommand = fullMotion, null/0 → Ready, ~4933) → the run cycle is replaced by idle while the body keeps its dead-reckon translation → glide; the creature is repeatedly knocked idle+attack rather than running (over-play / "stuck"). TheServerActionStamp15-bit gate (MoveToInterpretedState~2845) IS faithfully ported, so identical-stamp replays are already suppressed — the "uniform anim" is NOT a stamp bug. This coexistence of MoveTo (chase) + interpreted-state (attack) is explicitly R5/MovementManager scope (GameWindow ~4816 "LoseControlToServer autonomy handoff is R5/MovementManager scope"). So #170 sub-bugs 2/3 are downstream of the incomplete MovementManager port (R5-V4), not #159 and not a localized bug. NEXT: (a) confirm with a labelledACDREAM_DUMP_MOTION=1capture of the Mite Scamp (exact mt-6/mt-0 interleave + what ForwardCommand + stamps the attack UMs carry), (b) retail side-by-side — does retail keep the legs running during attacks and re-establish the chase between swings? — then port the retail MovementManager coexistence (R5-V4). Do NOT guess a fix in this revert-prone path.
#169 — Cold-spawn "hole": world doesn't load / invisible player when spawning far from Holtburg
Status: DONE (9b06a9b8, 2026-07-03) — root confirmed live via a landblock-load
probe. Cause: the two-tier streamer marks every window landblock "resident"
at bootstrap (StreamingRegion.MarkResidentFromBootstrap) BEFORE their async
loads land. A character saved FAR from the startup center (0xA9B4 Holtburg)
triggers a login-spawn RecenterTo against that stale, half-loaded window;
RecenterTo trusts _tierResidence, so the old/new window overlap is never
re-enqueued — a permanent HOLE of resident-but-never-loaded landblocks (zero
BUILD-NULL; they're simply skipped). The player spawns in the hole → its
landblock never loads → terrain/NPCs/player never draw ("world loads behind me,
character invisible"). Probe: spawn at 0xADAF, column 0xAD loaded Y=0xA3-0xAA +
0xB0-0xC0 but MISSING Y=0xAB-0xAF; player at 0xADAF (Y=0xAF) dead in the gap.
Fix: a far login-spawn moves the render origin like an outdoor teleport
(which already calls StreamingController.ForceReloadWindow — drop stale window
- re-bootstrap fresh at the new origin); the login-spawn recenter now flags that rebuild (network thread → set flag → render thread consumes it before the Tick). Verified: 0xADAF now loads (216 entities), full world draws, player recovers. No-op for a normal Holtburg login. NOT R5 (pre-existing streaming bug).
#168 — Invisible player / character disappears (pending-bucket trap)
Status: DONE (315af02f, 2026-07-03) — root confirmed live via
ACDREAM_PROBE_ENT. Cause: a persistent server-spawned entity that spawns
into a not-yet-loaded landblock is parked in GpuWorldState._pendingByLandblock.
RelocateEntity (per-frame, keeps the player homed to its current landblock so
it draws) scanned ONLY _loaded, so it silently no-op'd on a pending entity —
and the player fell through all recovery paths (the AddLandblock drain already
ran empty; the server-object re-hydrate excludes the player; RelocateEntity
couldn't reach pending) → stranded invisible. This is the mechanism behind BOTH
"cold-spawn invisible" AND "character disappears running out of Holtburg far
enough" (both = player parked in pending, never recovered). Fix:
RelocateEntity now removes the entity from whichever bucket it occupies
(_loaded OR _pendingByLandblock) then re-appends to its current landblock —
promoting a stranded pending entity to drawn as soon as its landblock loads.
Test: GpuWorldStateTests.RelocateEntity_StrandedInPending_MovesToLoadedTarget
(red→green). Composes with #169. NOT R5-V2 (verified read-only re: pos/streaming).
#167 — ConstraintManager leash unported (arming + two unknown x87 constants)
Status: DONE — 2026-07-30 (Campaign P Slice P5). Both blockers were
research-solved without a cdb session: the two x87-elided constants were
recovered by disassembling the matching retail binary's raw machine code
(docs/research/2026-07-30-constraint-leash-constants.md), and the arming
site is now every current acdream inbound-position acceptance seam. Commit
e0629145 (constants + ConstraintDistance), commit 7719d25b (arming at
LiveEntityNetworkUpdateController for remotes and
PlayerMovementController.SetPosition/BlipPosition for the local player,
plus the per-tick PhysicsBody.IsFullyConstrained push), and this commit
(TS-35 retirement + stale-comment cleanup). Register row TS-35 is
deleted in the same session. Full Core/Runtime/App suites pass with no
regressions; new conformance tests cover leash-armed jump refusal,
teleport-vs-blip anchor/teardown behavior, taper reduction over ticks, and
the remote-tick IsFullyConstrained push.
Severity: LOW (server-position rubber-band + jump-during-rubber-band gate)
Component: physics, constraint
Description: R5-V1 ported ConstraintManager (the server-position
rubber-band leash) as a Core class for structural completeness of
PositionManager, but it is never ARMED in acdream. Retail arms the leash
ONLY from SmartBox::HandleReceivedPosition (0x00453fd0) — on every inbound
server position packet, anchoring the mover to self (remotes) or the received
position (player) — with a start/max distance band from
CPhysicsObj::GetStartConstraintDistance (0x0050ebc0) and
GetMaxConstraintDistance (0x0050ec10). acdream's position reconciliation is
not SmartBox, so nothing calls PositionManager.ConstrainTo, and
IsFullyConstrained stays false (= register TS-35's current stub
behavior — jump never blocked by the leash).
Blockers (RESOLVED): (1) the two distance constants were x87 float
returns BN elided — GetStart/MaxConstraintDistance decompile to a bare
this->m_position; expression with the actual returned value lost to the
FPU-return-elision artifact. Recovered by disassembling the matching binary's
raw machine code directly (no cdb needed): outdoor start 10 / indoor 5,
outdoor max 50 / indoor 20 — ACE's start mapping is INVERTED (outdoor 5 /
indoor 10); the binary wins. (2) The arming site (SmartBox's inbound
position-reconciliation branches A/B/C) had no acdream equivalent — wired at
LiveEntityNetworkUpdateController (remotes, anchored to the object's own
position) and PlayerMovementController.SetPosition/BlipPosition (local
player, anchored to the received position), feeding the AdjustOffset taper
into the body integration at the same per-tick chokepoint as the sticky
wiring (R5-V3).
Where: src/AcDream.Core/Physics/Motion/ConstraintManager.cs (armed),
src/AcDream.Core/Physics/Motion/ConstraintDistance.cs (constants); the read
gate is PhysicsBody.IsFullyConstrained (former TS-35) via
jump_is_allowed. Decomp: docs/research/2026-07-03-r5-managers/,
docs/research/2026-07-30-constraint-leash-constants.md.
2026-08-03 correction (C4 route 2, #285): the BlipPosition half of this
arming site was an unbacked deviation for the ForcePosition branch
specifically — SmartBox::BlipPlayer (0x00453940), the function
HandleReceivedPosition's FORCE_POSITION branch calls, is not on the
"Player, normal" branch this slice modeled; retail's FORCE_POSITION early
return (0x0045409D) precedes every ConstrainTo call. BlipPosition is
deleted; the leash is no longer (re)armed on a ForcePosition. The arming
site for every OTHER inbound position (remotes, the local player's ordinary
teleport/SetPosition) is unaffected.
Acceptance: the two constants are recovered (byte-decoded from the
binary), acdream arms the leash on inbound server positions,
IsFullyConstrained fires while rubber-banding, and a jump attempt inside
the tight leash is blocked (0x47) matching retail; TS-35 + this issue retired
together.
#160 — Remote moveto: run animation pace vs actual movement speed mismatch
- Status: CLOSED (2026-07-03,
41006e79, user-verified same session). - Root cause: remote interps carried NO weenie, so retail's
- apply_run_to_command rate chain (`weenie ? (InqRunRate() ?: my_run_rate)
- 1.0
, raw 305062-305076) took the degenerate 1.0 branch and the wire's MoveToRunRate (stored in MyRunRate by the mt-6/7 unpack, M13) was never consumed — run dispatches at speed 1.0 = slow-motion legs + crawl. Fix:RemoteWeenie(retail's per-object ACCWeenieObject stand-in; InqRunRate fails → my_run_rate fallback) on every RemoteMotion interp. **Symptom:** observing a retail player's server MoveToChain from acdream: close-range movetos WALK correctly; at run distance the RUN cycle plays but the body moves visibly slower than the legs (treadmilling). The legs' pace comes from the manager's dispatch (_DoMotion→apply_run_to_command×MyRunRate= the wire's mt-6MoveToRunRate, observed 4.50 for a high-skill char); the body's position is queue-chased from ACE's actual UpdatePosition stream — evidently slower than that rate. Speed-source disagreement: either ACE's chain moves slower than the advertised runRate, or our dispatched cycle speed over-scales (compare: local player run pace was correct). **Evidence to gather:** UP cadence Δpos/Δt for the mover vsRunAnimSpeed × dispatched speed; check what speed ACE's MoveToChain actually steps at (Player_Move.cs / Creature GetRunRate usage). **Where:** GameWindowTickRemoteMoveTo+ the L.3 M2 queue chase vsMotionTableDispatchSink` dispatch speed. Related: TS-33 cadence rows.
#165 — Remote entities penetrate walls ("swallowed a bit") before stopping
Status: OPEN Severity: MEDIUM (visual-only, remote view) Filed: 2026-07-03 (user observation during the R2-R4 visual pass) Component: physics, remote dead-reckoning
Description: Observing a retail-client mover from acdream: when the mover runs into a wall, the observed remote runs INTO the wall a bit — "they get swallowed a bit by the wall" — instead of stopping flush at it like the mover does on their own screen. The mover's client stops them at the wall; ACE's UP stream reports at-the-wall positions; the acdream-side remote body ends up partially inside the geometry.
Root cause / status: Unknown — candidates, in suspicion order: (1) dead-reckoning overshoot: between UPs the DR tick advances the body along its velocity; if that segment's collision resolve doesn't stop at the wall (or runs with a start position already server-snapped into contact), the body tunnels until the next UP pulls it back; (2) the L.3 M2 queue-chase walking toward a waypoint AT the wall with the remote's cylinder ignoring the wall plane (check which resolve path the chase uses and whether it carries the remote's ShadowEntry exclusions only — #42 — or accidentally broader exclusions); (3) retail remotes collide via the same per-cell shadow lists as the player (see feedback_retail_per_cell_shadow_list) — verify our remote resolve consults building/EnvCell geometry at all in the observed cells. NOTE: capture first — ACDREAM_PROBE_RESOLVE on the remote's guid at a wall shows whether the resolver reports Collided-but-position-inside or never sees the wall.
Campaign P Slice P3 diagnostic pass (2026-07-30, no live client —
dat-free + dat-backed fixtures only, per
docs/research/2026-07-29-remote-and-world-specials-pseudocode.md §2.4b):
research P3.2 re-framed the three candidates as (a) the
InterpolationManager unclamped stall-fail "tail delta" snap
(node_fail_counter > 3) committing a position on the far side of / inside
a wall in one tick and the same-tick sweep failing to catch a large
delta, (b) the remote resolve gate (rm.CellId != 0 && _physics.Engine.LandblockCount > 0) skipping the sweep entirely on some
tick other than first-spawn, and (c) render/interpolation presentation lag
on the App side. Both (a) and (b) are now RULED OUT with direct evidence:
- (b): code-read every
FullCellId = 0write site (RuntimeEntityObjectLifetime.cs:TryApplyPickup,CommitAcceptedParentCellless,CommitWithdrawal) — all three are pickup/parent-attach/delete paths, never reachable for a live, freely moving remote mid-session. The gate's "one-frame grace" is genuinely first-spawn-only. - (a): three new fixture tests drive a SINGLE resolve call spanning an
entire large-tick jump (simulating the unclamped snap) instead of many
small ticks, against both synthetic sphere geometry AND the real
Holtburg door BSP slab (
Setup 0x020019FF/GfxObj 0x010044B5) already used by the door apparatus tests — both stop at the identical surface distance the proven small-step tests already pin, with a valid collision normal. The sweep is not distance-limited and does not tunnel on a large single-tick delta. SeeIssue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurfaceandDoorCollisionApparatusTests.Apparatus_SingleLargeTickJump_DeadCenter_StillBlocksOnBSP.
Candidate (c) is therefore the remaining hypothesis and is explicitly
OUT OF SCOPE for a physics-fixture-only pass — it is a claim about the
App-layer render/presentation frame relative to the committed
PhysicsBody.Position, not something a dat-free/dat-backed Core fixture
can observe. Per the campaign's own instruction ("diagnose only unless a
candidate confirms cheaply; otherwise stop"), this issue stays OPEN. The
next concrete step for whoever picks this up: an App-layer render-position
vs. physics-position diff across frames for a remote near a wall, or a
fresh ACDREAM_PROBE_RESOLVE/ACDREAM_CAPTURE_RESOLVE live capture (the
existing diagnostic recommendation in the research doc, still valid) if a
live repro becomes available.
Where: GameWindow remote DR tick (TickAnimations player-remote
pipeline + queue chase), PhysicsEngine.ResolveWithTransition remote
callers; src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
(resolve gate, candidate (b), ruled out); src/AcDream.Core/Physics/ InterpolationManager.cs (unclamped stall-fail snap, candidate (a), ruled
out); the render/presentation path (candidate (c), OPEN, App-layer, not
yet located).
Acceptance: a retail mover pressed against a wall shows flush at the wall from acdream, matching the retail-observer view side-by-side.
#166 — Slope-landing glide + bounce absent (retail "sled" on downhill jumps)
Status: FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the
user's visual-gate acceptance. The visual-matrix recheck this note asked
for DID happen (Campaign P matrix scenario 5) and found the glide/bounce
still missing even with all four register-predicted deviations
(AD-25/AP-7/AD-55/TS-4) landed — that negative result is exactly what
triggered the #265 capture bisect, which found a FIFTH, previously-
unnamed mechanism: PlayerMovementController.cs's grounded block was
hand-zeroing residual Velocity.X/Y every tick, discarding any landing
momentum before AD-25/AP-7/AD-55's now-correct machinery ever got a
chance to act on it. See #265 for the full root cause and fix (same
commit); this issue is the "downhill sled" half of that same mechanism.
Severity: LOW (feel/polish)
Filed: 2026-07-03 (user observation during the R2-R4 visual pass)
Component: physics, landing
Description: In retail, jumping down a hill often lands with a short glide (sled) and a bounce before settling; acdream lands clean and dead. The user explicitly classified this as later polish — "a bit deeper physics than this."
Root cause / status: This is the REGISTER-PREDICTED composite of
three known deferred deviations: AD-25 (landing wall-bounce velocity
reflection suppressed — its risk column literally names "slope-landing
momentum won't reproduce"), AP-7 (calc_friction threshold 0.0
without retail's 0.25-with-state-gate — sled deceleration differs), and
TS-4 (Path-6 steep-poly slide-tangent shortcut — airborne-steep
contact chain diverges). Retiring those three rows IS this issue; do them
together against a retail cdb capture of a downhill jump (velocity +
contact-plane trace at landing).
Reattribution (2026-07-30, Campaign P Slice P2 research pass —
docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3,
§6 Step 6): confirmed as the AD-25 + AP-7 + TS-4 composite above, with
two corrections to the original framing. First, AD-25's LOCAL-PLAYER
half was already ported in the #182 verbatim UpdateObjectInternal
rebuild (2026-07-07) — what remains open for AD-25 is remote/NPC-only and
is explicitly Campaign P P3 scope, not this issue. Second, and more
important: no client-side PhysicsState.Sledding auto-toggle exists in
retail, and this issue should not wait on inventing one. A cross-
reference of the named-retail decomp (zero hits for "sled" anywhere in the
1.4M-line pseudo-C, string or hex-constant search) against ACE's complete
PhysicsObj.cs (the same shared CPhysicsObj class that produced
calc_friction) found the ONLY write site for PhysicsState.Sledding
anywhere in any reference repo is a per-weenie game-data property
(WorldObject_Properties.cs:1105-1109, server/database-set, same pattern
as Ethereal/Static) — not a physics-engine landing response. Ordinary
downhill-jump glide-and-bounce in retail is therefore NOT the literal
Sledding state for an ordinary player; Sledding is most likely reserved
for specific data-authored content (e.g. an actual sled-ride mechanic),
outside this issue's scope. AP-7 landed this same session (calc_friction
now ports retail's confirmed 0.25f threshold); TS-4's removal was attempted
per its own fixture-first requirement and reproduced the historical
2026-04-30 wedge, so TS-4 stays deferred (see its register row and the
research doc §7 item 6 for the precise mechanism and the concrete next
step).
AD-25 closed (2026-07-30, Campaign P Slice P3): the remote dead-
reckoning post-resolve now calls the exact ported
PhysicsObjUpdate.HandleAllCollisions — the same function the local
player and every ordinary body already use — instead of its own
hand-inlined, narrower reflect gate (RuntimeRemotePhysicsUpdater.cs,
Tick). The old gate reflected only airborne-before-AND-after and
suppressed the sledding case backwards; the ported gate reflects on any
transition except grounded→grounded-and-not-sledding, matching retail's
shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding). Both
halves of AD-25 (local player and remote) are now retired.
TS-4 landed (2026-07-30, Campaign P final physics slice): the
Path-6 steep-poly shortcut this note previously flagged as "deferred" is
now retired — the decisive confirming run
(Ts4SteepRoofWedgeCaptureTests, horizontal-velocity variant) showed the
shortcut-removed engine converges cleanly for the realistic (non-
degenerate) case. See docs/research/2026-07-30-ts4-116-oracle-plan.md
§1 and its own register row (struck through, TS-4).
AD-55 also landed the same slice (the sled-flatness constant in this
SAME calc_friction function AP-7 already fixed): retail's Sledding
fast-sled override compares against cos(10°) ≈ 0.98480775f, byte-proven
against 0x0050ee70, not the previously-carried ACE-derived 0.99999536f
(≈0.175° from flat, essentially unreachable). This is the AP-7-family
completion the "sled deceleration differs" framing above was waiting on —
all four of AD-25, AP-7, AD-55, and TS-4 are now landed.
The fifth deviation found and fixed (2026-07-30, this session,
docs/research/2026-07-30-265-capture-bisect.md): with AD-25/AP-7/
AD-55/TS-4 all landed, the matrix recheck STILL found no glide/bounce —
the composite framing above was correct as far as it went, but it
missed a pre-existing (2026-07-20, ten days before Campaign P) R6
architectural fact: PlayerMovementController.cs's grounded quantum
block hand-zeroed Velocity.X/Y to exactly zero every tick once
OnWalkable, for the production animation-root-motion path. This ran
regardless of AD-25/AP-7/AD-55/TS-4's correctness — it simply erased the
residual velocity those fixes would otherwise have had something to act
on. A second gap compounded it: PhysicsBody.GroundNormal (the vector
calc_friction dots velocity against) had no production writer and
silently defaulted to Vector3.UnitZ, so slopes behaved like flat
ground even when velocity DID survive. Fixed by (1) syncing
body.GroundNormal from the committed ContactPlane.Normal in
PhysicsEngine.cs's existing per-resolve commit block, and (2) no longer
reconstructing Velocity in the grounded block for the animation-root-
motion case (root motion still fully owns commanded locomotion; only the
residual-momentum zero is gone). A synthetic case with the real mined
roof polygon but a velocity/normal pairing under retail's 0.25 threshold
(ComposedRoofLanding_NewFix_SyntheticGrazingApproach_DecaysViaCalcFriction)
demonstrates genuine exponential decay via calc_friction; the real
captured landing's own velocity happens to fall in the "moving away fast
enough, no friction" band (dot ≥ 0.25), producing a constant-velocity
glide across the roof instead — both are correct per retail's ported
formula for their respective geometries.
Closure of #166 therefore pends only re-checking Campaign P's final
visual matrix item 5 ("Downhill jump landing: sled glide + bounce")
against a fresh capture of THIS fix — if the glide/bounce still visibly
mismatches retail, that capture, not a guess, is what should drive any
further work here, and it should go through cdb against live retail
before any client-side Sledding-state mechanism is written (recall: no
client-side PhysicsState.Sledding auto-toggle exists in retail per the
reattribution above — a data-authored toggle exists only server-side).
Where: src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs
(remote reflect, AD-25 — DONE 2026-07-30),
src/AcDream.Core/Physics/PhysicsBody.cs (calc_friction, AP-7 and AD-55
— both DONE 2026-07-30), src/AcDream.Core/Physics/BSPQuery.cs +
FlatBspQuery.cs (Path 6 steep branches, TS-4 — DONE 2026-07-30),
src/AcDream.Runtime/Gameplay/PlayerMovementController.cs (grounded
residual-velocity zero, the fifth deviation — DONE 2026-07-30),
src/AcDream.Core/Physics/PhysicsEngine.cs (GroundNormal wiring —
DONE 2026-07-30).
Acceptance: side-by-side downhill jump: acdream glides/bounces like retail; flat-ground landings unchanged; no micro-bounce death spiral (the reason AD-25 existed) reintroduced. Every code-side composite deviation, including the fifth one found this session, is now landed; only the visual-matrix recheck remains before this issue can close.
#164 — UM action-replay dispatches drop the per-action Autonomous bit
Status: DONE (2026-07-04, R5-V4) — DispatchInterpretedMotion now threads
the action's autonomy into the dispatch params (Autonomous = the 0x1000
splice, raw 305982); the stored InterpretedState.Actions node carries the
real autonomy. Conformance:
MotionInterpreterFunnelTests.Actions_ReplayCarriesAutonomyIntoTheInterpretedList.
(Move to Recently closed on next ISSUES tidy.) Original finding below.
Filed: OPEN (2026-07-03, filed during #161)
Finding: retail's move_to_interpreted_state action loop sets the
dispatch params' Autonomous bit (0x1000) from each action's autonomy flag
(raw 305982: var_28 ^= ((autonomous << 0xc) ^ var_28) & 0x1000), which
flows into InterpretedMotionState::AddAction's stored node. Our
MoveToInterpretedState action loop dispatches with new MovementParameters { Speed } — Autonomous stays false. No observed
symptom today (stored-node autonomy has no current consumer); fix when
R5/R6 touches the action list. Where:
MotionInterpreter.MoveToInterpretedState action loop /
DispatchInterpretedMotion (doc comment marks the gap).
#159 — CombatAnimationPlanner uses 2013-decomp command numbering, not ACE/DRW
Status: DONE (2de5a011, 2026-07-04) — the whole CombatAnimationMotionCommands block now derives each constant directly from DatReaderWriter.Enums.MotionCommand by name (= (uint)Drw.Name), so the values ARE the oracle by construction and can never drift again. Ground truth was taken by reflecting over the same DatReaderWriter 2.1.7 assembly the runtime binds (409 enum values). Blocks 1-2 (stances + single melee, 0x3C-0x12A) were already correct; the late block (Offhand*/Attack4-6/Punch*, 0x173-0x19A) was all +3 shifted; Reload was the dead 2013 value 0x100000D4 (absent from DRW) → now the real 0x40000016 SubState. New parity test PlanFromWireCommand_LateCombatBlock_UsesAceDrwNumbering pins 14 ACE wire values through the full wire→resolve→classify pipeline. Caveat (relevant to #170): CombatAnimationPlanner is not yet wired into the runtime — the live dispatch is AnimationCommandRouter → MotionInterpreter/AnimationSequencer — so this is a latent correctness fix. It does NOT by itself change the #170 Mite Scamp symptom, whose attacks 0x62/0x63/0x64 live in the already-correct low block. #170 sub-bugs 2 (MOTIONDONE loop) + 3 (glide) remain.
Severity: MEDIUM (late-combat animation classification wrong against ACE)
Filed: 2026-06-30
Component: animation, combat
Description: CombatAnimationPlanner.CombatAnimationMotionCommands hardcodes the late-combat command constants (the Offhand* / Attack4-6 / Punch* block) using 2013-decomp numbering instead of the ACE/DatReaderWriter numbering that ACE actually broadcasts and that the local DAT MotionTables use. Per the +3-ish low-word shift documented in the ACE-vs-2013 gap research, e.g. OffhandSlashHigh should be 0x10000173 not 0x10000170; AttackLow6 should be 0x1000018E not 0x1000018B. Against a live ACE server these specific commands will be silently misclassified (resolver returns the correct ACE value, but the planner's set contains the 2013 value, so no match).
Root cause / status: Pre-existing — surfaced by the L.1b command-catalog slice (commit pending). The old blind 0x016E–0x0197 override in MotionCommandResolver masked the matching test (CombatAnimationPlannerTests.MotionCommandResolver_UsesNamedRetailLateCombatCommands) by force-mapping the same wire range to 2013-class values, so the test agreed with the planner's wrong numbering. Deleting the override (correct) exposed the mismatch. NOT a regression: for the real ACE wire value (0x0173), the resolver returns 0x10000173 both before and after the override deletion, so runtime behavior is unchanged — the planner was already misclassifying it. The fix is to renumber the CombatAnimationMotionCommands block to the ACE/DRW values (cross-check each constant against DatReaderWriter.Enums.MotionCommand).
Files: src/AcDream.Core/Combat/CombatAnimationPlanner.cs:268-307 (the hardcoded CombatAnimationMotionCommands block).
Research: docs/research/2026-06-26-ace-vs-2013-motion-command-gap.md.
Acceptance: Each late-combat constant in CombatAnimationMotionCommands matches its DatReaderWriter.Enums.MotionCommand value; a parity test asserts the ACE wire values (0x0173 → OffhandSlashHigh, etc.) classify correctly through ClassifyMotionCommand.
#202 — Port the portal String-table lookup for WeenieError / UseDone text
Status: OPEN Severity: LOW Filed: 2026-07-03 Component: ui / net
Description: UseDone (0x01C7) refusals now surface in chat, but the text comes from WeenieErrorText.For — a hardcoded subset map (0x001D/0x04EB/0x04FC/0x04FE) with a generic fallback. Retail resolves the WeenieError code through the client String tables into the canonical sentence. Port that lookup (portal String-table dat objects) and delete the map. Register row AP-74.
Files: src/AcDream.Core.Net/Messages/WeenieErrorText.cs, src/AcDream.Core.Net/GameEventWiring.cs (UseDone registration).
Acceptance: every WeenieError code prints retail's exact string; AP-74 retired.
#195 — Retail chat ChatVM lacks Fps/Position providers — /framerate and /loc degrade
Status: OPEN Severity: MEDIUM Filed: 2026-07-02 Component: ui
Description: Two live ChatVM instances exist over the same ChatLog: the ImGui panel's (gets FpsProvider + PositionProvider) and the retail chat window's (new ChatVM(Chat, displayLimit: 200) — gets neither, and the providers are init-only). Typing /framerate or /loc in the retail chat window returns "(provider unavailable)".
Root cause / status: Found by the 2026-07-02 UI architecture review (theme T8). Fix direction: construct ONE ChatVM (with providers) before either UI block and share the instance.
Files: src/AcDream.App/Rendering/GameWindow.cs:1438 (ImGui VM), :1937 (retail VM), src/AcDream.UI.Abstractions/Panels/Chat/ChatVM.cs:63/70.
Research: docs/research/2026-07-02-ui-architecture-review.md.
Acceptance: /framerate and /loc produce real values in the retail chat window; one ChatVM instance per ChatLog.
#196 — External-container lifecycle and retained window events are missing — DONE 2026-07-17
Status: CLOSED Severity: MEDIUM Filed: 2026-07-02 Component: ui / net
Description: UiRoot's window registry originally flipped Visible with no lifecycle event, toolbar-button sync was manually pushed at each mutation site, and acdream had no retained external/ground-container window. The original issue incorrectly assigned NoLongerViewingContents (0x0195) to closing or navigating the owned inventory's side bags. Named-retail evidence instead ties it to replacing ClientUISystem.groundObject; the authored close/range path sends Use(root) and waits for server event CloseGroundContainer (0x0052). Owned gmInventoryUI packs never send 0x0195.
Resolution: Typed retained-window visibility and subscriber-driven toolbar state shipped in Wave 4.4d. The remaining external lifetime shipped 2026-07-17: Core ExternalContainerState owns expected/current root identity; App ExternalContainerLifecycleController emits exactly one 0x0195 only on root replacement; GameEventWiring accepts the expected root ViewContents, preserves nested snapshots, and retires the complete temporary projection tree on authoritative 0x0052. ExternalContainerController mounts retail LayoutDesc 0x21000008, including its horizontal lists/scrollbar, nested-container selection, exact close/range behavior, loot-to-pack, return-to-container, and partial-stack requests. The live-gate correction adds the shared outer bevel, X-only resizing, compact opening width, shared ItemHolder::UseObject routing for corpses, and retail's destination-list m_pendingItem waiting projection at the chosen loot slot. Owned inventory no longer exposes any 0x0195 capability.
Files: src/AcDream.Core/Items/ExternalContainerState.cs, src/AcDream.App/World/ExternalContainerLifecycleController.cs, src/AcDream.App/UI/Layout/ExternalContainerController.cs, src/AcDream.Core.Net/GameEventWiring.cs, src/AcDream.Core/Items/ClientObjectTable.cs.
Research: docs/research/2026-07-17-retail-external-container-looting-pseudocode.md.
2026-07-17 interaction-routing follow-up: The production binding routes
keyboard R through the same application Use adapter as retained double-click,
preserving approach-then-send completion. It also supplies the live external
root to DetermineUseResult, so double-clicking a corpse child requests
loot-to-pack rather than ordinary item use.
2026-07-17 pending/replacement follow-up: Double-click loot now follows
ShowPendingInPlayer: it inserts a waiting projection in slot 1 of the current
owned pack and sends that same destination/placement on the wire. A new corpse
request now retires the previous ground-object presentation immediately, as
retail SetGroundObject does. This removes the stale range watcher whose close
Use canceled ACE's active MoveTo chain before the new corpse could open.
2026-07-17 input-order follow-up: A second connected trace showed the
remaining far-R failure as UseDone(0) with no ViewContents. Silk delivered
R before the object phase had serialized a preceding movement-key release, so
ACE received Use and then MoveToState; ACE correctly cancelled the
Use-created MoveTo callback while acdream's local MoveTo kept walking. The new
OutboundInteractionQueue drains keyboard Use, world double-click, and pickup
immediately after the same frame's movement output and before inbound dispatch.
The flow still sends exactly one request and relies on ACE's arrival callback.
Acceptance: replacement sends NoLongerViewingContents once; authored close/range sends Use once and waits for 0x0052; owned main/side-pack navigation sends neither; root/nested projections retire without deleting objects; corpse Use never attempts to pick up the corpse; a full-stack loot drop immediately shows a ghosted pending copy at the chosen destination slot and confirmation/failure clears it; canonical transfers remain server-authoritative. Focused Core, Core.Net, and App tests pin each path; visual acceptance remains the normal user gate.
#197 — Target-mode clicks: inventory grid cells bypass ItemInteractionController while the cursor says TargetValid
Status: DONE (Wave 3.3, 2026-07-11) Severity: MEDIUM Filed: 2026-07-02 Component: ui
Description: Target-mode interception is per-call-site opt-in. Toolbar, paperdoll, and the main-pack cell consult ItemInteractionController first; InventoryController.AddCell grid cells wire Clicked = () => SelectItem(guid) (and bags to OpenContainer) with no target-mode check — while CursorFeedbackController.ResolveUseTargetGuid returns slot.ItemId for ANY occupied slot, so the cursor shows the valid-target bullseye over cells whose click just selects.
Root cause / status: Fixed by typed ItemInteractionController.OfferPrimaryClick. Inventory contents/bags/main pack, paperdoll slots/body, toolbar, radar, and world picks offer the click before local fallback. Successful and rejected target attempts are consumed; only NotActive may select, open, or use.
Files: src/AcDream.App/UI/Layout/InventoryController.cs:307-308, src/AcDream.App/UI/UiItemSlot.cs:187-189, src/AcDream.App/UI/CursorFeedbackController.cs:240-254.
Research: docs/research/2026-07-02-ui-architecture-review.md.
Acceptance: with a health kit armed, clicking any inventory item applies the kit to that item (or rejects per useability), matching what the cursor promised.
#198 — Target-mode cursor is blind to world hover (always pending crosshair over world entities)
Status: DONE (769ebef3, 2026-07-03)
Severity: MEDIUM
Filed: 2026-07-02
Component: ui
Description: CursorFeedbackController.Update(UiRoot) builds HoverTargetGuid exclusively from the UI tree; hovering a world entity (e.g. a drudge with a health kit armed) shows the pending crosshair instead of valid/invalid. Retail keys the 0x28/0x29 cursors off the SmartBox target under the cursor — a world object (ClientUISystem::UpdateCursorState 0x00564630).
Root cause / status: Review finding (theme T7). Fix direction: inject a world-hover-guid provider (Func<uint>, from the existing world-pick machinery) into CursorFeedbackController and merge it into the snapshot when the UI pick misses.
Files: src/AcDream.App/UI/CursorFeedbackController.cs:53-72, src/AcDream.App/Rendering/GameWindow.cs (UpdateRetailCursorFeedback; world pick at the B.4b select path).
Research: docs/research/2026-07-02-ui-architecture-review.md.
Acceptance: hovering a valid world target in target mode shows the yellow bullseye (0x28); invalid shows the red blocked cursor (0x29).
Resolution: CursorFeedbackController now accepts the world-hover guid from
the existing world-pick path and merges it only when retained UI did not claim
the pointer. Valid and invalid world targets therefore select retail cursor
states 0x28 and 0x29.
#199 — Port the server-authoritative character raise flow
Status: OPEN Severity: MEDIUM Filed: 2026-07-02 Component: ui / core
Description: Character raises currently mutate XP/credits/ranks optimistically (CharacterSheetProvider.HandleRaiseRequest → ApplyLocalRaise). Named retail does not predict: it permits one request in flight, ghosts the clicked button, and leaves displayed state unchanged until an authoritative quality-change element message supplies the new values.
Root cause / status: Review finding (theme T2), resolved oracle question on 2026-07-10. Remove local mutation, add a one-in-flight owner, ghost the requested button, and clear awaiting state from the authoritative quality-change path. The exact rejection cleanup still needs a live ACE trace; do not mask it with a timer/retry.
Files: src/AcDream.App/UI/Layout/CharacterSheetProvider.cs (HandleRaiseRequest), src/AcDream.Core/Player/LocalPlayerState.cs (Apply*Raise).
Research: docs/research/2026-07-02-ui-architecture-review.md; authoritative flow docs/research/2026-07-10-retail-panel-behavior-pseudocode.md.
Acceptance: sending a raise does not change local XP/credits/ranks; a second request is blocked while one is awaiting; the matching authoritative update refreshes state and re-enables the button; rejection/relog cleanup follows captured server behavior without retry loops.
#200 — Migrate remaining retail-window mounts (vitals/chat/toolbar/inventory + MockupDesktop) to RetailWindowFrame
Status: OPEN Severity: LOW Filed: 2026-07-02 Component: ui
Description: RetailWindowFrame.Mount (extraction commit) is the shared nine-slice mount recipe; the character window uses it. Vitals, chat, toolbar, and inventory still inline the same block in GameWindow.OnLoad, and Studio/MockupDesktop hand-copies the recipes (already drifting). Each is a mechanical migration; do them one window at a time with visual confirmation (chat has Opacity + shrinkable MinWidth quirks).
Root cause / status: Review finding (theme T1).
Files: src/AcDream.App/Rendering/GameWindow.cs (~1973 chat, ~2120 toolbar, ~2290 inventory), src/AcDream.App/Studio/MockupDesktop.cs:184, src/AcDream.App/UI/Layout/RetailWindowFrame.cs.
Research: docs/research/2026-07-02-ui-architecture-review.md.
Acceptance: zero inline new UiNineSlicePanel window mounts left in GameWindow/MockupDesktop; all windows pixel-identical before/after (user visual check).
#201 — Reconcile the UI design docs with the shipped D.2b shape
Status: DONE (f9805085, 2026-07-10, retail UI fidelity Wave 0)
Severity: MEDIUM
Filed: 2026-07-02
Component: docs
Description: docs/plans/2026-04-24-ui-framework.md still says D.2b "remains design-only" and promises an AcDream.UI.Retail IPanelRenderer backend; docs/architecture/acdream-architecture.md still shows the swappable-backend diagram. Reality (acknowledged only in the 2026-06-14 panel-frame spec §0): the retail UI is the UiRoot tree in src/AcDream.App/UI/, the ViewModels are the shared seam, IPanel/IPanelRenderer is the devtools contract. The plugin UI story also fractured: IUiRegistry.AddMarkupPanel only renders under ACDREAM_RETAIL_UI=1, IPanel only under devtools — decide + document the plugin contract.
Root cause / status: Review findings (theme T8). One doc commit; CLAUDE.md requires the architecture doc never stay out of sync.
Files: docs/plans/2026-04-24-ui-framework.md:7-8/244, docs/architecture/acdream-architecture.md:80-99, src/AcDream.Plugin.Abstractions/IUiRegistry.cs.
Research: docs/research/2026-07-02-ui-architecture-review.md.
Acceptance: both docs describe the two-stack reality; the plugin markup-vs-IPanel decision is written down.
Resolution: CLAUDE.md, acdream-architecture.md, and
2026-04-24-ui-framework.md now describe the shipped two-stack architecture.
IPanel/IPanelRenderer is the permanent first-party ImGui devtools contract;
the retained UiRoot tree is gameplay UI; both share ViewModels/commands/state.
Plugin gameplay UI is explicitly IUiRegistry.AddMarkupPanel and remains
BCL-only.
#158 — Character window — deferred polish
Status: OPEN Severity: LOW Filed: 2026-06-26 Component: ui
Description: The Character window (LayoutDesc 0x2100002E, CharacterStatController) was user-accepted 2026-06-26 as good-enough for the D.2b track, but the user noted "still needs some polish for later." Known candidates (user will enumerate the full list later): level-number glyph fidelity vs retail's exact large dat font; icon crispness in the attribute rows; heritage/title strings are sample data, not wired from the live character; exact string-table wording not ported (caption text hardcoded vs sourced from dat string tables).
Root cause / status: Not a bug — the Attributes tab is functionally complete + visually confirmed. Deferred post-M5.
Files: src/AcDream.App/UI/Layout/CharacterStatController.cs, src/AcDream.App/UI/Layout/LayoutImporter.cs.
2026-07-09 triage: investigated, verdict STILL_OPEN — a fix for this exact bug (9444a328, normalizing the panel root to (0,0) for off-screen FBO captures) exists but only on unmerged branches (codex/mockup-stage / claude/peaceful-visvesvaraya-e0a196); StudioWindow.cs on this branch still has no position-normalization code and the mechanism is unfixed here.
#155 — Outdoor terrain textures looked stretched and blurry vs retail
Status: DONE — 2026-07-13, bb5acab9, user visually confirmed
Severity: MEDIUM
Filed: 2026-06-25
Component: rendering / terrain texture composition
Description: Cobblestone, roads, and grass appeared enlarged and soft even though the high-resolution source surfaces, mipmaps, and anisotropic filtering were active.
Corrected root cause / resolution: The first investigation attributed the
whole symptom to retail's separate detail-texture overlay and incorrectly
claimed the base TerrainTex.TexTiling field did not exist. The later
named-retail/DAT audit disproved that claim. Retail
TexMerge::CopyAndTile @ 0x00503580 and TexMerge::Merge @ 0x005038C0 pass
each base, overlay, and road surface's authored TexTiling into composition.
acdream had hard-coded one repeat per 24-metre landcell. bb5acab9 carries the
field through TerrainAtlas, uploads a layer-indexed table, and applies it in
the modern shader while leaving cell-scale alpha masks unchanged. The user
confirmed the outdoor textures now match the expected scale.
The optional high-frequency Environment Detail Textures pass is a different retail mechanism. It remains deferred under #226/TS-52 and does not keep this fixed user-visible regression open.
Files: src/AcDream.App/Rendering/TerrainAtlas.cs;
src/AcDream.App/Rendering/TerrainModernRenderer.cs;
src/AcDream.App/Rendering/TerrainTextureTilingTable.cs;
src/AcDream.App/Rendering/Shaders/terrain_modern.frag.
Research: docs/research/2026-07-13-retail-terrain-texture-tiling-pseudocode.md.
Acceptance: Authored grass, road, base, and overlay textures repeat at retail scale without reducing source resolution or changing blend masks — passed in the connected visual gate.
#151 — Far-town (Arwic) collision broken at login: terrain barely grounds + city/perimeter walls never block
Status: 🟡 city/perimeter walls FIXED + user-verified (9743537, 2026-06-24); terrain-grounding sub-question OPEN but re-scoped LOW (likely not a real defect).
Severity: MEDIUM (walls were the HIGH part — fixed; terrain residual unverified)
Filed: 2026-06-24
Component: physics — building collision-shell registration (walls); far-town terrain grounding (residual)
Description (user-observed 2026-06-24): In Arwic (a far town), the city/perimeter walls had NO collision even on a FRESH login (you walked straight through them); houses blocked fine. The ACDREAM_CAPTURE_RESOLVE capture also showed the player grounded only 3% of resolves at Arwic vs 100% at Holtburg.
⚠️ Premise corrected — this was NOT the #145 far-town frame. #146's bldOrigin probe proved Arwic buildings are correctly framed (anchored ~12 m from the player, not km off). So the streaming frame works fine at far towns; the walls were a different, tractable bug.
Root cause — CITY WALLS (FIXED 9743537): a town perimeter wall is stored in LandBlockInfo.Buildings as a doorless shell — Issue147ArwicBuildingsDumpTests shows 16 of Arwic's 30 buildings are portal-less, ringing the town at 24 m intervals (x=12/132, y=12/108). The building-collision cache loop skipped them — if (building.Portals.Count == 0) continue; (a filter meant only for the transit/entry feature, CellTransit.CheckBuildingTransit) — so their collision shell was never registered, even though retail's find_building_collisions (0x006b5300) tests the shell BSP independent of the portal list. Fix: cache portal-less buildings too (empty portal list, shell BSP intact). User-verified: walls now block (Collided/Slid).
Root cause — TERRAIN 3% GROUNDED (OPEN, LOW): a SEPARATE question, NOT confirmed as a real defect. The player never falls through Arwic terrain (z stable); the low contactPlaneValid rate is plausibly a flag/measurement nuance (server-held z + settled zero-move resolves not re-setting a contact plane) rather than missing terrain collision. Before treating it as a bug: re-capture ACDREAM_CAPTURE_RESOLVE at Arwic post-wall-fix and check jump/slope feel. If it IS real, it's #145 cell-relative-frame territory.
Files: src/AcDream.App/Rendering/GameWindow.cs (building-cache loop ~6958, portal-less skip removed); tests/AcDream.Core.Tests/Physics/Issue147ArwicBuildingsDumpTests.cs (dat fixture). Terrain residual: CellTransit.cs / PhysicsEngine.cs frame.
Acceptance: city/perimeter walls block in far towns — DONE. Terrain-grounding residual: confirm whether it's a real defect before acting.
#152 — Building/house-wall collision lost after portaling INTO a town (works on fresh login)
Status: DONE (49d743f, 2026-06-24) — root confirmed via the bldOrigin probe (Holtburg shell cached at the Arwic frame, ~5.5 km off) and fixed by re-basing the building cache on each landblock apply (PhysicsDataCache.RemoveBuildingsForLandblock + clear-then-repopulate in ApplyLoadedTerrain). Verified on the exact repro (Arwic login → Holtburg portal): bldOrigin now at the wall, channel returns Collided/Slid; suites green. (Move to Recently closed on next tidy.)
Severity: MEDIUM (clip through house/building walls after every portal-in; doors still block; terrain solid)
Filed: 2026-06-24
Component: physics — building collision channel + streaming-relative frame (teleport recenter)
Description (user-observed 2026-06-24): Static building/house walls block normally on a FRESH login to a town (Holtburg confirmed), but after logging in elsewhere and PORTALING into the town, the same walls no longer block — you clip straight through. Server-spawned DOORS still block (they register their own collision via CreateObject), and terrain stays solid — so the symptom is specifically dat-static building-wall collision after a portal-in.
Root cause (capture-narrowed, one probe from confirmed): The building collision channel (TransitionTypes.FindBuildingCollisions → PhysicsDataCache.GetBuilding(cellId) → BSP test vs BuildingPhysics.WorldTransform) IS reached after a portal-in — the [bldg-channel] probe fires at Holtburg post-portal (cell=0xA9B40022 model=0x01000C17) — but every test returns result=OK (no penetration) as the foot-sphere walks into the wall (arwic-to-holtburg-bldg-capture.log). The building's WorldTransform is computed from _liveCenter AT CACHE TIME (GameWindow.cs ~6969: building.Frame.Origin + origin, origin = (lb − _liveCenter)·192) and CacheBuilding is IDEMPOTENT (PhysicsDataCache.cs:444 if (_buildings.ContainsKey) return; — never re-cached, never cleared on unload). A teleport recenters _liveCenter, so the cached shell BSP can sit at a stale world offset → the sphere never penetrates → no block. Same streaming-relative-frame family as #145/#147.
Next step (one probe before fixing): add building.WorldTransform.Translation to the [bldg-channel] line; if it's offset from the visual building / player position after a portal-in, the stale-transform root is confirmed. Fix = invalidate _buildings[landcell] on RemoveLandblock so it re-caches with the current frame on reload (targeted), or store the transform cell-relative (the #145 frame). No guess-patch (DO-NOT-RETRY collision area).
Files: src/AcDream.App/Rendering/GameWindow.cs (~6944–7003 building cache loop), src/AcDream.Core/Physics/PhysicsDataCache.cs (CacheBuilding/GetBuilding/_buildings, ~441–464), src/AcDream.Core/Physics/TransitionTypes.cs (FindBuildingCollisions 2805, [bldg-channel] probe 2874).
Research: captures a-wall-resolve.jsonl (Holtburg 100% grounded, 876/882 wall-moves no block, doors collide), arwic-to-holtburg-bldg-capture.log (post-portal result=OK), holtburg-bldg-capture.log (fresh-login channel fires). Relates to claude-memory/project_physics_collision_digest.md + #145/#147.
Acceptance: after portaling into Holtburg from elsewhere, building/house walls block exactly as on a fresh login.
#153 — Far teleport onto an unstreamed landblock edge can run away
Status: DONE — 2026-07-30 (Campaign P P5 ledger item; closed on shipped
mechanism + pinned tests + connected evidence, no recurrence since the fix
landed). The residual's causal chain (recorded 2026-06-21, BEFORE the fix)
is severed at every link by work that shipped afterwards:
(1) the "fix direction 4a" hold shipped 2026-06-22 as register row AD-30
— an outdoor seed whose terrain is not resident preserves the seed cell
verbatim (CellTransit.cs FindCellSet, terrainResident guard), and the
seeded player additionally bypasses the fallback entirely via the #145
carried anchor (carriedBlockOrigin = the TRUE landblock origin even for an
unstreamed neighbour); (2) the stale-southward-velocity trigger is dead —
teleport arrival runs retail's full StopCompletely (R3-W6,
PlayerMovementController arrival idle: commands reset, velocity zeroed);
(3) the 17410 outbound wire artifact class is structurally gone — outbound
serializes PhysicsBody.CellPosition directly (canonical outbound position
ownership, 2026-07-14), never reconstructing from _liveCenter; (4) the
modern-runtime reveal barrier (AD-2/#229 + Slice E destination reservation)
holds incomplete destinations in the authored tunnel until collision domains
converge. Deterministic pins: TeleportFarTownRunawayTests
(SouthEdge_UnstreamedNeighbour_CarriedAnchor_DoesNotMarch,
EastEdge_...) reproduce the exact ~2 m-from-edge unstreamed-neighbour
arrival. Connected evidence: the 2026-07-29 Coldeve acceptance session ran
20 teleports with normal movement; the K3/K4 portal routes and canonical
nine-stop soaks passed repeatedly with zero movement faults. The map-edge
open item (hold could hover if the neighbour never streams) is mitigated as
predicted: the destination recenters streaming, so the hold is transient,
and arrival is at rest. Campaign P's final visual matrix includes portal
travel in its regression sweep as the last confirmation.
Status (historical): the original repeat-portal failure and streamed-arrival
cascade are fixed; a narrower unstreamed-arrival-near-edge residual remained.
The cell-relative carried anchor (Option B, Slices 1–3+7,
438bb68→403a338) passed roughly ten streamed far-town transitions. The
recorded residual arrives within about 2 metres of a 192-metre landblock edge
before the neighbour is resident, then advances membership through unstreamed
cells while producing an inconsistent outbound cell/local-position pair. Keep
the capture-first/DO-NOT-RETRY constraints and detailed verified mechanism
below; this is the issue formerly misreferenced in living docs as
“#145-residual.” Issue #145 is an unrelated completed UI Z-order bug.
Status (historical): REOPENED 2026-06-21 — RESIDUAL: a teleport to a FAR town triggers a per-frame resolver runaway (the 2026-06-20 source-drop fix holds for Holtburg↔dungeon, but far-town destinations regress). #138 (objects come back) is confirmed SEPARATE + fixed. Root cause under research (retail-decomp oracle workflow). Severity: HIGH (blocks far-town portal travel; breaks collision after the teleport — user, 2026-06-21) Filed: 2026-06-20 Component: net/streaming/physics — teleport (0xF751) + PortalSpace + arrival + per-frame cell-resolve / coordinate-frame rebase
⚠️ REOPENED 2026-06-21 — far-town teleport resolver runaway (capture in hand):
Teleport dungeon (0,7) → far town (201,91) = 0xC95B (reached via the Town Network hub). The
client PLACES the player correctly via the #145 verbatim path
([snap] claim=0xC95B0001 pos=(14.8,0.3,12.005) branch=NO-LANDBLOCK -> verbatim), then the
very next per-frame physics resolve runs away: cell membership marches one landblock SOUTH per
frame (0xC95B → 0xC95A → … → 0xC900, the landblock Y byte counting down) while the position
drifts (X +0.12/frame, Y −0.38/frame) and the player falls (Z 12 → −6.5) — i.e. the resolve never
REBASES the local position into the new landblock's frame, so it keeps "crossing" boundaries. It
settles "on ground" at cell 0xC900 local (−6.1, −30.2, 12). ACE then sees a move from the true
spot C95B0001 [14.8,0.3,12] to C9000008 [34.7, 17410, 24] — landblock Y byte = 0 but local
Y = 17410 ≈ 91 landblocks of leaked offset — an inconsistent (cell, local) pair → rejects every
move (MOVEMENT SPEED / failed transition) → no server-confirmed position → collision gone
(player falls through the world). reason=resolver (per-frame physics), NOT teleport.
Holtburg↔dungeon works because Holtburg is the startup center; the far town exposes the gap.
Re-hydrate (#138) EXONERATED: the re-hydrate fired only for Holtburg + the dungeon (log lines
412/498/710); it did NOT fire for the far town during the runaway (746+) — and it only adds RENDER
entities, never the physics _landblocks the resolver iterates.
Apparatus: [cell-transit] trail (ACDREAM_PROBE_CELL=1) + desync-capture.jsonl (72,401
ResolveWithTransition frames, ACDREAM_CAPTURE_RESOLVE). ROOT CAUSE — VERIFIED 2026-06-21 (multi-agent workflow + adversarial verification, decomp-confirmed against live Ghidra):
It is a cell-membership cascade, NOT a real free-fall. The physics body stays small + correct
(capture: max|Y|≈86 m, settles at the true rest (-6.1,-30.2,12.0)); only the cell id marches
one landblock south per physics quantum (~33 ms) until the landblock-Y byte underflows to 0x00. The
17410 is a wire-conversion artifact (the outbound localY = Position.Y − (lbY−_liveCenterY)·192
adds back 91×192 once lbY marched to 0), never a physics value.
The bug (single source line): CellTransit.cs:736 —
cache.CellGraph.TryGetTerrainOrigin(currentCellId, out var blockOrigin) discards the bool.
For a far town, the player is placed at the southern landblock edge (world-Y≈0.3) with stale
southward running velocity (teleport doesn't idle the motion state); the first tick crosses Y=0
into landblock 0xC95A, which has not streamed in yet → TryGetTerrainOrigin returns false
with origin=Vector3.Zero (the comment at CellTransit.cs:732-735 self-documents this "legacy
anchor-frame" fallback). (0,0) is handed to AddAllOutsideCells (:758) → GetOutsideLcoord
(LandDefs.cs:110-111) does ly += floor(worldY/24) = floor(-0.088/24) = -1 → cell marches one
block south. The correct origin (0,-192) would give floor(191.9/24)=+7 (right region) — the
missing −192 rebase IS the bug. Every subsequent neighbor is also unstreamed → the cascade
self-perpetuates 91 times. Far-town-only because Holtburg (the streaming startup center) has all
neighbors permanently registered, so the fallback never fires + spawns are mid-block (no Y=0 cross).
Retail contrast (decomp-confirmed, addresses re-verified in Ghidra patchmem): retail stores
position CELL-RELATIVE (Position{ uint objcell_id; Frame{ m_fOrigin ∈ [0,192) } }, acclient.h:30659)
and rebases the ACTUAL stored origin in place on every placement via Position::adjust_to_outside
(0x00504A40) → LandDefs::adjust_to_outside (0x005A9BC0, wraps origin into [0,192) + recomputes the
cell id) called from AdjustPosition (0x00511D80) / SetPositionInternal (0x00515BD0); cross-cell
offsets come only from get_block_offset (0x0043E630, delta of two cell ids, zero within a
landblock). An inconsistent (cell, local) pair is structurally impossible in retail. acdream's
streaming-relative physics frame (worldXY=(lb−_liveCenter)·192+local, per-landblock baked
WorldOffset) is the deeper divergence; the proximate bug is the discarded-bool (0,0) fallback.
FIX DIRECTION (report-only — awaiting user approval):
- 4a (targeted, faithful, this-session): honor
TryGetTerrainOrigin'sfalseatCellTransit.cs:736— when the current landblock's terrain isn't resident, the outdoor pick has no trustworthy block-local frame, so HOLD the current (cell, position) verbatim (no march), the same "frame-not-yet-authoritative ⇒ return input" contract the NO-LANDBLOCK Resolve branch already uses. PAIR with idle the motion state on teleport arrival (stop re-applying the dungeon running velocity the same frame asSetPosition) so the body never attempts the first crossing before terrain streams. Adaptation (not a direct retail port — retail gets it free via cell-relative storage) ⇒ needs a divergence-register row. Deterministic replay test from the capture (TeleportFarTownRunawayTests: with0xC95Aunregistered, resolve returnscurrentCellIdunchanged). Consumer sites to cover via the early-return::758/242(seed),:845(pick),:484. - 4b (architectural, file as a phase, brainstorm-gated): port retail's cell-relative physics
Position+ make_liveCenterrender-only +get_block_offsetas the only cross-cell translation. Removes the whole class structurally. Multi-commit. Open items: map-edge wedge (verbatim-hold could hover the player at a boundary if the needed neighbor never streams — likely mitigated by the motion-idle + the destination center always streaming; verify). Confidence: HIGH (all 3 adversarial lenses holdsUp=true; capture- and decomp-verified). Full report: workflowwf_87607d15-c43.
[ORIGINAL #145 — source-frame overlap, FIXED 2026-06-20; history below]
Description (user, 2026-06-20): A portal can only be used ONCE per session. The first teleport works; a subsequent portal use (run out, or re-enter) does not. Goal: run in, run out, repeatedly.
Root cause (CONFIRMED — code trace + live cdb-style [phys-lb] probe): the teleport OUT of a dungeon mis-rooted the player into the SOURCE dungeon's coordinate frame, desyncing all movement from ACE. acdream uses a streaming-RELATIVE coordinate frame (positions relative to _liveCenterX/Y) and recenters on teleport, but resident physics landblocks keep their load-time world-offset. After recentering onto the outdoor destination, the collapsed source dungeon (loaded at offset (0,0) when it was the center) and the destination (also the new center → offset (0,0)) overlap, and the Z-agnostic outdoor cell-snap (AdjustPosition, iterating _physicsEngine._landblocks) returns the dungeon — for the arrival placement AND every per-frame resolve — so the player was rooted at a dungeon cell (0x00070019) at Holtburg's position. acdream then sent dungeon-frame positions; ACE (which knows the player is at Holtburg) rejected every one (WARN: failed transition … to 0x0007…), so the player couldn't move, never reached a portal, and ACE never re-broadcast the Holtburg objects. Teleport IN works because its placement is cell-keyed (indoor FindVisibleChildCell validates the specific claimed cell) and it pre-collapses; the OUTDOOR resolve is the grid-snap, which the overlap fools.
Fix (2026-06-20) — server-authoritative teleport placement (user-approved approach):
- Drop the stale source center landblock from physics at the teleport recenter (
GameWindow.OnLivePositionUpdated,differentLandblockbranch →_physicsEngine.RemoveLandblock(EncodeLandblockId(oldCenter))). Only the offset-(0,0) center collides with the destination-local position, so removing it alone clears the overlap; the arrival + per-frame resolve then fall through to the server position (PhysicsEngine.ResolveNO-LANDBLOCK verbatim,:605) until the destination streams in. - Place outdoor teleports immediately (
TeleportArrivalRules.Decide— outdoor → Ready). Holding is futile: streaming does NOT progress while the player is held in PortalSpace (the destination only loads once placement flips to InWorld). Indoor unchanged (IsSpawnCellReadyhold). Gate suppression during the hold kept (DungeonStreamingGate). - Clear a dangling
CellGraph.CurrCellwhen its landblock is removed (PhysicsEngine.RemoveLandblock). Without this, dropping the dungeon left CurrCell pointing at the orphaned dungeon cell; the dungeon-streaming gate (keyed on CurrCell) kept streaming collapsed onto the gone landblock, so the destination never streamed → only skybox. Clearing it lets the gate read "not in a dungeon" →ExitDungeonExpand→ destination streams in → CurrCell re-acquires.
Tests: DungeonStreamingGateTests (4), TeleportArrivalRulesTests (4). Registers AP-36 + AD-2. Build + 2727 tests green. Edge logs added to EnterDungeonCollapse/ExitDungeonExpand.
Files: GameWindow.cs OnLivePositionUpdated (drop stale center) + TeleportArrivalReadiness; src/AcDream.App/World/TeleportArrivalController.cs (TeleportArrivalRules); src/AcDream.App/Streaming/DungeonStreamingGate.cs; src/AcDream.Core/Physics/PhysicsEngine.cs (RemoveLandblock CurrCell clear + NO-LANDBLOCK verbatim).
Acceptance: run into a portal, run out, re-enter — repeatedly in one
session, each time placing + streaming correctly. Verify the dungeon→outdoor
exit remains in portal space until canonical destination readiness (with the
centered retail wait cue if it exceeds five seconds), then completes with
streaming: dungeon EXIT-expand, the outdoor world fully streamed, and
collision working. Also re-checks #138.
#148 — Status-bar backpack icon should toggle the inventory window (stateful open/closed)
Status: OPEN Severity: MEDIUM Filed: 2026-06-22
The backpack icon in the status bar / toolbar should be clickable to open/close the inventory
window (toggle), and its art should reflect the inventory's state — a closed backpack when the
inventory is closed, an open backpack when it's open. Today the inventory opens via F12 only;
there's no clickable status-bar affordance and the icon isn't stateful. Wire the click → inventory
toggle (the window manager / UiHost RegisterWindow + the F12 toggle path) and swap the icon sprite
on the inventory window's visibility change. Investigate the retail status-bar backpack element +
its open/closed sprites when picked up.
2026-07-09 triage: investigated, verdict STILL_OPEN — the only inventory-toggle path is still the F12 keybind (GameWindow.cs ToggleInventoryPanel) with no status-bar button anywhere in src/; a status-bar-toggle implementation exists but only on an unmerged branch (b7dc91a0 on claude/peaceful-visvesvaraya-e0a196), not in this branch's history.
#147 — Inventory item-grid scrolling polish
Status: DONE 2026-07-11 — live visual gate passed Severity: LOW Filed: 2026-06-22
The inventory contents-grid scrolling (gutter scrollbar 0x100001C7 bound to UiItemList.Scroll,
whole-row clip via UiScrollable) works but needs visual/UX polish — smoothness, thumb behavior,
wheel step, row-clip edges. Confirm the exact symptoms with the user when picked up. Filed from the
D.2b inventory visual gate.
2026-07-11 resolution: retained list rebuilds now defer layout until all cells are restored, preserving the exact ScrollY across inventory changes. Whole-row visibility clipping is replaced by intersecting-row visibility plus a nested child viewport clip; sprite geometry and UVs are cropped together, so thumb dragging shows clean partially visible rows instead of black gaps. Named retail confirms the position itself is an integer pixel coordinate (ScrollToY/SetScrollableXY), while wheel/arrow line steps remain one row (InqScrollDelta = scrollableHeight / rowCount). The inventory's old 560 px maximum-height cap was also the reason a persisted 560 px window could not expand further; its maximum now uses available screen height (AP-54 remains for exact keystone bounds). Automated tests cover partial top/bottom rows, UV crop, scroll preservation through refresh, and resize geometry; warning-free App Release plus all 4,702 tests pass. User confirmed vertical resizing, partial-row scrolling, and offset preservation in the live Release client. Research: docs/research/2026-07-11-retail-inventory-scroll-pseudocode.md.
#146 — D.2b inventory capacity-bar visual polish
Status: OPEN Severity: LOW Filed: 2026-06-22
The per-side-bag / main-pack container capacity fill bar (faithful port of retail
UIElement_UIItem::UpdateCapacityDisplay 0x004e16e0, element 0x10000347) shipped + is
visually confirmed (src/AcDream.App/UI/UiItemSlot.cs CapacityFill;
InventoryController.SetCapacityBar). User: "looks good, we need to polish it a bit, but lets do
that later." Get the specific polish list from the user when picked up. Candidate points (confirm
against retail):
- Exact bar rect / anchor — currently right-anchored flush (
X = Width − barW); the dat element rect isX=26 Y=1 W=5 H=30(a 5px right margin). Flush was the visual-gate call; reconcile with the dat once the desired look is pinned. - Fill direction — currently bottom-up (assumed); the dat
m_eDirection(property0x6f) isn't read (cf. AP-50). Confirm bottom-up vs retail. - Closed-bag fill — a closed side bag reads empty until opened (contents aren't indexed until
ViewContents). If retail shows real fill for closed bags, the per-container item counts must be pre-loaded at login (the lazy-load question — also the container-switching "open verification").
See divergence register AP-59.
2026-07-09 triage: investigated, verdict STILL_OPEN — UiItemSlot.cs still right-anchors the bar and hardcodes bottom-up fill (m_eDirection unread) and InventoryController.SetCapacityBar still has no closed-bag lazy-load; no commit has touched this since the 2026-06-22 filing commit.
#145 — Inventory window panels occluded by the full-window backdrop (importer ignores ZLevel)
Status: DONE (2026-06-21 · 45a5cc5 + continuation 417b137) — VISUALLY CONFIRMED. The first fix (45a5cc5) folded ZLevel into ZOrder and put the backdrop behind the ZLevel-0 top-level panels, which made the paperdoll (its base root is ZLevel 0) render — so it looked done. But the mounted backpack/3D-items panels inherited their sub-window root's ZLevel 1000 (via ElementReader.Merge's zero-wins-base rule), so the ReadOrder − ZLevel·10000 fold sank them to ZOrder ≈ −10,000,000 — behind the backdrop (ZLevel 100 → ≈ −1,000,000). The Alphablend backdrop then washed out the backpack/3D-items captions + burden meter + item cells (only the bright paperdoll survived the wash). Surfaced once B-Controller populated those panels. Continuation fix 417b137: the sub-window mount keeps each slot's own frame ZLevel (not the base root's 1000), so panels sit in front of the backdrop. Confirmed live (Burden 17% + vertical bar + "Contents of Backpack" + full item grid all render on the dark backdrop). Locked by InventoryFrameImportProbe (real-dat: each mounted panel's ZOrder > the backdrop's). DO-NOT-RETRY: a mounted sub-window slot must NOT inherit the base layout root's ZLevel. Per-slot paperdoll silhouettes (generic UiDatElement sprite — need 0x10000032 UiItemSlot + per-slot art) → Sub-phase C.
Severity: MEDIUM (blocks B-Grid visual acceptance — the gmInventoryUI nested panels render blank)
Filed: 2026-06-20
Component: ui — LayoutImporter / DatWidgetFactory z-order
Description: With ACDREAM_RETAIL_UI=1, F12 shows the gmInventoryUI (0x21000023) frame + title + the dark full-window backdrop sprite, but the three nested panels (paperdoll / backpack / 3D-items) render blank. The B-Grid sub-window mount IS attaching the panels' content — confirmed: the paperdoll base element 0x100001D4 (root of 0x21000024) has 25+ equip-slot children — but the content is occluded.
Root cause / status: DatWidgetFactory.Create maps ZOrder ← ReadOrder only (~:81). In 0x21000023 the full-window backdrop element 0x100001D0 has ReadOrder=4 (drawn AFTER the panels at ReadOrder 1/2/3), so it paints OVER them. Retail keeps the backdrop behind via ZLevel (backdrop ZLevel=100 vs panels ZLevel=0); the importer ignores ZLevel because every vitals element was ZLevel=0, so it never mattered until now. Fix: factory ZOrder should honor ZLevel (higher ZLevel = further back) with ReadOrder as the tiebreaker — e.g. ZOrder = ReadOrder − ZLevel·K. ElementInfo has no ZLevel field yet (add it + read in ElementReader.ToInfo + carry in Merge). Regression risk: touches z-order for every window — must verify chat (0x21000006) + toolbar (0x21000016) ZLevels (vitals are all 0) and visually regression-test.
Files: src/AcDream.App/UI/Layout/DatWidgetFactory.cs (e.ZOrder = (int)info.ReadOrder, ~:81); src/AcDream.App/UI/Layout/ElementReader.cs (ElementInfo — add ZLevel; ToInfo; Merge); src/AcDream.App/UI/Layout/LayoutImporter.cs.
Research: docs/superpowers/specs/2026-06-20-d2b-inventory-grid-mount-design.md (B-Grid); dat dump of 0x21000023 (panels ReadOrder 1-3 / ZLevel 0; backdrop 0x100001D0 ReadOrder 4 / ZLevel 100).
Acceptance: F12 shows the nested paperdoll/backpack/3D-items panels over the backdrop (backpack burden meter + slot borders visible); vitals/chat/toolbar unchanged; full suite green.
#142 — Windowed-building interiors read "like outdoors" (indoor lighting regime is per-frame, not per-stage)
Status: DONE (2026-06-20) — ef5049f (per-instance sun gate) + 0d8b827. The diagnosed cause (per-frame sun/ambient regime) was a RED HERRING: the ambient + sun were already retail-faithful. The REAL bug was a landblock-key lookup in EnvCellRenderer.GetCellLightSet (cellId & 0xFFFF0000 vs the streaming key 0xXXYYFFFF) that starved EVERY interior wall of point lights. Once fixed, interiors lit correctly (+ the retail viewer light + weenie fixture lights). User-confirmed "looks like retail now." (Move to Recently closed on next ISSUES tidy.)
Severity: MEDIUM (visible — windowed town buildings + look-ins are sun-lit/flat instead of torch-lit warm vs retail)
Filed: 2026-06-20
Component: render — indoor lighting regime (sun + ambient)
Description (user, at the #140 gate): The Agent of Arcanum house is much brighter/lit indoors in retail (both looking in from outside AND when inside); in acdream it is "not lit" — looking in and inside both "feel like outdoors." The meeting hall (a sealed interior) looked OK, so it's specifically WINDOWED buildings + look-ins.
Root cause / status: acdream's lighting REGIME (sun on/off + which ambient) is a per-FRAME global keyed on the PLAYER's cell (GameWindow.cs:8107 playerInsideCell, from :8061 playerSeenOutside, into UpdateSunFromSky :8122/:10786). Retail's is per-DRAW-STAGE: PView::DrawCells (0x005a4840) draws ALL EnvCells in the useSunlightSet(0) interior stage (0x005a49f3) — torch-lit, no sun — regardless of SeenOutside. So acdream's windowed interiors (SeenOutside=true) + look-ins stay in the outdoor regime (sun + outdoor ambient) where retail uses the indoor regime. This is the AP-43 residual made visible. Torches are already per-cell (AP-43); the SUN + AMBIENT are the remaining per-frame-global parts. Fix direction: make sun+ambient per-draw (per-object/cell) like AP-43's torches — needs a brainstorm (UBO second-ambient + per-instance indoor selector vs a third uLightingMode). Resolves AP-43.
Files: GameWindow.cs:8061/8107/8122/10786 (regime), mesh_modern.vert accumulateLights (~:188/:193), WbDrawDispatcher.IndoorObjectReceivesTorches (:2076), EnvCellRenderer (mode-1).
Research: docs/research/2026-06-20-indoor-lighting-regime-handoff.md (full handoff — retail decomp + acdream refs + fix fork + validation plan). Register AP-43.
Acceptance: Agent of Arcanum interior torch-lit/warm both looking-in and inside (user side-by-side vs retail); sealed interiors + dungeons unchanged.
#143 — Portal swirl doesn't light the room (no dynamic-light registration)
Status: DONE (2026-06-20) — 0d8b827 (the portal weenie's magenta Setup.Light, intensity100/falloff6/(0.784,0,0.784), now registers via the weenie-light path + reaches the walls via the landblock-key fix) + 57c2ab7 (dynamic lights take retail's D3D 1/d attenuation + range×1.5 so the portal spreads softly instead of pooling). User-confirmed "looks good." (Move to Recently closed on next ISSUES tidy.)
Severity: LOW-MEDIUM (visible — retail's portal swirl tints the room; acdream's casts no light)
Filed: 2026-06-20
Component: render — dynamic point lights
Description (user, at the #140 gate): Inside the meeting hall, retail's portal swirl lights up the room; in acdream it does not.
Root cause / status: The portal swirl is a DYNAMIC light in retail (add_dynamic_light 0x0054d420 → minimize_envcell_lighting 0x0054c170 enables the cell's dynamic subset). acdream registers ONLY static Setup.Lights (GameWindow.cs ~:6404) — no dynamic lights, so the portal casts nothing. Captured retail params (predecessor cdb): intensity=100, falloff=6, color=(0.784,0,0.784) magenta. Fix: register a dynamic LightSource for portal-swirl entities (or read the portal model's own dat lights); it then flows through the existing point-light path and the EnvCell bake. Keep it indoor (out of the AP-43 outdoor gate).
Files: portal/particle spawn path (TBD); GameWindow.cs RegisterOwnedLight (~:6404); LightManager (PointSnapshot / UnregisterByOwner).
Research: docs/research/2026-06-20-indoor-lighting-regime-handoff.md (§#143).
Acceptance: portal swirl visibly tints the meeting-hall room vs retail.
#144 — Empty item-slot press+drag+release still emits a Click
Status: OPEN Severity: LOW Filed: 2026-06-20 Component: ui — D.2b drag-drop spine (B.1)
Description: Pressing an EMPTY UiItemSlot, moving the cursor >3 px (which makes UiRoot.BeginDrag cancel the drag because the empty cell's GetDragPayload() returns null), then releasing over the same cell still emits a Click (the press becomes a click on release, since no drag armed). This is the toolkit's general click semantics — every non-draggable widget (buttons included) fires Click after sub-gesture mouse movement; only an armed drag suppresses the click. Today it is a no-op: the toolbar's Clicked handler guards if (Cell.ItemId != 0), so an empty-slot click uses nothing.
Root cause / status: Surfaced in the B.1 spine code review (Task 3). Whether retail fires nothing vs. a no-op click on an empty cell after a >3 px move is unverified — deliberately NOT "fixed" with a speculative _dragCancelled guard, because that would make empty item-slots behave differently from every other widget and would be guessing at retail behavior (CLAUDE.md forbids both). Becomes relevant only if a future panel (e.g. the inventory grid in Stream C) wires an empty-cell click handler that does NOT guard on occupancy. Action when touched: verify retail's empty-cell press+move+release behavior (cdb/decomp) before changing anything; if it must differ, it earns a divergence-register row.
Files: src/AcDream.App/UI/UiRoot.cs (BeginDrag cancel path, OnMouseUp Click emit), src/AcDream.App/UI/UiItemSlot.cs (OnEvent Click→Clicked).
Research: docs/superpowers/specs/2026-06-20-d2b-drag-drop-spine-design.md §6.
#141 — Toolbar interactivity — selected-object display
Status: RESOLVED (2026-07-11; health/name/flash confirmed 2026-06-20; stack controls/transfers, selected-item mana, exact health-policy matrix, and exact paperdoll body selection confirmed 2026-07-11). Renumbered from #140 on the 2026-06-20 main merge — A7 Fix D held #140 on main; this branch's commits/spec still reference #140. Severity: MEDIUM Filed: 2026-06-17 Component: ui — D.5 toolbar / selection
Description: The action bar (D.5.1) is the retail "selected object" display. Wire the B.4 WorldPicker/selection state to the toolbar's currently-hidden elements: the two meters 0x100001A1 (selected-object Health) / 0x100001A2 (selected-object Mana) + the stack slider 0x100001A4 + the object-name line, so the bar shows what the player has selected in the world. Click-to-use + the peace/war stance indicator already shipped in D.5.1. Promote to roadmap D.5.3 (already listed there).
Root cause / status: The selection-state wire was deferred out of D.5.1 scope; the meter/slider elements are present in LayoutDesc 0x21000016 but hidden (no backing data). D.5.3 is the planned port.
- D.5.3a (2026-06-18): the Health meter (0x100001A1) + the object-name line (0x1000019F) + the overlay state (0x100001A0) are wired via
SelectedObjectController(port ofgmToolbarUI::HandleSelectionChanged);SelectionChangedevent onGameWindow;QueryHealth (0x01BF)sent on select. Spec/plan:docs/superpowers/specs|plans/2026-06-18-d53a-*. Mana was deferred from this slice and lands in D.5.3c below. - D.5.3a visual gate PASSED (2026-06-20): name top-aligned in the bar sprite's black band, friendly NPCs/Doors name-only, players/monsters get the bar (gated on PWD BF_ATTACKABLE/BF_PLAYER), bar appears on assess/damage (UpdateHealth-driven, AP-47 retired), brief green selection flash. Fixed during the gate: the two magenta end-lines (UiMeter.DrawHBar resolved slice id 0 → 1x1 magenta placeholder → 1px caps), the stack-entry black box (hid 0x100001A3), and the flash being eaten by a framebuffer-dump diagnostic. Commits
8f627cc(fixes),0796585(CLI apparatus). Remaining for #141: Mana meter (0x100001A2). - D.5.3b implementation (2026-07-11): stacked selection formats the retail
"%d %hs"count/name, now preserving the wirePluralNameselected byNAME_APPROPRIATE; reveals authored entry0x100001A3and horizontal DAT slider0x100001A4; initializes to the full stack; clamps entry edits; honors the entry's DAT right alignment; and uses the exact 1000-step slider conversion. The thumb retains its raw pointer position so minimum/maximum reach both endpoints. One CoreStackSplitQuantityStatefeeds the controls plus selected-source merge, container split (0x0055), and ground split (0x0056).GetObjectSplitSize @ 0x00586F00ensures unselected objects still move their full stack. Partial transfers do not optimistically move the original because ACE creates the destination object with a new guid. Warning-free App Release + 4,697 tests; controls/polish, partial inventory split, and partial ground drop visually confirmed. - D.5.3c implementation + live gate (2026-07-11): owned non-stack selections send retail
QueryItemMana (0x0263);QueryItemManaResponse (0x0264)now parses the trailing validity flag and flows through CoreItemManaState; valid matching responses reveal/fill authored meter0x100001A2, invalid responses cancel with guid zero, and changing selection cancels any visible mana or health query before hiding its meter. Stacked items stay exclusively on the split-control path. Live confirmation used an empty Mana Stone on a source magic item: the source was destroyed, its mana transferred into armor, and the selected armor's bar updated immediately from the server response. Named anchors:CM_Item::Event_QueryItemMana @ 0x006A8610,DispatchUI_QueryItemManaResponse @ 0x006A84D0,gmToolbarUI::RecvNotice_UpdateItemMana @ 0x004BD0C0. - D.5.3d implementation (2026-07-11; live gate pending): pure Core
SelectedObjectHealthPolicyportsClientCombatSystem::ObjectIsAttackable @ 0x0056A600and the toolbar's outer player/pet short-circuit. CreateObject now parses second-headerPetOwner (0x8)after MaterialType/Cooldown fields and carries it throughWorldSession/WeenieDataintoClientObject. This restores self, pet, and Free-PK creature queries while friendly NPCs and attackable doors remain name-only. AP-46 retired. Research:docs/research/2026-07-11-retail-selected-health-policy-pseudocode.md. - D.5.3d live gate/fix (2026-07-11): monster/player/friendly-NPC/door policy branches passed. The paperdoll exposed a separate deferred gap: its authored drag-mask handler offered target-use self but ordinary clicks had no fallback. The exact retail route now resolves enum DID
(0x1000000C, 7), samples all nine body colors, selects the highest-priority worn object on that part, and falls back to the player when uncovered; target mode still substitutes self. Real-DAT conformance verifies the map and mask dimensions match. - D.5.3d paperdoll re-gate PASSED (2026-07-11): uncovered body regions select self and show the health meter; covered regions select the visually upper worn item, matching retail. Issue #141 is complete.
Files: src/AcDream.App/UI/Layout/ToolbarController.cs + the selection/WorldPicker state (see claude-memory/project_interaction_pipeline.md).
Research: docs/research/2026-06-16-action-bar-toolbar-deep-dive.md (meter element ids + wire catalog).
Acceptance: Selecting a world object populates the toolbar meters and name line; deselecting clears them. Matches retail side-by-side.
#140 — A7 "Fix D": outdoor objects too bright near torches
Status: RESOLVED (b7d655b, 2026-06-19 — user-confirmed side-by-side at the Holtburg meeting hall)
Severity: MEDIUM (visible — buildings blow out warm near torches vs retail; ambient/sun itself is correct after Fix C)
Filed: 2026-06-18
Component: render — point lighting on outdoor objects
RESOLUTION (2026-06-19, round 2): The "bake vs D3D-FF" framing below was the WRONG question — neither lights the building exterior. Retail's per-object torch binder minimize_object_lighting (0x0054d480) runs ONLY if (Render::useSunlight == 0) (DrawMeshInternal 0x0059f398), and the OUTDOOR landscape stage runs useSunlightSet(1) (PView::DrawCells 0x005a485a before LScape::draw). So retail lights outdoor objects (building exterior shells, scenery, outdoor creatures) with the sun + ambient ONLY — never wall torches. acdream was torch-lighting them. Fix: WbDrawDispatcher.ComputeEntityLightSet now gates torch selection on the object being indoor (ParentCellId is an EnvCell) via IndoorObjectReceivesTorches; outdoor objects get the sun only. acdream reads the dat falloffs faithfully (the orange torch is genuinely Falloff 6; the "reach too long" theory was a red herring). Register AP-43; the indoor-vs-outdoor sun half uses a per-frame player-inside global (residual logged in AP-43). Full handoff: docs/research/2026-06-19-lighting-a7-fixD-round2-torch-reach-CHECKPOINT.md (RESOLVED banner). Indoor-lighting follow-ups the user raised at the gate (windowed-building interior regime; portal swirl as a dynamic light) are SEPARATE M1.5 work, not part of this issue.
Description (user): Outdoor buildings (e.g. the Holtburg meeting hall) read much brighter near torches in acdream than in retail — the walls blow out warm where retail stays dim. The general ambient/sun is correct after Fix C (57c1135); this is specifically the per-object point-light contribution.
Root cause / status: GROUNDED but BLOCKED on one capture. Retail's object point-light path (config_hardware_light 0x0059ad30): Diffuse=color×intensity, Attenuation=(0,1,0)⇒1/d, Range=falloff×rangeAdjust (rangeAdjust=1.5⇒9 m), material.diffuse=(1,1,1). CONTRADICTION: by that math a torch 3 m away = color×33 ⇒ retail walls should blow to WHITE — but they're DIM. Material/range/intensity all captured + ruled out. So the scaling is in the building's RENDER PATH (unknown). Leading hypothesis: static buildings DON'T use D3D hardware lighting — they use the SetStaticLightingVertexColors BAKE (calc_point_light, like cells), and the captured intensity=100 light was a different object (player/portal). DO NOT port the D3D-FF model — the math says it would make objects brighter, not dimmer.
Files: src/AcDream.App/Rendering/Shaders/mesh_modern.vert (pointContribution/accumulateLights); src/AcDream.Core/Lighting/LightManager.cs (SelectForObject); LightBake.cs (verbatim calc_point_light, unwired).
Research: docs/research/2026-06-18-lighting-a7-fixABC-shipped-fixD-handoff.md (full grounding + cdb cheat-sheet + the next capture); claude-memory/reference_retail_ambient_values.md.
Acceptance: Determine the building's actual render path (bake vs D3D-FF; is SetStaticLightingVertexColors 0x0059cfe0 called for it / is D3DRS_LIGHTING on), then make the object torch contribution match retail — user side-by-side sign-off (meeting hall stays dim near torches).
#139 — D.2b retail UI polish: chat buttons
Status: OPEN (narrowed 2026-08-09 — the chat-text-colors half CLOSED, see below) Severity: LOW (cosmetic fit-and-finish — the widget generalization works and matches the prior hand-made build; this is polish vs a side-by-side retail client) Filed: 2026-06-16 Component: ui — D.2b retail UI (chat buttons)
Description (user): After the widget-generalization pass landed (2026-06-16), two areas wanted a polish pass against retail. Item 1 (chat text colors) is now DONE — see below. Item 2 remains open:
Chat text colors— CLOSED 2026-08-09, Campaign CH slice CH1.ChatWindowController.RetailChatColor(ChatKind)(the old best-effort per-ChatKindmap) is deleted; coloring now keys off the entry's retail wireLogTextTypethrough the exact 34-entryRetailChatColorTable, a faithful port ofChatInterface::BuildChatColorLookupTable @0x004F31C0with every RGBA float read from the PDB-paired binary's.datasection — seedocs/research/2026-08-09-chat-retail-color-table.mdanddocs/plans/2026-08-09-chat-parity-campaign.md.- Buttons — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: pressed / hover state feedback (
UiButtoncurrently draws only its default-state sprite; the dat carriesNormal/Pressed/Highlightstates it does not yet switch on), plus a check that the face 3-slice + autosize read cleanly at all widths.
Root cause / status: Deferred polish, NOT a regression — the generalized chat matches the prior hand-made build (user-confirmed 2026-06-16). UiButton intentionally mirrors UiDatElement's single-state render (pressed-state was out of the generalization's scope).
Files:
src/AcDream.App/UI/UiButton.cs—ActiveFile()/OnEvent(no pressed-state swap yet; dat has Normal/Pressed/Highlight).src/AcDream.App/UI/UiMenu.cs—DrawButtonFace(Normal vs Pressed sprite) for the channel button.
Acceptance: Button (pressed/hover) states match a side-by-side retail client — user's visual sign-off. (Chat text colors already user-gated at Campaign CH's campaign-level gate.)
#138 — Teleport OUT of a dungeon loads the outdoor world incompletely + position desync
Status: ✅ DONE / CLOSED 2026-07-10 — full dungeon round-trip (enter → navigate → exit)
user-gated; this closed #138 and landed M1.5. (Move to Recently closed on next tidy.) Prior
resolution below: position-desync FIXED via #145; server-object re-hydrate
SHIPPED (LandblockEntityRehydrator, AP-48); avatar-vanish (symptom B) ACTUAL root found +
FIXED + user-confirmed (afd5f2a, 2026-06-24 — skip the per-frame RelocateEntity while
PortalSpace). Remaining collision residuals split out to #152 (Holtburg/building portal-in
walls, DONE 49d743f) + #151 (Arwic far-town frame, FIXED 9743537) — those are the live
follow-ups, not #138 itself. (⚠️ Numbering trap: #146/#147 in this file are unrelated D.2b
inventory-polish issues — see below — not the collision follow-ups; the correct numbers are
#151/#152.) See the 2026-06-24 update at the bottom of this entry.
Severity: MEDIUM (breaks the dungeon→outdoor transition; collision + visuals wrong after exit)
Filed: 2026-06-14
Component: streaming — dungeon collapse↔expand (the #133/#135 collapse) + teleport-arrival
Description (user): taking a portal OUT of a dungeon to the outdoor world often loads the world incompletely — fewer objects than expected (e.g. missing trees/scenery), and collision doesn't work properly. There's also a position desync: "it's like I'm not moving while my character is moving" (the avatar animates/advances but the player's actual position / camera doesn't track, or vice-versa).
Root cause / status (hypothesis — needs investigation): very likely a gap in the
dungeon-streaming collapse→expand introduced for #133/#135. Inside a dungeon, streaming
is COLLAPSED to the single dungeon landblock (radius-0). On teleport OUT,
StreamingController.ExitDungeonExpand must rebuild the full 25×25 outdoor window at the new
center. Suspects: (a) the expand doesn't fully re-enqueue / re-hydrate the outdoor landblocks
(→ missing trees/scenery + no collision because shadow-object registration never ran for the
un-hydrated blocks); (b) the teleport-arrival recenter (OnLivePositionUpdated) +
PreCollapseToDungeon/observer interaction leaves the streaming observer pinned wrong after
exit; (c) the position desync = the player controller / streaming observer disagree on the
post-exit world position (the avatar moves in one frame, the streaming/camera in another).
Pairs with #135 (712f17f/2c92375) — same collapse machinery; the EXIT path is the gap.
UPDATE 2026-06-20 — the position-desync HALF is FIXED via #145; the remaining half is narrowed + RE-SCOPED to entity render/lifecycle: #145 fixed the cell-rooting (the player was rooted into the source dungeon's frame on teleport-out → ACE rejected all movement → "avatar moves but position doesn't track"). With that fixed (server-authoritative placement + drop-stale-center + CurrCell clear), the outdoor TERRAIN now streams + renders and movement is accepted. What REMAINS, observed in the #145 verification run:
- (A) Server-spawned objects don't render after teleport-back. NOT a re-broadcast gap — the
ACDREAM_DUMP_LIVE_SPAWNStrace shows ACE DOES re-sendCreateObjectfor all Holtburg weenies on return (Doors, NPCs "Agent of the Arcanum"/"Wedding Planner", "Holtburg Meeting Hall Portal", chests, …) and acdream receives + processes them (OnLiveEntitySpawnedLocked→AppendLiveEntity). They just don't appear. So the bug is downstream: entity render/storage during the teleport streaming churn — candidates: the re-added live entities are dropped by a subsequentGpuWorldState.AddLandblockrecord-replace for the same landblock, OR the per-instance render data (WbEntitySpawnAdapter.OnCreate) isn't built/registered, OR a render-root/visibility gate whileCurrCellre-acquires. - (B) Own avatar stops rendering after a couple of round-trips. The player entity (persistent, rescued+re-injected via
GpuWorldState.DrainRescued→AppendLiveEntityatGameWindow~:7421) is lost/duplicated across repeated rescue/re-inject cycles. Cumulative (first trip OK, later trips vanish).
Both are the entity-lifecycle/render path across a teleport, NOT the streaming collapse/expand (which now works — terrain streams). Start here: instrument GpuWorldState (AppendLiveEntity / AddLandblock / RemoveLandblock / DrainRescued) to trace one guid (a Door + the player 0x5000000B) across an in→out cycle and find where it leaves the rendered set.
Files: src/AcDream.App/Streaming/GpuWorldState.cs (AddLandblock pending-merge vs record-replace, AppendLiveEntity, RemoveLandblock/rescue, DrainRescued, RelocateEntity), src/AcDream.App/Rendering/GameWindow.cs (OnLiveEntitySpawnedLocked ~:2696, rescued re-inject ~:7421), the per-instance render adapter (WbEntitySpawnAdapter). The streaming collapse/expand (StreamingController) is no longer the suspect.
UPDATE 2026-06-21 — root re-scoped again: it's RE-DELIVERY, not render-cull or cache. A deep dive (probes: [ent] append/remove + [ent-flat] rendered-set count + [dyn] DrawDynamicsLast cull) eliminated the 2026-06-20 hypotheses:
- NOT the Tier-1 cache. Re-created live entities get a fresh monotonic
Id = _liveEntityIdCounter++(GameWindow~:3251), soEntityClassificationCache(keyed onId) is ALWAYS a miss for them — never the cause. (Side-finding, separate minor bug: the cache has a demote-vs-unload invalidation asymmetry —RemoveEntitiesFromLandblockfires_onLandblockUnloaded(:~495) butRemoveLandblockdoes NOT, violating the documented "demote OR unload" intent. NOT the #138 cause; fix-with-verification later.) - Render path is FINE when entities are present. At login
[dyn] rootOutdoor=True dyn=54 drawn=33— the dynamics partition (InteriorEntityPartition:55, everyServerGuid!=0withMeshRefs>0→ dynamic) +DrawDynamicsLastdraw them. - The ACTUAL cause: re-delivery is unreliable / absent.
notan/+Jewalk-around run: after teleport-out,live:spawndoors = 0,[ent] +door appends = 0,[ent-flat] server=1(only the player),[dyn] dyn=1— the server delivered ZERO Holtburg objects on return; they never reach acdream. (An earliertestaccount2run got ~15 re-sent, but the user confirmed they stayed missing then too — so re-delivery is partial AND unreliable across accounts/sessions.) "Other clients see +Je" → the server has correct player state; acdream's LOCAL world is simply missing the objects. The 2026-06-20 "NOT a re-broadcast gap" claim above was WRONG — it IS (intermittently) a re-broadcast gap. - Mechanism: acdream UNLOADS the landblock's server-spawned objects on teleport-IN (the dungeon collapse unloads neighbours, including Holtburg), and on the way back NOTHING restores them; ACE does not reliably re-broadcast them (known-set/awareness desync, likely worsened by the rapid relaunch churn).
FIX SHIPPED 2026-06-21 — client-side re-hydrate from the retained spawn table (re-projection, not ACE re-send):
⚠️ Handoff correction: the 2026-06-21 handoff said to re-hydrate from ClientObjectTable ("keeps ALL objects with their positions"). That is WRONG — ClientObject (src/AcDream.Core/Items/ClientObject.cs) is the INVENTORY data model (container/slot/equip/icon/value/stack); it carries no world position, no cell id, no Setup/PhysicsDesc/mesh. It cannot build a render entity. The real retained world-object table is GameWindow._lastSpawnByGuid (Dictionary<uint, WorldSession.EntitySpawn>) — the parsed CreateObject records, carrying Position + Setup + MotionTable + AnimPartChanges + palette. It is pruned ONLY by a server DeleteObject or a spawn de-dup, so it survives the dungeon collapse (verified: the collapse path StreamingController.Tick → GpuWorldState.RemoveLandblock never calls RemoveLiveEntityByServerGuid, the only thing that touches _lastSpawnByGuid).
Cross-reference confirmation (this is retail-faithful, not a workaround): ACE (references/ACE/Source/ACE.Server/Physics/Common/ObjectMaint.cs) never clears a player's KnownObjects set on a normal teleport (teleport_visibility_fix is off by default + flagged non-retail), so it will NOT re-send objects it thinks we still have — it relies on the client retaining its table and doing its own 25 s/384 m visibility cull. holtburger (references/holtburger/.../handlers/player.rs) keeps its entire object table across a teleport (only suspends physics bodies) and re-projects its displayed world from that table; stale far objects self-evict after a 25 s timeout. So "client keeps the object table and re-renders from it" IS retail behavior. acdream's render entities are the projection; the collapse drops the projection for FPS (AP-36), and re-hydrate rebuilds it on reload.
Implementation (A — server objects): new StreamingController onLandblockLoaded callback fires after AddLandblock (Loaded path = dungeon-exit expand) and AddEntitiesToExistingLandblock (Promoted = Far→Near). GameWindow.RehydrateServerEntitiesForLandblock builds the set of server guids already present in GpuWorldState, snapshots _lastSpawnByGuid, and — via the pure LandblockEntityRehydrator.SelectGuidsToRehydrate (selects spawns in the loaded landblock that have a world mesh, are not the player, and are NOT already present) — replays OnLiveEntitySpawnedLocked for each missing guid under the dat lock. The replay's own RemoveLiveEntityByServerGuid de-dup scrubs the state the collapse orphaned (the entity lingers in _entitiesByServerGuid after RemoveLandblock even though its render entity is gone — so the present-gate keys on GpuWorldState, not that map). Idempotent + no double-build on initial login (entities already present → skipped). Commit: LandblockEntityRehydrator.cs + StreamingController.cs + GameWindow.cs; tests LandblockEntityRehydratorTests (7). Register row AP-48 (no 25 s cull → a re-hydrate may restore an object the server silently dropped — port holtburger's cull to close).
Implementation (B — player vanish, candidate): GpuWorldState.RemoveLandblock rescued persistent entities only from _loaded, silently dropping a persistent entity sitting in the pending bucket (the player is re-injected via AppendLiveEntity every frame; right after a teleport its landblock hasn't streamed yet → pending; if that landblock is then unloaded mid-churn the player was dropped → "vanishes after a couple round-trips"). Fix: rescue persistent pending entries too. Tests RemoveLandblock_RescuesPersistentEntity_FromPendingBucket (+ negative). This is a provable correctness fix for the "persistent ⇒ survives unload" invariant; it is the leading candidate for symptom B but needs user re-verification to confirm it is the complete cause.
UPDATE 2026-06-24 — symptom B (avatar vanish) ACTUAL root found + FIXED (afd5f2a, user-confirmed + trace-verified): the pending-bucket rescue above was necessary but NOT the whole cause. The real culprit: the per-frame avatar-sync (GameWindow ~:8018) calls GpuWorldState.RelocateEntity using the player controller's cell, which stays the FROZEN SOURCE cell until PlaceTeleportArrival materializes the destination. Mid-transit it dragged the avatar — which the teleport's DrainRescued re-inject had correctly placed at the destination center — back into the now-UNLOADED source landblock's pending bucket, where nothing recovers it (RelocateEntity only scans _loaded). Fix: skip the per-frame relocate while _playerController.State == PortalSpace. Verified by a new env-gated avatar-lifecycle probe (ACDREAM_PROBE_ENT / EntityVanishProbe): pre-fix [ent] APPEND lb=0x0007FFFF(source) -> PENDING -> DRAWSET ABSENT never recovered; post-fix every teleport goes RESCUE -> ABSENT -> APPEND(destination) -> PRESENT and stays drawn. The remaining #138 collision residual split into #152 (Holtburg/building portal-in walls, DONE 49d743f) + #151 (Arwic far-town frame, FIXED 9743537). (#146/#147 in this file are unrelated D.2b inventory-polish issues.)
Acceptance: portal out of the 0x0007 dungeon → full outdoor world streams (trees/scenery present), server objects (doors/NPCs/portals) render, own avatar renders across repeated round-trips, collision works, position tracks (no avatar-vs-camera desync).
2026-07-10 gate — PASSED, #138 CLOSED: the user confirmed the full dungeon round-trip end-to-end — ENTER + NAVIGATE (dungeon streams, renders, collides, navigable, doors work) AND the EXIT (portal OUT to the outdoor world, world streams, collision holds, position tracks). This completes the #138 acceptance and lands M1.5. Separate follow-up still open: #153 (the far-town teleport-OUT arrival cascade) is a narrow far-teleport streaming edge case tracked on its own; it is NOT a #138 or M1.5 blocker and stays open for capture-harness-first work if it recurs.
#137 — [DONE 2026-07-08] Dungeon collision incorrect at doors and wall openings
Status: CLOSED 2026-07-08 — user re-gate: "door collision is fixed" (clicked/passed through multiple dungeon door types, no phantom blocks, no fall-through). Combined with the 2026-07-06 corridor + window/opening gates already passed, all #137 COLLISION scope is done. (The doors-open-but-don't-ANIMATE gap the same check surfaced is a SEPARATE concern — filed as #187, since it's a visual/animation-dispatch issue, not collision.) Severity: MEDIUM (movement/collision correctness in dungeons) Filed: 2026-06-14 Component: physics — EnvCell collision (doors, portal openings, cell geometry)
Description (user): collision is still wrong in dungeons — doors and openings in walls in particular. (Symptoms not fully characterized yet: likely walking through openings that should block / blocking at openings that should pass, and door collision not matching the door's open/closed state.)
✅ CORRIDOR GATE PASSED 2026-07-06 evening (user: "not collision anymore. Good.") — the corridor phantom arc (mechanisms 1–3) is user-verified FIXED. REMAINING #137 scope from the same gate session:
- Window/opening climb FIXED + GATE PASSED 2026-07-06 (user: "Looks
good", incl. the taller-capsule regression sweep — doorways/seams/stairs
clean): the player's collision capsule TOPPED OUT AT 1.2 m. The callers passed
sphereHeight: 1.2fandInitPathplaces the head sphere center atheight − radius= 0.72 — the top 0.63 m of a 1.83 m character had NO collision. The dat human Setup 0x02000001 (dumped inHumanSetup_CollisionSpheres_DatTruth): spheres(0,0,0.475) r=0.48+(0,0,1.350) r=0.48(top 1.83 = Setup.Height 1.835); retail collides with that list verbatim (CPhysicsObj::transition0x00512dc0 →init_sphere(GetNumSphere, GetSphere, scale)). At the corridor-end window alcove (0x8A020179 → 0x8A02017E: sill face 0.70 m, opening 1.3 m tall, sloped funnel behind — full-vertex dump inWindowShaft_FullPolyDump), the missing head let the step-up's placement pass and the player climbed in head-through-lintel. Fix: both live callers now pass 1.835 (capsule top; head center 1.355 ≈ dat 1.350); register TS-46 documents the residual 5 mm scalar approximation. Pins:WindowOpening_HeadCannotFit_EntryBlocked(walked approach wall-slides and never enters 0x8A02017E) +WindowAlcove_RaisedPlacement_HeadInLintelSolid_Collides(the raised placement rejects on the head-vs-lintel). Captured-input replay fixtures keep their recorded 1.2 inputs — InitPath unchanged. - Doors half (block/pass per open state) — unchanged. Two RENDER issues also observed at the gate (filed separately below as #176/#177): the purple floor flashing at seams is angle/camera-dependent (the floor IS a portal polygon to the under-room — likely the portal surface drawn under some culling state), and a stairs pop-in/out between levels (the #119 visibility class, dungeon edition).
SEAM SHAKE FIXED (same day): the stale footCenter in CheckOtherCells'
per-cell loop — the P2 cellar-lip lesson one loop deeper. A mid-loop
other-cell query can MOVE the sphere (the boundary full-hit dispatches
step_sphere_up; the successful climb lifts the foot +0.6 mm and returns
OK), and the remaining cells were then queried with the by-value
pre-climb center — 0.4 mm inside the floor slab, grazing the under-room's
ceiling and firing the chain below. Retail's check_other_cells reads the
LIVE sphere_path.global_sphere per cell (pc:272717+). Fix: re-read
footCenter = sp.GlobalSphere[0].Origin per iteration. All three
Issue137CorridorSeamReplayTests repros un-skipped and GREEN; full suites
green. Visual gate pending. (The step 3 "restore clobbers CheckPos" wording
below was the right CLASS but the wrong site — CheckPos was fine; the
stale copy was the loop's captured parameter.)
GATE 2026-07-06 FAILED — THIRD MECHANISM CHARACTERIZED (the seam shake),
deterministic offline repro secured: with mechanisms 1+2 fixed the dead
stop became a SHAKE at cell seams (+ purple floor flashing there — almost
certainly the render exposing the same per-frame position/OnWalkable
oscillation; re-check after the physics fix). Full chain, every link
probe-traced (launch-137-seam-probes.log, capture
resolve-137-seam-capture.jsonl tick 4101 ×46):
- Corridor cells sit above under-rooms; the shared floor slab is double-faced (up-face + underside as separate physics polys) and IS a portal plane (e.g. 0x8A020165's ramp over 0x8A020166). The resting foot sphere is permanently within ±0.5 mm of THREE thresholds there (poly-hit r−ε, walkable r−ε, portal-straddle r+ε).
- Walking across the boundary at the flat-floor height penetrates the
ramp slab by ~0.4 mm → foot full-hit on the up-face → StepSphereUp →
step-down accepts the ramp (+0.6 mm lift, CheckPos −5.999,
[stepsphereup] stepped=True). - THE BUG: the lifted position is then LOST — the next pass runs at
the UNLIFTED height (GlobalSphere center −5.520 vs the lifted −5.519;
the P2 stale-snapshot class, single-slot Save/RestoreCheckPos clobber
suspected — retail
CTransition::step_up0x0050b6cc restores ONLY on failure) → the re-test at 0.4 mm inside the slab grazes the NEIGHBOR under-room's CEILING (the slab underside, n≈(−0.03,0,−1)) within the near-miss window → recorded (retail records it too — pos_hits_sphere registers geometric hits pre-cull) → neg-poly step-up dispatch with the DOWNWARD normal → the nested step-down finds no walkable at exact tangency → StepUpSlide → slide_sphere(down normal vs up contact plane) → the opposing branch → reversed-movement collision normal → Collided → validate revert (Contact/OnWalkable stripped) → next step's AdjustOffset zeroes → out==in every frame = the shake. Retail never enters at step 3: its kept step-up lift leaves the sphere ON the surface, no graze. - Offline repro:
Issue137CorridorSeamReplayTests(3 tests, currentlySkip="#137 seam shake") reproduce the block deterministically — the key was hydrating THREE portal rings (the under-room 0x8A020166 is ring-3; with fewer rings the flood can't add it and everything passes). NEXT: read ourTransitionalInsertattempt loop against retail 0x0050b6f0 to find the restore that clobbers the successful step-up's position; fix; un-skip the three tests.
CORRIDOR PHANTOM mechanisms 1+2 FIXED 2026-07-06 (see
docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md
for the full audit): mechanism 2 = BSPQuery Contact-branch stub slide
responses leaked sliding normals retail's BSP layer never writes (fixed:
real slide_sphere routing + success-gated body writeback). Mechanism 1 as
theorized is REFUTED: the recorded wall normal (−1.00,0.03,−0.03) matches
NO dat polygon (world-space sweep of both seam cells + all portal-adjacent
neighbors) — it is the SYNTHETIC negated movement direction from
slide_sphere's opposing-normals branch, which our port let survive by
returning OK where retail returns COLLIDED_TS (0x0053762c; second fix). The
PortalSide polys to 0x011E were a red herring: cell 0x8A02016E has IDENTITY
rotation, the polys are ±Y planes perpendicular to the run (directionally
culled), retail's physics-BSP leaves reference them too, and the dat's
keep-PortalSide/strip-ExactMatch asymmetry reads as intentional (solid
window/grate-class portals) — NO portal-poly filter needed, no cdb session
needed for this repro. Dat-backed replay
(Issue137CorridorSeamReplayTests) reproduces the live frame exactly and
runs the corridor clean. The issue's DOOR half remains open.
CHARACTERIZED 2026-07-05 (Facility Hub corridor repro, probe + dat evidence) — two stacked mechanisms (historical; see the 2026-07-06 resolution above):
-
PortalSide portal polygons are IN the physics polygon set and we treat them as solid. Live: running the corridor, the seam crossing
0x8A02016E → 0x8A02017A(x≈85.25) records a wall hit with normal (−1,0,0) — straight against the movement (launch-175-verify2.log:42858). Dat (Issue137CorridorSeamInspectionTests): cell 0x8A02016E's portals to 0x011E (polys 1/3/5, flags=PortalSide, no ExactMatch) are PRESENT inCellStruct.PhysicsPolygons— every ExactMatch portal in the same cell is absent from the physics set. The cell's rotation maps those local ±Y portal planes to world ±X — the phantom mid-corridor wall. Retail must honor the portal's SIDE (pass from one side / solid from the other, or pass when the neighbor is loaded); we collide with the raw polygon unconditionally. Oracle findings so far (2026-07-05 evening — greps done, question OPEN):CCellStruct::UnPack(0x00533d00) loads physics_polygons + physics_bsp verbatim — NO portal-poly stripping at load;CPolygon::pos_hits_sphere/hits_sphere/polygon_hits_sphere_slow_but_sure(0x005394f0/0x00539540/0x00538a10) are pure geometry — no portal check;CCellPortal(0x0053bab0) carries portal→CPolygon ptr + portal_side + exact_match but nothing in the BSP test chain consults it. So retail's passability for a PortalSide physics poly is NOT a load filter and NOT a poly-level flag — remaining candidates: the transit/membership order makes the sphere test the NEIGHBOR cell first (never hitting the portal poly from the passable side), or a sidedness interaction (stippling=NoPos + approach direction). NEXT: cdb-attach retail at this exact corridor (0x8A02016E→011E portals) per the CLAUDE.md step −1 protocol — the decomp alone hasn't settled it. -
The stale sliding normal then wedges all forward motion (the #116 slide-response family): after the single seam hit, EVERY subsequent forward resolve returns
ok=False hit=nowith zero advance — the body-persisted SlidingNormal (−1,0,0) projects the +X offset to exactly zero in AdjustOffset, aborting at step 0 BEFORE any collision test could update the state — an ABSORBING wedge escaped only by strafing ("push through on the side"). Retail re-derives slide state per frame (get_object_info pc:279992 governs only the NEXT frame — #116 notes); audit who clears the body's sliding normal when no contact recurs.MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in
docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md): retail's ONLY in-transition sliding-normal writer isvalidate_transition(0x0050ac21); the BSP/sphere layer never writes it, and the body persistence (SetPositionInternal0x005154c2/0x005154e1) is success-only. Our BSPQuery Contact-branch full-hit responses were STUBS (SetSlidingNormal + return Slid) where retail dispatches the realslide_sphere— the seam hit (a SUCCESSFUL full-advance resolve,ok=Truein the log, not a failed one) leaked the phantom wall's normal into the body, and the seed absorbed every later forward push. Fix: both stub sites now route through the realTransition.SlideSphereInternal(CSphere::slide_sphere0x00537440, in-frame, no sliding write) and the body writeback is gated on transition success. Pins:Issue137SlidingNormalLifecycleTests(2 site pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended (steep-tangent sites still write the normal — documented), TS-45 added (SphereCollision's write — same class, out of blast radius). The absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful (the persisted normal is a "still pressed" cache); only the phantom PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session.
Files: src/AcDream.Core/Physics/ (EnvCell collision, CellTransit, the door apparatus),
src/AcDream.Core/Physics/ShadowObjectRegistry.cs (per-cell registration). See
claude-memory/project_physics_collision_digest.md (the collision SSOT + DO-NOT-RETRY table).
Acceptance: doors block/pass per their open/closed state; wall openings pass; solid walls block — matching retail, in the 0x0007 dungeon.
#136 — DONE — "red cone" in the 0x0007 dungeon was an editor-only placement marker acdream drew (retail hides it)
Status: FIXED 6f81e2c (2026-06-14) — verified live via frame dump: the red cone +
green floor "petals" are gone, all real dungeon decorations still render. User-approved
frozen-phase fix.
Severity: LOW (cosmetic; one marker in one dungeon)
Filed/Fixed: 2026-06-14
Component: rendering — EnvCell static-object hydration (WB-derived path) vs retail degrade
Description: In the 0x0007 Town Network dungeon a bright-RED downward cone (+ a
green/red shape on the floor) rendered ~6 m from the login spawn; the user's side-by-side
retail client showed NOTHING there. Became visible only after the #135 login-into-dungeon
fix placed the player at the exact saved spawn next to it.
Root cause (definitive): the cone is ONE dat-hydrated EnvCell static object (guid=0,
id=0x40000835, Setup 0x02000C39 / GfxObj 0x010028CA) baked into cell 0x00070145,
using pure red+green MARKER surfaces (0x08000109 red, 0x0800010A green). It is an
editor-only placement marker: its DIDDegrade table 0x11000118 =
{slot0 Id=mesh MaxDist=0, slot1 Id=0 MaxDist=FLT_MAX} — visible ONLY at distance 0 (the
WorldBuilder editor origin) and degraded to GfxObj id 0 (= nothing) at any real distance.
Retail's distance-based degrade (CPhysicsPart::UpdateViewerDistance 0x0050E030 → Draw
0x0050D7A0 draws gfxobj[deg_level]) therefore never draws it in the live client. acdream's
render path is extracted from WorldBuilder, which — being an editor — renders every cell
static's base mesh directly and has no degrade handling at all (zero DIDDegrade refs in
references/WorldBuilder), so acdream inherited "show the marker" and drew it forever. (NOT
a texture/lighting bug — the cone's own object 0x70007055 decodes tan and was a red
herring; the marker is a separate guid=0 dat static.)
Fix (6f81e2c): GfxObjDegradeResolver.IsRuntimeHiddenMarker() detects the editor-marker
pattern (HasDIDDegrade + Degrades[0].MaxDist==0 + a degrade entry with Id==0). EnvCell
static-object hydration (GameWindow.cs ~5793) skips such GfxObjs — whole-stab for bare
GfxObj stabs, per-part for Setup stabs (an all-marker Setup then drops via meshRefs.Count==0).
Faithful equivalent of retail's runtime degrade for static geometry (always viewed at
distance > 0); real LOD objects (slot0.MaxDist>0) and degrade-to-real-mesh objects are
untouched. 4 new GfxObjDegradeResolver unit tests.
Follow-up (not done): outdoor LandBlockInfo.Objects stabs could carry the same markers;
apply IsRuntimeHiddenMarker there too if any surface. Also revealed (separate): the per-
pixel point-light shader overblows close torches (no per-channel min(scale·color,color) cap
vs retail calc_point_light) — the bright-red dungeon WALL under normal lighting; tracked
under the #79/#93 A7 lighting umbrella.
#135 — ~30 s low-FPS ramp at login (≈10 fps → high) before streaming settles
Status: DONE 712f17f+2c92375 (2026-06-14) — user-verified: login into the 0x0007 dungeon is FPS-steady from the start; dungeon loads + places the player. (NOTE: the teleport-OUT path has a separate streaming gap — see #138.)
Severity: LOW (startup-only; self-corrects)
Filed: 2026-06-14
Component: streaming — first-frame bootstrap vs the dungeon collapse
FIX (2026-06-14): pre-collapse streaming the instant we recenter onto a SEALED
dungeon cell at login/teleport, before the first NormalTick bootstraps the window.
StreamingController.PreCollapseToDungeon(cx,cy)— fires the existingEnterDungeonCollapseearly (idempotent), so the expensive ocean-grid neighbour window is never enqueued (teleport) / is enqueued-then-immediately-cleared for a cheap Holtburg frame (login).GameWindow.IsSealedDungeonCell(cellId)— reads theEnvCelldatSeenOutsideflag (the same flag the hydratedObjCell.SeenOutside+ the per-frame gate use) so a cottage/inn interior keeps its outdoor surround; excludes the 0xFFFE/0xFFFF shell ids.- Hooks in
OnLiveEntitySpawnedLocked(login) +OnLivePositionUpdated(teleport). - Observer robustness: during a teleport
PortalSpacehold the observer follows the recentered destination (not the frozen position);_lastLivePlayerLandblockIdis now filtered to the player guid (resolving a Phase A.1 TODO) so a stray NPC update can't drift the login-hold observer off the dungeon and tripExitDungeonExpand. Adversarially reviewed (3 lenses); register row AP-36 amended. Tests inStreamingControllerDungeonGateTests(5 new, incl. the real Tick-then-PreCollapse ordering).
Description: On login into a dungeon, FPS starts ~10 and climbs over ~30 s before settling (then 1000+ fps). User: "we still have about 30ish seconds before FPS is ramped up; when logging in I get like 10 then it slowly increases."
Root cause / status: The #133 streaming collapse (5686050/d9e7dd6/7d8da99) only
engages once CurrCell resolves to a sealed cell (the snap, a few s in). Before that the
first Tick bootstraps the full 25×25 window, so ~24 neighbour ocean-grid dungeons (+ their
~19k entities) load, then unload when the collapse fires. The collapse-at-snap change moved
the trigger from finalize-time (~30 s) toward snap-time but the bootstrap churn remains.
Clean fix = pre-collapse at login when the spawn cell is a sealed dungeon cell so the full
window never enqueues (touches the sensitive login spawn path — do carefully; no band-aid).
Files: GameWindow.cs:6885 (streaming Tick gate); StreamingController.cs (collapse);
login recenter OnLiveEntitySpawnedLocked ~2470.
Acceptance: Login into a dungeon reaches steady-state FPS within ~1–2 s (no full-window neighbour load/unload churn).
#104 — Scene VFX particles not clipped to the PView visible cell set
Status: OPEN Severity: LOW Filed: 2026-06-02 Component: render, vfx
Description: Scene-pass VFX particles (spell effects, smoke) are drawn from their world-space
position only; they are not gated by the PView visible cell set, so a particle emitter in a
sealed (non-visible) cell can bleed past a wall edge. In practice this is mostly masked: scene
particles ARE depth-tested (walls occlude most of their geometry), the dominant indoor entity
bleed is already gated by the Phase W Stage 5 entity gate
(WbDrawDispatcher.EntityPassesVisibleCellGate), and Stage 4 already scissors the SKY particle
passes to the doorway. The residual is the occasional additive particle visible past a wall edge.
Root cause / status: Particles carry no cell id. ParticleEmitter (Vfx/VfxModel.cs) has
AnchorPos + AttachedObjectId but no owning-cell id; Particle has a world Position only. A
clean fix adds an OwnerCellId to ParticleEmitter (set at spawn from the owning entity's
ParentCellId), threads a HashSet<uint>? visibleCellIds into ParticleRenderer.BuildDrawList,
and skips emitters whose OwnerCellId ∉ the visible set. That touches IParticleSystem.SpawnEmitter,
ParticleSystem, ParticleHookSink, and the SpawnEmitter call sites (~6–8 files) — a plumbing
pass, deliberately deferred out of the Phase W seal (which covers sky/terrain/walls/entities).
Files: src/AcDream.App/Rendering/ParticleRenderer.cs (BuildDrawList), src/AcDream.Core/Vfx/
(ParticleSystem, VfxModel), src/AcDream.App/Rendering/Vfx/ParticleHookSink.cs.
Acceptance: A scene-particle emitter in a non-visible cell does not draw; outdoor particles
(null visibleCellIds) unaffected; no regression on fireplace/spell VFX in the visible cell.
2026-07-09 triage: investigated, verdict STILL_OPEN — ParticleEmitter still has no OwnerCellId field and ParticleRenderer.BuildDrawList still has no visibleCellIds parameter; unattached scene particles (campfires, portal swirls) are drawn unconditionally every frame with only a depth test for occlusion (GameWindow.DrawUnattachedSceneParticles), reproducing the described wall-bleed for that class of emitter.
Active issues
#102 — A8.F PortalVisibilityBuilder — port retail update_count fixpoint (replace MaxReprocessPerCell cap)
Status: PARTIALLY RESOLVED (Phase U.2a, 2026-05-30, commit d880775)
Severity: MEDIUM → LOW (residual is diamond-topology clip-completeness only)
Filed: 2026-05-29
Component: rendering, visibility, EnvCell portal traversal
U.2a resolution (2026-05-30): Reading the decomp showed retail does NOT
re-enqueue on view-growth: AddViewToPortals (433446) enqueues a cell via
InsCellTodoList ONLY in the first-discovery branch (ecx_5 == 0); later
growth goes through AddToCell (433050) in place and never re-enqueues. U.2a
replaced the MaxReprocessPerCell cap with an enqueue-once gate (a seen
set = retail cell_view_done, 433784) + a distance-priority work list (retail
InsCellTodoList). This closes I-1 and I-2: the clip-region union into a
neighbour now runs UNCONDITIONALLY before the enqueue gate, so >4-portal cells
no longer under-count (I-1 gone), and each cell processes its exit portals
exactly once, so cyclic graphs no longer accumulate duplicate polygons (I-2
gone). The new Build_CyclicHub_TerminatesAndBounds test enforces the
acceptance (4-room ring ⇒ ≤5 cells, no dups). Residual scope: retail's
AddToCell ONWARD re-propagation of late growth (a cell reached via a longer
path AFTER it was drawn gets its own CellView unioned but does not
re-propagate that growth to ITS children) is NOT ported — this affects only
clip-region completeness on diamond topologies, never the visible cell set
or draw order. Track under U.6 (dungeon-scale validation). (The M-4
OtherPortalClip stub noted below is now CLOSED by Phase U.2b — a separate
concern from this onward-re-propagation gap.) A naive count-watermark
re-enqueue is NOT a valid fix (it never terminates, because CellView.Add
appends without merging) — the faithful fix is the in-place slice
re-propagation.
Description: A8.F Task 4 shipped a bounded-BFS port of retail's
PView::ConstructView → ClipPortals → AddViewToPortals in
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs.
Code review found NO correctness bugs (the cellar-flap fix works and the
BFS terminates), but two scaling issues that bite only on CYCLIC /
high-fan-in portal graphs (dungeons, network hubs), NOT on the cottage
cellar (a 2-3 cell chain) which is the current M1.5 goal:
- I-1 — the cap is load-bearing, not a safety net.
MaxReprocessPerCell = 4is the actual termination mechanism for cyclic graphs. Theif (nview.Polygons.Count > before)re-enqueue-on-growth guard is a near-no-op becauseCellView.Add(PortalView.cs) appends unconditionally and never dedupes, so a cell almost always "grows" and is re-enqueued — convergence relies entirely on the count hitting 4. A cell reachable through >4 contributing portals under-counts (drops legitimately-visible contributions). - I-2 — duplicate polygons accumulate on cyclic/multi-path graphs.
Measured on a synthetic 4-room ring: 34
OutsideViewpolygons and 216-polyCellViews where retail converges to a small fixed set. Correctness survives (overlapping stencil marks are idempotent) but it's per-frame cost feeding the stencil pipeline.
Root cause / status: We approximate retail's monotone-fixpoint
convergence with a fixed re-process cap. Retail instead converges via an
update_count / set_view(...,i) slice watermark — each cell records a
timestamp/watermark of how much of its view has been propagated, so a
re-visit only re-propagates the new slice and the graph reaches a true
fixpoint with no duplicate accumulation and no arbitrary cap.
Retail anchors (docs/research/named-retail/acclient_2013_pseudo_c.txt):
AddToCell433050 —esi[0x11]update-count/slice watermark on the cellInitCell— per-cell timestamp initAddViewToPortals433446 — change-detection that drives the fixpoint
Related M-4 stub — CLOSED (Phase U.2b, 2026-05-30; reciprocal-resolution
fix 2026-05-30): the neighbour-side OtherPortalClip (decomp:433524) is
ported. After a portal's near-side opening is clipped against the current
cell's view, PortalVisibilityBuilder.ApplyReciprocalClip resolves the
neighbour's matching back-portal by direct index via the dat's
CellPortal.OtherPortalId back-link (retail arg2->other_portal_id,
005a54b2), projects it through the neighbour's WorldTransform, and
intersects it into the propagated region before the union — so a cell's
clip region is the intersection of the opening seen from BOTH sides. The
reciprocal is neighbour.PortalPolygons[portal.OtherPortalId], NOT a scan
for the first OtherCellId match. The direct index is load-bearing: a cell
with TWO portals to the same neighbour (real on the Holtburg cellar —
0x148 has two portals to 0x149, polys 40/41, and 0x149 has two
reciprocals back to 0x148) clips each opening against its OWN reciprocal.
The earlier scan-by-first-match resolved both near-side openings to the
FIRST reciprocal, and disjoint apertures then intersected to empty —
HIDING the geometry through the second opening (under-inclusion). The fix
plumbs OtherPortalId through CellPortalInfo + BuildLoadedCell. Guards
degrade to over-include (never clip against a guessed polygon) when the
index is out of range, the polygon is missing/degenerate, or it projects
behind the camera. Can only TIGHTEN. Covered by
PortalVisibilityBuilderTests.Build_AppliesReciprocalOtherPortalClip
(reciprocal tightening) + …_DegradesGracefully_WhenNoBackPortal
(over-include degrade) + …_MultiplePortalsToSameNeighbour_EachResolvesOwnReciprocal
(the disjoint two-back-portal regression). (The diamond-topology onward
re-propagation of late growth remains out of scope here — tracked under
U.6.)
Files:
src/AcDream.App/Rendering/PortalVisibilityBuilder.cs— replace theMaxReprocessPerCellcap + re-enqueue-on-growth guard with a per-cell slice watermark; honest-limitation comment lives at theMaxReprocessPerCelldeclaration.src/AcDream.App/Rendering/PortalView.cs—CellView.Addcurrently never dedupes; the fixpoint port either dedupes here or tracks a propagated-slice index per cell.
Acceptance: On a cyclic/hub portal graph (synthetic 4-room ring +
the Town Network dungeon hub), OutsideView / CellView polygon counts
converge to a small fixed set (no duplicate accumulation), every cell
reachable through any number of contributing portals is included, and
the BFS still terminates. Existing cottage-cellar tests stay green.
MUST land before A8.F is relied on for dungeons (dungeons are
currently blocked on #95 regardless).
#84 — [DONE 2026-05-19] Blocked by air indoors
Status: DONE Closed: 2026-05-19 Severity: HIGH (blocks indoor navigation) Filed: 2026-05-19 Component: physics, collision
Description: While walking inside buildings, the player sometimes collides with invisible obstacles in mid-floor where there's nothing visible.
Root cause / status: Cell BSP geometry doesn't align with the visible cell mesh. Possibilities:
- The
cellTransformapplied to physics in_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)atGameWindow.cs:5384includes the+0.02fZ bump, but the BSP geometry may not be lifted with it — physics geometry sits 2cm BELOW render geometry, so invisible "ceilings" at floor-level cause blockage. - CellStruct BSP contains polygons that the cell mesh doesn't include (or vice versa) — the two are derived from different fields.
Files:
src/AcDream.App/Rendering/GameWindow.cs:5362-5384(cellOrigin Z bump- physics cache call).
Acceptance: Walking through interior cell space hits collisions only where visible walls/furniture exist.
Resolution (2026-05-19 partial · c19d6fb): Phase D of Cluster A
extended ResolveOutdoorCellId in PhysicsEngine.cs with an indoor
cell-containment scan: when the player's world position falls inside any
cached EnvCell's AABB, CellId is promoted to that indoor cell, which
enables the FindEnvCollisions indoor-BSP branch. This resolved the
"spawn in building and be stuck above the floor" variant of #84 —
player's CellId now promotes to the interior cell on spawn-in, the floor
is walkable, and the player can move freely. The "invisible air obstacle"
symptom for rooms the player walks INTO from outside was tracked under #87
and required portal-based cell tracking.
Resolution (2026-05-19 full · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772):
Indoor walking Phase 2 replaced AABB containment with portal-graph cell traversal
(CellTransit.FindCellList + CheckBuildingTransit). CellId now promotes to indoor
cells via portals and remains promoted during normal walking through doorways. Indoor
cell-BSP collision fires consistently. Indoor walkable plane synthesized from floor
poly (TryFindIndoorWalkablePlane) so the resolver tracks walkability correctly when
the player is standing on an indoor floor. User visually verified at Holtburg cottage:
walls block from inside, multi-room navigation works, walking outdoors through a door
works. Issue fully closed.
#85 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outside→in
Status: DONE
Closed: 2026-05-19
Commits: 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772
Filed: 2026-05-19
Component: physics, collision
Resolution (2026-05-19 · Indoor walking Phase 2): The root cause (CellId never promoted
to the indoor cell during outdoor→indoor walking) was resolved by portal-graph cell
traversal in CellTransit.CheckBuildingTransit. Once CellId promotes to the indoor
cell, the indoor-BSP collision branch in FindEnvCollisions fires for approaches from
both inside and outside. User visually verified walls block from outside (player must
use the door portal to enter). See #87 and handoff:
docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md.
Original description: Approaching a building from the outside, the player can walk THROUGH walls into the interior — one-directional wall collision. From the inside trying to exit, the wall does block.
The root cause was pinned (Cluster A 2026-05-19) as the same failure as
#84's remaining symptom — CellId wasn't promoted to the indoor cell
during normal outdoor→indoor walking because AABB containment was too
tight for threshold/doorway cells. Without CellId in the indoor cell,
the indoor-BSP collision branch in FindEnvCollisions never fired
regardless of approach direction.
#87 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Indoor cell tracking uses AABB containment instead of portal traversal
Status: DONE
Closed: 2026-05-19
Commits: 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772
Filed: 2026-05-19
Component: physics
Resolution (2026-05-19 · Indoor walking Phase 2): Portal-graph cell traversal
(CellTransit.FindCellList + CheckBuildingTransit) replaced the AABB containment
shortcut. Player CellId now correctly promotes to indoor cells via portals;
indoor cell-BSP collision branch fires consistently; walls block from inside.
Outdoor→indoor entry via BuildingPhysics + BldPortalInfo (CheckBuildingTransit)
wires the building-shell portal graph. Indoor walkable plane synthesized from the
cell's floor poly so the resolver tracks walkability during indoor movement (TryFindIndoorWalkablePlane).
See handoff: docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md.
Original description: PhysicsDataCache.TryFindContainingCell promotes the
player's CellId to an indoor EnvCell when their world position falls
inside any cached cell's local AABB. This is too tight to keep CellId
promoted to an indoor cell during normal walking. Threshold/doorway cells
(the polys that sit at a room boundary) have AABB Z ranges of only ~0.2 m;
a standing player at local Z=0.46 m is OUTSIDE the AABB and containment
fails. Because CellId drifts back to the outdoor cell, the indoor-BSP
collision branch in TransitionTypes.FindEnvCollisions is gated out for
most movement, so walls don't block from inside the house and the floor
physics is unreliable. The retail fix is portal-based cell traversal —
when the player crosses a cell portal boundary, the cell ownership
propagates through portal connectivity data in CEnvCell.
#90 — Cell-id ping-pong at indoor doorway threshold — [DONE 2026-06-11 · ca4b482, T6/BR-7]
Status: DONE — the 4ca3596 sphere-overlap stickiness workaround was
REMOVED in T6/BR-7: it had become dead code (its only caller path was the
cache-null test fallback in ResolveCellId), and the retail mechanism that
owns doorway hysteresis is the ordered-pick (current cell at CELLARRAY
index 0, interior-wins-break — BuildCellSetAndPickContaining, the
collide-then-pick advance), which production has used since the membership
rewrite. The ping-pong's original harm (outdoor ticks bypassing indoor BSP)
is structurally gone under the per-cell query: both classifications collide
the same per-cell lists at the threshold.
Severity: HIGH (workaround unblocks indoor visibility for M1.5 baseline; M1.5 acceptance requires the proper fix)
Filed: 2026-05-20
Component: physics — cell tracking
Description: Walking into the Holtburg inn through its doorway causes the player's CellId to ping-pong between outdoor cell 0xA9B40022 and indoor vestibule cell 0xA9B40164 every few ticks. Indoor BSP DOES detect walls (Collided/Adjusted/Slid all fire on push-back), but the push-back exits the indoor CellBSP's bounding volume → PhysicsEngine.ResolveCellId reclassifies the player as outdoor → next tick bypasses indoor BSP entirely → player advances freely → re-enters → repeats. Net aggregate behaviour: walls APPEAR to walk through even though indoor wall hits ARE firing on the indoor frames.
Root cause / status: Cell-id stickiness missing. When the indoor BSP pushes the foot-sphere back during wall collision, the resulting world position lies just outside the indoor cell's CellBSP volume (the BSP's volume is tightly bounded to the room's interior). The cell resolver then re-evaluates and prefers the outdoor cell. Retail likely has hysteresis or a "keep previous cell unless clearly outside" rule.
Files:
src/AcDream.Core/Physics/PhysicsEngine.cs:259-329—ResolveCellIdoutdoor-then-indoor branch logicsrc/AcDream.Core/Physics/CellTransit.cs:235-325—FindCellList/BuildCellSetAndPickContainingcontainment testsrc/AcDream.Core/Physics/BSPQuery.cs:950-963—PointInsideCellBsp(radius-less)src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit(line ~162) — outdoor→indoor entry test
Research: docs/research/2026-05-20-phase-a4-shipped-cell-pingpong-finding.md — full ping-pong analysis with launch-revert2.log evidence (61 indoor-bsp queries firing, 11 inside=True building-transit events, 18 cell-id flips between 0xA9B40022 ↔ 0xA9B40164).
Retail oracle for cell-id hysteresis: acclient_2013_pseudo_c.txt:308742-308783 (CObjCell::find_cell_list Position-variant). Not yet decompiled in detail. Bug-A cousin (see docs/research/2026-05-20-indoor-walking-bug-a-handoff.md) — different symptom (free-fall vs walk-through), same family (doorway-edge geometry mismatch).
Acceptance: Walking into the Holtburg inn, the player's CellId promotes to 0xA9B40164 and STAYS there while the user is spatially inside the inn (not flipping back to outdoor on each wall push-back). Walls visibly block. Indoor BSP results dominate the per-tick collision evaluation while user is inside the inn. A4's [other-cells] probe starts firing for indoor cells adjacent to the primary.
#94 — Held items project spotlight on walls
Status: OPEN — UNBLOCKED 2026-07-11. Retail ParentEvent/CreateObject child parenting now renders hand-held objects and follows the animated attachment part. The original lighting symptom can now be reproduced and investigated after the held-item visual gate; no lighting conclusion has been drawn yet. Severity: MEDIUM (visual fidelity; doesn't block gameplay) Filed: 2026-05-20 Component: lighting, rendering
Description: Items the player is holding (torches, light-source items) project a spotlight effect onto nearby walls. The spotlight direction is wrong — should be omnidirectional from the item, but appears to project specifically toward wall surfaces.
Root cause / status: Unknown. The original per-entity light-direction theory is only a hypothesis and must be re-tested now that held-item parenting exists. Do not treat it as established root cause.
Files:
src/AcDream.App/Rendering/Vfx/LightingHookSink.cs(suspected — verify during A7.L1)src/AcDream.App/Rendering/Shaders/mesh_modern.frag(point-light eval branch)
Acceptance: Held-item lighting illuminates nearby surfaces uniformly without directional cone artifacts. Matches retail's behavior at the same item in same scene.
#95 — Dungeon portal-graph visibility blowup (see-through-walls / other dungeons rendered)
Status: RESOLVED 2026-06-13 — the 9.1M-instance blowup was a SYMPTOM of Bug A
(wrong dungeon membership), NOT an unbounded portal flood. Chain of evidence: (1) a
headless diagnostic on the real 0x0007 dungeon (Issue95DungeonFloodDiagnosticTests,
95d9dab) measured PortalVisibilityBuilder visiting only 1–17 cells per root —
already tightly bounded and a strict subset of the stab_list (VisibleCells, which is
the BIG set: avg 120, max 204 of 205 cells). So porting grab_visible_cells stab_list
bounding would have made it WORSE — DO NOT do that. (2) The 9.1M blowup was captured at
the G.3a gate before Bug A's fix (2ce5e5c), when the player's membership wrongly
resolved to 0xA9B3 (Holtburg) → the render rooted at the wrong place. (3) With Bug A +
login-into-dungeon (47ae237) fixed, a live launch into 0x0007 measured
instances=~39,000 (down from 9.1M, ~230×), meshMissing=0, dungeon renders, no ACE
errors. The flood was never the bug. Originally also: explained user-observed
"dungeons are broken"
Severity: HIGH (blocks all dungeon navigation visually)
Filed: 2026-05-21
Component: rendering, visibility, EnvCell portal traversal
Description: When +Acdream enters a dungeon via portal (verified at Town Network hub in A6.P1 scen5), the visibleCells count per cell explodes from a normal ~4-7 to 135-145, and cells from multiple disconnected landblocks are loaded simultaneously. Observed result: the player can see through walls, sees geometry from other dungeons rendering inside the current dungeon, and rendering is generally garbled. This single bug is responsible for "dungeons are broken" as a whole — every portal-accessed dungeon hits this on entry.
Root cause / status: Suspected: portal-graph traversal in the EnvCell visibility computation walks outbound portals recursively without proper termination, so a network hub (which has many outbound portals to different dungeons) marks 100+ cells from disconnected dungeons as visible. The visibility computation likely needs to (a) cap traversal depth, (b) terminate at portal boundaries to OTHER landblocks, or (c) only include cells that share line-of-sight through a chain of portals from the camera's current cell.
Evidence (committed):
docs/research/2026-05-21-a6-captures/scen5_sewer_entry/acdream.log— full trace of the rendering breakdown after portal teleport.- Pre-teleport:
visibleCells=4per cell (normal outdoor). - Post-teleport:
visibleCells=135-145per cell at landblock 0x0007 + spurious cells from 0x020A and 0x0408 (different worldOrigins, i.e. different dungeons entirely). - Cell-transit chain:
0xA9B40003 -> 0x00070143 reason=teleportis the portal entry; everything after the teleport is corrupted.
Files:
src/AcDream.App/Streaming/— cell streaming + visibility logic (suspect: cell-cache visibility computation)- WB-extracted visibility:
src/AcDream.App/Rendering/Wb/(whichever file ownsvisibleCells) - Check
EnvCellRenderManager+VisibilityManagerinreferences/WorldBuilder/for the WB-original algorithm and where our extraction may have diverged
Research: scen5 acdream.log is the primary evidence. Compare against WorldBuilder's original portal-traversal termination logic.
Acceptance: After portal entry to any dungeon, visibleCells per cell stays in the normal ~4-15 range, cells from non-adjacent landblocks do NOT appear in the cell-cache, and visually no other-dungeon geometry renders through walls.
#97 — Phantom collisions + occasional fall-through on indoor 2nd floor (post-slice-1 happy-testing) — [DONE 2026-06-11 · T6/BR-7 + T5 gate]
Status: DONE — closed by T6/BR-7 (the +5 m radial query pad that made spheres test objects in cells they never overlapped — the structural producer of this phantom class per the WF1 verification — was deleted with the per-cell query) and user-confirmed at the T5 gate ("5. Check" — clean inn 2nd-floor walk, no invisible barriers). Severity: MEDIUM (intermittent; doesn't block stair-walking which works post-slice-1) Filed: 2026-05-21 Component: physics, ContactPlane stability
Description: During user happy-testing post-A6.P3 slice 1 (2026-05-21), walking on the inn 2nd floor in acdream produced:
- Intermittent "phantom collisions" — hitting invisible barriers in open floor space.
- One observed "fall-through the floor" — character dropped through the 2nd floor at a specific spot.
These are NOT the indoor stair-climb or cellar-descent symptoms (those WORK post-slice-1). They appear during normal flat-floor walking.
Root cause / status: Hypothesis: caused by issue #96 (L622 per-tick CP seed). The seed writes ci.ContactPlane every tick from body.ContactPlane, which may carry stale values across cell transitions or after the BSP didn't land a fresh plane. If a transient ci.ContactPlane value points to a plane that doesn't match the actual current floor geometry, ValidateWalkable (called from the outdoor terrain fallback) or downstream physics may briefly believe the player is below the floor → fall-through; OR may believe a wall is present where there isn't one → phantom collision.
Falsifiable: if #96 fix closes #97 as a side-effect, the hypothesis is confirmed. If #97 persists post-#96, deeper investigation needed (possibly cell-resolver stickiness — Finding 3 family).
Reproduction (informal — needs sharpening):
- Launch acdream, teleport to inn 2nd floor.
- Walk back and forth across the floor for ~30 seconds in various patterns.
- Phantom collisions appear intermittently — exact reproduction location unknown.
- Fall-through happened at one specific spot; location not recorded.
Files:
src/AcDream.Core/Physics/PhysicsEngine.cs(CP seed + body persist)src/AcDream.Core/Physics/TransitionTypes.cs(Transition.FindEnvCollisionsindoor branch +Transition.ValidateTransition)src/AcDream.Core/Physics/BSPQuery.cs(Path-6 land write site)
Acceptance: Walking on inn 2nd floor for ≥60 seconds in varied patterns produces zero phantom collisions and zero fall-through events.
#98 — [DONE 2026-05-24 · b3ce505] Cellar ascent stuck at top (NOT BSP step; per-cell-list architectural divergence)
Closed: 2026-05-24
Commit: b3ce505 fix(phys): A6.P3 #98 — gate outdoor shadow radial sweep on indoor primary cell
Resolution: The proximate fix is the indoor-primary radial-sweep
gate in ShadowObjectRegistry.GetNearbyObjects. Architectural root
cause: our landblock-wide spatial shadow registry diverges from
retail's per-cell shadow_object_list with portal-aware registration —
the cottage GfxObj (registered landblock-wide via cellScope=0) was
returned to sphere queries inside the cellar EnvCell, and its
downward-facing floor poly at world Z=94 head-bumped the climbing
sphere from below.
After ~10 failed speculative fix attempts across four sessions, the
fix landed cleanly once the apparatus converged. The "v3 stale ramp
contact plane" hypothesis was falsified by chronological replay against
a6-issue98-resolve-capture-2.jsonl — the player IS on the ramp at the
cap event; the contact plane is correctly the ramp's plane; the head
sphere bumps the cottage GfxObj's floor poly from below (the
evening-v2 finding was correct all along).
Decomp anchors (docs/research/named-retail/acclient_2013_pseudo_c.txt):
- 308742+ :
CObjCell::find_cell_list— indoor/outdoor branch - 308751-308769 : the branch — indoor adds 1 cell; outdoor calls
add_all_outside_cells - 308773-308825 : portal-visible neighbor recursion
- 308916 :
CObjCell::find_obj_collisions(this, ...)— strict per-cell iteration
Visual verification 2026-05-24: user confirmed "Finally I can go up!"
Knowledge artifacts:
- Findings doc resolution section:
docs/research/2026-05-23-a6-p3-issue98-comparison-harness-findings.md(bottom) - Memory:
feedback_retail_per_cell_shadow_list.md,feedback_apparatus_for_physics_bugs.md - A6.P4 phase planned to do the full retail-faithful per-cell port and obviate the
b3ce505stopgap
Known regression introduced: doors at doorway thresholds — see #99 below.
#99 — Run-through doors at building thresholds (regression from b3ce505) — [DONE 2026-06-11 · dbfbf85 + ca4b482, T6/BR-7]
Status: DONE — closed ARCHITECTURALLY by the A6.P4 per-cell shadow port
(T6/BR-7): registration computes cell membership via the retail
sphere-overlap portal flood (CellTransit.BuildShadowCellSet =
CObjCell::find_cell_list, Ghidra 0x0052b4e0), the query iterates strictly
per cell (FindObjCollisionsInCell = find_obj_collisions 0x0052b750,
primary + CheckOtherCells per retail order), building shells dispatch via
the per-LandCell building channel (FindBuildingCollisions =
CSortCell::find_collisions 0x005340a0), and the b3ce505 indoor gate +
radial sweep + 5 m pad + isViewer exemption are DELETED. The door is
covered twice like retail: registered into every cell its spheres overlap,
and reached from the indoor side via the straddle-admitted outdoor cells in
the player's own array. Pins: tick-13558 (indoor approach BLOCKS),
tick-22760 (outdoor block invariant), the flipped door apparatus, and the
registry membership tests. Residual: the lateral-slide delta at
near-perpendicular approach is #116 (slide response, pre-existing).
Visual confirmation rides the T5 comprehensive gate.
Severity: HIGH (M1 demo regression — opening doors was previously a working demo target)
Filed: 2026-05-24
Component: physics, shadow-object collision query
Description: With the issue #98 fix (commit b3ce505), the
indoor-primary radial-sweep gate causes our engine to miss outdoor-
registered door entities when a sphere has crossed the threshold and
the primary cell resolves to the indoor side. Players can walk through
doors that previously blocked them.
User report 2026-05-24: "I can also run through doors."
Root cause / status: This is the doorway edge case explicitly
flagged in the b3ce505 commit message. Doors are server-spawned
entities with their own cylinder collision, registered via
UpdatePosition to whichever cell their position resolves to. Doors
at building thresholds typically resolve to outdoor cells. The
b3ce505 gate skips the outdoor radial sweep when the sphere's primary
cell is indoor → outdoor-registered doors are not returned → no
collision → walk-through.
Retail handles this case via the portal-visible recursion in
find_cell_list (lines 308773-308825 of the named-retail decomp): at
registration time, an object is added to its position's cell PLUS all
portal-visible neighbor cells. So a door at a doorway portal ends up in
both the outdoor cell's shadow list AND the indoor cell's list — a
sphere on either side sees it.
Fix path: Closes naturally as part of A6.P4 (per-cell shadow
architecture refactor — see design spec at
docs/superpowers/specs/2026-05-24-phase-a6-p4-retail-shadow-architecture.md).
A6.P4 ports retail's find_cell_list indoor branch + portal recursion
into ShadowObjectRegistry.Register, eliminates the cellScope=0
landblock-wide approximation, and removes the b3ce505 stopgap.
If A6.P4 takes longer than expected, an intermediate "portal-aware
indoor query" patch (~20 lines: walk indoor cells' VisibleCellIds,
collect portal-reachable outdoor cells, include in GetNearbyObjects
indoor branch) would close #99 without touching registration. Tagged
as fallback option B in the A6.P4 spec.
Files:
src/AcDream.Core/Physics/ShadowObjectRegistry.cs—GetNearbyObjectsindoor branchsrc/AcDream.Core/Physics/TransitionTypes.cs:2180+—FindObjCollisionscaller
Acceptance: Doors at Holtburg cottage/inn doorways block the player from both sides (outside walking in, inside walking out). Issue #98's cellar-up fix remains intact.
Related: #98 (sibling — same architectural cause), #97 (phantom collisions on 2nd floor — also likely closed by A6.P4), Finding 3 family (sling-out — also likely).
#98-old-context-preserved-for-reference
(retained from the OPEN form for historical context — superseded by the DONE resolution above. Skip to next active issue if you've read enough.)
Status: OPEN — NEW diagnosis after A6.P3 slice 3 (2026-05-22) Severity: HIGH (blocks M1.5 demo cellar half — user can descend but cannot return) Filed: 2026-05-22 Component: physics, BSP step_up / step_down at cellar stair geometry
Diagnosis update 2026-05-22 (post A6.P3 slice 3): The cell-resolver ping-pong (the original hypothesis when this issue was filed) WAS confirmed and is now FIXED by slice 3 (commits 8898166 v1 + 3e140cf v2 — point-in stickiness check in ResolveCellId). Data confirms: scen4_cottage_cellar_slice3v2 capture shows only 1 cell-transit event (login teleport) vs 20+ pre-fix.
BUT the cellar-up symptom PERSISTS even with the cell-resolver fix. The remaining cause is a BSP step physics issue at the cellar stair geometry. User report: "I'm running up the stairs, at the top it looks like I'm running into something. Still running animation but not going up." Player can climb most of the stair flight but gets blocked at the TOP step where the cellar transitions to the cottage main floor.
Evidence from slice3v2 capture:
[push-back] site=adjust_sphere in=(*, -0.0752, 0.0077) out=(*, -0.0752, 0.7577)
delta=(0, 0, 0.7500) n=(0, -0.7190, 0.6950) d=-0.1007
r=0.4800 winterp=1.0000->0.0000 applied=True
- Surface normal
(0, -0.719, 0.695)— sloped 44° (walkable per FloorZ=0.664) - Push-back lifts sphere by 0.75m (step_down probe distance) repeatedly
winterp 1.0→0.0— entire walk interpolation consumed by the lift each tick- Player Z stays stuck around 0.0077 (relative to cell) → not progressing
Hypothesis: the step_down probe at the top of the cellar stair is hitting the sloped TOP step face (or possibly a wall poly), and consuming all walk interp pushing back. No remaining interp to actually walk forward over the top.
Diagnosis sharpened 2026-05-22 (commit 134c9b8) — paired retail+acdream cdb capture confirmed cellar ascent ends with retail's BP7 setting ContactPlane to the cottage main floor (flat plane at world Z=94, 18 BP7 hits all the same plane).
Diagnosis CORRECTED 2026-05-22 evening (slice 5 [place-fail] probe) — the morning handoff's "Path 5 vs Path 6 in BSPQuery.FindCollisions" diagnosis is WRONG. The slice-5 probe-driven evidence shows:
- Retail's BP4 trace has every find_collisions hit with
collide=0. Retail enters the same(state & 1) Contactbranch our acdream does. There is NO outer-dispatcher path-selection divergence. - Retail's BP5 fires on the ramp poly 17+ times during the ascent, NOT "30 hits all on flat planes" as the morning claim said. We misread the retail data.
- The actual blocker is polygon 0x0020 in the cellar cell's BSP (
n=(0,0,-1) d=-0.2in cell-local, world Z=93.82 — the cellar's ceiling). When step-up's step-down probe lifts the sphere onto a 45° walkable surface, the sphere top extends past the ceiling polygon andSphereIntersectsSolidInternalcorrectly rejects. - Retail succeeds because its
check_celltransitions to cottage main floor cell 0xA9B40146 during the ascent, where the cellar's ceiling polygon is absent. Ourcheck_cellstays at cellar 0xA9B40147.
Full slice 5 evidence + sharpened next-step pickup at docs/research/2026-05-22-a6-p3-slice5-handoff.md. Capture data at docs/research/2026-05-21-a6-captures/scen4_cottage_cellar_place_fail/.
Diagnosis FINALIZED 2026-05-23 evening (commit 28c282a, divergence doc at docs/research/2026-05-23-a6-p3-issue98-replay-comparison.md). After 4 sessions of speculative fixes (10+ variants, none worked), apparatus shipped to turn evidence-driven analysis into a 200ms test loop:
- Deterministic replay harness:
tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.csloads the three cottage/cellar cell fixtures (captured live via the newACDREAM_DUMP_CELLSprobe) and drives the failing-frame sphere through our walkable predicates. 7 tests, all pass, all reproduce the live failure without a client launch. - Retail comparison:
docs/research/2026-05-23-a6-captures/cellar_up_capture_1/retail.decoded.log— 35K cdb BP hits during the equivalent retail cellar-up.
REAL divergence: NOT cell-resolver. NOT path-selection. NOT polygon 0x0020 the cellar ceiling.
- Retail's sphere is at world Z ≈ 94.48 (resting on cottage floor) when
find_walkableaccepts the cottage main floor plane. - Our failing-frame sphere is at world Z ≈ 92.01 (2.47m lower) when our walkable query rejects the cottage main floor.
- Retail's
ContactPlanewrites during cellar-up are ONLY flat horizontal planes (cellar floor Z=90.95 OR cottage floor Z=94.00). Never the ramp. - Retail's
find_crossed_edgefires ONCE in 35K BPs. Acdream uses it heavily.
Fix targets (priority order, from the comparison doc):
- (HIGHEST) Step-up + ramp climb doesn't gain enough Z per tick. Retail climbs gradually across thousands of ticks; ours oscillates at Z≈92. Look at
Transition.AdjustOffsetslope projection +Transition.DoStepUpWalkInterp handling. - Cottage-cell candidacy uses wrong sphere reference (pre-step-up vs step-lifted center).
find_crossed_edgeover-use in our walkable acceptance path.- (LOW) Ramp polygon normal divergence.
Failed fix attempts (informational):
- WalkInterp reset before placement_insert (commit
bbd1df4) — logical retail-faithful improvement but doesn't fix the cellar-up symptom. Keep. - Slice 3 v1/v2/v3 cell-resolver stickiness — closed ping-pong but didn't help cellar-up. v3 reverted (
8bd3117). - Slice 5:
[place-fail]probe + diagnosis correction. Useful infrastructure; not a fix. - Slice 6 (2026-05-22 PM): 6 placement-insert bypass variants. None unstuck the player.
- Slice 7 (2026-05-23 AM): terrain hole cutout, multi-sphere CellTransit, building bldg-check, negative-side polygon support, render-vs-physics origin split. Triaged in commit
35b37df: kept render-physics split + multi-sphere CellTransit + diagnostic probes; reverted neg-poly + bldg-check (didn't fix #98).
Related:
- Inn stairs UP works (different geometry, doesn't trigger this specific failure mode)
- Cellar descent works (only ascent fails — direction matters)
- Issue #90 (cell-id ping-pong workaround in
ResolveCellId) is now superseded by slice 3 v2's stickiness check; can be removed in A6.P4 after broader visual verification
Description: Walking UP from a Holtburg cottage cellar in acdream gets stuck "just almost at the last step up." Stairs going UP elsewhere (inn 2nd floor) work fine post-A6.P3 slice 1. Cellar DESCENT works. Only the cellar ASCENT from the bottom back to ground level fails — specifically at the last step where the player should transition from the indoor cellar cell to the cottage ground-floor cell.
Evidence: captured in slice 2 v2 verification at docs/research/2026-05-21-a6-captures/scen3_inn_2nd_floor_slice2v2/acdream.log. Cell-transit chain shows the resolver ping-ponging between three adjacent cells:
0xA9B4014B → 0xA9B4014A → 0xA9B4013F → 0xA9B4014A → 0xA9B4014B → ...
(Z stays ~96.4 throughout the ping-pong — vertical position stable but cell classification oscillating)
Eventually the player gives up and returns down: 0xA9B4013F → 0xA9B40143 (Z drops to 94.020) → 0xA9B40146 (Z 93.426) → ...
Each cell-transit event has reason=resolver, meaning PhysicsEngine.ResolveCellId is making the decision. The resolver classifies the position into a different cell each tick → AdjustOffset operates against a different cell's geometry each tick → can't accumulate forward motion → stuck.
Root cause / status: Same family as scen4 sling-out (A6.P2 Finding 3) and issue #90 cell-id ping-pong (which has a workaround). The retail oracle is CObjCell::find_cell_list Position-variant at acclient_2013_pseudo_c.txt:308742-308783. Retail uses cell-array hysteresis / stickiness to prevent flipping CellId on adjacent-cell boundaries when the sphere is on the boundary.
Our ResolveCellId + CheckBuildingTransit lack this stickiness — every tick they re-classify based on current position, ignoring "we were already in cell X last tick; if the new position is still close to X, stay in X."
Fix sketch (slice 3):
- Port retail's cell-array hysteresis from
CObjCell::find_cell_list. - Modify
ResolveCellIdto prefer the previous tick's CellId when the sphere is close to (but slightly outside) the previous cell's CellBSP volume. - Modify
CheckBuildingTransitsimilarly for building-shell transitions. - May obsolete issue #90's workaround (the same stickiness mechanism would handle the doorway ping-pong too).
Related issues:
- Issue #90 — Cell-id ping-pong at indoor doorway threshold (existing workaround; should be removed if Finding 3 fix lands cleanly)
- Issue #97 — Phantom collisions + fall-through on 2nd floor (may also be the same cell-resolver instability)
- A6.P2 Finding 3 — Indoor cell-resolver sling-out (scen4)
Files:
src/AcDream.Core/Physics/PhysicsEngine.cs(ResolveCellId)src/AcDream.Core/Physics/CellPhysics.cs(CheckBuildingTransit)src/AcDream.Core/Physics/CellTransit.cs(cell list iteration; may need stickiness here)
Acceptance: User can walk up out of a Holtburg cottage cellar without getting stuck at the last step. Cell-transit log shows no ping-pong on the cellar boundary. Issue #90 workaround can be removed (verified by ping-pong staying absent at the inn doorway too).
2026-05-23 evening session update — Shape 1 attempted + reverted:
- New apparatus committed:
8a232a3—[step-walk-adjust]probe insideTransition.AdjustOffset(PhysicsDiagnostics.LogStepWalkAdjust + four branch tokens). Reveals which projection branch fires per call.8daf7e7— captured findings note atdocs/research/2026-05-23-a6-stepwalkadjust-findings.md+ log snapshot atdocs/research/2026-05-23-a6-captures/stepwalkadjust/acdream.log.
- Refined diagnosis (corrects the 2026-05-23 evening "fix targets" priority above): AdjustOffset is CORRECT — 145/146 calls take the
into-planebranch with consistent +0.045 m mean zGain per call when offset points into the ramp normal. Sphere world Z climbs monotonically 90.95 → 92.80 across the ramp. The climb caps at world Z ≈ 92.80 (cottage floor at 94.00 still 1.20 m above) because at the ramp top, the proposed check (Z=92.85) gets rejected by step-up's downward step-down probe — no walkable surface exists below the proposed position within stepDownHeight=0.6 m (cottage floor is ABOVE, not below). 101stepdown-rejecthits in the capture vs 1 acceptance. - Shape 1 fix attempted (
0cb4c59, reverted in402ec10): AddedPhysicsGlobals.ContactPlaneFlatThreshold = 0.99fand gatedBSPQuery.AdjustSphereToPlane's twoSetContactPlanecall sites byworldNormal.Z >= threshold. The intent: match retail's cdb-observed pattern where CP is ONLY ever set on flat polygons (cellar floor or cottage floor — Normal.Z = 1.0 in all 161 BPE writes). Live test confirmed the fix breaks OnWalkable tracking: 18,916 / 25,671 step-walk lines (74%) ended incontact=False onWalkable=False cp=n/a walkPoly=False(the falling state). User report: "can't get up the first step. Jumped, stuck in falling animation." The gate was too aggressive — sloped walkable polygons (stair tops, ramp faces) NEED ContactPlane set for the sphere to register as on a surface. - What we learned about Shape 1: simply skipping
set_contact_planeon sloped polygons doesn't match retail behavior. Either retail synthesizes a flat CP from a sloped contact (thestep_sphere_down:321203Plane::Plane(&plane, esi, &point)codepath —esimay be a synthesized direction, not the polygon's normal), OR retail's gate is upstream ofset_contact_plane(the polygon never reaches CP-setting in the first place), OR ourOnWalkabletracking is over-coupled toContactPlaneValidin a way retail's isn't. The named-decomp research did not converge on a definitive answer.
Session paused 2026-05-23 evening after two days of work. Apparatus + probe + findings + plan + first failed fix + revert all committed. M1.5 demo's cellar half remains blocked. The honest next-session moves, in order:
- Build a deterministic trajectory replay harness (drives the physics engine through N ticks with mocked input + snapshotted starting state, runs in <500ms). The Issue98 replay tests are half of this — they have the cell fixtures. The missing half is the per-tick driver. With a 200ms inner loop instead of 5-minute live-test iteration, evidence-driven fix attempts become tractable.
- OR pivot to another M1.5 issue with less cross-subsystem coupling. The cellar-up bug lives at the seam of AdjustOffset + ContactPlane + WalkInterp + step-up + walkable tracking + OnWalkable + cell-set membership — fixing one piece breaks another. Less-coupled issues (chronic open #2/#4/#28/#29/#37/#41, or #90 workaround removal) would yield faster forward progress.
- OR a deeper named-decomp research pass focused specifically on
CEnvCell::find_env_collisions→BSPTREE::find_collisions→ indoor CP-setting chain. This path was never fully traced; the first two research passes worked on the outdoor (CLandCell) path. The indoor path is where the cellar lives.
Replay tests at tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs document the failing-frame geometry and will be the regression oracle when a real fix lands. They do not currently simulate trajectory.
2026-05-23 PM extension — trajectory replay harness shipped, blocked on a SECOND bug:
Commits 4c9290c → 5c6bdbe ship a deterministic N-tick trajectory replay at tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs. 200-tick runs complete in <100 ms. 5 tests pass.
- Finding: the cellar ramp polygon is NOT in
cellStruct.PhysicsPolygons. It lives in a separate GfxObj (a static building piece, registered as a ShadowEntry on the landblock).CellDumpSerializercorrectly captures cell polygons; the ramp comes from a different data source entirely. The harness reconstructs the ramp polygon programmatically from the live capture's polydump data viaRegisterStairRampGfxObj. - Finding:
CellDumpSerializer.HydratesetsBSP=nullper its xmldoc — so the indoor BSP collision path is skipped for hydrated fixtures. Harness wraps cells with a synthetic one-leaf BSP viaAttachSyntheticBspto fire the indoor path. - Finding:
PhysicsBodyseeding requires BOTHContactPlane*ANDWalkablePolygon*fields. The engine atPhysicsEngine.cs:665-673only callsSpherePath.SetWalkable(...)ifbody.WalkablePolygonValid && body.WalkableVertices.Length >= 3. Without this the engine treats the sphere as "grounded but anchorless" — a contradictory state.
NEW BLOCKER (open finding): Even with the full apparatus (CP + WalkablePolygon seeded body, synthetic BSP, synthetic stair GfxObj registered, stub landblock), the sphere goes airborne at tick 1 with hit=(0,1,0) — a +Y wall normal matching no registered geometry. The hit is set by ValidateTransition between the after-insert and after-validate probe sites, but the inner TransitionalInsert call sets ci.CollisionNormal=(0,1,0) before ValidateTransition runs. 12 different SetCollisionNormal call sites in TransitionTypes.cs — root cause not yet isolated.
6 hypotheses tested via the harness, all failed to isolate root cause: WalkablePolygon seeding, initial Z lift (0 vs 0.05m), stair GfxObj presence, stub landblock terrain, cell BSP null vs synthetic, body=null vs seeded. Per systematic-debugging skill's "3+ failures = question architecture" rule, stop speculation; next session needs a side-by-side comparison harness against live PlayerMovementController state.
Pickup document: docs/research/2026-05-23-a6-p3-issue98-harness-handoff.md is the canonical resume artifact — has the chronological commit list, apparatus inventory, exclusion list, and three concrete next-session options ranked by recommendation.
Status: DONE Severity: MEDIUM (refactor blocker; doesn't affect main branch which is unchanged) Filed: 2026-05-16
Resolution (2026-05-16 · 0b25df5): Step 2 re-attempted with
[step2-diag] traces at every hypothesized fault point. The traces
showed all four hypotheses were wrong — session.hashcode was identical
through _liveSession, _liveSessionController.Session, and the
captured liveSession local in the chat-bus lambda, ruling out
identity mismatches and closure-capture bugs. Doors verified via
inbound OnLiveMotionUpdated round-trip (cmd=0x000B open, cmd=0x000C
close). Pickup verified via 4 successful [B.5] pickup calls. The
previous broken run was almost certainly a stale ACE session (no other
code-level explanation survives the diag trace). One small material
diff: the chat-bus lambda's var liveSession = _liveSession; capture
became var liveSession = session; (the non-null parameter) so the
compiler can statically prove non-null inside the lambda — both pointed
to the same WorldSession instance, only the static analysis changed.
Traces stripped before commit. Walking-range auto-walk bug observed during the second verification run is pre-existing (filed as #77, not caused by this refactor).
Description: A first attempt at Step 2 — extracting LiveSessionController
Description: A first attempt at Step 2 — extracting LiveSessionController
out of GameWindow.cs — was implemented and reverted in the same session
on the claude/hungry-tharp-b4a27b worktree. Visual verification at
Holtburg revealed:
- Chat input field accepts text + Enter but nothing is sent (no echo, no ACE response).
- Double-click on doors / NPCs fires
[B.4b] use guid=... seq=Noutbound (verified inlaunch.log) but no visible client-side effect (door doesn't swing, NPC doesn't dialogue). - R + click-target produces
[B.4b] use-deferred guid=... seq=N, the player auto-walks to the target, but the deferred Use does NOT fire on arrival (regresses the Phase B.6 / issue #63 / #75 work).
The Step 1 (eda936d RuntimeOptions) and Rule 5 follow-up
(32423c2 DumpSteepRoof → PhysicsDiagnostics) commits are NOT affected
and stay clean.
Root cause / status: Unknown. The refactor preserved every event
subscription line-for-line (verified by git diff — only one _liveSession.X +=
line moved, all others present). The new shape:
TryStartLiveSession()
→ _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents)
→ new WorldSession(endpoint)
→ wireEvents(session) // i.e. WireLiveSessionEvents(session)
→ Chat.OnSystemMessage("connecting...")
→ _liveSession.Connect(user, pass)
→ ...character validation + EnterWorld + post-setup...
Looks identical to the original control flow. Hypotheses to test on a clean re-attempt:
- Timing of
_liveSessionfield assignment. The new code assigns_liveSessioninsideWireLiveSessionEventsbefore subscriptions run, and again after CreateAndWire returns. The original code set_liveSessiononce at the inlinenew WorldSession(...)site. A subtle ordering bug between subscriptions and_liveSession's externally visible state may matter. - LiveCommandBus closure capture. The
var liveSession = _liveSession;capture inside the chat handler block may have been getting a different value than before — though the field IS set by the time the capture happens (line 1 ofWireLiveSessionEvents). - Inbound packet ordering. ACE may be sending the first
StateUpdate / spawn stream BEFORE the EnterWorld dance completes in
the new flow; if subscriptions are wired but
_liveSessionfield is briefly inconsistent, an early handler call could see a partial state. The_liveSession?.Tick()route now goes through_liveSessionController?.Tick(); verify that's not the difference. - Some non-subscription side effect in
WireLiveSessionEventsthat wasn't carried over correctly — over-indentation suggests a diff-friendly intermediate state; full re-indentation may surface the bug.
Files (in the reverted state — recover from worktree git reflog or re-write):
src/AcDream.App/Net/LiveSessionController.cs(new, ~115 LOC)src/AcDream.App/Rendering/GameWindow.cs—TryStartLiveSessionsplit- new
WireLiveSessionEventsmethod
- new
Research: No memory entry yet. If the re-attempt succeeds, add a
feedback_step2_extraction_pitfalls.md capturing whichever hypothesis
turned out to be the bug.
Acceptance: Step 2 lands when the full M1 demo loop (walk Holtburg, double-click inn door + door swings, double-click NPC + NPC dialogues, F-key pickup on a ground item) works identically to the pre-refactor behavior, AND chat input echoes back through the panel.
#75 — [DONE 2026-05-16 · f035ea3] Auto-walk should drive body directly, not synthesize player-input
Status: DONE Severity: LOW (functionally correct via grace-period band-aid; architectural cleanup only) Filed: 2026-05-16 Component: physics / auto-walk
Resolution (2026-05-16 · f035ea3): Refactored ApplyAutoWalkOverlay → DriveServerAutoWalk. Auto-walk now steps Yaw, sets _body.set_local_velocity from runRate, and calls _motion.DoMotion(WalkForward, speed) directly — NO MovementInput synthesis. Update gates the user-input motion + velocity section on !autoWalkConsumedMotion to prevent overwrite. The 500ms arrival grace period (band-aid) deleted. The wire-layer !IsServerAutoWalking guard at GameWindow.cs:6419 retained as a semantic statement (user-MoveToState is for user-driven intent only), not as a band-aid for the synthesis leak that no longer exists. Animation cycle plumbed through via localAnimCmd / localAnimSpeed for both moving-forward and turn-first phases (issue #69 folded in). Walk/run threshold corrected to 1.0m (overrides ACE's wire-supplied 15.0f; matches user-observed retail behaviour + ACE's own physics layer default). IsPickupableTarget now checks BF_STUCK (acclient.h:6435) to correctly block signs/banners that share Misc ItemType with real pickup items.
Description: ApplyAutoWalkOverlay in PlayerMovementController
synthesizes Forward+Run MovementInput during inbound MoveToObject
so the existing motion-interpreter pipeline drives the body. The
synthesis leaks: motion-interpreter sets MotionStateChanged=true,
which would fire an outbound MoveToState "user is running"
packet to ACE — interpreted as user-took-manual-control and cancels
ACE's MoveToChain. We mitigate with a guard
(!_playerController.IsServerAutoWalking at GameWindow.cs:6410)
plus a 500 ms post-arrival grace period to cover ACE's poll race.
Retail's MoveToManager::HandleMoveToPosition (decomp 0x0052xxxx)
steps the body POSITION directly when server MoveToObject arrives —
NO player-input synthesis, NO motion-interpreter involvement, NO
outbound MoveToState. Holtburger
(simulation.rs:178-206)
follows the same pattern (sets ServerControlledProjection, advances
the body, returns empty).
Acceptance: Refactor auto-walk to step _body.Position (or
equivalent) directly from the wire-supplied path data + run rate, NOT
via synthesized input. Motion state during auto-walk becomes a
SERVER-DRIVEN state (similar to how remote players' motion is driven
by inbound MoveToState packets), not a USER-DRIVEN one. The 500 ms
grace period in EndServerAutoWalk becomes unnecessary and can be
deleted; same for the IsServerAutoWalking guard at the wire layer
(no MoveToState would have been built in the first place).
Animation cycle currently driven by motion-interpreter's
MotionStateChanged → SetCycle(RunForward) would need a separate
path: probably mirror how remote-player animation is driven by
inbound motion packets (the sequencer accepts a SetCycle directly).
Files: src/AcDream.App/Input/PlayerMovementController.cs
(ApplyAutoWalkOverlay returns synthesized input today; refactor to
step body directly + drive animation via _animationSequencer.SetCycle
directly). src/AcDream.App/Rendering/GameWindow.cs (delete the
!IsServerAutoWalking guard once the leak is gone).
Estimated scope: Medium (~50-100 LOC + careful testing of animation cycle continuity). Not blocking M1 — the grace-period band-aid produces retail-faithful behaviour empirically.
#74 — [DONE 2026-05-16 · de44358] AP cadence is per-frame-while-moving, more chatty than retail
Status: DONE Severity: LOW (works; just sends ~60× the packets retail would during smooth motion) Filed: 2026-05-16 Component: physics / net cadence
Resolution (2026-05-16 · de44358): With #75 (MoveToState suppression refactor) closing the MoveToChain-cancellation race, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch ShouldSendPositionEvent gate (acclient_2013_pseudo_c.txt:700233-700285): cell/plane change during the sub-interval; cell-or-frame change after the 1s heartbeat. Added _lastSentContactPlane field + extended NotePositionSent(Vector3, uint, Plane, float) + added ApproxPlaneEqual helper + PlayerMovementController.ContactPlane public accessor. Effective rates now match retail: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne.
Description: The diff-driven AP cadence shipped in Commit B fires
HeartbeatDue on any position change each frame while grounded
on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
heartbeat when idle. Retail's ShouldSendPositionEvent
(acclient_2013_pseudo_c.txt:700233) only sends during the
sub-interval when cell or contact-plane changes, and only sends the
1 Hz heartbeat if (cellId, frame) changed since last_sent —
truly idle = 0 Hz. So retail during continuous smooth movement is
effectively 1 Hz (cell crosses + plane changes don't happen every
frame); we are ~60 Hz.
Root cause / status: Deliberate ACE-targeted choice. The
per-frame cadence is load-bearing for ACE's WithinUseRadius poll
to see the player arrive at a target during local speculative
auto-walk (issue #63's workaround chain). Going to 1 Hz would
re-introduce the arrival-lag bug for far-range Use/PickUp.
Files: PlayerMovementController.cs:1240-1275
— the HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed)
gate.
Acceptance: Either (a) fix issue #63 so we honor ACE's
MoveToObject server-side, removing the need for the per-frame
cadence, then revert to retail's cell-or-plane-change || (interval && frame-change)
shape (~5 LOC change); or (b) document this as a permanent
divergence and update commit messages / code comments to match.
Estimated scope: Small (~5 LOC + commit-message rewrite) once #63 is fixed. Currently blocked by #63.
#73 — Retail-message centralization plan — per-feature string sweeps
Status: OPEN Severity: LOW (per-feature work, not infrastructure) Filed: 2026-05-16 Component: ui / retail messages
Description: Commit A added AcDream.Core.Ui.RetailMessages as
the home for retail-decomp-sourced UI strings (CannotBeUsed,
CantBePickedUp, CannotPickUpCreatures). The retail decomp has
~750 more user-facing strings we'll need over time — combat misses,
spell fizzles, vendor dialogs, "you do not have enough" etc. Rather
than bulk-port them once, port per-feature as the feature lands:
when wiring vendor purchase, sweep vendor strings into
RetailMessages.Vendor.*; when wiring spell-cast feedback, sweep
RetailMessages.Spell.*.
Status: No infrastructure work pending. Pattern is established;
new strings get added to RetailMessages.cs with retail anchor
comments at the call site that triggered the need.
Files: RetailMessages.cs — class-level doc comment already describes the per-feature sweep pattern.
Acceptance: Each phase / feature that adds new user-facing
strings sweeps its retail-anchor strings into RetailMessages and
calls them by name rather than literal-in-place. Closing condition:
"all M1 demo strings are in RetailMessages" or similar per-milestone
gate, decided when M1 ships.
#72 — Confirm Humanoid TurnRight/TurnLeft omega.z base rate via cdb
Status: DONE — 2026-07-29 (Campaign P P5 ledger item; closed on
superseding evidence, no cdb session needed). Both open questions were
settled by later shipped work: (1) the R6 complete-root-frame cutover
(2026-07-19) READ the installed Humanoid MotionTable 0x09000001 — the
issue's premise that HasOmega is cleared was wrong; TurnRight carries
HasOmega with literal omega.Z = -1.5 rad/s, the ±π/2 convention
fallback and AP-76 were deleted, and every animated object now consumes
the DAT-authored omega (see the R6 section of
claude-memory/project_physics_collision_digest.md and
docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md). (2) the
run multiplier is a verbatim decomp-cited port: apply_run_to_command
(FUN_00527be0) with RunTurnFactor = 1.5f at
src/AcDream.Core/Physics/MotionInterpreter.cs:554,:1369. The DAT itself
plus the named decomp are stronger sources than the requested live cdb
capture; RemoteMoveToDriver.cs cited in the acceptance no longer exists
(superseded by the MoveToManager port).
Severity: LOW (current ±π/2 fallback matches all corroborating
evidence; cdb probe would settle the open question for good)
Filed: 2026-05-16
Component: physics / rotation / research
Description: Commit A's rotation rate uses
BaseTurnRateRadPerSec = π/2 based on the documented
AnimationSequencer.cs:734-741 claim that the Humanoid motion table
ships TurnRight/TurnLeft with HasOmega cleared (forcing the
convention fallback). The constant has 3 corroborating sources but
the actual dat content was never dumped — and the run-multiplier
run_turn_factor = 1.5 at retail 0x007c8914 from
apply_run_to_command (decomp 0x00527be0) likewise hasn't been
verified live.
Acceptance: Set a cdb breakpoint on CSequence::set_omega
(acclient_2013_pseudo_c.txt — find exact symbol address) while
holding A or D in a retail client. Capture the omega.z argument
value walking, then running. If ±π/2 walking and ±π/2 × 1.5 ≈ 2.356
running, close as confirmed. If different, file as a regression and
fix the constants in
RemoteMoveToDriver.cs.
Estimated scope: ~30 min cdb session + 1 commit if confirmed, or +small fix if different. Not blocking M1.
2026-07-09 triage: investigated, verdict STILL_OPEN — no cdb trace or dat dump of the Humanoid TurnRight/TurnLeft omega.z was ever captured; the retail-divergence-register still carries this as a live open risk under rows AP-75/AP-76 as of the current codebase.
#71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
Status: DONE 2026-07-17 Severity: MEDIUM (Stage A now causes real play mis-picks through open doors/windows) Filed: 2026-05-16 Component: selection / picker
Resolution: Replaced the projected Setup.SelectionSphere rectangle and
independent collision-polygon wall ray with the retail render-coupled path.
WbDrawDispatcher now publishes only server-object parts which survive the
normal visible draw and each part's drawing-sphere view-cone check.
RetailWorldPicker transforms the ray into each part,
uses GfxObj.DrawingBSP.Root.BoundingSphere as broadphase, scans visual
polygons in DAT order, keeps only the first polygon hit per part, and gives
every polygon hit global priority over sphere-only fallbacks. The obsolete
picker overloads and cell occluder were deleted. Conformance tests pin
single-sided rejection, first-polygon ordering, affine scale, fallback, and
global arbitration.
Description: Retail's mouse picker does two-tier sphere-then-polygon
selection (acclient_2013_pseudo_c.txt:0x0054c740
Render::GfxObjUnderSelectionRay):
- Per-part sphere reject via
CGfxObj::drawing_sphere. - Polygon-accurate refine via
CPolygon::polygon_hits_rayon every visual polygon; closest-t polygon hit wins over any sphere hit.
Commit B's Stage A
(WorldPicker.cs) does
screen-space rect hit-test against the projected
Setup.SelectionSphere (matching the indicator rect, deliberately
broader than the visible mesh polygons). Stage B would tighten clicks
to the visible mesh — under-pick what looks like empty space inside
the rect, catch visible mesh that pokes past the sphere boundary
(creature outstretched arm, sign edge).
New evidence (2026-05-28 / Phase A8 visual gate): User stood outside
a Holtburg building, saw a vendor through an open doorway/window, clicked
the visible vendor, and acdream selected the door instead:
[B.4b] pick guid=0x7A9B4015 name=Door. This is exactly the Stage A
failure mode: the open door's projected Setup.SelectionSphere rect is
closer than the vendor's rect, even though the visible door polygon is not
under the cursor. The fix is polygon refinement against visible GfxObj
triangles plus current animated part transforms; do not special-case doors.
Acceptance: Pipe per-part GfxObj visual polygons through a
PickPolygonProvider interface (don't duplicate mesh decoding —
hook the existing ObjectMeshManager cached data). Two-tier in
WorldPicker.Pick: sphere reject → polygon scan → polygon hit
dominates sphere hit. Acceptance test: visible-mesh accuracy on
Holtburg sign, Royal Guard outstretched bow arm, inn-door wood
frame edges.
Estimated scope: Medium (~4-6 hours). Defer until visual verification surfaces a Stage A miss in real play. The user confirmed 2026-05-28 that the door/vendor case is now observable in real play, so this should be scheduled soon after A8 rather than left as polish.
#70 — Triangle apex/size — final retail-feel UX pass
Status: DONE 2026-07-17 Severity: LOW (cosmetic — indicator already retail-anchored, this is final-feel polish) Filed: 2026-05-16 Component: ui / target indicator
Resolution: Deleted the procedural ImGui triangles and mounted a retained
gameplay-UI VividTargetIndicatorController. It resolves retail client-enum
category 0x10000009, values 1..12, to the four installed 12×12 corner
surfaces and eight 24×24 off-screen direction surfaces; colorizes their
grayscale masks with gmRadarUI::GetBlipColor equivalent colors; places them
outside the exact Setup selection-sphere screen rectangle; and ports retail's
8-pixel viewport clamp. The marker is available without devtools, remains
visible through walls, has no marker-side distance cutoff, and uses the larger
authored direction image when the live target leaves the viewport.
Installed-DAT and layout conformance tests pin the assets, direction sectors,
and placement.
Description: Per 2026-05-16 user feedback during the
SelectionSphere indicator ship, the triangle apex direction
(flipped to point inward at the target) and sprite size (currently
8 px legs) are heuristic visual choices. Retail uses an actual DAT
sprite from UIRegion::GetChild(0x1000003a/3b/3c) — the bitmap
shape and size come from the dat, not constants.
Acceptance: Extract the retail triangle sprite from the dat
(probably via tools/UiLayoutMockup or a new DatSpriteProbe) and
either (a) blit the exact bitmap, or (b) pick a procedural size +
shape that matches it pixel-for-pixel at standard zoom.
Files: VividTargetIndicatorController.cs — exact retained-UI DAT surface resolution, colorization, and placement.
Estimated scope: Small (~1-2 hours, mostly dat exploration). Not blocking M1.
#69 — [DONE 2026-05-16 · f035ea3] Local player rotation isn't animated (no leg/arm cycle while pivoting)
Status: DONE Severity: LOW (visual polish — rotation works, just looks stiff) Filed: 2026-05-15 (B.6 close-range turn-to-face) Component: motion / animation cycle
Resolution (2026-05-16 · f035ea3): Fixed as part of the auto-walk architectural refactor (issue #75). DriveServerAutoWalk now records the per-frame rotation direction in _autoWalkTurnDirectionThisFrame (+1 / -1 / 0); the animation override at the bottom of Update reads that flag and sets localAnimCmd to TurnLeft / TurnRight during the turn-first phase. User confirmed 2026-05-16 that the auto-walk turn-first case (click target, body rotates before walking) now plays the leg-shuffle animation. User-driven A/D rotation was always working — the original issue description was specific to the auto-walk turn-first case.
Description: When the auto-walk overlay rotates the local player
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
the body's Yaw rotates smoothly but no leg / arm animation plays —
the body just statue-pivots. Retail played a TurnLeft / TurnRight
motion cycle while rotating, visible to observers as the character
moving their legs / arms to turn.
Cause: ApplyAutoWalkOverlay synthesises Forward+Run input
during the walking phase (so the motion interpreter emits RunForward
cycle commands), but synthesises nothing during the turn-only phase
— so the motion interpreter emits no command and the sequencer
holds whatever cycle was last set (typically Ready / idle).
Approach: While turning (!walkAligned), synthesise
TurnLeft = delta > 0 / TurnRight = delta < 0 so the motion
interpreter emits the turn command. Care needed: the existing
Update body also steps Yaw on TurnLeft/TurnRight input — if
both apply, the body rotates twice as fast. Cleanest: set the input
flags AND skip the overlay's own Yaw step (let Update's existing
handling do the rotation).
Acceptance: A retail observer watching +Acdream turn to face
an NPC sees the turning animation play (leg shuffle / arm swing) for
the duration of the rotation.
Estimated scope: Small. ~30 LOC in ApplyAutoWalkOverlay plus
verification that retail's TurnLeft/TurnRight cycle is in the
human motion table.
#67 — [DONE 2026-05-15 · 301281d] Door Use action doesn't complete after auto-walk arrival
Status: DONE — fixed by 301281d (10 Hz heartbeat during motion).
With ACE seeing our position in near-real-time, its CreateMoveToChain
converges normally for doors as well as NPCs. Root cause was 1 Hz
position sync on our side, not anything door-specific. User confirmed
doors work after the heartbeat bump.
#64 — Local-player pickup animation does not render
Status: OPEN Severity: LOW (visual feedback only — pickup completes correctly) Filed: 2026-05-14 (B.5 visual verification) Component: motion / animation routing for local player
Description: When +Acdream picks up an item (B.5 close-range
path), retail observers see the character play the pickup animation
correctly, but the local view shows no pickup animation. The item
despawns, the inventory updates, but the character's own
bend-down-and-grab animation is missing.
Root cause / hypothesis: ACE broadcasts Motion(MotionCommand.Pickup)
via Player_Inventory.AddPickupChainToMoveToChain (line 711–713,
EnqueueBroadcastMotion(motion)), which arrives as a normal
UpdateMotion (0xF74D) packet. Retail observers route it through
their remote-creature animation pipeline and render the pickup. For
the local player, our OnLiveMotionUpdated likely filters self-echoes
(local player drives its own motion via prediction, not server
echoes) and drops the pickup motion. The pickup is a one-shot
animation initiated by the server, so the prediction path has no
trigger — and the echo path is filtered.
Acceptance: When +Acdream picks up an item, the local view shows
the same pickup animation retail observers see. Probably resolved by
either (a) admitting server-initiated one-shot motions through the
local-player motion filter, or (b) generating the pickup animation
locally on send (mirroring retail's client behavior).
Files: src/AcDream.App/Rendering/GameWindow.cs OnLiveMotionUpdated
(motion routing); the self-echo filter is somewhere along this path.
Estimated scope: Small-to-medium. Mostly investigation + 1–2 commits.
#63 — [DONE 2026-05-16 · f035ea3] Server-initiated auto-walk (MoveToObject) not honored
Status: DONE Severity: MEDIUM (blocks out-of-range Use + Pickup; close-range works fine) Filed: 2026-05-14 (B.5 visual verification) Component: motion / inbound MoveToObject handling
Resolution (2026-05-16): Closed in two parts:
- B.6 slice 2 (2026-05-14): inbound MoveToObject parsing +
BeginServerAutoWalkwiring atGameWindow.cs:3389— body auto-walks toward the server-supplied destination. - B.6 #75 refactor (
f035ea3, 2026-05-16):ApplyAutoWalkOverlay → DriveServerAutoWalkdrives the body directly from path data, no input synthesis. TheMoveToStateleak that previously cancelled ACE'sMoveToChaincallback is gone; the chain runs uninterrupted andTryUseItem/TryPickUpfires server-side on arrival. No client-side retry needed. Walk/run threshold corrected to 1.0m (matches retail-observed; overrides ACE's wire-default 15m).
Visual-verified end-to-end: far-range Use on NPCs / doors / spell components / corpses all complete via ACE's server-side callback. The far-range retry workaround from Task 1's first iteration (c61d049's _pendingPostArrivalAction arming) was deleted as part of #75 (f035ea3).
Description: When the player triggers a Use or PutItemInContainer
on a target outside ACE's WithinUseRadius (default 0.6 m), ACE
runs server-side auto-walk via CreateMoveToChain →
PhysicsObj.MoveToObject + EnqueueBroadcastMotion(Motion(MoveToObject, target)).
Our client receives the UpdateMotion(MoveToObject) broadcast for
the player but doesn't honor it: the character either visually
drifts a bit toward the target and snaps back, or just stands still.
ACE's MoveToChain then times out, the success: false path
broadcasts InventoryServerSaveFailed (ActionCancelled), and the
pickup/use never completes.
User-visible symptom: Double-click a ground item from any distance, or F-key it from > 0.6 m: character partially walks toward the item, then flips back to original position. No pickup.
Reference: holtburger simulation.rs:33–41 + 178–191
already implements client-side MoveToObject motion projection +
auto-walk handling. That's the shape of the fix.
Root cause: Our OnLiveMotionUpdated has no handler for the
MoveToObject motion type; the broadcast is silently dropped.
Acceptance: Double-click a ground item from 2–5 m away. Character auto-walks to within use radius, ACE's MoveToChain confirms success, pickup completes (including the existing PickupEvent despawn). Same behavior for Use on out-of-range NPCs.
Files: src/AcDream.App/Rendering/GameWindow.cs OnLiveMotionUpdated
(routing); likely a new MoveToObjectMotion handler in the motion /
prediction layer + a server-acked position-update echo so ACE sees the
player has reached the target.
Estimated scope: Medium. Probably its own phase (B.6 or similar); not a one-commit fix. Compose from holtburger's pattern.
#62 — [DONE 2026-05-14 · ec9fd52] PARTSDIAG null-guard for sequencer-driven entities
Status: DONE
Severity: LOW (latent crash; not reachable for doors today — see notes)
Filed: 2026-05-13 (code-quality review of B.4c Task 1)
Component: diagnostic / GameWindow.TickAnimations PARTSDIAG block
Description: The PARTSDIAG block at GameWindow.cs:7657 reads
ae.Animation.PartFrames.Count without a null-guard. B.4c introduced
Animation = null! for sequencer-driven door entities (per the same
pattern at line 7857). Today this is safe: doors never enter
_remoteDeadReckon (ACE never sends UpdatePosition for them), and
_remoteDeadReckon membership is one of the outer guards on the
PARTSDIAG block. The diagnostic never fires for doors.
Risk: Future code that admits more non-creature entities via the
B.4c branch — or extends ACE to send UpdatePosition for doors — would
make _remoteDeadReckon membership reachable for null-Animation
entities. The next time someone enables ACDREAM_REMOTE_VEL_DIAG=1
and that scenario occurs, the diagnostic crashes the tick.
Acceptance: PARTSDIAG block tolerates null ae.Animation. One-line
fix:
int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
? ae.Animation.PartFrames[0].Frames.Count
: -1;
Files: src/AcDream.App/Rendering/GameWindow.cs:7657 (one-line null-coalescing change).
Estimated scope: Trivial. One-line edit + a build verification.
#61 — [DONE 2026-05-18 · 9f069e1] AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
Status: DONE — fixed by 9f069e1 (also widened scope: same bug
manifested as the local-player run-stop twitch — user-observed during
the M2 anim-pass session). Root cause was BuildBlendedFrame wrapping
nextIdx to rangeLo unconditionally at the high-frame boundary —
correct for looping cycles (idle/run/walk loops), wrong for one-shot
links. During the ~30 ms fractional tail of any link, the renderer
blended frame[end] with frame[0], producing the flash through the
anim's starting pose. Fix: gate the wrap on curr.IsLooping. Pinned by
the new Advance_LinkTailDoesNotBlendIntoLinkFrame0 regression test.
Visual-verified by the user end-to-end on 2026-05-18.
Severity: LOW (visual polish — animation works, brief one-frame flash through prior pose at end of swing)
Filed: 2026-05-13 (visual test of B.4c)
Component: animation / AcDream.Core.Physics.AnimationSequencer link+cycle transition
Description: When a door receives UpdateMotion(NonCombat, On) via the
B.4c spawn-time-registered sequencer, the swing-open animation plays
correctly but exhibits a brief one-frame flash through the closed pose
at the END of the swing before settling at the open pose. Same flash on
close (settles at closed pose after one-frame flash through open).
Root cause hypothesis: AnimationSequencer.SetCycle enqueues a
transition link (the swing motion) followed by the target cycle (likely
a single-frame static rest pose). If the link's last frame and the
cycle's frame 0 don't match exactly, the renderer reads one frame of
the cycle's start pose before the cycle's natural rest. Cumulative
effect: link plays Closed→Open over N frames → cycle's frame 0 is
Closed → cycle resets to frame 0 for one render → cycle advances to
its single rest frame which IS the open pose. Visible as a flap.
Acceptance: Door open / close cycles play cleanly with no closed/open pose flash at the link→cycle transition. Test: in Holtburg, double-click inn door, watch swing animation rest at open pose with no intermediate flash.
Files (likely):
src/AcDream.Core/Physics/AnimationSequencer.cs— link+cycle queue boundary handling- (read the link node's last-frame extraction + the cycle's frame-0 evaluation)
Estimated scope: Moderate. Requires understanding the sequencer's link-vs-cycle queue semantics and possibly the underlying MotionTable's cycle data shape for doors. Could be a one-line fix (e.g. "preserve last link frame as cycle rest pose") or a deeper sequencer behavior change.
Workaround: None needed for M1 — the flash is brief enough that doors are usable.
#58 — [DONE 2026-05-13] Door swing animation: UpdateMotion not wired for non-creature entities
Status: DONE
Closed: 2026-05-13
Severity: MEDIUM (was M1 demo cosmetic — doors functioned but didn't visually animate)
Filed: 2026-05-13
Component: animation / UpdateMotion (0xF74D) routing for non-creature entities
Closure: Closed by Phase B.4c on branch claude/phase-b4c-door-anim
(4 implementation commits). The complete animation round-trip for door entities
is now wired and visual-verified at the Holtburg inn doorway: double-click a
closed door → swing-open animation plays → player walks through → ~30s later
ACE broadcasts UpdateMotion (NonCombat, Off) → swing-close animation plays.
Implementation: spawn-time AnimationSequencer registration for door entities
in GameWindow.OnLiveEntitySpawnedLocked (Task 1, commit 9053860), with
initial state seeded from spawn.PhysicsState so closed doors initialize to
the Off cycle and open doors initialize to the On cycle. A [door-cycle]
diagnostic line in OnLiveMotionUpdated (Task 2, commit b89f004) confirms
each UpdateMotion is processed. A shared IsDoorName predicate (Task 2
review, commit 8a9b15e) eliminates duplication. A stance-value fix (bonus,
commit 454d88e) corrected NonCombat = 0x3D (not 0x01), which was causing
doors to render halfway underground due to empty sequencer frames.
Two follow-up items were filed: issue #61 (link→cycle boundary flash — brief visual flap at end of swing animation; low severity) and issue #62 (PARTSDIAG null-guard for sequencer-driven entities; latent, not currently reachable).
See docs/research/2026-05-13-b4c-shipped-handoff.md
for the full evidence trail, log output, and bonus-discovery narrative. M1
demo target "open the inn door" now has full visual feedback.
Files (what shipped):
src/AcDream.App/Rendering/GameWindow.cs—IsDoorSpawn/IsDoorNamehelpers, spawn-timeAnimationSequencerregistration branch inOnLiveEntitySpawnedLocked,_doorSequencersdict,[door-cycle]diagnostic inOnLiveMotionUpdated,TickAnimationsloop extended to advance door sequencers.src/AcDream.Core/Physics/AnimationSequencer.cs— no changes required; existing link+cycle API was sufficient.
#57 — [DONE 2026-05-13] B.4 interaction-handler missing: clicking on doors / NPCs / items silently does nothing
Status: DONE
Closed: 2026-05-13
Severity: HIGH (was M1 blocker)
Filed: 2026-05-12
Component: input / interaction / GameWindow.OnInputAction
Closure: Closed by Phase B.4b on branch claude/compassionate-wilson-23ff99
(9 implementation commits, Tasks 1-4 per plan + 4 bonus fixes). The
full round-trip — double-click door → WorldPicker.BuildRay + Pick →
InteractRequests.BuildUse → ACE SetState reply → ShadowObjectRegistry
mutation (via fixed ServerGuid→entity.Id translation) → CollisionExemption.ShouldSkip
exempts (widened to ETHEREAL-alone) → player walks through — was
visual-verified at the Holtburg inn doorway 2026-05-13. Four bonus
discoveries were required beyond the original plan: (1) InputDispatcher
had no double-click detection, (2) OnInputAction gate blocked
DoubleClick activations, (3) CollisionExemption required both
ETHEREAL+IGNORE_COLLISIONS while ACE sends only ETHEREAL, (4)
OnLiveStateUpdated passed server GUID to a local-entity-ID-keyed
registry. M1 demo target "open the inn door" met. See
docs/research/2026-05-13-b4b-shipped-handoff.md
for full evidence and rationale.
Files (what shipped):
src/AcDream.Core/Selection/WorldPicker.cs(new; formerly zero callers, now wired)src/AcDream.App/Rendering/GameWindow.cs—OnInputActionswitch cases forSelectLeft/SelectDblLeft/UseSelected;OnLiveStateUpdatedServerGuid→Id translation;_entitiesByServerGuidreverse-lookup dictsrc/AcDream.UI.Abstractions/Input/InputDispatcher.cs— double-click detectionsrc/AcDream.Core/Physics/CollisionExemption.cs— widened to ETHEREAL-alone
#55 — Static-entity slow path reports ~1.45M meshMissing per 5s at r4 standstill
Status: OPEN
Severity: LOW (no visible regression — affects a diagnostic counter, not rendered output)
Filed: 2026-05-11
Component: rendering / WbDrawDispatcher static-entity classification path
Description: During the Phase N.6 slice 1 baseline measurement (docs/plans/2026-05-11-phase-n6-perf-baseline.md §2),
the radius=4 standstill scenario reported meshMissing ≈ 1,450,000 per 5-second
[WB-DIAG] window. The same scenario while walking drops to near-zero (meshMissing = 0
in the steady state) as new landblocks stream in and previously-missing meshes resolve.
This suggests the static-entity slow path's mesh-load lifecycle has some delay before
populating for newly-streamed content but eventually catches up; the standstill case
keeps re-counting the same set of entities-with-unresolved-meshes for the duration of
the run. The counter is per-frame so the absolute number scales with FPS — at the
measured ~150 FPS that's ~290K reports/s, or ~1900 entities each reported each frame.
Root cause / status: Not investigated. Hypothesis: an entity classification path
counts mesh-missing on every frame for static entities whose MeshRef resolution races
the streaming loader. The Tier 1 cache (#53) populates only for entities whose
classification succeeded, so persistently-failing entities run the slow path every frame
forever and bump meshMissing every time. If true, the fix is either (a) cache the
"this entity's mesh genuinely doesn't exist" result so we stop re-checking, or (b)
deferred-classify the entity once its MeshRef resolves.
Files: src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs (the slow path that
increments _meshesMissing), src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs
(the Tier 1 cache — likely needs to learn about "permanently missing" entries).
Acceptance: meshMissing should drop to near-zero within ~5 seconds of streaming
settle at any radius/motion combination, not stay at ~1.45M/5s indefinitely at standstill.
#50 — [DONE 2026-05-11 · accepted WB divergence] Road-edge tree at 0xA9B1 visible in acdream but not retail
Status: DONE Closed: 2026-05-11 Severity: LOW (cosmetic; one spawned tree near the road in Holtburg) Filed: 2026-05-08 Component: scenery placement / Phase N (WorldBuilder rendering migration)
Resolution: Same disposition as #49 — accepted as WB-upstream
divergence from retail. The earlier fix attempt (e279c46, ACME-style
per-vertex road check) successfully removed this specific tree but
over-suppressed scenery elsewhere; revert at 677a726 stood. Without
a coherent port of ACME's full per-vertex filter set, piecemeal
patching is net-negative. Left as a documented WB divergence.
Original investigation (kept for reference):
Description: With ACDREAM_USE_WB_SCENERY=1 (default since commit b84ecbd),
a tree at landblock 0xA9B1 around (lx=85.08, ly=190.97) appears in acdream but
neither retail nor ACME WorldBuilder render it. Upstream Chorizite/WorldBuilder
DOES render it, so our migration to WB's helpers (Phase N.1) inherited this
discrepancy from upstream.
Root cause (suspected): ACME WorldBuilder includes a per-vertex road check that
skips the entire vertex when its road bit is set (see
references/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074).
The current vertex (4,8) has a road bit set in the dat. ACME skips it;
Chorizite/WorldBuilder doesn't; we don't.
Fix attempt that didn't work: commit e279c46 added the per-vertex road check
directly to our GenerateViaWb (and legacy Generate for parity). It successfully
removed the offending tree but over-suppressed scenery in other landblocks (visual
regressions during user testing). Reverted in commit 677a726. ACME's check likely
interacts with other factors (per-vertex building check, or something else in ACME's
pipeline) that we'd need to port together, not the road check alone.
Next steps:
- Investigate ACME's full per-vertex filter set (road + building + anything else) and port them as a coherent unit, not piecemeal.
- OR upstream the per-vertex road check to Chorizite/WorldBuilder (which is now our submodule fork) so it lands as a generic ACME-conformance improvement.
- OR consider switching fork target from Chorizite/WorldBuilder to ACME WorldBuilder for future phases (N.2+).
Visually undetectable to most users; one extra tree at one landblock. Defer until other Phase N work catches a similar issue and a coherent fix becomes obvious.
Files:
src/AcDream.Core/World/SceneryGenerator.cs—GenerateInternalis the active pathsrc/AcDream.Core/World/WbSceneryAdapter.cs— adapter used byGenerateInternalreferences/WorldBuilder-ACME-Edition/WorldBuilder/Editors/Landscape/GameScene.cs:1074— ACME's per-vertex road filter
#49 — [DONE 2026-05-11 · accepted WB divergence] Scenery (X, Y) placement drifts from retail at some landblocks
Status: DONE
Closed: 2026-05-11
Severity: LOW (minor cosmetic placement difference)
Filed: 2026-05-06
Component: scenery placement / SceneryGenerator
Resolution: Accepted as WB-upstream divergence from retail. Since the N.1 phase (WorldBuilder-backed scenery, see roadmap), acdream defers scenery placement math to the WB fork; retail and WB diverge slightly here on some landblocks. Piecemeal patching against WB upstream would create a maintenance burden disproportionate to the visible impact (a handful of trees positioned a few meters off across the world). Left as-is; revisit only if WB upstream patches the divergence or if a coherent ACME-style filter port (see issue body below) becomes worthwhile.
The original investigation plan (cdb trace of retail's
CLandBlock::get_land_scenes for diff against acdream's
SceneryGenerator output) is preserved below for historical
reference if anyone picks this up.
Original investigation (kept for reference):
Description: While verifying the #48 Z fix at Holtburg
landblock 0xA9B30001, the user spotted a scenery tree placed at
the wrong (X, Y) in acdream relative to retail at the same
character coords. Specifically: a large tree that retail places far
across the road on the right (east) side appears in acdream on the
left (west) side, near a chess board / picnic-bench area. Side-by-
side screenshot pair captured 2026-05-06.
This is not a Z bug — every tree in the same screenshot has its
trunk meeting the visible terrain (the #48 SampleTerrainZ fix is
working). It's also not the LandBlockInfo Stab path — the chess
board / bench themselves are correctly placed, so the landblock
origin and lbOffset math are right.
Hypotheses (need cdb retail trace to disambiguate):
- The displacement-noise math in
SceneryGeneratordiffers from retail'schunk_005A0000LCG by a constant or a sign flip. Auditeeee4c5claimed "all MATCH" against the decomp, but a runtime trace would prove or disprove. - Coordinate-system handedness: cell-local
(lx, ly)in our path may map to retail's(ly, lx)somewhere, rotating tree XY 90° around the cell's NW corner. - The
obj.Align != 0path in retail (FUN_005a6f60, aligns the object to the landcell polygon's normal) may use a different reference point than ours, drifting placement on sloped cells. - Slope filter could reject a cell retail accepts (or vice versa), pushing trees into adjacent cells.
- Region-table /
SceneInfolookup might select a different scenery list for the cell type.
Investigation plan (gold-standard, per project_retail_debugger.md):
- Run the existing
ACDREAM_DUMP_SCENERY_Z=1diagnostic to capture acdream's full per-spawn (gfx, world XY, scale, partT) for landblock0xA9B3FFFF. - Attach cdb to a live retail client at the same Holtburg spot
(
tools/pdb-extract/check_exe_pdb.pyconfirms PDB pairs with v11.4186). Set a breakpoint onCLandBlock::get_land_scenes(or the innerchunk_005A0000placement function); capture every(gfxObjId, worldX, worldY, scale, heading)retail emits for the same landblock. - Diff the two tables. The spawn that's offset will be obvious; the offset pattern (one tree, all trees, one species, constant delta, etc.) determines which hypothesis above is correct.
Files:
src/AcDream.Core/World/SceneryGenerator.cs— placement math (LCG noise, displacement, rotation, scale, slope filter)acclient!CLandBlock::get_land_scenes(docs/research/named-retail/acclient_2013_pseudo_c.txt) — retail entry pointchunk_005A0000.c— referenced retail source perSceneryGenerator.cscommentsdocs/research/named-retail/symbols.json— for cdb breakpoints
Acceptance: Side-by-side outdoor screenshot pair (acdream vs retail, same character coords, same time of day) shows scenery positions matching at multiple landblocks. The cdb trace + diagnostic diff documents quantitative agreement (zero offset within float precision) on at least one landblock end-to-end.
Out of scope here (kept under #48): Z floating. That's fixed.
#48 — [DONE 2026-05-06 · a469395] A few specific scenery trees hover above terrain (per-GfxObj Z misplacement)
Resolution: Hypothesis 2 (physics-sampler vs bilinear-fallback Z
mismatch). The bilinear fallback in GameWindow.SampleTerrainZ had
its two diagonal arms swapped — used the SEtoNW triangle test on
SWtoNE cells and vice versa. Every scenery hydration in our
diagnostic ran through the bilinear path (source=bilinear in all
[scenery-z] log lines) because physics hadn't yet built a
TerrainSurface for the streaming-in landblock — so on sloped
cells, scenery sat at a different Z than the visible terrain mesh
by up to ~1.5 m. The bug was latent since ff325ab (2026-04-17)
which upgraded the fallback from naive 4-corner bilinear to
triangle-aware barycentric, but with the diagonal-pair tests
swapped. TerrainSurface.SampleZ (used by the physics path / player
Z) was always correct, so player feet stayed flush — the two paths
just disagreed and only scenery noticed.
Fix: extracted the canonical triangle-pick math into
TerrainSurface.InterpolateZInTriangle (private static); added
TerrainSurface.SampleZFromHeightmap (public static) that reads
heights directly from the landblock byte array using the same
canonical math; redirected GameWindow.SampleTerrainZ to delegate
to it. New conformance test
SampleZFromHeightmap_AgreesWithInstance_AcrossWholeLandblock pins
both sampler paths together at 1500 sample points across both
diagonals, so future drift gets caught. User visually confirmed
2026-05-06.
The diagnostic dump (ACDREAM_DUMP_SCENERY_Z=1,
GameWindow.cs:4661) is kept committed — it's gated by env var,
zero cost when off, and is the right starting point for #49
(scenery X/Y placement) too.
Pseudocode: docs/research/2026-05-06-issue-48-fix-pseudocode.md.
Status: DONE Severity: LOW (cosmetic; ~3 trees per landblock, easy to ignore but obvious once spotted) Filed: 2026-05-06 Component: rendering / scenery placement / terrain Z sampling
Description: In outdoor landblocks, a small subset of tree scenery instances render visibly floating above the terrain (trunk base ~0.5–1.5 m above the ground line). The vast majority of scenery (other tree species, bushes, rocks) sits flush. The bug is per-GfxObj-id: the same handful of species float wherever they spawn; other species at the same (x, y) cell sit correctly. Side-by-side with retail in the same area: retail places the same species flush. User-confirmed via screenshot pair 2026-05-06.
The user noted this is the only thing left wrong with terrain rendering (canopy density / shape were not the issue — those match retail when looked at carefully). The bug is purely vertical offset on a few species.
Investigation 2026-05-06:
SceneryGenerator.cs:204
returns LocalPosition.Z = obj.BaseLoc.Origin.Z (just the
ObjectDesc's BaseLoc Z offset, no terrain). GameWindow.cs:4642
adds the terrain ground Z:
float groundZ = _physicsEngine.SampleTerrainZ(worldPx, worldPy)
?? SampleTerrainZ(lb.Heightmap, _heightTable, localX, localY);
float finalZ = groundZ + spawn.LocalPosition.Z;
Both samplers claim to use the AC2D split-direction terrain mesh formula. Player feet land flush, so player Z sampling is correct; scenery for most species is also flush; only specific GfxObjs float.
Three competing hypotheses (need one diagnostic to disambiguate):
-
Per-GfxObj origin convention. Most AC tree GfxObjs are authored with local origin at the trunk base (mesh vertices have
Z >= 0measured up from the origin). A few species may be authored with origin at bbox-center or visual top — for those,finalZ = groundZ + BaseLoc.Zplants the center at ground and the visible trunk floats by half its height. Per-GfxObj-id ⇒ deterministic across instances ⇒ fits the "same 3 species everywhere" pattern. -
Physics-sampler vs bilinear-fallback Z mismatch on NE↔SW-cut cells. The physics path uses the AC2D split-direction formula. The bilinear-fallback at
GameWindow.cs:4643uses naive bilinear over heightmap corners — wrong on cells whose visible triangle slopes the other way. If physics hasn't registered a landblock yet when scenery hydrates (timing race), affected scenery uses the bilinear sampler and lands on a different Z than the visible terrain. Player Z is fine because player movement always goes through the physics sampler. -
Same close-degrade story as #47, applied to scenery. Some tree GfxObjs have
DIDDegradetables; slot 0 (close-detail) and the base-LOD-3 mesh may have different mesh-local origins. We currently draw the base GfxObj id directly for scenery (the close-degrade resolver is scoped to humanoid setups only). Retail draws slot 0 for nearby trees. If slot-0 has origin at trunk-base while base-LOD-3 has origin at bbox-center, those species float by exactly the offset between the two origins.
Cheapest first move: add a one-shot scenery placement dump
gated by ACDREAM_DUMP_SCENERY_Z=1 that logs, per spawn:
[scenery-z] gfxObj=0xXXXXXXXX setupOrGfx=… worldPos=(x,y,z)
BaseLoc.Z=… groundZ=… meshZRange=[zMin..zMax]
hasDIDDegrade=true/false degrades[0]=0xXX
User identifies one floating tree → grep that GfxObj id in the
log → look at meshZRange and hasDIDDegrade. That tells us
hypothesis 1 (zMin > 0 by the float amount), hypothesis 2 (matching
species correctly placed elsewhere → timing race), or hypothesis 3
(hasDIDDegrade=true and slot 0 mesh has different zMin). One log
sample answers the question.
Files:
src/AcDream.Core/World/SceneryGenerator.cs:204— BaseLoc.Z passthroughsrc/AcDream.App/Rendering/GameWindow.cs:4632-4655— groundZ resolution + finalZ assemblysrc/AcDream.Core/Physics/TerrainSurface.cs— physics sampler (AC2D split-direction formula)SampleTerrainZ(private, in GameWindow.cs) — bilinear fallbacksrc/AcDream.Core/Meshing/GfxObjDegradeResolver.cs— close-degrade resolver if hypothesis 3 confirmed; would need scenery-scope expansion (drop theIsIssue47HumanoidSetupgate or add a scenery-aware variant)
Acceptance: All scenery species rest flush on the visible terrain mesh in side-by-side outdoor screenshots vs retail. No regression on the species that already render correctly.
Handoff: docs/research/2026-05-06-issue-48-handoff.md
#39 — Run↔Walk cycle transition not visible on observed player remotes (acdream-as-observer)
Status: CLOSED 2026-07-02 (superseded by L.2g; refinement machinery DELETED in the S5 commit)
Closure note: the root-cause narrative below is WRONG — the 2026-05-06
"wire goes silent on Shift toggle" finding was refuted at three oracles + a
fresh live capture (docs/research/2026-07-02-inbound-motion-deviation-map.md,
S0 section): retail sends a fresh MoveToState on HoldRun toggle while moving
(CommandInterpreter 0x006b37a8 → SendMovementEvent), ACE rebroadcasts every
MoveToState (GameActionMoveToState.cs:36), and the S0 capture shows explicit
0x0005↔0x0007 UMs on each toggle. Retail has NO pace→cycle adaptation
anywhere (DEV-2). The ApplyPlayerLocomotionRefinement layer this issue added
was itself causing Ready↔Run thrash against legitimate flags=0 stop UMs and
was deleted; player-remote cycles are UM-driven only. Remaining transition
polish (funnel, stop path, link pose) is tracked as roadmap L.2g S2–S4.
Original status: OPEN — VERIFY-PENDING (cases #1/#2/#4/#5 user-verified working 2026-05-06; cases #3/#6/#7 unverified in live test) Severity: LOW (most cases now visibly correct after the 2026-05-06 fix sequence; remaining unverified cases are direction-flip — believed to work via direct UM but not explicitly exercised) Filed: 2026-05-03 Component: physics / motion / animation
Description: When observing a remote-driven player character through acdream and the actor toggles Shift while keeping a direction key held (Run↔Walk demote/promote), the visible leg cycle does NOT update on the observer side. Body position eventually corrects via UpdatePosition hard-snaps (causing visible position blips), but the animation cycle stays at whatever it was last set to (Run sticks; Walk sticks).
Observation matrix:
| Observer | Actor | Cycle Run↔Walk | Z on slopes |
|---|---|---|---|
| Retail | Retail | ✓ | ✓ |
| Retail | Acdream | ✓ | ✓ |
| Acdream | Acdream | ✓ | ✗ (only with env-var path) |
| Acdream | Retail | ✗ | ✗ |
Root cause / status:
ACE only broadcasts a fresh UpdateMotion (UM) when the wire's
ForwardCommand byte changes — i.e. on direction-key state changes
(W press, W release). Toggling Shift while W is held changes
ForwardSpeed and HoldKey but NOT ForwardCommand, so ACE does
NOT broadcast a UM for the demote/promote. The speed change DOES
propagate via UpdatePosition (position-delta velocity changes
between Run-pace and Walk-pace), confirmed via [VEL_DIAG]
serverSpeed varying ~2.5 m/s (walk) ↔ ~9 m/s (run).
Retail's inbound code uses UP-derived velocity to refine the visible
cycle when no UM tells it. Acdream has the equivalent function —
ApplyServerControlledVelocityCycle in GameWindow.cs:3274 — but
it's gated if (IsPlayerGuid(serverGuid)) return; for player
remotes, exactly the case where the gap matters.
(Earlier hypothesized as H2 in the 2026-05-03 four-agent investigation but marked refuted because the [UPCYCLE] diag never fired — that was BECAUSE of the gate; un-gating reveals it firing per UP, which is the correct behavior.)
Fix sketch (~10 lines): un-gate ApplyServerControlledVelocityCycle
for player remotes when currentMotion is a locomotion cycle
(Run/Walk/Sidestep/Backward). UMs still drive direction-key changes
authoritatively; UP-derived velocity refines the speed bucket within
the same direction. Add a LastUMUpdateTime grace window (e.g.
500ms) so UMs win when fresh.
Files:
src/AcDream.App/Rendering/GameWindow.cs:3274—ApplyServerControlledVelocityCycle(the gateif (IsPlayerGuid(serverGuid)) return;to remove with conditions)src/AcDream.App/Rendering/GameWindow.cs:3640-3660— call site (already passes through with HasServerVelocity from synthesized UP-deltas)src/AcDream.Core/Physics/ServerControlledLocomotion.cs:54-76—PlanFromVelocitythresholds (may need re-tuning if banding is observed)
Research:
docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md— full background of the four-agent investigationdocs/research/2026-05-06-locomotion-cycle-transitions/investigation-prompt.md— expansion to the full 7-transition matrix (Run↔Walk forward + backward, Fast↔Slow strafe L+R, direction-flip cases) with TTD-driven workflowdocs/research/2026-05-06-locomotion-cycle-transitions/findings-static.md— static-analysis findings + scope of the 2026-05-06 candidate fix (case #1, Run↔Walk forward only)- This session's diagnostic logs at
tools/diag-logs/walkrun-A1b-*.log(UM_RAW, FWD_WIRE, SETCYCLE traces) confirming ACE's wire pattern
Acceptance:
- Observer in acdream watching a retail-driven character toggle Shift while holding W: visible leg cycle switches Run↔Walk within ~200ms of the wire change.
- No regression on the working cases (acdream-on-acdream, retail observers, idle↔Run, idle↔Walk).
- No spurious cycle thrashing during turning while running (ObservedOmega doesn't trigger velocity-bucket changes).
Progress 2026-05-06 — Shift-toggle cases (#1, #2, #4, #5) fixed; user-verified:
Five-commit sequence on this branch (claude/determined-solomon-d0356d):
| Commit | Effect |
|---|---|
8fa04af |
First candidate — added RemoteMotion.LastUMTime + ApplyPlayerLocomotionRefinement with 500 ms UM grace + forward-direction hysteresis. Ineffective because the call site lived in dead code for player remotes. |
863d96b |
Skip transition link in SetCycle for direct cyclic-locomotion → cyclic-locomotion. Reduces queue accumulation (qCount climbs slower); not the actual case-#1 fix but architecturally correct. |
bb026b7 |
Per-tick [CURRNODE] diagnostic — exposed that _currNode was correctly tracking SetCycle's intent and so the bug was elsewhere. Read-only. |
2653b30 |
Wire ApplyServerControlledVelocityCycle into the L.3 M2 player-remote path. Found via the diag — the existing call site at OnLivePositionUpdated line ~3879 was unreachable for players because the L.3 M2 routing returns at line 3755. New synth-velocity computation + call inserted in the player branch. User-verified working for forward Run↔Walk via Shift toggle. |
cc62e1c |
Handle backward (CurrentSpeedMod < 0 → preserve negative sign) and sidestep (low byte 0x0F / 0x10 → keep motion ID, refine magnitude). Backward regression resolved. |
349ba65 |
Use SidestepAnimSpeed (1.25) instead of WalkAnimSpeed (3.12) when computing sidestep magnitude — fix #4's mapping was 2.5× too small for slow strafe. |
Wire-level finding refuting the original ISSUES.md root-cause hypothesis: Earlier diagnostic claims that ACE broadcasts UMs on Shift toggle were misread. A clean test (launch-39-diag2.log) holding W and toggling Shift while held shows [FWD_WIRE] for retail-driven actor only emitting Ready ↔ Run transitions — no Walk wire transitions at all, despite a clear walk-pace ↔ run-pace shift visible in [VEL_DIAG]. So retail's outbound DOES go silent on HoldKey-only changes. The earlier launch's many Walk↔Run [FWD_WIRE] lines came from W press/release cycles with Shift held continuously — different scenarios.
Verified working (user, 2026-05-06):
- Forward Run↔Walk via Shift toggle (case #1)
- Backward Walk slow↔fast via Shift toggle (case #2) — animation matches direction, no rubber-band
- Strafe-left / strafe-right slow↔fast via Shift toggle (cases #4 / #5) — cadence visibly changes
Residual / not yet verified:
- "Not as fast as retail" — ~500 ms
UmGraceSecondswindow adds latency on top of the UP cadence (5–10 Hz). Could be tuned shorter once cases #3 / #6 / #7 are validated. - Direction-flip cases (#3 W↔S, #6 A↔D, #7 W↔A/D) — believed to work via direct UM, not explicitly verified yet.
New related issue filed: #45 — local-player slow-strafe-walk renders too slow. Same SidestepAnimSpeed vs WalkAnimSpeed mismatch pattern as fix #5, but on the local-player render path (UpdatePlayerAnimation), not the observer side.
#42 — [DONE 2026-05-05 · ec59a08] Airborne XY drift on observed player remote jumps (~1 m horizontal offset over arc)
Status: DONE
Severity: MEDIUM (pre-existing PhysicsEngine bug; exposed by L.3 M2 airborne UP no-op + M4 CellId fix)
Filed: 2026-05-05 (root cause confirmed same day)
Closed: 2026-05-05
Commit: ec59a08
Component: physics (PhysicsEngine.ResolveWithTransition → FindObjCollisions self-skip)
Resolution (2026-05-05): Self-collision in FindObjCollisions, not
any of the three originally-hypothesised mechanisms below. Live
entities (local player, remotes) register a Cylinder in
ShadowObjectRegistry at spawn (GameWindow.cs:2545) which
UpdatePosition keeps tracking the entity's live world position.
With no self-skip filter, the moving sphere's own cylinder is always
sitting at the body's exact position and CylinderCollision slides
the sphere out of overlap on every airborne tick. Validated by the
[SWEEP-OBJ] diagnostic added in commit a36369d: every drift event
showed gfxObj=0x02000001 (humanoid setup) at obj.Position exactly
matching the body's pre. Mirrors retail's CObjCell::find_obj_collisions
self-skip at named-retail line 308931:
if ((physobj->parent == 0 && physobj != arg2->object_info.object))
result = CPhysicsObj::FindObjCollisions(physobj, arg2);
Plumbing: ObjectInfo.SelfEntityId field, optional
movingEntityId = 0 parameter on ResolveWithTransition,
PlayerMovementController.LocalEntityId refreshed per-tick from
_entitiesByServerGuid[_playerServerGuid].Id, remote sweep at
GameWindow.cs:6474 passes kv.Key. Lock-the-fix unit test at
PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches.
Verified via two visual + log runs (launch-42-verify.log /
launch-42-verify2.log): zero stationary-jump drift across both,
gfxObj=0x02000001 phantom no longer appears in [SWEEP-OBJ],
no >0.5m pushes anywhere. The originally-listed hypotheses (H1
slope-driven AdjustOffset projection, H2 step-down probe, H3
EdgeSlide) were all RULED OUT by the first evidence run — cpN
was (0, 0, 1) flat for every drift event.
Diagnostic kept in tree: ACDREAM_AIRBORNE_DIAG=1 enables the
[SWEEP] + [SWEEP-OBJ] traces for future regression hunts.
The original investigation log is preserved below for context.
Root cause (verified 2026-05-05 via A/B test):
ResolveWithTransition running per-tick during the airborne arc is the
source of the drift. Verified by A/B-toggling the M4 CellId fix
(rmState.CellId = p.LandblockId) which is the gate that lets the
sweep run for player-remote jumps:
- CellId line removed → sweep skipped → jumps render with geometrically-correct XY (no drift) but body falls through the floor (no terrain catch).
- CellId line present → sweep runs → jumps land correctly but arc shows ~1 m horizontal offset from actor's actual XY; body snaps back on next inbound UM.
So the drift originates inside ResolveWithTransition itself, not
from wire data, not from local Euler integration, not from stale
velocity. Decision recorded in commit history: kept CellId fix in
production code so jumps land (fall-through-floor is more disruptive
to gameplay than ~1m visual jitter that resolves on next input).
This issue tracks the proper fix.
Description: When observing a retail-controlled remote that jumps in place (no horizontal input), the visible jump arc renders with a small horizontal offset from the actor's actual position — typically ~1 m to one side and slightly forward. Body lands at offset position (~X+1m). On the next inbound UM/UP from the actor (e.g., turning or moving), the body snaps back to the server's authoritative X.
User report 2026-05-05 (after M4 CellId fix): "I stand at position X and jump, it looks like im jumping slightly to the left of X like 1m-ish (if I observe jumping char from behind). It also lands at X + 1m-ish. Position resets to X when I issue some other command to the client like turning."
Why it surfaced now:
Pre-M2 (legacy path), OnLivePositionUpdated hard-snapped
rmState.Body.Position = worldPos on EVERY UP including mid-arc
airborne ones. ACE broadcasts intermediate UPs at ~5–10 Hz during
the jump arc with the actor's authoritative mid-arc position;
each snap kept our local body close to server, masking
local-integration error.
L.3 M2 (commit 40d88b9) implemented the retail-spec airborne no-op
in OnLivePositionUpdated:
if (!update.IsGrounded) {
entity.Position = rmState.Body.Position;
return;
}
Per docs/research/2026-05-04-l3-port/03-up-routing.md § 3:
Air branch (
has_contact == 0): the function falls through toreturn 0. This is the "AIRBORNE NO-OP" … The body keeps integrating gravity locally; received position is discarded.
This matches retail MoveOrTeleport @ 0x00516330 semantics. But it
removes the periodic server snapping that was masking ~1 m of
accumulated local-integration drift. The drift is pre-existing — the
user reports having seen it before — but is now visible for the
full arc duration instead of being corrected every ~200 ms.
Likely mechanism (ranked by probability):
-
Initial-overlap depenetration along non-+Z terrain normal — at jump start the collision sphere is touching the floor at body Z. Most outdoor terrain triangles are not perfectly horizontal — their normals have a small horizontal component. The sweep's first action each tick is to resolve overlap by separating the sphere along the contact normal; on a tilted terrain triangle that separation has horizontal magnitude. The body gets shoved sideways the first frame of the jump and the rest of the arc carries that initial drift. Direction-correlation with terrain orientation would confirm (test in different landblocks; if drift direction varies with the slope of the launch tile, this is it).
-
Step-down probe firing despite
isOnGround: false— sweep's internal "search for nearest walkable surface" might still scan horizontally during airborne ticks even when we passisOnGround: !rm.Airborne(= false for airborne). Check whether thestepUpHeight/stepDownHeightparameters are unconditionally used insideResolveWithTransitionregardless of theisOnGroundflag. -
EdgeSlide on near-vertical motion against a near-vertical surface — if the sphere even slightly grazes a wall while ascending or descending, EdgeSlide projects motion tangent to the wall, redirecting some Z velocity into XY. Less likely for open-ground stationary jumps but could explain drift near buildings.
Fix paths:
a. Skip initial-overlap depenetration when airborne — gate the
"separate from initial contact plane" step inside
ResolveWithTransition on isOnGround: true. Trusts the previous
tick's resolve to have left the body in a non-overlapping position.
This is the most likely-correct fix if hypothesis (1) is right.
b. Zero step-up/down for airborne sweeps — pass
stepUpHeight: 0f, stepDownHeight: 0f when rm.Airborne. Kills
hypothesis (2) without other side effects (airborne bodies don't
step anyway).
c. Stripped airborne sweep — replace the full sphere sweep with a simpler vertical sphere-vs-terrain intersection + wall-collision stop. Loses some retail fidelity but eliminates all three mechanisms. Probably overkill if (a) or (b) suffices.
Files:
src/AcDream.Core/Physics/PhysicsEngine.cs—ResolveWithTransitionand any internalCTransition/find_valid_positionhelpers. The initial-overlap depenetration path is the primary investigation target.src/AcDream.App/Rendering/GameWindow.cs:6478+(legacy airborne TickAnimations, the call site) — reference only; not the bug.
Reference:
Retail equivalent at
docs/research/named-retail/acclient_2013_pseudo_c.txt:
CTransition::find_valid_position(called fromtransition())SpherePathinitialization- The verbatim retail depenetration logic for airborne bodies
If our port differs from retail in this region, that diff is likely the bug.
Repro:
- Launch acdream + retail client side-by-side connected to local ACE.
- Have retail char stand still on outdoor terrain at any position X.
- Jump in place.
- Observe acdream window: arc renders ~1 m offset from X, lands offset, snaps back on next UM.
To verify the depenetration hypothesis specifically, repeat the jump in different landblock spots — drift direction should correlate with the local terrain normal, not the actor's facing.
Acceptance:
- Visual jump arc + landing render at the actor's actual XY position, no perceptible horizontal offset, no snap-back on next UM.
- Wall-collision airborne (jumping into building doorways, jumping puzzles) still works — fix must not strip collision wholesale.
#41 — Residual sub-decimeter blips on observed player remotes (M3 baseline)
Status: FIX IMPLEMENTED 2026-07-17 — pending acdream-observer visual gate
Severity: MEDIUM
Filed: 2026-05-05
Component: physics / motion / animation (per-tick remote prediction)
Phase: L.2 (Movement & Collision Conformance) — inbound-motion fidelity sub-piece.
2026-07-30 gate reconciliation (Campaign P P7): this issue's "pending user visual gate" status (2026-07-05/17) is superseded — its confirmation is formally folded into the Campaign P visual matrix scenario 8 (docs/plans/2026-07-30-physics-parity-visual-matrix.md) as the one consolidated gate. Automated backing accumulated since the fix shipped: the R6 rebaseline acceptance, every nine-stop soak (latest PASS 2026-07-30, logs/connected-r6-soak-20260730-131141), and the P3 sphere-list/response-swap conformance suites exercise these exact paths. The matrix result closes or reopens this issue.
Description: Observed characters walked forward, blipped backward, then continued, periodic with the server's UpdatePosition cadence. The earlier 2026-05-05 report called the residue sub-decimeter; on 2026-07-17 the user reported the same mechanism as plainly visible repeated rollback.
Root cause / fix: Retail CPhysicsObj::UpdatePositionInternal @ 0x00512C30
advances CPartArray::Update into a complete local root-motion Frame, then
lets PositionManager::adjust_offset replace that Frame with interpolation
catch-up. add_motion @ 0x005224B0 writes only literal
MotionData.Velocity × speed into CSequence. acdream instead synthesized
Walk/Run constants into AnimationSequencer.CurrentVelocity and reconstructed
a delta after the queue emptied. The installed Humanoid MotionTable proves
both Walk and Run carry zero MotionData velocity, so acdream ran past the last
server waypoint; the next UpdatePosition pulled it backward.
Remote animation now advances before remote physics into the already-ported
CSequence root-motion Frame. RemotePhysicsUpdater consumes that exact local
delta, and RemoteMotionCombiner applies the retail interpolation-replaces-
root-motion rule. Command-derived locomotion synthesis was removed from
CSequence; the local interpreted body path retains retail
CMotionInterp::get_state_velocity through its existing zero-data fallback.
Files:
src/AcDream.Core/Physics/AnimationSequencer.cssrc/AcDream.Core/Physics/RemoteMotionCombiner.cssrc/AcDream.App/Physics/RemotePhysicsUpdater.cssrc/AcDream.App/Rendering/GameWindow.cstests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs
Research:
docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.mddocs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md- named retail
CPhysicsObj::UpdatePositionInternal @ 0x00512C30,CPartArray::Update @ 0x00517DB0,add_motion @ 0x005224B0, andInterpolationManager::adjust_offset @ 0x00555D30
Acceptance:
- Visual blips disappear on flat-ground steady-state running.
- Side-by-side acdream-as-observer vs retail-as-observer of the same server-controlled toon: indistinguishable body trajectory.
#40 — [DONE 2026-05-05 · 40d88b9] ACDREAM_INTERP_MANAGER=1 env-var path regressed (staircase + blips)
Status: DONE — closed by L.3 M2 (feat(motion): L.3 M2 — queue-only chase for grounded player remotes, commit 40d88b9)
Resolution: The env-var gate was retired entirely. Both
OnLivePositionUpdated and TickAnimations now use
IsPlayerGuid(serverGuid) to route player-remote UPs through the
retail-faithful queue path (formerly the env-var path, but with two
key fixes per the L.3 spec):
PositionManager.ComputeOffsetis the per-tick translation source (REPLACE semantics: queue catch-up overrides anim root motion when active, anim stands when queue is idle / head reached). Mirrors retailUpdatePositionInternal @ 0x00512c30.ResolveWithTransitionis not called for grounded player remotes — server already collision-resolved the broadcast position, and sweeping per-tick on tiny queue catch-up deltas amplified micro-bounces into visible blips. This was the staircase + blip regression. Trade-off documented in audit § 6.
User-verified 2026-05-05: smooth body chase, no staircase on slopes, no per-UP rubber-band on flat ground. Residual sub-decimeter blips filed separately as #41 (velocity-synthesis magnitude).
Filed-original-context (for archive):
Status: OPEN (do-not-enable; pending L.3 follow-up rebuild) Severity: N/A (gated; default behavior unaffected) Filed: 2026-05-03 Component: physics / motion (per-tick remote prediction)
Description: The ACDREAM_INTERP_MANAGER=1 per-frame remote tick
introduced by commit e94e791 (L.3.1+L.3.2 Task 3) is a regression and
should not be enabled. Two visible symptoms:
-
Z staircase on slopes: observed remotes running up/down hills sink into rising terrain or float over receding terrain, then snap to correct Z at each
UpdatePositionarrival. Body never follows the terrain mesh between UPs. -
Position blips during steady-state motion: XY drifts unconstrained between UPs, then UP hard-snaps cause visible jumps.
Both symptoms ABSENT when env-var unset (default legacy path).
Root cause: the env-var path was designed to mirror retail
CPhysicsObj::MoveOrTeleport (acclient @ 0x00516330). MoveOrTeleport
is retail's network-packet entry point — minimal work. The per-frame
physics tick is retail's update_object (FUN_00515020) — full chain
including apply_current_movement → UpdatePhysicsInternal →
Transition::FindTransitionalPosition (collision sweep). The legacy
path mirrors update_object correctly. The env-var path stripped the
collision sweep on a wrong assumption that this was "more retail-
faithful" — it was the opposite.
Commit B (039149a, 2026-05-03) ported ResolveWithTransition into the
env-var path, but the symptom persisted because the env-var path also
clears body.Velocity for grounded remotes (no Euler integration of
horizontal motion → sweep input is the catch-up offset only, which
itself stair-steps because UPs are sampled at ~1 Hz).
Files:
src/AcDream.App/Rendering/GameWindow.cs:6042-6260— env-var per-frame branchsrc/AcDream.App/Rendering/GameWindow.cs:6260+— legacy per-frame branch (works)src/AcDream.Core/Physics/PositionManager.cs— class itself is retail-faithful (port of CPositionManager::adjust_offset), only the integration was wrong
Research:
- This session's
2026-05-03chronological commit log + visual verification docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.mdfor the four-agent investigation that traced this
Fix path (separate L.3 follow-up phase, NOT this session):
The PositionManager class is correct retail-port. Re-integrate it as
ADDITIVE refinement on top of the working legacy chain (small
correction toward queued server positions, applied AFTER
apply_current_movement + UpdatePhysicsInternal + collision sweep)
— not as a REPLACEMENT for them. Match retail's actual update_object
chain ordering: position_manager::adjust_offset runs after the
primary motion + collision resolution.
Acceptance:
- New per-tick path enabled via env-var (or default after stabilization) produces the same smooth slope motion + zero blips as the legacy path.
- Inbound
UpdatePositionqueue catch-up nudges body toward server authoritative position without overriding terrain Z snap or causing position blips. - Verification: side-by-side vs legacy default in 2-client setup, identical visible behavior.
#38 — [DONE 2026-05-06 · (this commit)] Chase camera + player feel "30 fps" since L.5 physics-tick gate
Status: DONE
Severity: MEDIUM (gameplay-feel regression; not a correctness bug)
Filed: 2026-05-01
Closed: 2026-05-06
Commit: (this commit)
Component: rendering / physics / camera
Description: User reports that running around in third-person / chase camera feels less smooth than it did before the L.5 physics-tick work. FPS counter still reads 60+, but the motion of the player character + camera looks like it's updating at ~30 fps.
Root cause / status:
Almost certainly the L.5 _physicsAccum gate in
PlayerMovementController.cs (lines ~448-456). Retail integrates
physics at 30 Hz (MinQuantum = 1/30 s); we ported that faithfully so
collision behavior matches. Side effect: _body.Position only updates
on physics ticks, i.e. every 33 ms. Render runs at 60+ Hz but the
chase camera follows _body.Position directly — so the visible
position changes in 33 ms steps, even though we render at 60+ FPS.
First-person is less affected because the world rotates with Yaw (which
does update every render frame); third-person is hit hardest because
the character itself is the moving thing.
Retail in 2013 didn't see this because render was also ~30 fps — render rate ≈ physics rate. Our 60+ Hz render exposes the gap.
Discussion + fix options at the end of docs/research/2026-05-01-retail-motion-trace/findings.md
("Other things still don't have…" → camera smoothness discussion in
chat, not yet captured in the doc — TODO migrate the discussion in).
Recommended fix: render-time interpolation between physics ticks
(standard fixed-timestep + interpolated rendering pattern from Quake /
Source / Unreal). Snapshot _prevPhysicsPos and _currPhysicsPos at
each tick; render player + camera target at
Lerp(_prev, _curr, _physicsAccum / PhysicsTick). Cost: ~33 ms visual
latency between input and what you see (matches retail's perceived
latency anyway). Network outbound stays on the discrete tick value —
no wire change.
Quick confirmation test before any code change: temporarily set
PhysicsTick to 1.0/60.0 and see if chase camera feels smooth again.
If yes, gate is confirmed cause. (Don't ship that — it'd undo the L.5
collision fixes.)
Files:
src/AcDream.App/Input/PlayerMovementController.cs:172—PhysicsTickconstantsrc/AcDream.App/Input/PlayerMovementController.cs:448-456—_physicsAccumgatesrc/AcDream.App/Rendering/GameWindow.cs— wherever player render position + chase camera read_body.Position
Research:
- L.5 background:
memory/project_retail_debugger.md(the 30 Hz MinQuantum gate, the cdb trace evidence) - Discussed during 2026-05-01 motion-trace work
Acceptance:
- Chase-camera run-around at 60+ FPS feels as smooth as render rate suggests (no perceptual stepping) — user visually confirmed 2026-05-06.
- Network outbound (MoveToState / AutonomousPosition cadence + values) unchanged from current behavior
- Collision behavior unchanged (the L.5 wedge / steep-roof scenarios still resolve correctly)
- Observer view from a parallel retail client unchanged
#37 — [DONE 2026-05-11 · resolved by 0bd9b96] Humanoid coat doesn't extend up to neck (visible "skin stub" between hair and coat)
Status: DONE
Closed: 2026-05-11
Commit: 0bd9b96 (the #47 humanoid degrade-resolver fix, 2026-05-06)
Severity: LOW (cosmetic; doesn't affect gameplay)
Filed: 2026-05-01
Component: rendering / clothing / textures
Resolution: Closed by the same mesh-fidelity work that resolved #47.
The GfxObjDegradeResolver (commit 0bd9b96, 2026-05-06) swapped
humanoid parts to their higher-detail Degrade[0].Id meshes (e.g.
upper arm 0x01000055 → 0x01001795, lower arm 0x01000056 → 0x0100178F).
The higher-detail meshes include the coat-collar polygons that the
low-detail meshes were missing — which is what was exposing the
skin-toned palette indices in the upper-coat region. With the
correct mesh resolution, those polygons cover the previously-visible
"skin stub". User confirmed visually 2026-05-11.
The original 2026-05-01/2026-05-04 investigation work (palette range analysis, SubPalette overlay tracing) is preserved below for historical reference; it was a correct read of what was rendering, but the root cause was the missing collar polygons, not the palette gap.
Original investigation (kept for reference):
Description: Every humanoid character (player + NPCs) wearing a coat shows a visible skin-colored region at the top of the coat where retail shows continuous coat fabric. From the back view: hair → skin stub → coat top. In retail: hair → coat collar (no exposed skin). This was originally reported as "head/neck protruding forward" — the apparent forward shift is an optical illusion caused by the missing coat collar.
Investigation 2026-05-01 (~3 hr session, conclusively ruled out many hypotheses):
What we ruled out:
- Animation source.
ACDREAM_USE_PLACEMENT_BASE=1(force chars toSetup.PlacementFrames[Resting]instead ofAnimation.PartFrames[0]) → stub still visible. - Backface culling / mesh winding.
ACDREAM_NO_CULL=1(disableglCullFaceentirely) → stub still visible. - Palette overlay (SubPalettes).
ACDREAM_NO_PALETTE_OVERLAY=1(skipComposePalette) → stub still visible (other colors broke as expected — confirms overlay was firing). Bug is NOT a body-skin SubPalette being mis-applied to coat fabric. - Bug source = part 16 (head).
ACDREAM_HIDE_PART=16→ head goes away, stub remains UNCHANGED (clean coat top with same shape). Stub is NOT from head GfxObj polygons. - Per-part placement frame Origin.
ACDREAM_NUDGE_Y=-0.1confirmed+Y = forwardin body-local; head Origin (0, 0.013, 1.587) places head correctly relative to spine. Math checks out.
What we confirmed (data is correct):
- Player Setup
0x02000001(Aluvian Male), 34 parts. - Server (ACE) sends
animParts=34 texChanges=12 subPalettes=10. - Part 9 (upper torso/coat) has gfx
0x0100120Dafter AnimPartChange. - Part 9 has 2 surfaces, BOTH covered by 2 TextureChanges
(
oldTex=0x050003D5→0x05001AFE,oldTex=0x050003D4→0x05001AFC). - Stub IS from part 9:
ACDREAM_HIDE_PART=9→ entire torso (including stub region) disappears. - Per-part composition formula (
Scale × Rotation × Translation) matches ACME'sStaticObjectManager.cs:256-258and retail decomp'sFrame::combineat0x00518FD0.
Investigation 2 (2026-05-04, 5 parallel agents + dat probes):
ALL of the obvious hypotheses ruled out:
- Byte-level decode primitive matches ACViewer. INDEX16/P8/DXT/BGRA paths are byte-identical.
- Polygon emission matches retail. All 43 polygons of gfx
0x0100120DareSidesType=0(ST_SINGLE), all surfaces areBase1Image— NO ST_DOUBLE polygons we'd be missing, NO surfaces lacking theType & 6bits that retail'sDrawPolyInternalskips. - Per-PART texture-override scoping is correct.
resolvedOverridesByPart[partIdx]gets per-MeshRef'd; not a global flat map (Agent 3's claim was wrong). - SubPalettes are full-size (Colors.Count=2048) palettes. Our
subPal.Colors[idx]indexing matches ACViewer'snewPalette.Colors[j + offset]. - The
*8wire un-pack is correctly single-applied (parser stores raw bytes; ComposePalette multiplies once).
The actual smoking gun (Investigation 2):
For +Acdream the server sends 10 SubPaletteSwap ranges that overlay palette indices:
[0..320), [576..1024), [1392..1488), [1728..1920). The complement — indices [320..576), [1024..1392), [1488..1728), [1920..2048) — is NOT overlaid. Base palette 0x0400007E at those indices contains the original red/skin tones (sampled values: 0x46 0x22 0x04, 0x4A 0x28 0x09, etc).
If the coat texture's UVs at the upper region map to texel-bytes whose palette index lands in one of those non-overlaid ranges, those pixels render with base-palette skin tones. That's the visible "skin stub at the top of the coat".
Working hypothesis: either
- ACE sends incomplete SubPalette ranges (retail-original would cover the full palette)
- Retail does additional client-side compute that ACE pre-resolves wrongly
- The base palette
0x0400007Eitself is supposed to have coat colors at those indices in retail's interpretation (different palette decode)
Next investigation (deferred):
- Diff ACE's
WorldObject_Networking.csCharGen ObjDesc construction against retail'sClothingTable::BuildObjDesc(acclient_2013_pseudo_c.txt:436261). Check if ACE actually walks every CloSubPaletteRange in the chosen PaletteTemplate, or skips some. - RenderDoc capture: confirm which texel/palette-index the upper-region polygons sample.
tools/InspectCoatTex/Program.csis the diagnostic harness — extend it.
Files (diagnostic env vars committed for next-session reuse):
(file deleted in N.5 ship amendment)src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275—ACDREAM_NO_CULLenv varsrc/AcDream.App/Rendering/GameWindow.cs—ACDREAM_HIDE_PART=Nhides specific humanoid part;ACDREAM_DUMP_CLOTHING=1dumps AnimPartChanges + TextureChanges + per-part Surface chain coverage.src/AcDream.App/Rendering/TextureCache.cs:159-204—DecodeFromDatsis the texture decode entry. Compare againstreferences/WorldBuilder-ACME-Edition/.../TextureHelpers.cs.
Reproduction:
$env:ACDREAM_LIVE = "1"; $env:ACDREAM_DEVTOOLS = "1"
# normal launch — visible from chase camera looking at +Acdream's back
Stub is visible on +Acdream and on every NPC humanoid (Pathwarden, Town Crier, Shopkeeper Renald, etc.).
Acceptance: Side-by-side retail + acdream rendering of +Acdream shows coat extending up to chin level on both. No exposed skin between hair and coat.
#L.1 — Hotbar UI panel
Status: OPEN Severity: MEDIUM Filed: 2026-04-26 (deferred from Phase K) Component: ui / hotbar
Description: Number keys 1-9 are bound to UseQuickSlot_1..9
actions but no panel exists. Actions fire (visible via the [input]
console log) but produce no visible result. Phase L feature: drag-drop
hotbar with up to 5 bars × 9 slots, drag spell/skill icons to slots,
key activates the slot's contents. Server-side: CreateShortcutToSelected
(action 0x0A9 in retail motion table) sends a UseSelected on slot
fire.
Files: src/AcDream.UI.Abstractions/Panels/Hotbar/ (TBC).
Acceptance: Drag an item or spell into slot 1, press 1, server
responds as if the user clicked the item.
#L.2 — Spellbook favorites panel
Status: DONE 2026-07-15 Severity: MEDIUM Filed: 2026-04-26 (deferred from Phase K) Component: ui / magic
Resolution: The authored gmSpellcastingUI page inside combat layout
0x21000073 now exposes all eight server-persisted favorite tabs, equipped
caster endowment, spell selection/name, drag-remove/drop-add favorite edits,
cast button, and UseSpellSlot_1..9. Cast intent goes through the single
SpellCastingController; ACE remains authoritative for turning, motion,
components, mana, fizzle, impact, and completion.
The same slice also mounts the authored Helpful/Harmful effect indicators
(0x21000071, buttons 0x100000F5/F6) and routes their panel IDs 4/5 through
the shared retail gmPanelUI lifecycle, so closed effect lists have an exact
reopen path and replace the current main panel instead of becoming orphaned.
#L.3 — Combat-mode tracking + scope-aware Insert/PgUp/Delete/End/PgDn dispatch
Status: DONE 2026-07-15 Severity: MEDIUM Filed: 2026-04-26 (deferred from Phase K) Component: input / combat
Resolution: Every binding now persists its retail InputScope; schema 4
migrates older files. CombatState.CurrentMode selects the Melee/Missile/Magic
shadow scope, held actions release atomically on any scope/binding transition,
and reentrant callbacks cannot replay a stale held-chord snapshot. Settings
conflict checks and rebinds preserve the scoped identity.
#L.4 — F-key panels: Allegiance / Fellowship / Skills / Attributes / World / SpellComponents
Status: PARTIAL — magic/character/inventory surfaces shipped Severity: LOW Filed: 2026-04-26 (deferred from Phase K) Component: ui
Current state: Retained Inventory, Skills, Attributes, Spellbook, and
SpellComponents panels now exist and their F-key actions route through the
window manager. The spell/component book is the authored 0x21000034 tree,
with learned-spell filters, exact DAT spell/component icons, component
categories, selection, scrolling, and server-persisted desired amounts.
Allegiance, Fellowship, and World remain separate open panel features.
#L.5 — Floating chat windows (Alt+1-4)
Status: OPEN Severity: LOW Filed: 2026-04-26 (deferred from Phase K) Component: ui / chat
Description: Alt+1..4 toggle four floating chat windows in retail.
Phase K binds the actions; ChatPanel currently is a single window.
Floating windows would need filtered-by-channel-type chat tail
rendering.
#L.6 — [DONE 2026-07-13] UI layout save/load (saveui / loadui / lockui)
Status: DONE Severity: LOW Filed: 2026-04-26 (deferred from Phase K) Component: ui
Resolution: The retained production UI now implements @saveui [name],
@loadui [name], @saveautoui, @loadautoui, and @lockui. Named profiles
persist all mounted retail windows independently of character/resolution;
automatic profiles continue to use the existing character-and-resolution
layout store. Both / and @ prefixes route through the shared typed retail
command catalog. The JSON storage remains the documented IA-15 modern
adaptation of retail's UIElement_Position serialization.
#L.7 — Joystick / gamepad bindings
Status: OPEN Severity: LOW Filed: 2026-04-26 (deferred from Phase K) Component: input
Description: Retail keymap declares 11 Joystick devices in the
Devices block but no actions are bound by default. acdream uses
Silk.NET keyboard+mouse only. Adding Silk.NET joystick support + a
JoystickInputSource adapter would unlock controller play.
KeyChord.Device byte already supports values >1, so the binding
side is ready.
#L.8 — Plugin / scripting / macro input subscription
Status: OPEN Severity: MEDIUM Filed: 2026-04-26 (deferred from Phase K) Component: plugin / input
Description: CLAUDE.md goal: "Build acdream's plugin API to
support scripting/macros for player automation." Plugins should be
able to register custom actions (with namespaced IDs like
mymacro.heal-rotation) and subscribe to InputAction events. Phase K
foundation supports this via the multicast InputDispatcher; what's
missing is the plugin-API surface.
#334 — Large static formations lose collision at their boundaries (Neftet)
Status: DONE (2026-08-06) — retail's CPhysicsObj::find_bbox_cell_list @0x00510fc0 path is ported. A physics-BSP object's outdoor cell membership is now the FILLED land-cell rectangle its authored CGfxObj::gfx_bound_box spans, crossing landblock boundaries freely, instead of the fixed 3×3 sphere neighbourhood. Awaiting the user's live gate at the same Neftet formations.
Fix. CellTransit.BuildShadowCellSetFromParts + CellTransit.AddAllOutsideCellsFromParts (CLandCell::add_all_outside_cells @0x00533360 + add_cell_block @0x005331d0, disassembled from the PDB-paired 2013-09-06 binary — Binary Ninja mis-renders four separate constructs inside that one function); ShadowPartGeometry / ShadowPartBox carry the BSP root sphere AND the authored box as one value; ShadowObjectRegistry.RegisterMultiPart dispatches on HAS_PHYSICS_BSP_PS exactly as retail does at 0x00515285, and BuildFloodSpheres' BSP arm is deleted rather than left unreachable. Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read “extra broadphase candidates, never a missed one” — #334 is a missed one); AP-159 (indoor part-array overload, issue #335) and AD-49 (seed-time rectangle) filed.
Cost, measured over the installed DATs BEFORE any code was written (1,258 physics-BSP GfxObjs with vertices): cells/object p50 = 4, p90 = 4, p99 = 12, max 49 (7×7). The port is CHEAPER than the old 3×3 = 9 for 98.97% of them — the crossover is exact, any object under 24 m of XY extent yields at most 2×2. Row totals (shapes × cells) over all 1,031 landblocks carrying BSP owners fall from 97,173 to 15,607 (0.161×); dense Arwic 0xC6A9 falls 342 → 43 (0.126×). Exactly ONE landblock more than doubles (0x8964, 45 → 112 rows, 2.489×). The worst single-owner rectangle in the whole world is 81 cells (9×9) in 0x8766 — above the 7×7 bound predicted from root-sphere statistics, because that bound assumed the BSP root sphere bounds the whole vertex array and it bounds only the physics polygons' subset.
Precondition confirmed before any expected cell set was pinned: 0x010046D8's authored box is 96 m × 96 m about a part origin at block-local (63.78, 56.29) — cell (2,2) = 0x87640013, which independently corroborates the 3×3-centred-at-0x87640013 diagnosis derived from the live probe. Its rectangle spans cell columns 0..4 on both axes and DOES contain 0x87640011 and 0x87640019, the two cells the probe measured empty.
Gate note (AP-158 / #333): the fix is necessary and not sufficient in general. A player at the far corner of a large new rectangle can still be discarded by the broadphase reach filter, which measures from the part origin. The live gate FAILS if inCell rises while rejectedReach rises with it; the remedy for that is #333, not a wider budget here.
Original finding below.
Status (at filing): OPEN Severity: HIGH — walk-through and fall-through on world geometry. Filed: 2026-08-06, user-reported in live play. Component: physics / collision / broadphase
User report, verbatim in substance: "In Neftet I miss collision on the large stone formations. I can run up to them, but then at a boundary between I can run through. I can jump over it but then I just fall through."
NOT a regression from the 2026-08-06 collision work. Confirmed by A/B: the
user reproduced it on a purpose-built client at 52175aa1 — before AP-22,
AP-152, AP-156, AD-10 and #276's remainder — and the behaviour was identical.
It is pre-existing and was simply never filed. AP-156's cell-membership fix
did not resolve it either, which is itself a diagnostic clue (see below).
The signature: solid on approach, permeable at a boundary between two formations, and no floor above it — jumping over lands you inside/through. "Solid near the middle, absent at the edges" is the shape to reason from.
MEASURED IN GAME 2026-08-06 — cause found, and it is NOT the reach filter
The reach-filter theory is REFUTED. A live probe (ACDREAM_PROBE_REACH,
commit b61f5fd4) was run with the user standing in front of, and then inside,
a Neftet formation. Evidence: 334-neftet-probe.log, 8,401 lines.
Standing inside the formation the collision system reported:
[reach-q] cell=0x87640019 inCell=2 exempt=2 reached=0 rejectedReach=0 tested=0 blocked=0
Two candidates, both the player's own body spheres. Nothing was rejected
because there was nothing to reject — the formation is not in the collision
candidate set at all. rejectedReach=0 kills AP-158 as the cause here.
The blocking part the user found is the discriminator. One object does
collide: gfx=0x010046D8, a BSP with objR=69.471 — the landblock's baked
rock geometry, one object spanning a large area. Its coverage:
| Cell | Rock object present? |
|---|---|
0x8764000A, 0x87640012 |
YES — tested-collided / tested-slid, player blocked |
0x87640011, 0x87640019, 0x87630018 |
NO — inCell=2, both entries the player |
Same object, same landblock, present in some cells and absent from directly
adjacent ones. 0x87640012 is cell index 17 → grid (2,1); 0x87640011 is
index 16 → grid (2,0), immediately beside it and empty.
Cause: a landblock-spanning object is registered by a single bounding sphere
ShadowObjectRegistry.BuildFloodSpheres derives cell membership from one
sphere per part. This object's own radius is 69.471 m while a landblock is
192 m across, so a single sphere cannot geometrically reach every cell the
mesh occupies. Cells beyond its reach receive no registration and the player
walks through.
Retail does not use a sphere here. CPhysicsObj::calc_cross_cells @0x00515230
routes BSP-bearing objects to find_bbox_cell_list @0x00510fc0 →
calc_cross_cells_static @0x00518160, i.e. a walk over the object's
extent, not a single enclosing sphere.
Relationship to AP-156 (b52967de), which did not fix this and was not
expected to: AP-156 moved the flood sphere to the right place (it was
centred on the part origin while sized from the geometry). That was necessary
and is confirmed correct — but the sphere is also the wrong shape for objects
whose extent exceeds their own radius. AP-156 fixed position; this issue is
about coverage. Sequential, not alternative.
Superseded — the original leading candidate, retained for provenance
Superseded detail — AP-158 / #333, the broadphase reach filter
TransitionTypes.cs:3898-ish measures currPos - obj.Position (the part
origin) against obj.Radius, admitting a contact only when the geometry
sits within roughly movement + 2 m of that origin. Retail has no distance
pre-filter at all: CObjCell::find_obj_collisions @0x0052b750 dispatches
unconditionally, its only early-out being INITIAL_PLACEMENT_INSERT. The 2 m
slack is acdream invention; retail's own epsilon in this family is 0.0002 m.
118 of 477 unique BSP GfxObjs exceed that ~2.5 m budget; 46 exceed 5 m. A "large stone formation" is precisely the class that would: geometry extending many metres from its part origin. Near the origin you collide; past the budget the broadphase rejects the object before its mesh is ever consulted — solid in the middle, permeable at the edges, no floor overhead. That matches the report without needing a second mechanism.
This also explains why AP-156 did not help: AP-156 put the object in the right cells; AP-158 is why it is still rejected within those cells. AP-156 was a prerequisite, not a cure — the two are sequential, and this issue is the observable that proves the second half still bites.
Second candidate, if the first is disproved
Static-object collision registration at EnvCell seams — the per-cell shadow
list family (#98's architecture, closed b3ce505, and #137's door/wall-opening
work, closed 2026-07-08). Both are closed, so this would be a new gap rather
than a regression of either.
How to settle it
- Identify the specific Setup/GfxObj of a Neftet formation that reproduces, and measure its BSP root-sphere origin offset. If it exceeds ~2.5 m, AP-158 is confirmed as the cause and this issue closes with AP-158's fix.
- If the offset is small, the reach filter is exonerated and the second candidate takes over.
Do not attempt a fix before step 1. The reach filter's + 2f slack has no
retail counterpart, so "widen the budget" would be tuning an invented constant
— the fix is to remove the filter, which needs AP-158's own gate.
#32 — Retail edge-slide / cliff-slide / precipice-slide incomplete
Status: CLOSED 2026-08-07 — both halves. Local half fixed 332045c7
(set/init contact-plane split), user-passed 2026-08-07 at the Rithwic cliff;
see the closure entry near the top of this file. Remote half below.
Original status line: REMOTE HALF CLOSED — fixed 204d0ae0, user-passed 2026-08-04
("it lands and slides correctly now"). A remote observed in acdream now slides
down a steep face under gravity instead of freezing on it and then blipping.
The three recorded gaps below (LeaveGround chatter bound, the !Ok airborne
latch, and the contact_allows_move action-animation watch item) remain open,
as does the AP-140 follow-up (point the two routing gates at Body.InContact).
Local-player edge-slide is unchanged by this work.
Severity: HIGH
Filed: 2026-04-29
Component: physics / collision
Description: When walking along walls, roof edges, cliff edges, or failed step-down boundaries, retail often slides along the boundary. acdream still hard-blocks or accepts too much in several of these cases.
Root cause / status: Tracked under Phase L.2c. Wall-adjacent
step_up_slide now feels acceptable in live testing. Local/remote movement
passes the retail-default EdgeSlide flag. The first precipice-slide slice now
preserves terrain/BSP walkable polygon vertices and runs the retail back-probe
before SPHEREPATH::precipice_slide; edge-slide Slid / Adjusted results
now feed the TransitionalInsert retry loop instead of being reverted by outer
validation, and a synthetic diagonal terrain-boundary test covers tangent
motion. ACDREAM_DUMP_EDGE_SLIDE=1 now reports whether a failed step-down had
polygon context.
L.4/L.5 update 2026-04-30: A retail debugger trace (cdb attached to v11.4186 acclient.exe — see #35) confirmed that retail does NOT wedge on the steep-roof scenario that produces the wedge in our acdream port. Three concrete findings:
- Retail's
OBJECTINFO::kill_velocityrarely fires in normal play — gated onlast_known_contact_plane_valid, which our L.2.4 proximity guard tends to clear before steep-poly hits land. Retail trace: 0 kill_velocity hits across 40,960 update_object calls. Our Phase 3 reset path now matches retail's gate (only kills when valid). - Retail integrates physics at 30Hz (
MinQuantum = 1/30 s); render is 60+ Hz. UpdatePhysicsInternal/update_object ratio = 0.61. We ported this gate as L.5 inPlayerMovementControllervia_physicsAccum. Render still runs at 60+ Hz; only the physics integration step is 30Hz. - The remaining wedge cause — body's pre-position drifts to the
polygon's tangent and gravity's tangent component into surface
produces a stable retain-collide-revert loop — is a downstream
consequence of retail's grounded-on-steep escape chain
(
step_sphere_up→step_up_slide→cliff_slide) being incompletely ported. Live test confirmed retail-strict Path 6 produces "lands on roof in falling animation, can't slide off" half-state because that chain doesn't produce smooth descent.
Pragmatic ship-state: BSPQuery Path 6 keeps the L.4 slide-tangent deviation (project-along-steep-face-and-return-Slid) for steep-poly airborne hits. It produces user-acceptable "slide off the roof" behavior at the cost of departing from retail's Path 6 → SetCollide → Path 4 → Phase 3 reset chain. Retail-strict requires the step_up_slide / cliff_slide audit below; until that lands, slide-tangent is the right deviation.
Remaining gaps: real-DAT building-edge fixtures, fuller cliff_slide
coverage, NegPolyHit dispatch, and the retail-strict
step_up_slide / cliff_slide audit (filed for follow-up). Named retail
anchors include CTransition::edge_slide, CTransition::cliff_slide,
SPHEREPATH::precipice_slide, and SPHEREPATH::step_up_slide.
Files: src/AcDream.Core/Physics/TransitionTypes.cs,
src/AcDream.Core/Physics/BSPQuery.cs,
tests/AcDream.Core.Tests/.
Research: docs/plans/2026-04-29-movement-collision-conformance.md,
docs/research/2026-04-30-precipice-slide-pseudocode.md.
Acceptance: Synthetic and real-DAT tests cover wall-slide, roof-edge slide, cliff/precipice slide, failed step-up/step-down, and the jump-clears-edge case.
2026-08-04 live route 4a test — Bug B (remote roof-plant half-state, one of the two symptoms this row already named) confirmed and root-caused: the user's two-client test reproduced exactly the "lands on roof in falling animation, can't slide off" half-state this row already describes, and this time as a REMOTE jumping onto a house: it plants on the roof, then blips to a slid-down position after drifting away rather than sliding smoothly.
Root cause: the player-remote landing block
(src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs, the
if (rmState.Airborne) transition) and its per-tick twin
(src/AcDream.Runtime/Physics/RuntimeRemotePhysicsUpdater.cs:~493-551) both
assert Body.TransientState |= Contact | OnWalkable UNCONDITIONALLY on
landing. Retail derives on_walkable from the contact plane instead —
CPhysicsObj::SetPositionInternal (named symbol @0x00515330, pseudo-C
:283501-283509):
if (contact_plane.N.z < floor_z)
set_on_walkable(0);
else
set_on_walkable(1);
A steep roof is Contact (the sphere is touching it) but NOT on_walkable (its
normal.Z is below floor_z) — retail keeps sliding it. Forcing both bits true
suppresses the slide response outright; the body then sits planted on the
roof until AP-87's 4 m drift-snap backstop (docs/architecture/retail- divergence-register.md row AP-87) fires and blips it to the server's
already-slid-down position — the visible "plant, then teleport" the user
reported. This code is byte-identical to the pre-C4-route-4a version (verified
via git show 19d95094:src/AcDream.App/Physics/LiveEntityNetworkUpdateController.cs,
which shows the identical unconditional TransientState |= Contact | OnWalkable at the same landing site) — this is not a route 4a regression;
do not revert 44830a0e.
Fixing OnWalkable alone at the landing block may not be sufficient to
reproduce retail's slide, because retail's slide response also depends on two
other pieces that are either incomplete or unverified for remotes:
- #173 (this file) shipped the remote collision-velocity reflect
(
CPhysicsObj::handle_all_collisionspc:282699-282715) but its dedicated visual gate was folded into the Campaign P matrix scenario 8 and that gate has not actually been run/confirmed yet — the reflect path this fix needs is unverified in practice, not just untested in isolation. - AD-10 (
docs/architecture/retail-divergence-register.md) — remote slope projection samples ONLY the terrain normal (PhysicsEngine.SampleTerrainNormal, consumed bysrc/AcDream.Core/Physics/RemoteMotionCombiner.cs), which cannot see building/EnvCell geometry at all. A house roof has no terrain normal to project against, so even a correctedOnWalkablewould need a real contact-plane-derived slide, not the terrain-only approximation AD-10 already flags as a divergence. Superseded 2026-08-06: AD-10 was RETIRED BY DELETION, and this paragraph's premise turned out to be inverted. Remote bodies DO run the full sweep (ResolveWithTransition->Transition.AdjustOffset, retailCTransition::adjust_offset0x0050a370 per sub-step), so the roof slide was already driven by a real contact-plane projection; the terrain sample was an EXTRA, non-retail layer on top of it and is now gone. Measured redundant before deletion — see the retired register row.
The investigation stopped there per project policy and instrumented a probe
(ACDREAM_PROBE_REMOTE_LANDING, then the [remote-slide-*] family in
PhysicsDiagnostics.cs). See
docs/research/2026-08-04-remote-landing-investigation.md for the companion
Bug A (falling-animation-lingers) hypothesis set and the probe's decision
table, and docs/research/2026-08-04-bug-b-remote-slide-diagnosis.md for the
full four-link chain.
Bug B FIXED 2026-08-04 (awaiting the user's two-client visual gate). The
live capture settled it: two adjacent ticks 63 ms apart on a 52.4-degree roof
(contact-plane Normal.Z 0.6097 against FloorZ 0.6642) showed the sweep
reporting rsInContact=True rsOnWalkable=False, and the tick then committing
contact=True onWalkable=True gravity=False velBeforeZero=(2.146,2.264,0.000)
and moved=0.0000 on every tick afterwards. acdream's classifier was
correct and was being overruled. Four independent writes did it, and all four
are gone:
RuntimeRemotePhysicsUpdater.TickassertedTransientState |= Contact | OnWalkableon every tick a remote was not flagged airborne. Retail writes CONTACT_TS fromcollision_info.contact_plane_valid(CPhysicsObj::SetPositionInternal@0x00515330, 0x00515430) and routes ON_WALKABLE_TS throughset_on_walkable(@0x00511310) purely oncontact_plane.N.z >= PhysicsGlobals::floor_z(0x00515465-0x0051548E). With both bits forced,calc_acceleration(@0x00510950) returned zero acceleration andcalc_friction(@0x0050EE70) — whose entire body sits insidetransient_state & 2— could not engage either.- The same block zeroed
Body.Velocity. Retail'sMoveOrTeleport(@0x00516330) never reads or writes a remote's wire velocity at all; the zeroing discarded both the authoritative0xF74Evector and everything gravity had accumulated. - The tick consumed only
ResolveResult.Position/CellId/IsOnGroundand calledHandleAllCollisionsbare — the TAIL ofSetPositionInternalwithout its prefix. The sweep's ownInContact/OnWalkablewere never committed, and the landing edge was decided fromIsOnGround, which isinContact || ...and is therefore TRUE on a steep contact. The tick now runs the fullPhysicsObjUpdate.CommitSetPositionTransitionsequence (contact prefix ->set_on_walkableedge ->handle_all_collisions@0x005154FE), gated onOk && candidateMovedexactly likePlayerMovementControllerand retailUpdateObjectInternal(pc:283657). - Both landing blocks cleared
PhysicsStateFlags.Gravity. Retail never toggles GRAVITY_PS on a ground edge: theCPhysicsObjconstructor seeds it (state0x400C08@0x00512508) andset_state(@0x00514DD0) assigns the description's state wholesale, post-processing only lighting/nodraw/hidden. The matchingState |= Gravityin the VectorUpdate jump handler is deleted too, so the bit is now wire-owned end to end.
Consequential cleanups in the same change: MovementManager::HitGround now has
the single retail source it has in the binary — the set_on_walkable(1)
edge — so the packet-side landing block no longer dispatches its own
(which would have double-fired the landing re-apply); and RemoteMotion.Airborne
is now derived on the SetPositionInternal commit from the committed
Body.OnWalkable, which is the project's one existing definition of the flag
(PlayerMovementController.IsAirborne, the spawn settle,
RemoteTeleportPlacement, TickHidden). Only the fact it derives FROM moved,
from the hand-rolled IsOnGround test to the sweep's contact-plane result.
Known follow-up, deliberately not changed here — now register row AP-140.
Because Airborne remains !OnWalkable, a remote sliding on a steep face is
classified airborne, so an accepted grounded Position takes the
AirborneSnap/landing-snap arm and hard-snaps to the server position at UP
cadence instead of feeding the interpolation queue. Retail's predicate for that
same decision is the CONTACT transient, not walkability
(InterpolationManager::adjust_offset @0x00555D30 gates its whole body on
transient_state & 1 @0x00555D52), so a retail body in contact with a
non-walkable face keeps interpolating. The two disagree on exactly one state,
and this fix turned that state from unreachable (the deleted forge made every
non-airborne remote walkable by construction) into ordinary — which is why it
is now a filed divergence rather than an unremarked one. The bound is one UP
interval to the authoritative position, and the between-packet motion is now a
genuine local slide rather than a freeze, so the composite reads as continuous.
The follow-up slice is re-shaped (2026-08-04 review): do NOT re-derive
Airborne from CONTACT. That was prepared and backed out here because it
perturbs all five Airborne = !Body.OnWalkable writers and contradicts a
pinned assertion in
RemoteTeleportPlacementTests.Apply_PendingGroundToSteepContact_RestoresSourceWalkabilityForFirstAcceleration
(InContact: true, OnWalkable: false → Assert.True(remote.Airborne)). The
right change is smaller: point the two routing gates
(ApplyRemoteContactRouting's if (remote.Airborne) and OnPosition's
player-remote if (rmState.Airborne)) at remote.Body.InContact directly and
leave the Airborne flag alone. That is the literal retail predicate at the
one place the predicate is used, and it touches no existing test.
AP-87's 4 m snap is deliberately untouched. It is the #184
invisible-but-solid backstop; this change removes the CAUSE of the divergence
that made it fire, and the expected consequence is that it fires far less often.
InterpolationManager's node_fail_counter > 3 stall snap is likewise
untouched — the capture confirmed producer=ap87-4m, so the stall snap was
never the producer here, and it is a faithful port of
InterpolationManager::UseTime @0x00555f20.
Register: AP-81 narrowed (its whole GRAVITY half retired), AP-87
annotated, AP-139 filed for the interpolation-queue clear the deleted
landing block used to own, AP-140 filed at the review for the
walkability-vs-CONTACT routing predicate above. Coverage:
tests/AcDream.Runtime.Tests/Physics/RuntimeRemoteSteepContactSlideTests.cs
(10 tests over a synthetic constant-gradient ramp, each individually
discriminated against a reverted fix).
Recorded gaps from the 2026-08-04 Opus review — the fix PASSED; these are known, deliberately unfixed, and none of them was changed in the tightening pass that recorded them.
- The
LeaveGrounddispatch is new and unbounded. ThepreviousOnWalkable && !finalOnWalkablearm callsMotionInterpreter.LeaveGround(), which is a per-remote dispatch acdream never made before. It is retail-shaped (CMotionInterp::LeaveGround@0x00528B00 — creature gate @0x00528B36, Gravity-state gate, then the velocity install @0x00528B66), but note what it DOES:GetLeaveGroundVelocity(@0x005280c0) replaces the body's velocity withget_state_velocity()plus a jump Z, thenRemoveLinkAnimations+apply_current_movementre-dispatches motion. Nothing bounds how often it can fire: the suite bounds the HitGround edge at exactly 1 (WalkableLandingStillLandsAndFiresTheGroundEdgeOnce) but has no counterpart for LeaveGround, and noisy geometry — a walkable lip alternating with a steep face across the sweep — could chatter the edge and re-dispatch motion every tick. Compare the #270 lesson inclaude-memory/project_physics_collision_digest.md: a per-stats-refreshReportExhaustionre-dispatch produced 490 spurious stance re-queues in one session; never re-add a per-tick re-apply. A LeaveGround-count bound is the missing test. - The primary watch item for the visual gate: action animations on remotes
that fail to establish contact. The deleted per-tick force was, in
practice, a blanket guarantee that every non-airborne remote carried
Contact | OnWalkable.contact_allows_move(@0x00528dd0) silently refuses action animations for a body lacking both — that is the exact root cause of closed issue #270 ("monster attacks but the animation never fires", "stuck in cast pose"). Post-fix, any remote whose sweep fails to establish contact loses its attack/cast animations. On a 52.4-degree roof that is retail-correct and is the point of the change. Anywhere else it is thefeedback_latent_bug_masked_by_fallbackshape: the forge was masking contact failures, and removing it exposes every one of them. Watch for missing attack/cast animations on ordinary flat ground during the two-client gate; if any appear, the bug is in contact establishment, not in this fix. - A remote whose transition keeps failing never re-derives
Airborne. The whole SetPositionInternal commit is gated onOk && candidateMoved, andrm.Airborne = !rm.Body.OnWalkableis assigned only inside it, while the packet-side landing block no longer clearsAirborneeither. A remote whose transition keeps returning!Ok— the #116/#182 wedge class — therefore stays flagged airborne indefinitely: it keeps integrating gravity, and every grounded UP hard-snaps it back, producing sink-and-snap jitter at UP cadence. Bounded (each snap is to the authoritative position) and its reachability is unproven, but it is new with this change and did not exist while the forge ran.
Still open on this row: the two dependencies named above. #173's remote
collision-velocity reflect now genuinely runs on a steep contact (the old code
passed IsOnGround as nowOnWalkable, which suppressed the reflect exactly
where retail forces it), but its visual gate is still unrun. AD-10's
terrain-only slope projection still cannot see building geometry; it is now
correctly gated OFF while the body is not on walkable ground, so a roof slide
is driven by gravity plus the sweep's own plane projection rather than by that
approximation. Closed 2026-08-06: AD-10 was retired by deletion — the
terrain-only projection no longer exists anywhere in the tree, so this
dependency is discharged rather than merely gated. The roof slide is driven by
gravity plus the sweep's own contact-plane projection, which is what retail
does. The retail-strict step_up_slide/cliff_slide audit that this
row was originally filed for is unchanged.
#35 — [DONE 2026-04-30] Retail debugger toolchain (cdb + PDB GUID matching)
Status: DONE Severity: N/A (infrastructure) Filed + closed: 2026-04-30 Component: tooling / research
Description: When the question is "what does retail actually DO at runtime?" — wedges, animation flicker, geometry-specific bugs where the decomp is correct but the visible behavior is mysterious — there was no way to attach a debugger to a live retail acclient.exe and trace it. This issue tracks the toolchain that closed that gap.
What shipped:
tools/pdb-extract/check_exe_pdb.py— reads any PE's CodeView entry and reportsMATCH/MISMATCH (expected GUID = …)against ourrefs/acclient.pdb. Always run before attaching cdb.tools/pdb-extract/dump_pdb_info.py— dumps a PDB's expected build timestamp + GUID + age. Used to figure out which acclient.exe build pairs with our PDB (answer: v11.4186, Sept 2013 EoR).- CLAUDE.md "Retail debugger toolchain" section — full workflow:
cdb path, sample
.cdbscript, PowerShell wrapper pattern, watchouts (PDB name conventions,;parsing, kill-target-on-detach behavior, high-hit-rate lag). - Step
-1added to the development workflow — "ATTACH cdb TO RETAIL (when behavior is the question, not code)". Tells future sessions: when guessing has failed twice in a row, don't keep guessing.
Discoveries this toolchain enabled (closed in same session):
- Retail integrates physics at 30Hz (
UpdatePhysicsInternal/update_objectratio = 0.61). Drove the L.5 fix in PlayerMovementController. OBJECTINFO::kill_velocityrarely fires in normal play (gated on last_known_contact_plane_valid). Our acdream port now matches.- Retail does NOT wedge on the steep-roof scenario. Confirmed our L.4 slide-tangent deviation in Path 6 is necessary until the retail step_up_slide / cliff_slide chain audit lands.
Files: tools/pdb-extract/check_exe_pdb.py,
tools/pdb-extract/dump_pdb_info.py, CLAUDE.md,
memory/project_retail_debugger.md.
Acceptance: Future sessions can attach cdb to a live retail client in under 5 minutes by following the CLAUDE.md workflow.
#36 — [DONE 2026-05-11 · promoted to Phase C.1.5c] Sky-PES dispatch port (consolidates #2 / #28 / #29 visual gaps)
Status: DONE (promoted to Phase C.1.5c)
Closed: 2026-05-11
Promoted to: Phase C.1.5c (Sky-PES dispatch chain) — see roadmap docs/plans/2026-04-11-roadmap.md
Severity: MEDIUM (aesthetic feature-parity, but addresses a cluster of bugs)
Filed: 2026-04-30
Component: sky / weather / particles
Resolution: Promoted to a roadmap phase (C.1.5c) — the work is
multi-commit (decomp dive + persistent-emitter creation + PES timeline
driver + PES script execution + live-trace verification) and warrants
a named phase rather than living forever as an "open issue." The
decomp anchors, live-trace evidence (24,576-frame GameSky::Draw
trace), and 6-step implementation outline in the body below remain
the authoritative implementation reference; the roadmap phase entry
is the schedule/scope tracker. Issues #2 (lightning), #28 (aurora),
and #29 (cloud thinness) auto-close when C.1.5c ships.
Original investigation (kept as implementation reference):
Description: Three open sky bugs (#2 lightning, #28 aurora, #29 cloud density) all trace back to the same missing infrastructure: retail's sky-PES (Particle Effect Script) dispatch chain. We have it now from a 2026-04-30 cdb live trace.
What retail does (live trace evidence):
Trace over 24,576 GameSky::Draw frames:
GameSky::Draw = 24,576 (60 Hz render rate)
GameSky::UseTime = 12,288 (30 Hz — half rate, MinQuantum)
GameSky::CreateDeletePhysicsObjects = 12,288 (also 30 Hz)
CPhysicsObj::CallPES = 372 (~150/min average)
CallPESHook::Execute = 372 (1:1 with CallPES)
CreateParticleHook::Execute = 62 (15 at cell load + 47 burst at transition)
CPhysicsObj::create_particle_emitter = 62 (matches CreateParticleHook)
Three findings:
- Retail has persistent particle emitters on celestial / sky objects. Created at cell load (15 initial) and dynamically as conditions change (the trace caught a +47 burst on a region/weather/time transition).
- The PES script-hook system (
CallPESHook::Execute→CPhysicsObj::CallPES) drives those emitters periodically, ~150 times per minute on average. - Earlier research said "GameSky doesn't read pes_id" — correct in scope, but missed that the dispatch chain runs through the script- hook system, not from inside GameSky directly. Cell/region/weather handlers schedule PES script hooks; those hooks call into CallPES.
Decomp anchors:
CallPESHook::Execute@0x00526e20— script-hook action that fires CallPESCreateParticleHook::Execute@0x00526ec0— particle-creation hookCPhysicsObj::CallPES@0x00511af0CPhysicsObj::create_particle_emitter@0x0050f360GameSky::CreateDeletePhysicsObjects@0x005073c0LongNIHash<ParticleEmitter>instance — emitter registryCelestialPosition.pes_id@ struct offset +0x004 — populated bySkyDesc::GetSkybut consumed downstream ofGameSky(via the hook system, not GameSky itself)
Implementation outline:
- Decomp dive: read
CallPESHook::Execute,CreateParticleHook::Execute,CPhysicsObj::CallPES, andGameSky::CreateDeletePhysicsObjects(and any cell/region weather handlers that spawn the dynamic 47). - Identify what triggers
CreateParticleHookfor sky objects — is it insideCreateDeletePhysicsObjects, the region/weather change handler, or somewhere else? - Port the persistent-emitter creation path: when a cell loads or weather/time changes, instantiate the appropriate ParticleEmitters on celestial objects.
- Port the PES timeline driver — periodic dispatch from a script
timeline into our equivalent
CallPES. - Port the actual PES script execution (rate of emission, particle parameters, etc.) into our particle system.
- Live verify with cdb during specific weather windows: aurora at dusk on Rainy DayGroup, lightning during storm.
Files (likely):
src/AcDream.App/Rendering/Sky/SkyRenderer.cs— emitter wiringsrc/AcDream.Core/World/SkyDescLoader.cs— already parses pes_idsrc/AcDream.Core/Particles/*— particle system foundationsrc/AcDream.App/Rendering/ParticleRenderer.cs— visual layer
Live-trace verification plan (next cdb session): Reattach to retail
during a specific aurora moment, log this pointer + pes_id arg on
every CallPES invocation, log the GfxObj being attached on every
create_particle_emitter. That tells us EXACTLY which celestial
objects retail PES-drives and with which IDs.
Acceptance: During the same in-game time/weather where retail shows aurora-style light play (Rainy DayGroup, dusk/dawn windows), acdream shows comparable colored sky effects. Cloud sheets look as dense / purple as retail. Lightning flashes appear during storm windows.
Closes-when-done: #28, #29, partially #2 (lightning may need additional flash-shader work).
#2 — Lightning visual mismatch (sky PES path disproved)
Status: OPEN Severity: MEDIUM Filed: 2026-04-25 Component: weather / sky / vfx
Description: Lightning/storm sky visuals still do not match retail. A 2026-04-28 named-retail recheck disproved the prior assumption that SkyObject.PesObjectId drives sky-render flash particles: SkyDesc::GetSky copies the field into CelestialPosition.pes_id, but GameSky::CreateDeletePhysicsObjects, GameSky::MakeObject, and GameSky::UseTime never read it.
Root cause / status: Open again. The sky-PES path is non-retail and must stay disabled for normal rendering. The remaining mismatch likely lives in the sky/weather mesh material path, the lightning/fog flash path, or another weather subsystem outside GameSky; do not reintroduce per-SkyObject PES playback without new decompile evidence.
Files:
src/AcDream.App/Rendering/Sky/SkyRenderer.cs— sky/weather mesh draw, material state, pre/post splitsrc/AcDream.App/Rendering/Shaders/sky.frag— flash/fog/lightning coloration pathsrc/AcDream.Core/World/SkyDescLoader.cs— keepPesObjectIdparsed for diagnostics, not render playback
Research:
docs/research/2026-04-28-pes-pseudocode.md— C.1 correction:CelestialPosition.pes_idcopied but ignored by GameSkydocs/research/2026-04-23-sky-pes-wiring.md— earlier decompile trace reached the same no-sky-PES conclusiondocs/research/2026-04-23-lightning-real.md(decompile trace + dat discovery)docs/research/2026-04-23-physicsscript.md(runtime semantics)docs/research/2026-04-23-lightning-crossfade.md(crossfade mechanism)
Acceptance: During a Rainy DayGroup's storm window, visible flashes appear in the sky at the dat-scripted moments, the fragment-shader flash bump briefly brightens the scene, and (later, once thunder audio is wired) a thunder clap plays with a short propagation delay.
See also #36 (Sky-PES dispatch port) — the lightning visuals likely route through the same PES-hook chain that drives aurora and cloud-density. Most of #2's storm-flash visuals will be unblocked by the #36 port.
#3 — Client clock drifts from retail after ~10 minutes (periodic TimeSync missing)
Status: OPEN
Severity: MEDIUM
Filed: 2026-04-25
Component: net / sky
Chore tag: Single-commit fix — well-scoped ~10-line wiring. WorldTimeService.SyncFromServer(double) already exists; just needs WorldSession to detect header-flag 0x1000000 and call it. Pickup at any opportunistic session.
Description: Our WorldTimeService.DayFraction syncs with the server once at login via ConnectRequest + TimeSync, then advances from the local wall-clock. Retail receives periodic TimeSync refreshes (header flag 0x1000000) carrying a fresh PortalYearTicks double and re-anchors its clock. Without those, acdream's keyframe state drifts from retail's over 10+ minutes — observed during the 2026-04-24 sky-color debug sessions where retail was at DayFraction 0.976 while acdream was at 0.634.
Root cause / status: Mechanism is well-understood (see research). WorldTimeService.SyncFromServer(double) already exists — we just need to detect the periodic flag in the packet header and call it whenever a fresh tick arrives.
Files:
src/AcDream.Core.Net/WorldSession.cs— header-flag parsing; currently only the initial sync is consumedsrc/AcDream.Core/World/WorldTimeService.cs—SyncFromServer(double ticks)ready; needs caller wiring
Research: docs/research/deepdives/r12-weather-daynight.md §TimeSync (line ~563). References retail packet-header flag 0x1000000 carrying PortalYearTicks double.
Acceptance: Probe retail via tools/RetailTimeProbe and acdream's ACDREAM_DUMP_SKY log at the same wall-clock moment after a 20-minute session without re-login; abs(acdream.DayFraction - retail.DayFraction) < 0.01.
#28 — Aurora ("northern lights") effect not rendered
Status: OPEN Severity: LOW (aesthetic feature-parity) Filed: 2026-04-26 Component: sky / vfx
Description: Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect.
Root cause / status: Open again. The prior root cause was wrong: CelestialPosition.pes_id exists in the retail header and is populated by SkyDesc::GetSky, but named retail GameSky code does not read it during sky object creation, update, or draw. A 2026-04-28 C.1 experiment that played those PES ids produced colored blobs/wash that did not match retail's broad aurora-like rays, and the path is now debug-only behind ACDREAM_ENABLE_SKY_PES=1.
Retail header at acclient.h line 35451 still documents the copied field:
struct CelestialPosition {
IDClass<...> gfx_id;
IDClass<...> pes_id; // ← particle scheduler ID
float heading; float rotation;
Vector3 tex_velocity;
float transparent; float luminosity; float max_bright;
unsigned int properties;
};
StarsProbe confirmed Dereth Rainy DayGroup 3 carries multiple PES-bearing entries (verified 2026-04-27). Sample for the user's observed Warmtide-Rainy state:
| OI | Gfx | PES | Active window | Notes |
|---|---|---|---|---|
| 5 | 0x02000714 | 0x330007DB | always | low-rate background |
| 7 | 0x02000BA6 | 0x33000453 | 0.03–0.19 | early morning |
| 17 | 0x02000589 | 0x3300042C | 0.27–0.91 | active during user's screenshot |
acdream's geometry half is now wired (commit landing 2026-04-27 — EnsureSetupUploaded walks Setup.Parts for 0x020xxx IDs). The remaining dynamic visual half is not SkyObject.PesObjectId; likely suspects are sky/weather mesh material state, texture transform/blending, or a separate weather/lightning subsystem outside GameSky.
Implementation outline:
- Keep
SkyObject.PesObjectIdparsed for diagnostics only. - Compare retail/acdream material state for the active sky/weather GfxObj/Setup ids (
0x02000588,0x02000589,0x02000714,0x02000BA6). - Trace the named retail sky/weather draw path for texture transforms, translucency, diffusion, luminosity, and any non-GameSky weather effect dispatch.
- Only add a new runtime visual path once the decompile has an actual caller.
Decomp pointers:
SkyDesc::GetSkynamed retail0x00501ec0— copiesSkyObject.default_pes_objectintoCelestialPosition.pes_id.GameSky::CreateDeletePhysicsObjectsnamed retail0x005073c0— creates/updates sky objects fromgfx_id, does not readpes_id.GameSky::MakeObjectnamed retail0x00506ee0— callsCPhysicsObj::makeObject(gfx_id, 0, 0), no PES.GameSky::UseTimenamed retail0x005075b0— updates frame/luminosity/diffusion/translucency, no PES.
Files:
src/AcDream.Core/World/SkyDescLoader.cs— carriesPesObjectIdfor diagnostics.src/AcDream.App/Rendering/Sky/SkyRenderer.cs— likely material/texture-transform parity work.src/AcDream.App/Rendering/GameWindow.cs— sky-PES playback remains debug-only, disabled by default.
Acceptance: When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time.
See #36 (filed 2026-04-30) — a live cdb trace confirmed retail's aurora rendering uses the script-hook PES dispatch chain (CallPESHook::Execute → CPhysicsObj::CallPES) on persistent particle emitters, with a cell-load population (15 initial emitters) plus dynamic spawning on region/weather/time transitions (caught a +47 burst). Implementation work consolidated under #36.
#29 — Cloud surface 0x08000023 still appears thinner than retail despite blend-mode + Setup fixes
Status: OPEN Severity: LOW (aesthetic feature-parity) Filed: 2026-04-27 Component: sky / clouds
Description: User screenshot comparison showed acdream's clouds let too much sun through; retail's are denser and have a purpleish tint. Two follow-up fixes landed without visible improvement:
TranslucencyKindExtensions.FromSurfaceTypenow applies retail's Translucent-override atD3DPolyRender::SetSurface(decomp 425246-425260) — surface0x08000023(Type=0x10114=B1ClipMap | Translucent | Alpha | Additive) is now correctly classified asAlphaBlendinstead ofAdditive.SkyRenderer.EnsureSetupUploadednow loads0x020xxxxxSetup IDs (e.g.0x02000588,0x02000589,0x02000714,0x02000BA6) which were silently dropped. Setup parts are flattened viaSetupMesh.Flattenand uploaded with their per-part transform baked into vertex positions.
Despite both being decomp-correct fixes, the user reports no observable visual change in dual-client comparison. Two follow-up hypotheses:
- The Setup objects are tiny placeholder meshes (one
0x010001ECpart each) that exist mainly to anchor a PES emitter — the cloud "density" / "purple sheen" the user perceives is entirely the PES particle layer, not the static mesh. - The cloud surface might still be rendering correctly per its dat data, and what looks "thicker" in retail is the additional aurora-like PES sheen overlaid on top.
If hypothesis (a) is correct, this issue effectively rolls into #28 — the PES rendering work would resolve both.
Files:
src/AcDream.Core/Meshing/TranslucencyKind.cs— Translucent overridesrc/AcDream.App/Rendering/Sky/SkyRenderer.cs—EnsureSetupUploaded
Acceptance: Cloud sheets look as dense/purple as retail in dual-client side-by-side. May require #28 (PES) to land first.
See #36 (filed 2026-04-30) — confirmed via live cdb trace: retail's cloud density comes from the same PES-driven particle-emitter chain as aurora. Implementation consolidated there.
2026-07-09 triage: investigated, verdict STILL_OPEN — the #36 consolidation that was supposed to auto-close this via Phase C.1.5c never shipped (still PLANNED per the roadmap) and the sky-PES path is gated off by default (ACDREAM_ENABLE_SKY_PES, off), so the underlying cloud-density fix never landed on main.
#47 — [DONE 2026-05-06 · 0bd9b96] Humanoid Setup 0x02000001 renders bulky / lacks shape detail vs retail
Status: DONE
Closed: 2026-05-06
Commit: 0bd9b96
Severity: MEDIUM (cosmetic — characters readable but visibly different from retail)
Filed: 2026-05-06
Component: rendering / mesh / character animation
Resolution: Root cause was that we drew the base GfxObj id from
Setup / AnimPartChange directly. Retail's CPhysicsPart::LoadGfxObjArray
(0x0050DCF0) treats that base id as an entry point to the
DIDDegrade table; for close/player rendering it draws
Degrades[0].Id, which is the higher-detail mesh that carries the
bicep / deltoid / shoulder geometry. ACViewer also has this bug —
that was the key signal it wasn't acdream-specific.
Concrete swaps the resolver now performs:
- Aluvian Male upper arm
0x01000055→0x01001795(14/17 → 32/60 verts/polys) - Aluvian Male lower arm
0x01000056→0x0100178F - Heritage variants:
0x010004BF → 0x010017A8,0x010004BD → 0x010017A7,0x010004B7 → 0x0100179A, etc.
Fix landed as GfxObjDegradeResolver, default-on and scoped to humanoid
setups (34-part with ≥8 null-sentinel attachment slots). Set
ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for diagnostic before/after
comparisons. User confirmed visually 2026-05-06.
Files: src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs,
src/AcDream.App/Rendering/GameWindow.cs (wiring), 5 unit tests in
tests/AcDream.Core.Tests/Meshing/GfxObjDegradeResolverTests.cs.
Research note: docs/research/2026-05-06-issue-47-close-degrade-pseudocode.md.
Original investigation (kept for reference)
Description: Every humanoid character using Setup 0x02000001
(Aluvian Male) renders in acdream with a "bulky, less-defined" silhouette
compared to retail's view of the same character. Specifically: shoulders
look smoother/rounder where retail has pointier shoulder pads; back has
less contour; arms appear puffier. The effect is identical for player
characters (+Acdream, +Je) and for humanoid NPCs using the same
setup (e.g. Woodsman, Sedor Wystan the Blacksmith, Thelnoth Cort).
Drudges and other monster setups (e.g. 0x020007DD) render
identically to retail, so this is not a pipeline-wide bug.
The bug is independent of equipment — +Je stripped naked still
shows the same bulky silhouette.
Investigation 2026-05-06 (~3 hr session, ruled out many hypotheses):
What was ruled out:
- 0xF625 ObjDescEvent appearance updates being dropped. Was a real
bug for skin/hair colors; fixed in commit
e471527. Does not affect the bulky-shape issue (which persists with the fix in place and with no equipment). - Position-pop on equip toggle. Caused by re-applying with cached spawn's stale position; fixed in same commit. Doesn't affect shape.
- Clothing/armor overlapping the base body (HiddenParts hypothesis). User stripped naked; bulky shape persists.
- ParentIndex hierarchy not walked in
SetupMesh.Flatten. Setup0x02000001has a real hierarchy (-1, -1, 1, 2, 3, -1, 5, 6, 7, 0, 9, 10, 11, 12, 13, 14, 15, 0, ...), but implementing parent-walk produced no visible change — confirming AC's idle animation frames are already in setup-root coordinates, not parent-local. - Equipment / wielded items. No equipment on
+Jeand bug persists. - Player-specific data flow. Humanoid NPCs using same setup (Woodsman) show same bug.
What was confirmed (data captured via ACDREAM_DUMP_CLOTHING=1):
- Setup
0x02000001:setup.Parts.Count = 34,flatten.Count = 34,APC = 34..38depending on equipment. - All 34 parts emit triangles successfully (no silent GfxObj load failures). Total ~648-700 tris per character.
- Idle animation frames place parts at sensible humanoid Z-heights (head Z=1.587, mid-body Z=0.5-1.0, ground Z=0.085).
- Per-part orientations are nearly all 180° around -Z (W≈0, Z≈-1) — a setup-wide coordinate-flip convention. Drudges have varied per-part orientations.
setup.DefaultScale.Count = 0for both humans and drudges → all parts use Vector3.One scale.
Working hypotheses (next session):
- Per-vertex normal style. AC dat may store per-face normals for human GfxObjs (one normal per polygon, copied to all 3 vertices) but smooth normals for monster GfxObjs. acdream uses dat normals directly. Test by computing smooth normals from face adjacency and comparing render. User said "not shaders" but the screenshots clearly show smooth-vs-faceted lighting differences.
- Lighting setup. Cell ambient may be too low, leaving back-
facing surfaces in flat shadow. Compare
uCellAmbientvalue against retail's behaviour at the same time-of-day. - Anti-aliasing. Retail may use MSAA; acdream window may not. Polygon edges in acdream would be visibly stair-stepped, reading as "more faceted" / blockier.
- Surface flags interpretation. Specific Surface.Type bits for
character textures (skin, fabric) may need handling acdream
doesn't yet do (e.g.
SmoothShadeflag, or a mip bias).
Diagnostic infrastructure landed this session (env-var-gated, no runtime cost when off):
ACDREAM_DUMP_CLOTHING=1extended:setup.Parts.Count,flatten.Count,APCcount on header lineParentIndex[]array dumpDefaultScale[]array dumpIdleFrame.Frames[]per-part Origin + Orientation (first 17 parts)EMIT part=NN gfx=0xXX subMeshes=N tris=Nper partTOTAL tris=N meshRefs=Nper entity
Files (suspect surface area for next investigation):
src/AcDream.Core/Meshing/SetupMesh.cs— Flatten compositionsrc/AcDream.Core/Meshing/GfxObjMesh.cs— polygon emission + vertex normal handling (line 142)src/AcDream.App/Rendering/Shaders/mesh.frag— lighting eqsrc/AcDream.App/Rendering/Shaders/mesh.vert— normal transform
Acceptance: Side-by-side screenshots of +Acdream (or any humanoid
NPC using 0x02000001) viewed from the same angle in acdream and
retail show matching silhouette and shape definition.
#45 — [DONE 2026-05-06 · e9e080d] Local +Acdream sidestep walking renders too slow
Status: DONE
Closed: 2026-05-06
Commit: e9e080d
Component: physics / animation (local player path: UpdatePlayerAnimation)
Resolution: PlayerMovementController.cs:871 computes localAnimSpeed as raw runRate || 1.0, but ACE's BroadcastMovement converts the inbound MoveToState.SidestepSpeed via speed × 3.12 / 1.25 × 0.5 (Network/Motion/MovementData.cs:124-131). Observer-side cycles play at the ACE-scaled value (~1.248 slow / ~3.0 fast clamped); the local cycle was playing at the raw 1.0 / runRate — about 80% of retail cadence for slow strafe.
UpdatePlayerAnimation now multiplies animSpeed by WalkAnimSpeed / SidestepAnimSpeed × 0.5 = 1.248 when animCommand is SideStepLeft / Right (low byte 0x0F or 0x10). User-verified: local strafe cadence matches retail / observer-side rendering.
Original investigation note (preserved): Same constant mismatch pattern as #39 fix #5 (commit 349ba65) but on the local-player render path instead of the observer-side ApplyPlayerLocomotionRefinement — both fixed by aligning the speedMod base to ACE's wire formula.
#108 — Cellar↔main-floor transition: terrain (grass) sweeps across the upstairs door opening — [CLOSED 2026-06-12 · user-gated]
Status: CLOSED — user visual gate 2026-06-12 ("Yes it is fixed.")
after the terrain-backface-cull fix (96a425a). Root cause: terrain
drew double-sided; the grass was the grade sheet's underside seen from
a below-grade cellar eye. Membership/viewer EXONERATED by the vertical
cellar-ascent harness (007af13).
ROOT CAUSE (2026-06-12): terrain was drawn DOUBLE-SIDED — the grass was the UNDERSIDE of the grade sheet. Two steps:
- The membership/viewer re-diagnosis below is REFUTED by the vertical
cellar-ascent harness (
Issue108CellarAscentViewerReplayTests, dat-backed A9B4 corner-building cellar 0x0174→0x0175→0x0171, production FindCellList pick + the camera probe chain mirrored verbatim): 0 outdoor/null viewer resolutions while the eye is below grade, 0 sweep failures, 0 fallback branches across boom distance {2.61, 5} × damping lag {0, 0.3}. The viewer enters 0x0171 at eye z 94.01 — exactly as the head pops above grade (the stairwell portal sits at grade), matching the user's wording. The root is INTERIOR the whole window. - Retail terrain is SINGLE-SIDED:
ACRender::landPolysDraw(0x006b7040) draws each land triangle ONLY when the camera is on the POSITIVE (upper) side of its plane (Plane::which_side2vsRender::FrameCurrent). A below-grade eye gets NO terrain — through the door retail shows sky. WB renders the world with face culling DISABLED frame-globally (WBGameScene.cs:841— editor heritage), andTerrainModernRenderer.Drawset no cull state of its own → terrain drew double-sided. From a below-grade eye every aperture sight-ray RISES, so the only "terrain" it can see is the underside of the z≈94 grade sheet — which painted the whole exit-door aperture (the landscape slice's 2D NDC clip planes(nx,ny,0,dw)have no depth axis and cannot exclude it) and slid down off the door exactly as the eye crossed grade. Fix: port the landPolysDraw eye-side gate as terrain backface culling —TerrainModernRenderer.Drawnow owns Enable(CullFace) + Cull(Back) + FrontFace(Ccw) (set→draw→restore; 7th instance of the self-contained-GL- state rule). Pins:LandblockMeshTests.Build_AllTriangles_WindCounter- ClockwiseInWorldXY(every emitted triangle CCW in world XY — cull-safe winding) +TerrainCullOrientationTests(above-eye ⇒ CCW window winding kept / below-eye ⇒ CW culled under the production camera convention). Gate: climb out of the corner-building cellar — the grass window over the exit door must be gone (sky/world through the door instead); plus a general outdoor sanity glance (terrain intact from above — a wrong FrontFace would blank it). Severity: MEDIUM Component: render / terrain (single-sidedness) — membership/viewer EXONERATED
During the cellar→main-floor ascent (Holtburg), the door opening visible on the main floor
shows the outdoor GRASS texture sweeping over it — "like outdoor ground rising up from the
floor to cover it (as if watching it from below) and lowering back down when crossing up"
(user gate, 2026-06-10, post-dac8f6a).
ROOT CAUSE FOUND (BR-2 visual gate, 2026-06-11): this is NOT a render depth bug — it
is a MEMBERSHIP flip. The BR-2 far-Z punch (wired for OUTDOOR roots + look-in ONLY)
suppressed #108 when wired and #108 returned when reverted; since the punch never runs on a
clean interior frame, the grass-sweep frames must render through the outdoor root, i.e.
the player is being classified OUTDOOR mid-cellar (the #112/#106 cellar membership
ping-pong family). The outdoor root then draws the landscape, whose terrain crosses the
doorway region as the eye rises. The punch was MASKING it — and harmfully (it erased the
depth of dynamic objects standing in doorways, so characters went transparent by their
overlap with the opening; reverted 88be519). Fix belongs in the membership track:
stop the cellar-transition root from flipping to outdoor (render is downstream of
membership). The genuine interior-root exit-door depth seal (retail
DrawPortalPolyInternal maxZ2, kept reserved in PortalDepthMaskRenderer.cs) is a
separate real mechanism to rebuild under BR-3 — it does NOT fix #108.
#109 — Exit door across the room oscillates between door texture and background color — [DONE 2026-06-11 · T5 gate]
Status: DONE — user-confirmed at the T5 comprehensive gate ("7. No." — the far exit door no longer oscillates). Closed by the holistic-port stack (T1 dynamics-last frame order + depth discipline + T2 flood fidelity). No isolated fix commit — the discipline retired the class. Severity: MEDIUM Component: render / indoor PView (exit-portal region vs door entity draw order)
In a Holtburg house with a second exterior door: standing inside and looking at the OTHER
exit door across the room, the door surface oscillates between its real texture and the
background color, "almost like a mix of both" (user gate, 2026-06-10, post-dac8f6a).
Suspect family: the per-frame interaction between the exit-portal OutsideView slice for
that doorway, the doorway depth-clear (ClearDepthSlice), and the door ENTITY's draw —
alternating which wins per frame. Distinct from the (fixed) flood strobe: the flood is
stable now; this is a draw-order/depth oscillation localized to the door surface.
#112 — A9B3 hill cottage: containment gap inside the house demotes to outdoor with no re-promotion (transparent interior while walking)
Status: CLOSED 2026-06-12 (be03146) — user gate "OK seems to work"
after run-speed in/out cycles; live capture shows threshold promotions +
room tracking + clean exits, zero errors.
ROOT CAUSE (instrumented capture cottage-112-capture1.log + dat
replay): the cottage's entry cell 0x104 is a 0.22 m-wide THRESHOLD
band; a running player crosses it between two physics ticks. Our
membership pick's outdoor-seed branch ran CheckBuildingTransit over a
landcell snapshot and STOPPED — building entry cells were never
expanded — so the tick after the skip (centre in deep room 0x100) found
no containing candidate and the pick kept the outdoor landcell
FOREVER (absorbing state): the render faithfully drew an outdoor frame
= transparent walls; promotion fired only on touching portal-adjacent
0x102's own volume. Retail's CObjCell::find_cell_list (0x0052b4e0)
runs ONE growing-array walk for EVERY seed (0052b576: vtable
find_transit_cells over the GROWING array) — recovery fires one tick
after any skip. Fix = the unified retail walk ported verbatim; pins in
Issue112MembershipTests (tick-skip recovery RED pre-fix; run-speed
phase-swept entry replay; gap over-fix guard; full promotion-chain
replay diagnostic). The earlier legs (escape-hatch removal 2d6954e,
straddle gate 414c3de) remain correct — they fixed the demote side;
this closes the promotion side.
(History below predates the close.) NOTE: the #120 reciprocal ping-pong
fired at exactly A9B3 0103↔010F during the 2026-06-11 session — the
runaway duplicate views were a plausible alternate mechanism for the
transparent frames; #120's fix (dede7e4) landed first.
Severity: MEDIUM-HIGH (any house with interior containment gaps; user-observed
"sometimes transparent" while walking around inside)
Filed: 2026-06-10 (late — user exploration after #111 closed)
Component: physics / membership (outdoor→indoor promotion) + cell containment
Symptom (user, A9B3 hill cottage — interior cells 0xA9B30100/0x103/0x104, z=116):
walking around INSIDE the house, the interior intermittently goes transparent;
membership ping-pongs indoor↔outdoor 0xA9B3003C across a ~4 m band (x≈181–185
world frame; log issue111-verify7.log lines 8444-68375). Separately: some objects
inside lack collision — that part is the known #99 stopgap shape (outdoor object
sweep gated while indoor-classified; A6.P4 debt), filed here as a data point only.
Mechanism (dat-scan evidenced, scan in this entry):
- The cottage's containment volumes have a REAL GAP inside the visible house: (184.9, −109.5, z 116.5) [A9B3-local (184.9, 82.5)] is contained by NO interior cell, while 1 m away (184.2, −109.2) is inside 0x100 and (180.5, −109.0) is inside 0x103. Walking through the gap demotes the player to outdoor 0x3C (correct per containment — the sphere-overlap stickiness releases once the center leaves the volume by more than the foot radius).
- Once outdoor-classified, nothing re-promotes from INSIDE a room: the pick
with seed 0x3C returns 0x3C even at points the scan proves are inside 0x103 —
our outdoor→indoor promotion (
CheckBuildingTransit) fires only on PORTAL crossings, so the player stays outdoor (→ outdoor flood → transparent interior) until they happen to re-cross the doorway portal.
PRIMARY FIX SHIPPED 2026-06-10 late (2d6954e) — residual remains, live gate
pending. Oracle reads settled the mechanism: retail keeps curr_cell on a
null pick result (pc:308788-308825); CLandCell::point_in_cell is terrain-poly
only (:316941); building promotion = sphere_intersects_cell per portal-adjacent
cell (:309827) — all retail-matched in our port EXCEPT the 6dbbf95 escape
hatch, a non-retail demoter that converted the cottage's interior containment
gap into an outdoor stranding. Fix: hatch removed; per-tick pick now does
lateral stab-graph recovery (retail find_visible_child_cell :311444 — the #111
adjacent-claim shape self-heals, dat-tested) then retail keep-curr. Poisoned
saves stay covered at the snap (#107/#111 AdjustPosition). P1 retail-golden
gates explicitly green (11/11).
RESIDUAL RESOLVED 2026-06-10 (414c3de, live-binary oracle): retail's
gate read straight from the running 2013 client (cdb attach,
CEnvCell::find_transit_cells @ 0052c820 — BN pseudo-C had invented
portal_side tests in this branch): outdoor cells are admitted IFF a path
sphere STRADDLES an exterior portal's polygon plane,
|dist| < radius + F_EPSILON(0.0002). Ported as the straddle gate on the
membership PICK's outdoor branch; the collision cell SET keeps the A6.P5
topology widening until #99/A6.P4 (outdoor-registered doors must stay
findable from indoors). Consequences: (a) the at-doorway gap demote is
RETAIL-FAITHFUL (gap point 0.23 m from 0x104's door plane < 0.48 m foot
radius → retail straddles + demotes + self-heals inward) — test renamed
...DemotesRetailFaithfully, expectation unchanged; (b) deep-interior
containment gaps in ANY house now keep-curr like retail instead of demoting
(new pins: A9B3Cottage_GapBeyondStraddleDistance_KeepsCurrCell +
FindTransitCellsSphere_ExitPortalStraddleGate_MatchesRetail). Live gate
pending: user re-walks the A9B3 cottage; expect interior to stay rendered
(brief doorway flicker at most). The missing OBJECT collision in that
cottage = #99 data point (A6.P4 debt). Scan data: standing-now → [0x103];
flip-a → []; flip-b → [0x100].
#114 — Indoor PView shell-clip regions are not draw-quality (clip scoped to outdoor roots)
Status: OPEN
Severity: MEDIUM-HIGH (blocks the indoor half of retail's draw-side portal
clip; several user-visible indoor artifacts to re-test ride on it)
Filed: 2026-06-11 (first user gate on 927fd8f)
Component: render (PortalVisibilityBuilder regions / ClipFrameAssembler)
Finding: enabling GL_CLIP_DISTANCE for the shell pass (#113 fix) was
correct for OUTDOOR eyes (phantom staircase gone, user-verified) but exposed
that INDOOR per-cell clip regions are admission-quality, not draw-quality —
applying them as geometric crops produced: chopped interior staircase +
missing candle-holder area and a neighbour room's water barrel visible
through a clipped-away wall (meeting hall interior, user screenshots
2026-06-11), and inner walls vanishing momentarily while passing a building
exit. Scoped in 9ce335e: clip enabled only for RootCell.IsOutdoorNode +
the DrawPortal look-in path; indoor roots draw unclipped (pre-#113 state).
Suspects for the indoor region quality gap: (a) knife-edge regions when
the eye is near/on a portal plane (the §4 family — fixed for admission
stability, not pixel exactness); (b) MergeBuildingFrame/CellView handling
of cells visible through MULTIPLE portals (first-view-wins drops the other
aperture → over-crop); (c) the >8-plane slot-0 fallback drawing pass-all
(under-crop, opposite sign). Retail's reference: exact per-poly software
clip against the accumulated portal view (planeMask=0xffffffff :427922).
Re-test against the scoped build (may be pre-existing, may be #114):
- intermittent transparent interior when ENTERING the hilltop cottage;
- particles (candle flames) inside other buildings visible through walls (statics' meshes not drawn but their emitters are — particle pass is not gated by the same flood);
- meeting-hall interior anomalies from the gate screenshots.
#115 — Camera feels draggy/jittery vs retail when turning in cramped interiors
Status: OPEN Severity: LOW-MEDIUM (feel; no geometry errors reported) Filed: 2026-06-11 (user, same gate session) Component: camera (collision sweep / smoothing)
Symptom (user): "does not feel as smooth as retail — like it's dragging
over walls instead of gliding when I turn in cramped spaces, a bit jittery."
Likely the camera-collision sweep (verbatim SmartBox::update_viewer port,
Residual A) lacking retail's smoothing of the collided boom distance, or
per-tick re-collide jitter against near walls. Pre-existing (not from the
#113/#112 session — render-only + membership-gate changes). Investigate
retail's viewer-distance smoothing (update_viewer region) before touching.
#116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop
Status: OPEN (narrowed further, 2026-07-30) — shape-2 CLOSED
(D4 un-skipped and passing, oracle-plan-confirmed, no cdb needed after
all — see the 2026-07-30 update below); shape-1 narrowed, not closed:
a real, independently-decomp-confirmed Path-6 head-sphere fix landed, but
it does NOT explain the tick-22760 symptom this issue was filed against —
see below for the new evidence and the concrete open candidate.
Severity: LOW-MEDIUM (over-blocking, never under-blocking — no
walk-throughs; feel-level divergence at walls/doors)
Filed: 2026-06-11 (BR-7 / A6.P4 ship session)
Component: physics (slide response — SlideSphere degenerate-offset
guard + first-contact-frame behavior)
GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a second decompiler — Ghidra MCP, patchmem.gpr, full PDB):
- SHIPPED (faithfulness fix):
CSphere::slide_sphere(Ghidra0x00537440) compares its SQUARED magnitudes against::F_EPSILON(= 0.000199999995 ≈ 0.0002 =PhysicsGlobals.EPSILON):if (::F_EPSILON <= |cross|²)(crease) andif (|offset|² < ::F_EPSILON) return COLLIDED_TS(degenerate guard). Our port compared againstEpsilonSq(0.0002² = 4e-8) — a ~5000× too-tight threshold (the BNtest ah,5obscured it). Fixed atTransitionTypes.cs:3098,3105; full physics suite (612) + full Core (1443) green, no regression. Crease now needs ≥0.81° between normals (was 0.011°); the guard stops slides under ~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row existed — it was an undocumented porting error; the fix matches retail). ⚠️ This does NOT fix either reported shape below. - Shape-1 RE-DIAGNOSED — our
cn=UnitZdefault is RETAIL-FAITHFUL. Ghidravalidate_transition(0x0050aa70) does exactly ourTransitionTypes.cs:3701-3702:if (collision_normal_valid == 0) set_collision_normal(UnitZ). So the harnesscn=(0,0,1)is the faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 ourcollision_normal_validwas FALSE (→ UnitZ) where retail's was TRUE (it had recorded the door-face normal(0,+1,0)). The bug is in the COLLISION-RECORDING path (find_collisions / collide_with_environment), not slide/validate. Next: replay tick-22760 (DoorBugTrajectoryReplayTests) instrumented to see where our collision-normal recording drops the wall normal. - Shape-2 NARROWED — D4 stays skipped. Ghidra confirms slide_sphere
applies the slide IN-FRAME (
add_offset_to_check_pos→ SLID_TS), so our Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the SUSPECT half. But the threshold fix did NOT change D4 (its offset is a real slide, not degenerate), so whether retail's first airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb trace of an airborne wall hit before flipping the assertion.
Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left them byte-identical):
-
Tick-22760 lateral-slide loss (door capture, 2026-05-24): live blocked the southward push at the cottage door face and KEPT the tiny lateral component (X −0.0357, cn=(0,+1,0)); the harness hard-stops both components (cn=(0,0,1) from the post-stop ground refresh). The movement is near-perpendicular to the face, so the projected slide offset is tiny and the degenerate-offset guard converts it to a full stop. Repro:
DoorBugTrajectoryReplayTests.Diagnostic_Tick22760_DumpEngineInternals(door found + BSP-only dispatched correctly —[bsp-test]/[cyl-skip-bsp]probes prove the cell-set layer is innocent).LiveCompare_DoorBlocksFromOutside_Tick22760now pins the blocking invariant only. -
D4 first-airborne-frame slide (
BSPStepUpTests.D4_*, skipped with this issue id): the L.2c pin expects the first airborne wall frame to hard-stop (Z stays 2.0) with the slide starting frame 2 off the cached sliding normal; since the P1-eraslide_spherework the engine slides in-frame (Z reaches the 1.92 target on frame 1). Retail's cached-normal mechanism (CPhysicsObj::get_object_infopc:279992, transient bit 4 →init_sliding_normal) only governs the NEXT frame — whether retail's first-frame response is hard-stop or in-frame slide needs a focused oracle read (collide_with_environment/slide_spherefirst-contact path) before either the engine or the pin is declared wrong.
Fix shape: one oracle-driven pass over the slide response
(SlideSphere + first-contact frame), with the 22760 capture and the D4
fixture as the acceptance pair. Do NOT patch the degenerate-offset guard
ad hoc — the DO-NOT-RETRY table's slide entries (physics digest) apply.
ORACLE DESK READ DONE (2026-06-12) — needs a LIVE cdb session to
finish. Both sides quoted + verified against source (our
CSphere::slide_sphere port = TransitionTypes.cs:3054-3133; retail
CSphere::slide_sphere = decomp 0x00537440, lines 321403-321532).
Three concrete leads, none safely fixable from the static BN decomp:
-
Shape-1 re-attributed — it is NOT the degenerate-offset guard threshold. Retail's guard kills slides under ~1.4 cm (
|offset|² < 0.000199999995at0x537735); the lost tick-22760 slide was 3.57 cm (X −0.0357), well above it — retail would keep it too. The real divergence is the COLLISION-NORMAL SOURCE: our harness recordedcn=(0,0,1)(ground), live retailcn=(0,+1,0)(the door face). Strong lead:TransitionTypes.cs:3701-3702— on a blocked move with no valid collision normal we DEFAULTcn = Vector3.UnitZ("push up"); that exact (0,0,1) is what the harness sees. Whether retail has an equivalent default (vs keeping the wall normal) is a runtime question. -
Shape-2 — retail's slide_sphere applies the slide IN-FRAME (
add_offset_to_check_pos@0x53777e, returns 4=SLID), so our in-frame slide to Z=1.92 on frame 1 is likely retail-faithful and the D4 frame-1 hard-stop pin (BSPStepUpTests.D4_*, expects Z=2.0) is the STALE expectation. BUT retail always usescontact_planeORlast_known_contact_plane(0x53755a); it has no "airborne wall-only, no plane" third branch like ours (TransitionTypes.cs:3080-3092) — the first-airborne-frame plane state needs a trace before flipping the pin. -
Candidate epsilon-squaring divergence (real, but explains neither shape). Retail compares SQUARED quantities (
|cross|²@0x5375a5,|offset|²@0x537735) against0.000199999995(≈0.0002, NON-squared); our port compares againstEpsilonSq = 0.0002²(line 3105 + thedirLenSq >= EpsilonSqbranch @3098) — potentially ~10⁴× too small. DO NOT change this without cdb confirmation: the BNtest ah, 0x5branch polarity (lines 321466-321467/321484-321485) is the exact undecodable construct the PosHitsSphere saga warned about, and the register reuse garbles which quantity is squared. A wrong guess here regresses ALL wall-slide behavior.
Next (cdb session, well-scoped): (a) cdb -z uf acclient!CSphere::slide_sphere OR a live attach to disassemble
0x00537440 and settle the two test ah,5 branch signs + the
squared-vs-not threshold (prefer LIVE attach — prior lesson: static
-z uf misdecodes at OMAP boundaries); (b) live trace the tick-22760
door push to confirm whether the cn=(0,0,1) comes from our
UnitZ-default (lead 1) and what retail's normal is at that instant.
2026-07-09 triage: investigated, verdict STILL_OPEN — the pinned harness diagnostic (Diagnostic_Tick22760_DumpEngineInternals) still shows the harness hard-stopping laterally where live retail slides, and BSPStepUpTests.D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames remains explicitly Skip-tagged citing this issue; only one Ghidra-confirmed partial fix (bf18a543, F_EPSILON vs EpsilonSq) has landed.
2026-07-30 (Campaign P final physics slice) — shape-2 CLOSED, shape-1 narrowed with new evidence, no cdb session needed for either after all:
- Shape-2 CLOSED — no cdb needed. The oracle plan
(
docs/research/2026-07-30-ts4-116-oracle-plan.md§3, cross-referencing the raw BN pseudo-C, ACE'sBSPTree.cs, and current source) found the ROUTING question (does retail's first airborne wall-contact frame reachslide_sphere?) answerable from static structure alone: retail's dispatch never callsslide_sphereon a genuine first-airborne-frame FOOT-sphere hit —Path 6setsCollidewithout repositioning, the retry routes toPath 4(find_walkable), which for a sheer vertical wall finds no candidate, andPhase 3'ssp.Collideblock then hard- stops with the wall's real normal. A confirming instrumentation run (probes on whichBSPQuery.cspath fires + whetherFindWalkableInternalfinds a candidate, added in5e2be19b) reproduced exactly this sequence for the D4 fixture once TS-4's shortcut was removed (5e2be19b) — Path 6 → Path 4 (changed=false) → Phase 3CollidedwithStepUpNormal. D4 un-skipped as a test-only change (01492205) and passes. TS-4's own removal is what unblocked this; D4's own code was never wrong. - Shape-1: the Path-6 head-sphere fix landed, but does NOT explain
tick-22760. The oracle plan's §2.3 hypothesis (a foot-clear/head-hit
airborne contact deferred through
SetCollide/Adjustedinstead of retail's directCollided+SetCollisionNormal) is CONFIRMED as a real, independently-decomp-confirmed divergence (pc:323824-323834, ACEBSPTree.cs:221-230) and is now fixed in bothBSPQuery.csandFlatBspQuery.cs(db2889af). But re-runningDiagnostic_Tick22760_DumpEngineInternalsafter the fix shows NO CHANGE (harness stillcn=(0,0,1)vs livecn=(0,+1,0)). New dispatch-entry probes ([path-dispatch],[path5-diag], permanent, gated onProbeIndoorBspEnabled) traced the ACTUAL tick-22760 call sequence: the mover is GROUNDED (Contactbit set in the seeded snapshot), so it never reaches Path 6 at all. It dispatches Path 5 →StepSphereDown(Path 3, bothDoStepDownhalf-steps fail to find a walkable candidate on the door's simplified BSP registration) →EdgeSlideAfterStepDownFailed→SpherePath.PrecipiceSlide, whosefind_crossed_edge-false fallback returnsCollidedwith no collision-normal write. A fresh byte-level read of retail'sSPHEREPATH::precipice_slide(pc:274316-274326,0x0050cc80) confirms this is byte-exact retail behavior (if (eax == 0) { walkable = 0; return 2; }, noset_collision_normalcall) — not a bug;validate_transition'sUnitZdefault fires identically in both engines here. The tick-22760 divergence is therefore NOT explained by anything in the response/slide layer this issue was filed against. Leading candidate (not yet chased):DoorBugTrajectoryReplayTests.BuildEngineWithDoorFixtureregisters the door's raw BSP directly at its captured bounding-sphere center rather than via the faithfulShadowShapeBuilder.FromSetup+PlacementFrametransformBuildFaithfulDoorEngineuses elsewhere in the same file — the harness's door geometry may simply not be where live retail's was at that exact tick, which would make this a test-fixture gap, not an engine bug. Seedocs/research/2026-07-30-ts4-116-oracle-plan.mdAddendum 2 for the full trace. Next step, if picked back up: re-run the tick-22760 capture againstBuildFaithfulDoorEngine's Setup-based registration (not the simplified fixture) to see whether a real BSP hit against the door — instead of the seeded generic floor triangle — changes the outcome, before considering any further code change.
#118 — Character clipped + disappears for a moment when exiting houses — [DONE 2026-06-11 · 5a80a2e, user re-gate "Yes solved"]
Status: DONE — user-confirmed at the 2026-06-11 re-gate ("Yes solved"), including the outdoor-NPC-through-doorway companion symptom ("Yes fixed").
Root cause (pinned by the exit-walk harness, HouseExitWalkReplayTests):
NOT the cone stack — candidates 1–3 all exonerated (cone-level walk passes
every step; the camera publishes (eye, ViewerCellId) from the SAME SweepEye
call and updates before the visibility read, so the pair is coherent; the
side-test window is ≤ PortalSideEpsilon and never occurs under healthy
resolution). The mechanism is DEPTH ORDERING: under an interior root, the
exit-portal SEAL stamps the door fan at TRUE depth after the full depth
clear, and T1's "ALL dynamics last" then draws the outdoor-classified player
depth-tested — every fragment beyond the door plane z-fails against the seal
across the whole aperture. Full vanish once the center exits (harness: the
entire s=0.04→2.64 m window until the eye crosses, ~2.2 s at walk speed);
the body's beyond-plane half clips at the plane while straddling.
Retail oracle: PView::DrawCells (0x005a4840) runs LScape::draw FIRST (pc:432719), THEN the gated depth clear (pc:432731) + seals (pc:432786); outdoor cell objects draw inside the landscape stage via DrawBlock → DrawSortCell (0x005a17c0, pc:430124), and an object draws once per overlapped shadow cell (pc:430056-430064) — so a threshold-straddling body draws in both stages and neither half clips.
Fix (RetailPViewRenderer): under an interior root, outdoor-classified
dynamics draw in the OUTSIDE (landscape) stage — before the clear+seal, so
the seal protects their pixels — and indoor dynamics whose sphere straddles
an exit-portal plane draw in BOTH stages (DynamicDrawsInOutsideStage).
Outdoor roots keep all-dynamics-last (the BR-2 punch lesson). Pins:
ExitWalk_PlayerStaysConeVisible_EveryStep,
ExitWalk_PlayerSurvivesSealDepth_WhenConeVisible,
ExitWalk_StraddlingPlayerDrawsInOutsideStage.
Severity: MEDIUM-HIGH (every house exit, brief)
Filed: 2026-06-11 (T5 comprehensive gate, user item 10)
Component: render — dynamics handling at the indoor→outdoor transition
Symptom (user): "We get clipped and disappear when we exit houses. Like when we are just outside for a moment." Transition frames where the viewer is still indoors and the player is just outside the door.
Narrowed (2026-06-11 post-T5 session — two suspects EXONERATED by
read): (1) the partition is correct — the local player entity carries
its ServerGuid and routes to Dynamics; (2) the entity's ParentCellId is
NOT stale — it syncs per tick from the controller
(pe.ParentCellId = result.CellId, GameWindow ~6855).
Live candidates (the doorway-crossing decision stack):
- Eye/cell incoherence under camera damping (#115 family / BR-8a):
the render root comes from the sweep (
RetailChaseCamera.ViewerCellId) while the projection eye (camPos) is the DAMPED position — during a crossing they can disagree by the damping lag. Retail damps FROM the published collided viewer (verified divergence, plan BR-8a), so its (eye, cell) pair stays coherent. - Exit-portal side test at the threshold: with the eye ε-outside the
door plane while the root is still the interior cell,
CameraOnInteriorSideculls the exit portal → OutsideView EMPTY →SphereVisibleOutsideculls ALL outdoor dynamics (the player) for those frames. Retail's AdjustPosition demotes the viewer cell to outdoor the moment the point exits (seen_outside → adjust_to_outside), making the inconsistent state structurally brief. - The doorway-aperture cone test for an outdoor-classified player while the viewer is legitimately still inside (cone tightness).
Next step (apparatus, not guessing): a deterministic exit-walk
harness over the corner-building cells — drive the production decision
stack headlessly per step of an eye+player path crossing the doorway
(viewer-cell resolution → PortalVisibilityBuilder.Build →
ViewconeCuller → the DrawDynamicsLast visibility predicate) and
assert the player sphere stays visible on every step. All CPU; the
failing step pins which candidate fires.
#119 — Old tower: stairs partially invisible + extraneous water barrel; two meshes permanently invisible at startup
Status: CLOSED 2026-06-12 — user gate "the tower seem to work good now" (run-from-town stairs complete, barrel gone, climb + top stable). Severity: MEDIUM (pre-existing — "same issue as before" per the user) Filed: 2026-06-11 (T5 comprehensive gate, user items 9+13) Component: render — mesh upload / content inclusion
RESOLUTION (2026-06-12) — three root causes, fixed in sequence, each
pinned by the ACDREAM_DUMP_ENTITY decisive probe (3cf6bcc):
2163308— Tier-1 cross-entity batch serving (the broken stairs + "water barrel"): interior entity ids discarded the landblock X byte (0x40YYFF00— Holtburg town A9B3's 9th interior stab == the AAB3 tower staircase, both 0x40B3FF09) AND the classification cache hinted entities with the PLAYER's landblock at bucket-draw time, so the colliding twins shared one cache key: whichever classified first served its batches to the other all session. Town-login + run → the staircase drew a town object's 3 zero-RestPose batches (= "the water barrel"); tower-login → usually clean. Captured live:cache=hit:3 restZero=3on a 43-part staircase. Fixed:0x40XXYY##ids + owner-derived cache hints (ResolveCacheLandblockHint).987313a— knife-edge clip port (climb strobes, top flap family):ProjectToClip→ exact W=0 eye-plane clip per retailACRender::polyClipFinish(0x006b6d00); zero-area in-plane views now PROPAGATE (segment-key CanonicalKey) like retail's ClipPortals; theEyeInsidePortalOpeningrescue DELETED (CornerFloodReplay passes without it under the W=0 port).1ca412d+6a9b529— entity bounds must cover the mesh (the gaze-dependent vanish: stairs visible climbing down, gone climbing up):WorldEntity.RefreshAabbwas a fixed ±5 m ANCHOR box feeding both the dispatcher frustum cull and the viewcone sphere — 15 of the staircase's 17 m stuck out of it. Final fix derives root-local bounds from the dat VERTEX data at hydration (GfxObjBounds + LocalBoundsAccumulator, all four hydration sites) — data, not a promise; retail needs no equivalent because it viewcone-checks each part's dat-authoredCGfxObj.drawing_sphereper part (CPhysicsPart::Draw 0x0050d7a0 → DrawMesh 0x005a09a4).
The [up-null] lead was exonerated earlier (legit no-draw models); the
f35cb8b lift fix (below) was real but not THE bug. #113's
distance-dependent phantom staircase should be RE-CHECKED against
2163308 (the town twin wore the tower's staircase batches).
Symptom (user): the old tower has missing stair parts (pre-existing; the tower stairs ARE visible in retail — user axiom recorded 2026-06-11 in the render digest) and shows a water barrel that retail doesn't.
Lead (from the T5 launch log): exactly two
[up-null] upload returned null for 0x00010002B4 / 0x00010008A8 — caching EMPTY render data (permanently invisible) lines at startup.
Narrowed 2026-06-11 (the [up-null] lead is EXONERATED, dat-proven):
Issue119UpNullGfxObjDumpTests — both GfxObjs are legitimately no-draw
models: 0x010002B4 = 9 polys, ALL NoPos, all surfaces Base1Solid;
0x010008A8 = 1 poly, NoPos, Base1Solid|Translucent. Retail's
skipNoTexture never draws them either (the BR-1 equivalence) — the empty
cache is the CORRECT terminal state, and the alarming log line was the
only defect (reworded; it stays as a tripwire for the real-failure shape).
Second fact, same test: on the hall/tower shell 0x010014C3, ZERO textured
polys are dropped by the extraction gates (137/149 draw; the 12 dropped
are the known #113 no-draw orphans) — the per-poly extraction is
exonerated for building shells, pinned by
ShellModel_NoTexturedPolyIsDropped.
Remaining hypothesis space (needs the re-gate to identify the exact tower): the missing stair parts draw from somewhere other than the shell GfxObj's per-poly extraction — most plausibly interior stair-CELL shells whose visibility depends on the flood admitting those cells from the outside view, or a different building model than assumed. At the re-gate: have the user point at the tower (one sentence / approx location) — then the cell set + flood can be replayed headlessly like #118. The extraneous water barrel remains a separate static-inclusion question (which cell owns it; is it admitted by a view it shouldn't be).
User split (re-gate 2026-06-11) — THREE distinct artifacts in the area:
- The PHANTOM walkable-but-invisible stairs (the #113 family) is still present and now reads as located at the HILL COTTAGE — "the stairs half embedded into the outside wall." (#113's reopened drawing-BSP-orphan investigation owns this.)
- A tower CLOSE TO the hill cottage has the MISSING stairs + the extraneous water barrel in its middle — this entry (#119) proper.
- The hill house sometimes turns ALL walls transparent when entering —
tracked under #112; note the #120 ping-pong fired at exactly A9B3
0103↔010F, so re-check after the #120 fix (
dede7e4).
DECODED (2026-06-11 evening): the user's logout position pinned the
tower (cell 0xAAB30107, AAB3 building[1] model 0x01001117). Dat truth
(Issue119TowerDumpTests): the stairs are ONE static — Setup
0x020003F2, a 43-part spiral staircase at the tower center (placement
frames perfect, all parts drawable). Pipeline exonerated layer by layer
(extraction, hydration ParentCellId=envCellId, per-MeshRef registration,
dispatcher compose); clean WB_DIAG counters at the tower spawn:
meshMissing=0, entSeen==entDrawn.
⚠️ USER AXIOM (2026-06-11 late): the barrel is NOT in the tower in retail. The earlier "legit dat barrels on the landings" claim is RETRACTED — what the user saw was itself a render artifact. Post-#120 verdict: "Barrel is gone and more stairs exist" — both improved together, consistent with the "barrel" being mis-drawn staircase geometry under the corrupted floods. (What the four 0x020005D8 cell statics actually render as remains UNVERIFIED — do not assume barrel.)
REMAINING (user, post-#120 build):
- Running UP the tower, the TOP stairs disappear visually but stay walkable.
- On top of the tower, the roof and edges FLAP into existence and back.
ROOT CAUSE FOUND + FIXED 2026-06-11 (f35cb8b) — the +0.02 m render
lift leaked into the portal-visibility graph.
BuildInteriorEntitiesForStreaming lifts the render-side cell transform
2 cm (shell z-fighting vs terrain — a DRAW concern) and passed that
LIFTED transform to BuildLoadedCell, so every visibility-graph plane
sat 2 cm high. The side test's in-plane window is ±10 mm: an eye
standing ON a floor containing a HORIZONTAL portal (the tower deck lip
0x010A→0x0107, landings, cellar mouths) sits 10–20 mm BELOW the lifted
plane → outside the window → the cell behind the portal side-culled out
of the flood. Captured live at the stair top (the user's climb +
[viewer-diff]): root=0x010A, eye z=126.803 vs the plane at 126.80,
flood=1, 0x0107 dropped WHILE LOOKED AT — "stairs disappear and you can
walk on them"; the roof/edge flap = the same marginal admissions swinging
with the gaze. Vertical doorways immune (the lift slides their planes
along themselves) — why this hit exactly stairs/decks/floors. Headless
replay reproduces ONLY with the lift; fix = BuildLoadedCell gets the
PHYSICS (unlifted) transform; shells keep their draw lift. Pins:
CapturedTopOfStairs_MainCellStaysInFlood (unlifted asserts admission;
lifted arm = the mechanism canary). Likely also feeds the #108 residual
(cellar mouth = a horizontal portal) — re-check at the gate.
The earlier synthetic roof-lip-band pin
(TowerAscent_StaircaseStaysConeVisible_EveryStep) stays SKIPPED — its
band came from the harness's AABB root model, not the production sweep;
re-validate against the real resolver before un-skipping.
#120 — [pv-ERROR] in-place propagation tripwire: convergence invariant broken at depth 128 (cottage interior cells)
Status: FIXED 2026-06-11 (dede7e4) — pending re-gate (watch for
zero [pv-ERROR] lines in the next launch log)
Severity: HIGH (self-detected invariant break in the new flood growth)
Filed: 2026-06-11 (T5 launch log; fired during normal cottage play)
Component: render — PortalVisibilityBuilder in-place growth (T2/BR-4)
RESOLVED (2026-06-11): the armed tripwire self-attributed on the
re-gate launch — a pure TWO-CELL reciprocal ping-pong (0xA9B4015C ↔ 0x0162 and 0xA9B30103 ↔ 0x010F, 64 laps each). Mechanism: eye within
PortalSideEpsilon (±1 cm) of the portal plane → in-plane counts interior
for BOTH cells → views lap A→B→A; near-edge-on aperture re-clips wobble
beyond the 1e-3 dedup grid → every lap keys "new". The prior sweeps
couldn't reproduce because they only loaded the corner building — both
firing pairs are outside it. Issue120ReciprocalPingPongTests loads the
full landblock and reproduces deterministically (tripwire firings +
65-polygon CellView piles). Fix: CellView.Add rejects polygons
CONTAINED in an already-stored polygon (a round-trip re-emission is a
subset of its originator in exact math) — union growth is strictly
area-increasing, the lap dies at iteration 1. Corner-flood completeness
pins stay green. PortalSideEpsilon untouched (DO-NOT-RETRY).
Evidence: [pv-ERROR] in-place propagation tripwire at depth 128 on cell=0xA9B40175 / 0xA9B40174 / 0xA9B40162 — convergence invariant broken, investigate (3+ firings in the T5 session, exactly the cottage interior
cells the user was walking). T2's in-place growth (which replaced the
MaxReprocessPerCell=16 cap) re-propagated one cell's view 128 times
within a single build — a re-emission cycle the dedup misses, or growth
ping-ponging through a reciprocal portal pair. May be load-bearing for
#117/#118 (runaway view growth → wrong clip/punch volumes).
Investigation (2026-06-11, post-T5): retail RECURSES natively too
(AddViewToPortals → FixCellList → AdjustCellView → AddViewToPortals,
Ghidra 0x005a52d0/0x005a5250/0x005a5770 — no depth guard), so the
recursion shape is faithful and retail's safety is FAST CONVERGENCE; our
depth-128 means slow/non-saturation our dedup admits (each lap of a
portal cycle nests one level deeper). Two dat-backed harness sweeps over
the full corner-building cell set could NOT reproduce
(CornerFloodReplayTests.PortalPlaneCrossings_InPlacePropagationConverges
— ±6 cm across every portal plane, both seed sides — and
InCellDirectionSweep_InPlacePropagationConverges — 3024 builds, in-cell
eye grid × 8 yaw × 3 pitch): firings = 0. Production-only ingredients
suspected: the full lookup graph (production reaches far more cells; one
T5 firing was 0x0162, a different building) and/or the real camera path.
Tripwire armed for self-attribution (DumpPropagationChain): the next
firing logs the root cell, eye, per-cell frequency, and the chain tail —
the cycle's structure reads directly off the log. Both sweeps stay as
regression pins (PortalVisibilityBuilder.ConvergenceTripwireCount).
Revisit on the next firing (the #117/#118 re-gate launch will carry it).
#121 — All world portals invisible (portal swirl VFX gone everywhere)
Status: FIXED 2026-06-11 — pending re-gate Severity: HIGH (user: "all portals that were previously showing at various places are now gone") Filed: 2026-06-11 (re-gate launch) Component: render — particle pass routing under the pview path
Root cause (by read): dynamics' ATTACHED emitters (portal swirls on
server-spawned portal entities, creature effects) fell through EVERY
particle filter under the unified pview path: the landscape slice's
filter carries outdoor STATICS (+ the #118 outside-stage dynamics), the
per-cell callback carries cell STATICS, and T4 deleted the old
clipRoot==null global pass from normal frames. T5 never checked
portals (not on the checklist) — the gap dates to the T3/T4 one-gate
work, surfaced at this re-gate. Fix: a dynamics-owner particle pass
— DrawDynamicsLast hands its cone-surviving dynamics (minus
outside-stage entities, whose emitters already drew in the landscape
slice) to a new DrawDynamicsParticles callback; GameWindow draws
Scene-pass emitters filtered to those owner ids (mirror of
DrawRetailPViewCellParticles). Retail shape: emitters draw with their
owner object.
#124 — Looking out through an opening: far buildings with openings show missing/transparent back walls
Status: CLOSED (user-gated 2026-06-12 evening: "124, that one is solved") Severity: MEDIUM Filed: 2026-06-11 (re-gate; pre-existing — "still have that issue"; user 2026-06-12: "especially visible when I look out through a door opening when inside a building") Component: render — per-building look-in floods under INTERIOR roots
From inside a building, looking out through a door/window at ANOTHER
building that has an opening: the far building's back walls are
missing/transparent. The lead confirmed by decomp: retail runs the
look-in INSIDE the landscape stage for ANY root — LScape::draw is the
FIRST call of PView::DrawCells' outside-view branch (pc:432719),
strictly BEFORE the depth clear (pc:432732) and the seals (pc:432785);
ConstructView(CBldPortal)'s GetClip runs under the INSTALLED view
(the doorway region), and all apertures far-Z punch (pass 1) before any
interior cell draws (pass 2).
Fix (2026-06-12):
- The per-building gather (frustum pre-gate on
Building.PortalBounds) now runs for interior roots too; the root's own doorway self-excludes via the seed eye-side test. BuildFromExteriorgainedseedRegion— the port of retail's installed-view clip: interior-root look-ins seed clipped against the OutsideView (doorway) polygons, so a building not visible through the doorway never floods. Outdoor roots keep the full-screen default.- NEW
DrawBuildingLookInssub-pass inside the LANDSCAPE stage (before the depth clear + seals): per building, punch ALL apertures (DrawLookInPortalPunch, always far-Z), then draw the flooded cells' shells + statics far→near. NOT merged into the main frame — a merged cell would draw post-clear and z-fail against the root's seal. - Look-in cells join the Prepare/partition set (shells get batches, statics route to ByCell, consumed only by the sub-pass).
Pins: Issue124LookInSeedRegionTests (containing region floods ⊆
full-screen flood; disjoint region floods nothing; interior-side eye
never seeds its own exit door). Register: AP-33 (look-in statics drawn
whole — no per-part viewcone; look-in DYNAMICS deferred — an NPC inside
a far building stays invisible; both documented).
Gate: from inside a building, look out the door at another building with an open door/window — its interior/back walls render through its aperture instead of see-through to the world behind.
#125 — GL InvalidOperation during staged texture upload: failed uploads are STICKY (never retried) + uncaught crash in GenerateMipmaps
Status: CLOSED 2026-06-12 — the GL root cause was fixed fcade06
(2026-06-11, live-verified); the remaining sticky-drop DESIGN DEBT is now
fixed too (bounded upload retry, below). No visual gate (robustness).
RESOLVED (root cause): the GL errors were the gpu_us QUERY RING's own — a glGenQueries name isn't a query object until first glBeginQuery, and GetQueryObject on a never-begun name is GL_INVALID_OPERATION. The N.6 ring assumed ONE Draw/frame with both passes non-empty; the pview pipeline's many small Draws routinely skip a pass → the slot read queued an error EVERY frame under ACDREAM_WB_DIAG=1; WB's texture-path glGetError checks ate the stale errors (the attribution trap) → fake upload failures + the ProcessDirtyUpdates throw. Fix: begun-flags per slot; read only begun queries. Live-verified in-tower: 0 [wb-error] (was 7), no crash, gpu_us reads real values (9–11 µs) for the first time under pview, meshMissing=0. Normal runs (WB_DIAG off) never had these errors — this mechanism is RETIRED for #119.
Remaining debt — FIXED 2026-06-12 (bounded upload retry): the exact
stick was the CPU-cache short-circuit, not just the early TryRemove: a
failed UploadMeshData (catch → null) consumed the staged item and left
_renderData empty while the prepared data lingered in _cpuMeshCache,
so PrepareMeshDataAsync's cache-hit path (ObjectMeshManager.cs:448-453)
returned it WITHOUT re-staging → never re-uploaded until CPU-cache
eviction (effectively session-sticky under low cache pressure). Fix: the
Tick drain (WbMeshAdapter.cs) now re-stages a failed upload for the NEXT
frame via ObjectMeshManager.UploadOrRequeue, bounded by
MaxUploadRetries (3) using a counter on the ObjectMeshData object
(resets to 0 on re-prepare). Re-stages are collected and re-enqueued
AFTER the drain loop — never inside it — so a deterministic failure can't
spin the queue in one frame; past the cap it gives up with a loud
[up-retry] … giving up line (surfaces a genuine GL defect instead of
the old silent permanent drop). Retail loads synchronously and has no
such failure mode; this converges the async pipeline toward that
guarantee. Build + App.Tests (264) green; no GL-context test seam exists
for the upload path so the retry is verified by construction + the
regression suite. The uncaught GenerateMipmaps path (open-question c)
is INTENTIONALLY left to surface errors — adding a blanket catch there
would mask future real defects (no-workarounds rule); its trigger
(fcade06) is already retired.
Filed: 2026-06-11 (in-tower WB_DIAG launch, tower-wbdiag3.log — preserved in the worktree root)
Component: render — WB staged texture pipeline (ObjectMeshManager / ManagedGLTextureArray)
Evidence (one launch, character spawned inside the #119 tower):
[wb-error] Error uploading mesh data for 0x0100321D— GLInvalidOperationthrown inManagedGLTextureArray..ctor:70(TextureAtlasManager ctor → CreateTextureArrayInternal), caught byUploadMeshData's try/catch → returns null. The drop is STICKY:_preparationTasks.TryRemoveruns BEFORE the upload (ObjectMeshManager.cs:685), so a failed upload is never re-prepared — that mesh is permanently invisible for the session (only a one-line [wb-error] marks it).- Same session,
Tick()→GenerateMipmaps()→ManagedGLTextureArray.ProcessDirtyUpdatesInternal:283threw the SAME GL InvalidOperation uncaught → process death (exit 82). Both on the render thread (Tick/OnRender) — not a thread-affinity bug.
Why this matters for #119: the missing tower stairs are per-cell Setup statics whose parts are individually uploaded; an intermittent GL error burst during atlas creation/flush kills whichever uploads are in flight — "partially invisible", varying with load order, hitting the late-loading AAB3 interior statics consistently. The dat + extraction + registration + dispatcher are all exonerated by read/test (Issue119TowerDumpTests; the [up-null] pair was a separate, legitimate no-draw class).
Open questions (next session): (a) what makes the GL context error
out — a stale error queued by an earlier unchecked call being
mis-attributed to WB's diligent glGetError checks (classic GL
attribution trap; suspects: the #117 stencil punch state, the new #118 /
#121 passes, or a pre-existing per-frame state leak), vs. a genuine
invalid texture-array creation state; (b) whether upload failures should
re-enqueue instead of dropping (retail has no such failure mode — the
sticky drop is OUR invention and must go regardless); (c) the uncaught
GenerateMipmaps path needs the same handling either way.
Repro lever: the test character's save spawns INSIDE the tower —
every launch loads the exact content; ACDREAM_WB_DIAG=1 prints the
meshMissing counters.
#127 — Per-building flood admissions are BISTABLE per frame under the outdoor root (the building-flap mechanism)
Status: CLOSED 2026-06-12 — user re-gate ("Seems to have been
fixed" — ran past distant buildings, no flicker/vanish) + desk
confirmation. The bistable-admission mechanism died with the W=0
polyClipFinish clip port (987313a, the #119/#120 work that
"kills the knife-edge class everywhere") plus the #120 containment-
rejection growth fix. NOTE the captured-pair evidence in
tower-viewer-capture.log predates all of those fixes — it was the
near-eye knife edge, the same class. Pins (both green at HEAD):
Issue127FloodFlipReplayTests.CapturedFlipPair_AdmissionIsStable
(the original 4 cm flip pair now |A|=|B|, zero diff, all FOVs, both
pre-gate states) + DistantBuildingStrafe_NoAdmissionChurn (the
regression pin: 0 churn across 21 building groups × {10,30,60,120,190} m
× 100 mm-steps run-past strafe, both pre-gate states). DO-NOT-RETRY:
do not re-open the BuildFromExterior seed gates for flap symptoms
without a FRESH repro at HEAD — the captured-pair lead is dead.
Filed: 2026-06-11 (tower capture run)
Component: render — BuildFromExterior seed admission / per-building
flood stability
Evidence (tower-viewer-capture.log, 551 [viewer] lines in one short
run): under the outdoor root near the tower, the merged per-building
flood size oscillates ±1–3 cells nearly EVERY frame at millimetre eye
deltas — standing on the tower roof: flood 45↔46↔47↔48 per line with
the eye moving mm at a time (and one stretch flipping at a byte-static
eye). Every oscillation = some building's interior cells (including
this tower's roof-lip cells) dropping in/out of the visible set → the
roof/edges flap; a building whose cells flap while running past =
#123. The INTERIOR side shows the same family: inside the tower the
flood flickers 1↔2–3 with outPolys 0↔1 during the climb.
Next: the [viewer] probe now logs the camera forward (fwd=) — one
more capture run gives exact (eye, fwd) pairs to replay in a
deterministic harness; then pin WHICH admission gate is bistable
(seed side test / in-plane reject / clip-empty / the frustum pre-gate
on PortalBounds) and stabilize it retail-shaped.
#128 — Tower staircase invisible with a HEALTHY interior root (session-sticky; renders fine in other sessions)
Status: CLOSED 2026-06-12 — same root causes as #119 (see its
RESOLUTION block): the session-sticky invisibility was the Tier-1
cross-entity batch serving (2163308 — session order decided which
colliding twin won the cache slot, exactly the observed
nondeterminism), and the healthy-root climb invisibility was the ±5 m
anchor bounds feeding the viewcone sphere (6a9b529). The "FullScreen
views — cone cannot cull" reasoning below missed that the camera
frustum planes still cull via the same undersized box. User gate
2026-06-12: tower works.
Filed: 2026-06-11 (tower capture run + user report)
Component: render — entity draw path (suspect: session-order state)
Evidence: during the user's climb the root was the tower's main
cell 0xAAB30107 (FullScreen views — the cone CANNOT cull a 0107
static), yet the 43-part staircase was invisible the whole way up; in a
different session same build (the in-tower diag spawn,
tower-wbdiag4.log + screenshot) the same staircase rendered perfectly
with meshMissing=0. Session-sticky, nondeterministic across sessions:
suspect state accumulated by session order — Tier-1 classification
cache shapes (#53 family — though the known veto paths read correct),
LRU eviction + the no-re-prepare-on-re-registration gap, or the #125
sticky-drop cousin. The user's "barrel" sighting tracks this bug (a
partial subset of staircase parts rendering ≈ a barrel-shaped pile) —
NOT dat content (the barrel is NOT in retail — user axiom). Next:
reproduce under ACDREAM_WB_DIAG=1 with the user's session shape (spawn
mis-grounded inside via #126, walk out/in, climb) and read
meshMissing + [indoor-lookup]; if meshMissing>0 persists at standstill
the parts are unloaded (eviction/registration); if 0, instrument the
staircase entity's per-frame draw decision.
#129 — Doors/doorways leak through terrain and houses from over a landblock away
Status: FIX SHIPPED — awaiting user visual gate Severity: MEDIUM (visible at distance during normal outdoor play) Filed: 2026-06-12 (user report, post-#119-close session) Component: render — aperture depth punch at distance (#117 family, AD-18)
Symptom (user): "leakage of like doors and doorways through the terrain and houses over a landblock" — door/doorway-shaped patches visible THROUGH intervening terrain and nearer buildings when the source building is roughly a landblock (~192 m) or more away.
Root cause (lead 1 confirmed analytically, Issue129PunchBiasTests):
the #117 mark-pass bias was a CONSTANT 0.0005 NDC. NDC depth is
non-linear — a constant NDC bias b spans ≈ b·d²/near meters of eye
depth at distance d. With retail's znear 0.1 that is 0.125 m at 5 m
but ~190 m at a landblock: every hill/house in front of a distant
aperture passed the LEQUAL mark and was far-Z punched → the door-shaped
leak. Exactly AD-18's recorded "Risk if assumption breaks".
Fix (2026-06-12): cap the bias's EYE-SPACE span —
biasNdc(d) = min(0.0005, 0.5 m × near / d²)
(PortalDepthMaskRenderer.MarkBiasNdc, mirrored in the vertex shader).
Below the ~10 m crossover the constant term wins, bit-identical to the
T5-validated behavior (#108 grass coverage untouched); beyond it the
punch can never reach an occluder more than 0.5 m in front of the
aperture plane. Pins: Issue129PunchBiasTests (old form spans >100 m
at a landblock; capped form ≤0.5 m at all distances; close range
unchanged).
Gate: the original spot — distant building doors no longer show through terrain/houses at ~a landblock; AND the #108 cellar grass-sweep stays gone up close. If a >10 m-range #108-class residue appears, the cap constant (0.5 m) is the tuning knob — see AD-18.
#130 — Background-color strip along the TOP outer edge of a doorway when looking out from inside
Status: FIX 2 SHIPPED — awaiting user visual re-gate Severity: LOW-MEDIUM (small strip, but on the most-stared-at pixels in the game) Filed: 2026-06-12 (user report, post-#119-close session) Component: render — drawn-shell lift vs draw-space portal consumers (AP-32)
Symptom (user): standing inside looking out through a doorway, a
thin strip of background (clear/world) color runs along the OUTER edge
of the TOP of the doorway opening. Survived the scissor fix (6c4b6d6)
— user screenshot 2026-06-12 evening, "very subtle".
Root cause (the REAL strip, pinned by
Issue130DoorwayStripTests.UnliftedGate_LeavesTheStripAtTheDrawnTopEdge):
the +0.02 m shell render lift. Cell shells DRAW 2 cm above the dat
origin (z-fight vs terrain, AP-32); since f35cb8b (the #119-residual
fix) the visibility graph deliberately uses the PHYSICS (unlifted)
transform — but the OutsideView color gate and the seal fans, which are
DRAW-space consumers, kept the unlifted polygons. The drawn lintel
therefore sits one lift-projection ABOVE the gate's top edge —
6.7 px at a 2.4 m doorway (measured) — and that band gets no
terrain/sky color while the seal also stamps 2 cm low. Regression from
f35cb8b (2026-06-11), NOT from the W=0 clip port. Vertical edges are
immune (the lift slides them along themselves) — top edge only, exactly
as reported.
Fix 2: draw-space consumers re-apply the lift —
PortalVisibilityBuilder.Build(drawLiftZ:) projects the exit-portal
OutsideView region with the lifted transform (flood admission, side
tests, CellViews stay physics-space per f35cb8b), and the seal/punch
fans lift their world verts. One shared constant
PortalVisibilityBuilder.ShellDrawLiftZ now feeds the shell
registration, the gate, and the fans. AP-32 register row added (the
lift had no row). Pins: the lifted gate covers the drawn aperture to
0.00 px across the 147-combo sweep; the unlifted gate shows the 6.7 px
strip (sensitivity).
Fix 1 (also real, sub-pixel): 6c4b6d6 — the doorway-slice scissor
Floor(origin)+Ceiling(size) cut up to 1 px off the top/right edges;
now a conservative outer bound (NdcScissorRect, AD-17 doctrine).
The W=0 clip port 987313a is exonerated (CPU pipeline sub-pixel exact
in like-for-like space).
Gate: stand inside, look out the door with the lintel on screen, sweep the gaze — no background strip at the top edge at any alignment or distance.
#131 — Portal swirl invisible when viewed from inside a building through the doorway
Status: CLOSED (user-gated 2026-06-12 night: "Ok now it works" — fix 4, d208002)
Severity: MEDIUM (portals are landmark objects; the through-door view is common)
Filed: 2026-06-12 (user report, #124 gate session)
Component: render — UNATTACHED emitters have no pass under interior roots
Symptom (user, axiom): "the portal swirl is missing, when I look out from inside a house. Appears when I walk out again."
Root cause (confirmed by read + the [outstage] capture): every
particle pass under an interior root is id-FILTERED: the landscape
slice's Scene pass and the cell/dynamics passes all require
emitter.AttachedObjectId != 0 and membership in an owner set. An
UNATTACHED emitter (AttachedObjectId == 0 — portal swirls, campfires,
ground effects anchored at a position) therefore draws NOWHERE when the
root is interior. The outdoor root has the dedicated T3 pass for
exactly this class (its own comment: "unattached ones had NO pass on
outdoor-node frames") — the identical hole on interior-root frames was
never plugged. Walk out → the T3 pass picks the swirl up → "appears
when I walk out again". The capture corroborated the rest of the chain
healthy: outside-stage routing + cone PASS for the dynamics, 57
attached emitters matched and drawn through the doorway.
Fix (2026-06-12): DrawUnattachedSceneParticles — invoked ONCE per
interior-root frame at the end of the landscape stage (pre-clear; drawn
later they would z-fail against the doorway seal), after the #124
look-ins so swirls blend over far interiors, NOT per slice (alpha
particles must not double-draw — the #121 lesson). Mutually exclusive
with the outdoor T3 pass by root kind. Residual (documented): unattached
INDOOR emitters now draw pre-clear and are overpainted by the room's
shells — same invisibility as before this fix; the proper per-emitter
cell classification is a future port.
Apparatus (kept, env-gated): ACDREAM_PROBE_OUTSTAGE=1 —
[outstage] (per-slice routing + cone verdicts) + [outstage-pt]
(slice id set, attached matched count, unattached count).
FIX 1 INSUFFICIENT (user screenshots, same evening): the swirl is
the portal's TRANSLUCENT MESH, not (only) unattached particles. The
real mechanism — shared with #132 — is the #124 look-in ordering: the
slice drew the portal mesh (and all scene particles) BEFORE the look-in
sub-pass; translucents write no depth, so the far building's interior
(drawn into its far-Z-punched aperture) overpainted them wherever a
look-in opening sat behind them on screen. Both screenshots show the
swirl exactly in front of the hall's doorway. Retail cannot have this
bug: all landscape-stage alpha draws are deferred into ONE flush after
LScape::draw (D3DPolyRender::FlushAlphaList, DrawCells pc:432722).
FIX 2 (the FlushAlphaList deferral, same commit family as #124): the landscape stage is now TWO phases per frame — EARLY per slice: sky, terrain, outdoor static meshes (the look-in punches need their depth, the #117 lesson); then the #124 look-ins; then LATE per slice: outside-stage dynamics' meshes + ALL attached scene particles + weather + the unattached pass. (This FIXED #132 indoors but not the portal.)
ROOT CAUSE (fix 4 — structurally forced; fixes 1–3 were
real-but-adjacent): the teleport capture flipped pCell to
0xA9B4017A — the hall's porch EnvCell (the portal is a SERVER
object standing inside a look-in cell), and the headless replay of the
captured indoor frame proved the look-in flood ADMITS 0x017A (14 cells
incl. the porch — Issue131SetupProbeTests.Diagnostic_LookInFlood_*).
The partition routes server objects to the dynamics-last pass, where
(a) the viewcone has NO entries for look-in cells → culled, and (b)
even un-culled they would z-fail post-seal beyond the root's door plane
(the #118 lesson). This is exactly AP-33's recorded "look-in DYNAMICS
are not drawn (deferred)" — the deferred case was the town portal.
Outdoors the merge path puts the porch in the main cone → drawn →
"appears when I walk out."
Fix 4: look-in-cell DYNAMICS draw inside DrawBuildingLookIns
pass 2 (with the statics, whole — AP-33's over-include), and their
emitters ride the same DrawCellParticles call (fix 3). Retail
equivalent: the nested DrawCells draws the cell's objects
(DrawObjCellForDummies pc:432878+). No double-draw: dynamics-last
keeps culling them (cell absent from the main cone);
DrawDynamicsParticles only sees dynamics-last cone survivors.
Gate: stand inside, look out the doorway at the town portal — the swirl renders through the door.
#132 — Candle flame disappears when the through-opening background is behind it
Status: CLOSED (user-gated 2026-06-12: indoors "now the candle light is visible", outdoors "Candle works now") Severity: LOW-MEDIUM Filed: 2026-06-12 (user report, #124 gate session) Component: render — slice particles drawn before the #124 look-ins
Symptom (user, axiom): "I have a candle, when I look at the candle when a wall is behind it it shows, but if I turn a bit and the opening through a house is behind it candle light disappears."
Root cause (= #131's fix-2 mechanism): the candle/lantern's flame is an attached emitter drawn in the landscape slice's Scene-particle pass, which ran BEFORE the #124 look-in sub-pass. Particles write no depth; whenever a look-in opening ("the opening through a house") sat behind the flame on screen, the far building's interior — drawn into its far-Z-punched aperture — overpainted the flame. Against a plain wall (no look-in aperture behind), nothing overdraws it → visible. Background-dependence explained exactly.
Fix: the landscape stage's two-phase split (see #131 FIX 2): all scene particles moved to the LATE phase, after the look-ins.
Gate 1 result (user): indoors FIXED ("now the candle light is visible when I'm in the house when it is in front of the opening") — but the OUTDOOR sibling surfaced ("when I go out it is not showing unless I turn so the angle doesn't put it in front of the opening"): under an OUTDOOR root the merged building interiors draw AFTER the landscape stage, so a slice-drawn flame is overpainted by the punched aperture's interior — the residual AP-34 had already recorded.
Fix 2 (outdoor): outdoor roots skip the slice Scene pass; attached outdoor-static scene emitters draw in the POST-FRAME pass alongside the T3 unattached pass (depth complete there — flames composite correctly against interiors). The owner-id filter carries over; cell-pass and dynamics-pass emitters keep their own passes (owners never in the outdoor-static set → no double-draw).
Gate: both sides — indoors with the opening behind the candle, and outdoors at the angle that previously erased it.
Recently closed
#362 — [DONE 2026-08-09] Four new CH4 outbound requests have no inbound response handler
Closed: 2026-08-09, Campaign CH user-gate round 1, item E. Filed: 2026-08-09, Campaign CH slice CH4. Register row: TS-70, RETIRED in the same commit.
Resolution: @index, @clist, @hslist, and @allegiance info sent
byte-correct retail GameAction requests
(ClientCommandRequests.BuildIndexChannels/BuildListChannel/
BuildListAvailableHouses/BuildAllegianceInfoRequest), but their
GameEvent responses (ChannelIndex 0x0149, ChannelList 0x0148,
AvailableHouses 0x0271, AllegianceInfoResponse 0x027C) had no
GameEventWiring handler — ACE's reply was silently dropped. New
ClientCommandResponses.cs (src/AcDream.Core.Net/Messages/) parses all
four wire shapes (cross-checked against ACE's
GameEventChannelIndex/GameEventChannelList/GameEventHouseAvailableHouses/
GameEventAllegianceInfoResponse writers) and renders retail-shaped
LogTextType 0x00 (Default) lines ported verbatim from the named-retail
decomp:
- ChannelIndex/ChannelList:
Handle_Communication__ChannelIndex/ChannelList@0x0057d0c0/@0x0057d230 — a header line then one line per name. - AvailableHouses:
Handle_House__Recv_AvailableHouses+DisplayListOfCoords@0x00585d50/@0x00585c20 — the "There are N available." summary, then one indentedRadarCoordinates-formatted location line per landblock (skipped for apartments, which have no world location, matching retail'sarg2 != 4gate), then the >400-locations truncation notice whenTotalAvailable > 0x190. - AllegianceInfoResponse:
Handle_Allegiance__AllegianceInfoResponseEvent@0x0056a1d0 — the asterisk-legend note, "Allegiance information for <name><* if online>:", an optional "Patron:" line, and an optional "Vassals:" block, all reconstructed from the wire's flat parent-tagged record list via ports of retail's ownGetData/GetPatron/GetFirstVassal/GetNextVassalwalk. A player with no allegiance record produces NO lines, matching retail's own early return — this is not a residual bug.
17 new parser/format/routing tests
(tests/AcDream.Core.Net.Tests/Messages/ClientCommandResponsesTests.cs)
cover round-trips for all four shapes (including the empty-allegiance and
apartment-skip edge cases) and a GameEventDispatcher-level routing test
proving each reaches the ChatLog transcript with
RetailLogTextType.Default.
#329 — [DONE 2026-08-09] The portal wait cue arms five seconds late; retail emits it per tunnel rotation segment, unconditionally
Closed: 2026-08-09, Campaign CH user-gate round 1, item D. Severity: LOW (cosmetic, but it is a retail divergence on every single portal, in both directions) Filed: 2026-08-06, #280 retail-conformance review, finding F2. Register row: AP-150, RETIRED in the same commit.
Resolution: PortalTunnelPresentation.TickRotation now writes
"In Portal Space - Please Wait..." directly and unconditionally in the
rotation-segment-expiry branch, on every segment boundary, matching
gmSmartBoxUI::UseTime's else arm at 0x004D6FCD verbatim — confirmed
against docs/research/named-retail/acclient_2013_pseudo_c.txt:219499-219525
before coding, which shows no hold/threshold test anywhere in that branch.
acdream's own RotationDurationMin/Max (0.6-1.8 s) already matched
retail's RandDouble window byte-for-byte; only the arming — gating the
write on _waitCueVisible, which only ever went true after the invented
five-second RuntimeWorldTransitState.RetailWaitCueDelay hold — was wrong.
That hold/ObserveWait/SetWaitCue plumbing remains as
LocalPlayerTeleportController's own telemetry
(RuntimePortalSnapshot.WaitCueShown) but no longer gates the on-screen
cue; a dedicated ClearWaitCueNotice() now hides the notice unconditionally
on Enter/Exit/Dispose so a line written by the per-segment path can never
survive past the presentation going invisible. The notice text also now
renders in the same bright yellow as an incoming Tell
((1, 1, 0.247, 1), PortalWaitNoticeController), per the user's live
side-by-side observation (round 1 also pinned the SpewBox to the same
colour, AP-178).
Consequence (fixed): every portal, regardless of duration, now shows the
notice from the first rotation segment (which expires immediately on entry
since _rotationDuration starts at 0), refreshed every 0.6-1.8 s for as
long as the tunnel presentation is visible — matching retail instead of
silently skipping short transits and running 3.2-4.4 s late on long ones.
#234 — [DONE 2026-07-23] Cancelled close-range Use could strand the busy cursor
Closed: 2026-07-23 Severity: HIGH Component: interaction / retained UI / local approach
Resolution: ItemInteractionController acquired retail's shared busy
reference before SelectionInteractionController completed a close-range
turn. If that pre-wire approach was cancelled, failed to install, lost its
session, or crossed an entity-incarnation boundary, no Use packet existed and
therefore ACE could never return the UseDone needed to release the count.
An explicit, generation-bound ItemUseRequestReservation now owns that
boundary. Successful wire dispatch transfers ownership to authoritative
UseDone; every pre-dispatch cancellation path releases the reservation
locally and idempotently. Session reset invalidates late reservations so an
old callback cannot decrement a new session. Focused tests pin cancellation,
failed movement installation, stale identity, synchronous turn completion,
successful dispatch, and late post-reset resolution.
The connected report itself completed normally: the log contained the
successful NPC Use and UseDone(0), and a managed heap snapshot afterward
showed busy count zero with no pending inventory or auto-wield transaction.
That evidence separated an ordinary in-flight rejection from the real latent
pre-wire leak.
Connected gate: Passed 2026-07-23. Cancelling the close-range turn no longer strands the busy cursor, and the next NPC/item Use succeeds normally.
Research:
docs/research/2026-07-23-retail-use-busy-ownership-pseudocode.md.
#229 — [DONE 2026-07-20] Initial login revealed an incomplete world
Closed: 2026-07-20 Resolution: Login auto-entry waited only for the terrain height or EnvCell floor needed by local physics. It then removed the sky-only render gate while the destination's static meshes, nearby scenery, and composite textures could still be uploading. Portal travel appeared to repair the world because it already waited on all of those domains before revealing its destination.
Login and portal arrival now share WorldRevealReadinessBarrier, the async
equivalent of retail SmartBox::UseTime keeping position completion behind
CellManager::blocking_for_cells. Both paths invalidate destination texture
readiness, prepare composites after mesh publication, and require the same
render-plus-collision predicate. Focused login/barrier tests and the complete
Release suite pass. The user confirmed the capped first-login reveal, and the
follow-up deterministic connected gate now repeats fresh login plus five
portal/dungeon transitions, returns to the same outdoor location, closes with
the retail character-logoff/transport-disconnect handshake, and reconnects in
a fresh uncapped process. All six saved PNGs show complete geometry; all
structured checkpoints report ready render/composite/collision domains and
zero reveal invariant failures or pending teardown/upload/warmup work. See
docs/plans/2026-07-20-automated-world-lifecycle-gate.md.
Research:
docs/research/2026-07-16-portal-completion-pseudocode.md §2.
#215 — [DONE 2026-07-13] Same-dungeon death respawn removed the dungeon floor physics
Closed: 2026-07-13
Commit: 0ad6700a
Resolution: Teleport landblock classification now compares the player
controller's authoritative current Position.objcell_id with the received
destination cell ID. It no longer floors render-space XYZ, which had turned the
starter dungeon's valid Y -30.4 m into the false source landblock 0x8C03 and
removed the resident 0x8C04FFFF floor physics. Same-landblock respawns keep
their published physics; true cross-landblock teleports retain the existing
recenter/hydration path. Release build and all 5,054 runnable tests passed.
User visual gate passed: /die, respawn, then movement remained on the dungeon
floor. Research:
docs/research/2026-07-13-same-dungeon-respawn-landblock-identity-pseudocode.md.
#214 — [DONE 2026-07-13] Dungeon login could publish a partial EnvCell landblock
Closed: 2026-07-13
Commit: b0c175af
Resolution: Each streaming job now owns one private, immutable
EnvCellLandblockBuild, and the render thread commits its visibility, physics,
and drawable shells as one unit. The obsolete post-spawn forced reload was
removed, eliminating the duplicate job that could replace 1,123 complete
shells with the 11 entries remaining in shared pending collections. User visual
gate passed after relaunching directly into the starter dungeon: its textures
and geometry loaded completely. Research:
docs/research/2026-07-13-envcell-landblock-transaction.md.
#157 — [DONE 2026-06-26] Live game threads per-element dat-font resolver
Closed: 2026-06-26
Commit: feat(studio): #156 #157 mockup desktop and font resolver
Resolution: Mirrored the studio RenderStack.ResolveDatFont pattern in GameWindow: a per-window ConcurrentDictionary<uint,UiDatFont?> seeded with the default dat font, loaded lazily under _datLock, and passed to the live vitals, chat, toolbar, and inventory LayoutImporter.Import/Build calls. Live panels now get dat-authored FontDid defaults the same way the studio does; explicit controller-set fonts still override.
#156 — [DONE 2026-06-26] Studio inventory preview draws off-canvas
Closed: 2026-06-26
Commit: feat(studio): #156 #157 mockup desktop and font resolver
Resolution: The inventory layout imported correctly, but its top-level dat rect is the live-game screen position (Left=500, Top=138). Screenshot mode sized the FBO to the panel itself (300x362), so the whole tree rendered outside the off-screen canvas and only the dark clear color remained. StudioWindow now normalizes isolated dat-layout previews to (0,0) and disables root-level anchoring; the inventory screenshot changed from a flat 1.2 KB image to a rendered panel.
#192 — Login to a non-Holtburg position sometimes showed stabs/scenery floating in the wrong place
Status: DONE (2026-07-09, fa9aedca, user visual gate passed: "Looks good"). User-reported
while testing tonight's A7 lighting/particle fixes; traced via a threading/lifecycle read of the
login path, not inference. WorldSession transitions to InWorld immediately after the login
handshake (WorldSession.cs:608) — well before the player's own spawn CreateObject (which
carries their real position) has arrived over the network. The streaming gate's old condition
(!IsLiveModeWaitingForLogin || liveInWorld) opened the instant InWorld fired, letting the
background streaming worker (LandblockStreamer's dedicated Thread) build real landblocks
using whatever _liveCenterX/Y held at that moment — the Holtburg startup placeholder, not a
"not known yet" sentinel. A landblock that started building in that window bakes its world
offset from that placeholder at build time; if the build was still in flight when the real spawn
arrived and recentered the world (ForceReloadWindow, which only unloads already-resident
landblocks), it finished and got applied anyway — stale-positioned geometry landing wherever the
guess put it relative to whatever streamed in afterward with the corrected center. Explains both
observed traits: "sometimes" (depends on whether a pre-real-data build happened to be mid-flight
at the exact recenter moment — a genuine timing race) and specifically at login to a
non-Holtburg position (nothing to race against if the real spawn coincides with the placeholder).
User explicitly pushed back on "just use a different/no placeholder" — correctly: any placeholder
racing against the real answer reproduces the identical bug. Fix:
AcDream.Core.World.StreamingReadinessGate.ShouldStream requires an explicit liveCenterKnown
flag (true only once the player's own spawn has been processed) in addition to liveInWorld —
nothing streams in live mode until the real position is confirmed, so no placeholder value is
ever acted upon. Preserves the pre-existing #106 gate-3 fix this gate originally existed for
(auto-entry waits for terrain under the spawn; terrain streaming must not wait for chase mode in
turn, or the two deadlock) since liveCenterKnown becomes true independently of chase mode,
driven purely by the spawn packet's arrival. The stricter render gate (GameWindow.cs:9596,
hides ALL world geometry until chase mode engages) was already a partial safety net and is
unchanged — this fix stops the stale geometry from ever being built, rather than relying on the
render gate to hide it until the reveal. Files: src/AcDream.Core/World/StreamingReadinessGate.cs
(pure, unit-tested gate predicate — the regression test asserts liveInWorld alone must NOT open
the gate), src/AcDream.App/Rendering/GameWindow.cs (_liveCenterKnown flag + call site). Core
2680+2skip / App 741+2skip / UI 425 / Net 385 green.
#93 — Indoor lighting broken (M1.5 lighting umbrella)
Status: DONE (2026-07-09). Two real root causes found + fixed (A7.L1): (1)
LightManager.BuildPointLightSnapshot candidate-pool starvation in dense hubs — fixed by
scoping candidacy to last frame's rendered visible-cell set (d275ed55); (2) THE actual
fountain-room darkness — the mesh-empty hydration gate dropped an entity's whole
registration (Setup.Lights included) whenever its visual mesh flattened to zero parts, a
common "light attach point" dat pattern; fixed via EntityHydrationRules.ShouldKeepEntity
(9ebb2060) — dungeon-wide registered lights jumped 498→892. User-confirmed at the Town
Network fountain room ("lightning is better") and separately at a 2nd-floor room (#80,
re-verified fixed). #189 (missing fountain particle, found investigating this same room)
turned out to be a third, unrelated bug (#190, entity-id overflow), also closed. #94
(held-item spotlight) does NOT gate this closure — it's currently untestable (acdream
doesn't yet support equipping hand-held items) and stays open on its own, blocked on that
unrelated feature. Files: src/AcDream.Core/Lighting/LightManager.cs
(BuildPointLightSnapshot), src/AcDream.App/Rendering/GameWindow.cs (hydration gate,
_lightPoolVisibleCells), src/AcDream.Core/Meshing/EntityHydrationRules.cs. Apparatus:
Issue93TownNetworkFountainRoomLightInspectionTests (dat-truth, reusable).
#80 — Camera on 2nd floor goes very dark
Status: DONE (2026-07-09, user re-verified: "Number 80 is closed, I verified again").
Closed as part of the #93/A7.L1 lighting-umbrella fix arc (candidate-pool scoping +
mesh-less light-carrier hydration, d275ed55 + 9ebb2060) — not independently
root-caused to this specific symptom, but the user's direct re-check at a 2nd-floor room
confirmed it's fixed. See #93 for the fix mechanism.
#189 — Ambient particle scripts (fountain water, possibly candle flames) don't render indoors
Status: DONE (2026-07-09, user visual gate passed — "Fountain is back! We
can close it"). Root cause was NOT the A7.L1 mesh-carrier hydration fix itself
(the fountain's Setup 0x02000AA3 always had a surviving mesh part + a real
DefaultScript.DataId) — it was #190, an entity-id overflow that fix
triggered as a side effect (see #190 below for the full mechanism). Fixed by
e651cb6d; ACDREAM_DUMP_ENTITY confirmed the fountain's id decodes
correctly post-fix, and the user confirmed the water spray visually. Residual
note, not re-raised by the user, kept for a future session: the guessed
"candle" objects (0x02001967, 16 arranged around the fountain) turned out to
have real mesh and DefaultScript == 0 — not candles, no script at all — so
if candle-flame absence resurfaces, the actual source objects in this room are
still unidentified.
#190 — Interior entity id counter overflowed past its 8-bit budget, aliasing into the next landblock
Status: DONE (2026-07-09, e651cb6d). Found while investigating #189
(missing fountain particle): a user-requested revert-test of the A7.L1 light
fix (9ebb2060) made the fountain work again, which didn't fit the earlier
dat-truth finding that the fountain's own entity was untouched by that fix.
Traced with ACDREAM_DUMP_ENTITY: the fountain's hydrated entity.Id shifted
between reverted (0x400007F8) and fixed (0x40000815) builds — the extra
mesh-less light carriers the A7.L1 fix now keeps alive earlier in the same
landblock's hydration pass shifted localCounter. 0x40000815 decodes as
landblock Y=0x08 — NOT the Town Network's true Y=0x07 — the exact #119
cross-landblock id-aliasing bug, reincarnated by entity COUNT (277, past the
8-bit/256 budget) rather than a computation bug; the #119 fix's own comment
had explicitly flagged this as a residual risk ("counter overflow past 0xFF
still bleeds into the lbY byte"). EntityScriptActivator keys particle-script
instances by entity.Id directly (no landblock-hint disambiguation, unlike
#119's Tier-1 batch cache), so the aliased id silently broke the fountain's
script tracking. Fix: AcDream.Core.World.InteriorEntityIdAllocator widens the
counter 8→12 bits (256→4096) by shrinking the fixed class-prefix from a full
byte (0x40) to its top nibble (0x4_) — verified safe against every
entity.Id classification check in GameWindow.cs (none decode X/Y back out,
only threshold/prefix checks). Added a loud one-time [id-overflow] log if a
landblock ever exceeds the new budget. Post-fix: fountain's id (0x40007115)
decodes correctly (Y=7, counter=277, safely under 4095). Core 2675+2skip /
App 741+2skip / UI 425 / Net 385 green.
Historical closing summary for #170 — Remote creature chase+attack
Status: DONE (2026-07-04, user visual gate passed — "as close to retail now as
I can see"). Three-fix arc: 427332ac deleted the per-frame
apply_current_movement re-dispatch that flooded pending_motions to ~1.3M
(chase turn permanently blocked → slide); d2ccc80e refreshed remote body
velocity from get_state_velocity; 1051fc83 fixed the residual — the
SERVERVEL per-tick branch skipped MoveToManager.UseTime for any UP-receiving
NPC, starving the armed moveto for the whole server-side chase (funnel 16 arms →
1 run install; retail runs MovementManager::UseTime unconditionally,
UpdateObjectInternal 0x005156b0 @0x00515998) — an armed moveto now always takes
the MOVETO leg. The intermediate "Ready drain too slow" hypothesis was DISPROVEN
by the full-stack offline harness (RemoteChaseEndToEndHarnessTests +
RemoteChaseDrainBisectTests, kept as conformance). Register: TS-41 narrowed,
TS-42 added (one-frame drain-order divergence, R6). Gate telemetry
(launch-170-gate2.log): installs ≈ arms for every creature, zero armed-moveto
UseTime skips, queue depth 1. Probes stripped in the closing commit. The full
investigation record stays in the OPEN-section body (moves here on next tidy) +
docs/research/2026-07-04-170-creature-run-handoff.md.
#163 — Strip the R4-V5 stall-investigation diagnostics
Status: DONE (2026-07-03, 5ebe2be3) — removed [autowalk-gate] (+
_lastAutowalkGateLogTime throttle clock, PlayerMovementController),
[autowalk-feed] (GameWindow player tracker feed), and the short-lived
[MOVETO-CANCEL] #162 capture probe. The durable ProbeAutoWalk family
(PhysicsDiagnostics owner + DebugPanel toggle + the permanent [autowalk]
probe sites) stays. Suite 3,964 green.
#162 — Observer-side moveto cancelled by ACE's autonomous MTS reflections (the "glide" class)
Status: DONE (2026-07-03) — resolved WITHOUT adaptation; user-verified.
The user's A/B came back retail does NOT glide (new-fact branch), and
the follow-up acdream capture (launch-162, post-#161) showed acdream now
matches: walk/run by distance on mt-6 movetos (retail
MovementParameters.GetCommand threshold), clean takeover on a mid-chain
key press. Three-part evidence:
- Mechanism re-audit vs raw: the head-interrupt IS unconditional
(
unpack_movement0x00524440 →interrupt_current_movement0x005101f0 →MovementManager::CancelMoveTo(0x36)), andMoveToManager::CancelMoveTo(0x00529930) is gated on an ARMED moveto (drain + CleanUp + StopCompletely; no-op otherwise). Our ports are verbatim — retail's observer loses its armed moveto on the first reflection TOO. - Wire capture: ACE's mt-0 reflections carry the mover's REAL
locomotion (WalkForward @ -2.92 backward, RunForward @ 4.50, turns) —
so a cancel is always followed by correct legs; the no-glide outcome
needs no moveto protection. The
[MOVETO-CANCEL]probe showed zero mover-side cancels in normal use-walks (the mover's client doesn't emit MTS mid-chain unless keys are pressed) and retail-correct replace-cancels on an NPC's own successive TurnToObject UMs. - The originally-observed glide is no longer reproducible — killed by
the R4-V5 fix stack (un-ticked remote MoveToManagers
006cf659, TS-40 link-strip350fb5e3, #160 run-rate41006e79) + #161's apply-pass params fixb1cf0102(ModifyInterpretedState=true corrupted interpreted state on EVERY UM apply, not just landings). The theorized "narrow ACE-compat adaptation" is dead — nothing to adapt.
#161 — Remote jump landing holds the falling pose (no landing anim)
Status: DONE (2026-07-03, b1cf0102) — user live-verified ("Yes works!").
Three legs, all retail-decode, zero adaptations added:
- Apply-pass params: retail's
apply_interpreted_movementruns ALL its dispatches withModifyInterpretedState=false— the ctor default 0x1EE0F is REWRITTEN by a bitfield store the BN decomp smears into the mush expression at raw 305778 ((word & 0x37ff) | cancelMoveTo<<15 | disableJump<<17; 0x37ff clears SetHoldKey/ModifyInterpretedState/ CancelMoveTo). ACE MotionInterp.cs:444-449 confirms. Our pass dispatched with ctor defaults → the airborne Falling substitution clobberedInterpretedState.ForwardCommand→ HitGround's re-apply re-dispatched Falling instead of the preserved pre-fall wire command. (The retail exit: fwd is PRESERVED through the fall; HitGround re-dispatches it; the motion table plays the Falling→X landing link — no wire input needed.) Bonus decode: raw arg3 = DisableJumpDuringLink, so(N, 0)callers mean allowJump=TRUE — all 9 caller polarities fixed; the W6 entry-cache (built on the wrong "retail self-heals via hoisted registers" theory) deleted;copy_movement_from'scurrent_stylecopy (raw 0051e757) added to the UM flat-copy. NOTE: the handoff's "HitGround raw @305949" was a mislabel — 305949 is insidemove_to_interpreted_state; real HitGround is 0x00528ac0 →apply_current_movement(0,0). - Landing order: both GameWindow landing blocks cleared the Gravity
state bit BEFORE
Motion.HitGround(), whose verbatimstate & 0x400gate no-opped the whole retail re-apply (retail never clears GRAVITY on landing); the UP-driven block never called HitGround at all. Both now: transients →Motion.HitGround()→MoveTo.HitGround()→ THEN the DR gravity-clear (register row AP-81; retire in R6). - K-fix17 (the handoff's Q3): its SetCycle DID execute — and re-set
the Falling cycle, because it read the leg-1-clobbered ForwardCommand
(0x40000015 ≠ 0 defeated the Ready fallback). Both SetCycle blocks
deleted — superseded by the retail path, which also plays the landing
LINK animation SetCycle never did.
Tests:
HitGround_AfterFall_RedispatchesPreservedForward_ExitsFallinglifecycle pin;AirborneBody_NoCycleDispatches_OnlyTurnStopassertion flipped (it had PINNED the bug value). Suite 3,964 green. Spawned #164 (action-replay Autonomous bit).
#150 — Open doors still blocked at the threshold (ethereal target not skipped in the step-down pass)
Status: DONE (2026-06-25) — after the retail collision sweep, an OPEN door
(ETHEREAL_PS 0x4 set on Use) still stopped the player with a small residual block at
the sill ("can-sized cylinder on the ground threshold"), even though the door swings
open visually. Root: the resolver runs two collision passes per step — the main
sweep and a step-down (foot-sphere "is there floor?") sub-pass. acdream tested the
ethereal door in BOTH; the main pass cleared it (Layer-1 Path-1 + the Layer-2
override) but the step-down pass had no escape and its Collided result survived
as the threshold block. Retail's CPhysicsObj::FindObjCollisions (pc:276795-276806)
SKIPS an ethereal target entirely when sphere_path.step_down != 0 — it only tests
it in the main pass — so an open door is fully passable everywhere (the swung panel's
position is irrelevant; ethereal = no collision, the animation is purely visual).
Fix: port that exact branch — if ((state & 0x4) || (mover.Ethereal && (state & 0x1) == 0)) && sp.StepDown → continue. TransitionTypes.cs per-object loop. Live-verified
(user "Yes it works"; the door now blocks 0× while open [0x1000C], still blocks while
closed [0x10008]; pre-fix it blocked 217× while open). Core suite green (1595/0). The
earlier "animate the door collision" theory was WRONG and dropped — the user correctly
noted that if collision followed the swung panel you'd bump the panel in its open
position, which retail does not do. Likely also advances the door half of #137.
#149 — BSP-less landblock statics (torches/braziers/lamp-posts) were walk-through
Status: DONE (2026-06-25, 4cf6eeb) — town props placed as landblock stabs
whose ONLY collision is a Setup CylSphere/Sphere (no physics BSP) had ZERO collision
shapes registered → walk-through. Root: the ISSUES #83 / A1.6 gate !_isLandblockStab
skipped Setup cyl/sphere for ALL stabs, on the false assumption "landblock stabs
collide via BSP only (retail CBuildingObj)." Confirmed end-to-end via live retail
cdb: the Holtburg torch (Setup 0x020005D8, world (105.99,17.17)) hits
CPhysicsObj::FindObjCollisions with num_cylsphere=1, cyl h=2.2 — a cylsphere,
matching the dat (cylSphere r=0.2 h=2.2) exactly; the StabList confirms stab[95]=
0x020005D8 at that position. Fix: gate the Setup cyl/sphere registration on
entityBsp == 0 (retail's binary dispatch — BSP if the object has one, ELSE
cyl/sphere; see feedback_retail_binary_dispatch) instead of stab-ness — preserves
#83's anti-doubling (stab WITH a BSP → BSP-only) while restoring collision for
BSP-less stabs. Live-verified (torch + candle/brazier family block now; ~115
cyl/sphere Setups register across streamed landblocks). The earlier selection-sphere
hypothesis was WRONG and reverted — the cdb's r=0.48 sphere was the player/NPC body
(every body sphere is ~0.48), not the torch; capturing the target at
FindObjCollisions (not this in intersects_sphere) + confirming by position +
Setup-id is the correct cdb method. GameWindow.cs:7281. Core suite green (1595/0).
(Numbered #149 to stay clear of main's #148; worktree branched before #145–148.)
Dense-town (Arwic) FPS deep-dive — 75 → ~165 fps
Status: DONE (2026-06-24) — 290e731 (cell-object draw batching) + 9f51a4d
(cell-particle consolidation, also fixed a latent additive double-draw in
multi-aperture cells); apparatus stripped a9d06a6. Pixels-identical, build +
full suite green. The handoff's "GPU-bound / ~12 ms GPU" was wrong — that was a
glFinish self-measurement artifact; real GPU ~0.5 ms, the frame is ~96 % CPU-bound
(per-cell rebuild cost scaling with visible buildings). Remaining headroom
(scenery-CPU WbDrawDispatcher rebuild ~2.1 ms + terrain-GPU ~2.1 ms) deliberately
NOT pursued — both in frozen load-bearing subsystems, HIGH risk for ~15 fps at one
extreme view already over target. SSOT: docs/research/2026-06-23-dense-town-fps-attribution-report.md.
Lessons: memory/feedback_render_perf_measurement.md.
D.2b — Inventory window finish (Stage 1): scroll + frame + resize + 102 slots SHIPPED
Closed: 2026-06-21 Component: ui — D.2b inventory window (UiItemList scroll, UiNineSlicePanel frame, vertical resize)
The inventory window now matches retail's 2D presentation (minus the 3D paperdoll doll = Stage 2). Shipped 366af0c→1be7e65 (build + full suite green: Core.Net 334 / App 543 / UI 425 / Core 1530; spec/plan docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-window-finish*.md):
- Scroll —
UiItemListclip+scroll via the sharedUiScrollable; cells remain exempt from the per-frame anchor pass (the original "grid escapes the window" root cause). Issue #147 later completed pixel-clipped partial rows and refresh offset preservation; gutter scrollbar0x100001C7remains bound like ChatWindowController; mouse-wheel line steps remain one row. - Frame — wrapped in the 8-piece bevel chrome (
UiNineSlicePanel) like vitals/chat/toolbar. - Vertical resize — bottom-edge drag, horizontal blocked (
ResizeX=false); grid/sub-window/scrollbar/backdrop stretch (Left|Top|Bottom), paperdoll + side-bags pinned; scrollbar thumb reflects view/content. Expand-only Min=default; issue #147 replaced the obsolete fixed 560 px maximum with available screen height (AP-54). - 102-slot grid — contents grid pads empty frames to the main-pack capacity (default 102, AP-53); side-bag column pads to 7 (AP-52).
Visually confirmed (scroll, frame, resize, 102 slots). Remaining (handoff
docs/research/2026-06-21-d2b-inventory-finish-handoff.md): wrong empty-slot background art (inventory + equip slots — the OPEN issue above) + main-pack backpack icon (AP-51); Stage 2 = paperdollUiViewportdoll + per-slot equip silhouettes.
D.2b-B — Inventory wire layer (B-Wire): player-property delivery + builders/parsers SHIPPED
Closed: 2026-06-21 Component: net/ui — D.2b inventory wire (player props + inventory GameMessages/GameActions)
The burden bar now reads the server's wire EncumbranceVal (PropertyInt 5) instead of the client-side sum. Root cause was delivery, not binding — the binding already read Properties.Ints[5], but the value never arrived: login PD parsed the player's int table then dropped it; live PrivateUpdatePropertyInt 0x02CD was unparsed; ObjectTableWiring gated all non-UiEffects ints out. Fixes: ClientObjectTable.UpsertProperties (create-if-absent) + the PD handler upserts the player's PropertyBundle; new PrivateUpdatePropertyInt 0x02CD parser + WorldSession dispatch + player-int route; loosened the int-apply gate to apply ALL ints; InventoryController.Concerns refreshes on the player's own object (C1d). Plus latent-bug fixes (0x0022 4th field containerType; 0x00A0 weenieError), new builders (DropItem 0x001B, GetAndWieldItem 0x001A, NoLongerViewingContents 0x0195) + Send* wrappers, new parsers (ViewContents 0x0196, SetStackSize 0x0197, InventoryRemoveObject 0x0024) + GameEvent registration. Spec/plan docs/superpowers/{specs,plans}/2026-06-21-d2b-inventory-wire*.md. Commits b56087b→7badecf (build + full suite green: Core.Net 334 / App 534 / UI 425 / Core 1530; Opus phase-boundary review APPROVED with byte-for-byte ACE verification). AP-48/AP-49 reworded to fallback-only (confirm + delete at the visual gate). Pending the burden-bar visual gate (does the live bar match ACE + update on pick-up/drop?). Remaining D.2b inventory: contents-grid scroll polish (below), B-Drag (inventory drag SOURCE + SourceKind==Inventory), Sub-phase C (paperdoll).
D.2b-B — Inventory controller (B-Controller): grid population + burden meter + captions SHIPPED
Closed: 2026-06-21 Component: ui — D.2b inventory window (gmInventoryUI 0x21000023)
InventoryController.Bind wired into the existing inventory-init block in GameWindow.cs (commit 03fbf44). The controller populates the "Contents of Backpack" grid (6 cols × 32 px) and the pack-selector strip from ClientObjectTable, drives the vertical burden meter via BurdenMath.EncumbranceCapacity/LoadRatio/LoadToFill, and attaches "Burden" + "Contents of Backpack" + % captions. Four divergence rows added: AP-48 (client-side burden sum), AP-49 (aug capacity unwired), AP-50 (meter direction from geometry), AP-51 (main-pack icon placeholder). Divergence register + docs commit: docs(D.2b-B).
VISUALLY CONFIRMED 2026-06-21. At the visual gate the controller's logic was correct (live diagnostics: 36 items + 1 side bag bound, burden Str 290 → 17%, captions' DrawStringDat called, meter sprites resolved), but two RENDER bugs hid the result; both fixed (417b137): (1) backdrop wash-out — the #145 continuation above; (2) captions — the caption elements resolve to UiText, and driving a nested child UiText didn't paint, so AttachCaption now drives the host UiText directly. After the fixes: dark backdrop behind, "Burden 17%" + vertical bar + side-bag + "Contents of Backpack" + full item grid all render. Remaining open sub-phases: B-Wire (parse EncumbranceVal/PropertyInt 5 from the wire to retire AP-48), B-Drag (drag-from-inventory SourceKind branch closes B.2), Sub-phase C (paperdoll equip-slot UiItemSlot registration), + the contents-grid scroll polish (next issue).
Inventory "Contents of Backpack" grid overflows (no scroll)
Status: DONE (2026-06-21 · D.2b inventory finish Stage 1, 366af0c→1be7e65) — the grid clips to its panel + scrolls via the gutter scrollbar 0x100001C7 (UiScrollable + whole-row clip; cells exempted from the anchor pass), pads to the full 102-slot main-pack capacity, and the window is vertically resizable (bottom edge). Visually confirmed.
Component: ui — D.2b inventory (gm3DItemsUI grid 0x100001C6)
Description: InventoryController populates the 6-col contents grid with ALL of the player's loose pack items via UiItemList grid mode, so a pack with >18 items (6 cols × 3 visible rows in the 192×96 panel) overflows BELOW the panel/frame ("part of the inventory hanging off the window"). Retail scrolls the grid via the side gutter scrollbar (0x100001C7). Fix: wire the gutter UiScrollbar to the grid + clip the grid to the panel (scroll the overflow). Spec listed scrolling as out-of-scope for B-Controller (docs/superpowers/specs/2026-06-21-d2b-inventory-controller-design.md §10), so this is a deliberate follow-up, not a regression.
Inventory + equipment slots show the wrong empty-slot background art
Status: COMPLETE + VISUALLY CONFIRMED 2026-07-13. Inventory contents/containers were confirmed 2026-06-22; all 24 paperdoll locations, including progressive Aetheria visibility and blue/yellow/red backgrounds, were confirmed 2026-07-13. The main-pack icon and selected-container indicators were completed in their later sub-phases. Filed: 2026-06-21 Component: ui — D.2b inventory (UiItemSlot empty sprite + paperdoll equip slots)
Description: User-flagged at the Stage-1 visual gate: the empty cells in the contents grid + side-bag column use UiItemSlot.EmptySprite = 0x060074CF (the TOOLBAR empty-slot border) — the wrong art for inventory pack slots. The paperdoll equipment slots render a generic blue UiDatElement border, not per-slot equip silhouettes. Both look wrong vs retail.
Root cause / status: UiItemSlot.EmptySprite began as one shared hardcoded default. Inventory lists were corrected first by resolving their list-selected prototype. Paperdoll retained the contents-grid fallback because the earlier investigation inspected the ItemList elements' own media and missed retail's cell-creation step: each paperdoll list clones a distinct UIElement_UIItem prototype from catalog 0x21000037.
Resolution (inventory portion, 2026-06-22): Ported retail's UIElement_ItemList::InternalCreateItem (0x004e3570) resolver into ItemListCellTemplate.ResolveEmptySprite — reads attribute 0x1000000e off each list element → catalog 0x21000037 prototype's ItemSlot_Empty. Correction to the original diagnosis: 0x060074CF is the generic shared item-slot empty (the ItemSlot_Empty of many catalog prototypes), not "the toolbar's"; 0x21000037 is a catalog of ~50 per-slot-kind empties — so it was never a one-constant swap. Pinned per-list (real-dat test): contents 0x100001C6 → 0x06004D20; side-bag 0x100001CA + main-pack 0x100001C9 → 0x06000F6E (the inner dark slot, resolved through BaseElement inheritance). Visual-gate correction (2026-06-22): the first cut's frame-first heuristic grabbed the container prototype's DirectState child 0x06005D9C — which is the open/selected-container TRIANGLE indicator, not a background — and stamped it onto every empty container cell. Fixed: resolve the inner ItemSlot_Empty through inheritance (FindIconEmpty); the triangle + green/yellow selection square is deferred to the container-switching sub-phase (AP-56). UiItemList.CellEmptySprite carries it; InventoryController + GameWindow wire it. Divergence AP-55 (toolbar still hardcoded) + AP-56 (selected-container indicator deferred). Spec/plan docs/superpowers/{specs,plans}/2026-06-22-d2b-empty-slot-art*.md.
Resolution (paperdoll portion, 2026-07-13): Re-read named retail gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0, PostInit @ 0x004A5360, and UIElement_ItemList::InternalCreateItem @ 0x004E3570, then enumerated the live 0x21000037 catalog. PaperdollSlotBackgrounds is now the single element/location/prototype definition table; runtime and UI Studio resolve the exact 21 authored surfaces through the existing inheritance-aware FindIconEmpty path. Real-DAT tests pin every prototype and RenderSurface. AP-66 retired. Research/pseudocode: docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md.
Aetheria extension (2026-07-13): The first visual gate passed for the original 21 locations and exposed the intentionally deferred Aetheria row. The same definition table now includes sigil elements 0x10000595..97, equip masks 0x10000000/0x20000000/0x40000000, prototypes 0x10000592..94, and exact blue/yellow/red surfaces 0x06006BEF..F1. Visibility ports gmPaperDollUI::UpdateAetheria @ 0x004A3E50: player PropertyInt.AetheriaBitfield (322) bits 1/2/4 independently expose the three slots at login and on live 0x02CD updates. A missing property hides all three. AP-108 narrowed.
Aetheria live gate PASSED (2026-07-13, edc9be30): ACE /enable-aetheria states 0, 1, 3, and 7 progressively showed none, blue, blue+yellow, and all three slots immediately without relogging; each slot displayed its correct authored background.
Files: src/AcDream.App/UI/UiItemSlot.cs; src/AcDream.App/UI/Layout/ItemListCellTemplate.cs; InventoryController.cs; PaperdollSlotBackgrounds.cs; PaperdollController.cs.
Research: .layout-dumps/uiitem-0x21000037.txt; docs/research/2026-06-16-equipment-paperdoll-deep-dive.md; docs/research/2026-07-13-retail-paperdoll-slot-backgrounds-pseudocode.md.
Acceptance: empty inventory + side-bag cells show the retail pack-slot frame; all 24 equip locations show their authored backgrounds; Aetheria slots remain hidden until their individual unlock bits arrive.
#113 — Phantom staircase / holistic building-render port
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
The Holtburg meeting-hall's walkable stair-ramp painted across the exterior wall because the PView shell-clip pass computed correct clip regions but never enabled GL_CLIP_DISTANCE — fixed by 927fd8f. The bug reopened 2026-06-11 when a suspected second root cause surfaced (dictionary-referenced-but-undrawn BSP polys); per the user's mandate this was folded into the holistic building-render port effort (#113/#114/#108/#109/#99, charter at docs/research/2026-06-11-building-render-holistic-port-handoff.md), which shipped as part of the broader render-pipeline redesign (Option A) closed 2026-06-12. A 2026-07-16 named-retail audit later disproved the proposed global DrawingBSP polygon filter: CGfxObj::InitLoad/D3DPolyRender::ConstructMesh consume the complete polygon array, and the alleged shell orphans are portal references omitted by the old diagnostic collector. See retired divergence TS-20; do not retry e46d3d9's filter, which made doors disappear.
#111 — ACE-mutated indoor restores: transparent interior / wrong placement at login — [DONE 2026-06-10 · 5f1eb7c + 5706e0e + 2735695]
Status: DONE (user-gated: clean indoor logins at two different buildings —
"it worked", "looked great"; further self-testing across houses ongoing)
Closed: 2026-06-10 (late)
Commits: 5f1eb7c (claim-authoritative snap + [snap] apparatus) →
5706e0e (ground via physics walkable polygons) → 2735695 (entity snap parity)
Component: physics / player snap
The peel (each layer caught live by the [snap] apparatus):
- bestCell clobber (
5f1eb7c): the legacy Resolve floor-pick scanned every CellSurface in the landblock (123 at Holtburg) and broke same-height ties by iteration order — it clobbered ACE's CLEAN validated claim 0xA9B40171 with 0xA9B4013F, seeding the poison loop (our heartbeats reported the clobbered cell; ACE persisted it; the next login inherited it). Fix: a VALIDATED indoor claim is authoritative (retail SetPositionInternal commits the AdjustPosition cell and only settles Z); the snap grounds onto the claim's own floor. - Triangle-soup grounding (
5706e0e): CellSurface includes ceiling/roof TOP faces — first-hit grounded onto 0x171's 99.475 ceiling (then poisoned ACE's save with the roof height); nearest-to-reference self-confirmed the poison. Fix: ground via the PHYSICS walkable polygons (normal.Z ≥ PhysicsGlobals.FloorZ, retail find_walkable's filter) —WalkableFloorZNearest, cell-local plane drop. Verified eating the poisoned restore: claim (0x171, z=99.475) → grounded 94.000. - Entity snap asymmetry (
2735695): login entry snapped only the CONTROLLER; the renderer kept the entity at the restored height ("spawned 2 m in the air" over a fully-correct interior). Fix: entity.SetPosition + ParentCellId at entry, parity with the teleport-arrival path.
The ACE-side behavior (server persists ITS physics state, not our reports —
SetRequestedLocation feeds ACE's server-side player) is by design and now fully
survivable: every restore shape observed tonight (clean / adjacent-room /
cross-building / cellar-sunk / roof-lofted) lands correctly placed or loudly
corrected ([spawn-adjust]/[snap] lines). The [snap] diagnostic stays (one line
per login/teleport).
#107 — Indoor-login spawn wedge — [DONE 2026-06-10 · 1090189]
Status: DONE (live-verified incl. ACE's own poisoned teleport; final indoor
logout→login gate pending user)
Closed: 2026-06-10
Commit: 1090189
Component: physics / player snap, teleport arrival, outbound wire pairs
Root cause (capture resolve-107-login1.jsonl + dat scan): ACE restored a
POISONED (cell, position) pair — cell 0xA9B40162 (one building) with a position
inside 0xA9B40171 (a different building 55 m away). The entry snap trusted the
claim verbatim → fake-grounded limbo (no contact plane/walkable; zero-move
resolves short-circuit) → first movement demoted the claim to outdoor
mid-building → 2.4 m fall through the cottage floor onto the terrain under the
house. Second shape: the PortalSpace teleport-arrival detection gated on
differentLandblock || farAway>100m (invented) — ACE's same-landblock short-hop
corrections matched neither → movement input frozen all session.
Fix (four legs, retail-anchored): (1) PhysicsEngine.Resolve (player snap)
runs retail AdjustPosition first (SetPositionInternal :283892 step 1;
AdjustPosition :280009) — [spawn-adjust] logs corrections; (2) the deferred
indoor seen_outside → adjust_to_outside sub-fallback completed (+
CellPhysics.SeenOutside); (3) PortalSpace arrival = any player position update
(holtburger-conformant); (4) outbound wire pairs self-consistent (landblock
frame from the resolver's full cell id, not the position) + the gate-2 hold
extension (IsSpawnCellReady). Live verification: ACE sent a same-lb dist=69.8
teleport whose destination was ANOTHER poisoned claim (0xA9B40150) — arrival
completed, [spawn-adjust] corrected, player fully controllable.
Tests: Issue107SpawnDiagnosticTests (3 dat-backed conformance facts).
#105 — Intermittent white/missing indoor wall textures — [DONE 2026-06-10 · c787201]
Status: DONE (probe-verified both directions; visual gate pending user)
Closed: 2026-06-10
Commit: c787201 (fix + the ACDREAM_PROBE_TEXFLUSH apparatus)
Component: render (GL texture upload)
Root cause: TextureAtlasManager.AddTexture only STAGES texture content (PBO write +
ManagedGLTextureArray._pendingUpdates); the actual TexSubImage3D copies + mipmap
regeneration happen in ProcessDirtyUpdates, which WB drives once per frame via
ObjectMeshManager.GenerateMipmaps() from its render loop (WB GameScene.cs:975).
GameScene is the file the N.4/O-T4 extraction replaced with GameWindow, so the per-frame
driver was silently dropped. Staged updates only reached the GPU as a side effect of PBO
growth; every layer staged after an array's LAST growth kept undefined TexStorage3D
content behind a valid resident bindless handle — white/garbage walls, zh==0, all dat
tripwires silent (the dat→decode→stage side had delivered correctly). Only
ObjectRenderBatch.BindlessTextureHandle consumers were affected (EnvCellRenderer cell
shells = indoor walls); entities resolve via TextureCache (immediate) and terrain via
TerrainAtlas (immediate) — which is why only indoor walls ever struck. Intermittency =
background decode-completion order shuffling which textures land in the never-flushed tail.
Fix: WbMeshAdapter.Tick() now calls GenerateMipmaps() after the staged-upload
drain (Tick runs before all draw passes — the WB-equivalent position).
Evidence: pre-fix texflush-prefix.log: pending updates climb 0→48→…→142 and park at
126 across 34/34 atlas arrays forever at standstill. Post-fix texflush-postfix.log +
nearplane-reland-1.log: after=0 on every line. The earlier exonerations (dat reads
safe, membership healthy, "not the probes") all stand — this was the predicted
"between staging and the draw" GL-side loss.
Tripwires: the four dat-side tripwires stay (permanent anomaly logging);
ACDREAM_PROBE_TEXFLUSH stays env-gated (zero cost off).
#110 — Near plane 0.1 m vs missing indoor textures — [DONE 2026-06-10 · c787201 + re-land]
Status: DONE (mechanism resolved; near plane exonerated and re-landed; corner press USER-GATED 2026-06-10 evening — camera pressed into the corner no longer clips into the wall) Closed: 2026-06-10 Component: render / camera projection
Resolution: the missing-texture correlation was the pre-existing #105
(staged-texture-flush drop, see above), NOT a near-plane mechanism. znear=0.1 merely
raised #105's trigger probability exactly as the handoff's only-credible-link predicted:
a closer near plane makes close-up geometry newly visible → more prepare/upload pressure
indoors → a larger never-flushed tail. With #105 fixed, retail Render::znear = 0.1
(decomp :342173, initializer :1101867) is re-landed on all four cameras — closing the §4
corner see-through (the 0.3 m-collided eye no longer near-clips the pressed wall).
User re-gate: corner press PASSED (2026-06-10 evening, "camera does not clip in the wall
now when pressed into the corner"). Outstanding (low-risk): distance scan for z-shimmer
(none expected; retail ships 0.1 with D24) + indoor texture watch over coming launches.
#106 — Outdoor membership freezes at landblock boundaries — [DONE 2026-06-09 · 7078264 + 23adc9c + 6dbbf95 + e6913ac]
Status: DONE (user-verified: collision + solid walls everywhere; probe-verified crossings)
Closed: 2026-06-09
Commits: 7078264 (LandDefs global-lcoord port) + 23adc9c (legacy Resolve full
prefixed ids) + 6dbbf95 (bogus-indoor-claim recovery + spawn-ground entry hold) +
e6913ac (in-world streaming before chase entry)
Component: physics, membership
Resolution: the outdoor candidate proposal (CellTransit.AddAllOutsideCells) AND the
find_cell_list containing-cell pick were clamped to the current landblock's 8×8 grid —
one step over a boundary → zero candidates → membership frozen forever. Retail runs both
in a GLOBAL landcell grid (lcoord 0..2039); ported as AcDream.Core.Physics.LandDefs
(decomp-cited; BN int8_t + dropped-192f artifacts and ACE's add_cell_block "FIXME!"
same-block guard documented and avoided —
docs/research/2026-06-09-landdefs-outside-cells-pseudocode.md). The b3ce505 #98 gate
was investigated first and definitively exonerated (collision-only, indoor-primary-only).
The gate runs surfaced and fixed three adjacent pre-existing bugs (each wedged the
verification walk a different way): legacy PhysicsEngine.Resolve returned BARE low-word
cell ids on every computed exit (the 2026-05-12 L.2e finding — a bare indoor id kills wall
BSP + the #98 gate misfires + the pick can't recover; prefix survival had been a streaming
race artifact); the membership pick had no recovery from a hydrated-but-not-containing
indoor claim (ACE's save was poisoned by the wedged session — restored the #83/A1.7 + #90
sphere-overlap demotion as the pick's escape hatch); and player-mode entry raced terrain
hydration (free-fall into void — added the spawn-ground auto-entry hold, which exposed and
fixed the K-fix1 streaming-vs-chase circular gate).
Verification (gate 4, probe-cell-106-gate4.log): 49 clean [cell-transit]
transitions — south crossing 0xA9B40039→0xA9B30040 at y=−0.19 (the originally frozen
boundary), east crossing 0xA9B3003D→0xAAB30005 at x=192.2 (a third landblock), clean
single flips at the block corner, and the originally-failing A9B3 cottage tracked
room-by-room (0x0104→…→0x0110, stairs climbing z 116→119). User confirms collision and
solid walls work everywhere.
Residual NOT this issue: transient parts-of-screen-turn-background-color artifacts
while running and at cottage/room enter–exit persist WITH a correctly-following membership
anchor — gate 4 disproves the capture doc's full attribution of the running distortion to
the stale anchor. That residual is the render §4 flap family (edge-on doorway grey +
corner camera-seal) tracked in claude-memory/project_render_pipeline_digest.md.
Cottage doorway "flap" — [DONE 2026-06-03 · 22a184c + e5457f9 + 79fb6e7] membership pick + render-root clobbering (the TWO causes)
Status: DONE (user-verified inside-looking-out)
Closed: 2026-06-03
Commits: b44dd14/bc56545/22a184c/e5457f9 (membership Stage 1) + 79fb6e7 (blue-hole render-root)
Component: physics/membership, rendering
Resolution: The cottage doorway flap (full-screen bluish void + flicker) had TWO independent causes, both fixed this session:
- Membership pick ping-pong —
CellTransit.BuildCellSetAndPickContainingused an unorderedHashSet+ a pre-pick fork inFindEnvCollisions. Ported retail's verbatim orderedCELLARRAYfind_cell_listpick (current cell at index 0, interior-wins-break) + the collide-then-pick order (find_env_collisions→check_other_cells, removing the pre-pick that swapped collision geometry with the cell mid-tick).[cell-transit]47→13→DELTA=0 while standing still. (Stage 1; faithful.) - Render-root clobbering —
CellGraph.CurrCell("the player's cell", the render root) was written by the PER-ENTITYResolveWithTransition/ResolveCellId. A jumping Holtburg NPC near the doorway overwrote the player's render root every tick → render rooted at the NPC's tiny connector cell (0170) instead of the player's room (0171) → only its ~8-tri shell drew, rest = GL clear color = the blue void. Fixed:CurrCellis now written ONLY by the player (PhysicsEngine.UpdatePlayerCurrCellviaPlayerMovementController.UpdateCellId).
Diagnosed via [flap-cam]/[shell]/[cell-transit] (player stable in 0171, render rooted at 0170
for 77,951 frames). Residuals are NOT the flap — three known render phases remain (A
camera-collision: walls grey while inside; B R1b/#104 particles through ground; C R2 outside-looking-in
transparent walls) + membership Stage 2 (uniform collision + intrinsic entry, faithfulness debt). Full
record: docs/research/2026-06-03-membership-and-bluehole-shipped-handoff.md.
Phase U.4c doorway "flap" — [DONE 2026-05-31 · 0ee328a] indoor visibility rooted at the camera eye
Status: DONE (Phase U.4c flap sub-step)
Closed: 2026-05-31
Commits: 0ee328a (fix) + 13d58ca/b5f2bf2/8941d1e (characterization)
Component: rendering, visibility
Resolution: Crossing a doorway, terrain + building shells + cell shells flapped off
(grey void + floating entities). Root cause (converged on a live ACDREAM_PROBE_FLAP
capture, after disproving a side-test/PortalSide hypothesis and a PVS-grounding
hypothesis): indoor portal visibility was rooted at the 3rd-person camera eye, which
drifts out of the player's cell; FindCameraCell then returned a stale cell for its 3
grace frames, and from that stale root the doorway portal was culled as "behind" the eye
→ the exit cell + terrain dropped. Fix: root indoor visibility (cell resolution + portal-
side test) at the player's cell (retail CellManager::ChangePosition tracks curr_cell
by the player; acdream already roots lighting at the player). Eye still drives projection.
Visual-verified "flap gone." Residuals are NOT the flap — see #78 (terrain not gated
inside, now more visible) + a new camera-collision need (the chase eye is outside the
player's cell ~79% of frames → eye-projected clip over-includes → transparent outer walls)
- U.5 (outside-looking-in). Full record:
docs/research/2026-05-31-u4c-flap-fixed-and-residuals-handoff.md.
#100 — [DONE 2026-05-25 · f48c74aa + a64e6f2] Transparent rectangular patches around every house (terrain rendering)
Status: DONE
Closed: 2026-05-25
Commits: f48c74aa, a64e6f2
Component: rendering, terrain
Resolution (2026-05-25 · #100): Replaced the cell-level
hiddenTerrainCells mechanism with retail's per-vertex Z nudge
(zFightTerrainAdjust = 0.00999999978) applied inside the modern
terrain vertex shader. Render terrain everywhere; coplanar building
floors win the depth test by being 1 cm higher than the rendered
terrain. Physics path untouched. ~50 LOC of BuildingTerrainCells
plumbing removed across LandblockMesh / LoadedLandblock /
LandblockLoader / GameWindow / GpuWorldState / LandblockStreamer
plus the corresponding unit test. Retail anchors:
acclient_2013_pseudo_c.txt:1120769 + :702254.
Description: Standing outside any Holtburg house, the ground in a rectangular footprint around the building appears as a flat dark patch instead of cobblestone / grass terrain. Visible as a sharp-edged rectangle the size of the house's outdoor footprint. Same shape on every house observed.
User report 2026-05-24 (with screenshot): "around every house now I missing the ground texture, it is transparent. I can see through the ground."
Root cause: Bisect 2026-05-24 — commit 35b37df is the introducer. It
added a hiddenTerrainCells parameter to LandblockMesh.Build that collapses
terrain triangles owned by buildings to zero-area degenerates. The hide
mechanism works at outdoor-cell granularity (24 m × 24 m cells), so the entire
cell terrain was hidden but the cottage geometry only covers a smaller area inside
it — leaving a dark transparent rectangle. The fix renders terrain everywhere and
uses retail's Z nudge to ensure building floors win the depth test.
#101 — [DONE 2026-05-25 · 5240d65 + 6ca872f] Stair-step cylinder phantom blocks player on multi-part EnvCell entity
Closed: 2026-05-25
Commits: f6305b1 — feat(physics): #101 — add IsPhantomGfxObjSource predicate; 5240d65 — fix(physics): #101 — suppress mesh-aabb-fallback for phantom GfxObj stabs; 6ca872f — docs(test): #101 — sync stale GameWindow.cs line ref in test class doc
Component: physics, dat-handling
Resolution. PhysicsDataCache.IsPhantomGfxObjSource(gfxObjId) predicate returns true when
the entity's SourceGfxObjOrSetupId has the GfxObj high byte (0x01) AND no cached
GfxObjPhysics entry exists (or its BSP.Root is null) — i.e., the underlying GfxObj had
HasPhysics=False so PhysicsDataCache.CacheGfxObj short-circuited. The inline
mesh-AABB-fallback gate at GameWindow.cs:6127 checks this predicate and skips the shadow-shape
registration entirely when the source is a phantom. The 10 phantom stair cyls from
GfxObj 0x0100081A (hasPhys=False) that previously blocked the player at the foot of the
Holtburg upper-floor staircase are no longer registered. Collision falls through to entity
0x40B50089 (GfxObj 0x01000C16, hasPhys=True BSP with walkable inclined polygon at
Normal.Z=0.717, world ramp from (111.10, 25.50, 94.00)→(107.50, 27.10, 97.50)). 3 unit tests
in PhysicsDataCachePhantomSourceTests.IsPhantomGfxObjSource_* (no BSP → true; has BSP →
false; non-GfxObj high byte → false) shipped alongside the predicate.
Investigation: docs/research/2026-05-25-a6-stairs-cyl-retail-investigation.md.
Plan: docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md.
Verification. Visual-verified at Holtburg upper-floor cottage stairs 2026-05-25 — [cyl-test]
count on obj=0x40B500* post-fix = 0 (was 7101 pre-fix); src=0x0100081A mesh-aabb-fallback
count = 0 (was 28 pre-fix). Player climbed Z=94→97.5 holding W continuously over the full 45°
ramp — no phantom diagonal slides.
#86 — [DONE 2026-05-19 · 3764867 + 4e308d5] Click selection penetrates walls
Closed: 2026-05-19
Commits: 3764867 — fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker; 4e308d5 — test(picker): Cluster A #86 — screen-rect cell-occlusion tests
Component: input, interaction
Resolution: WorldPicker.Pick now accepts a cellOccluder callback
(CellBspRayOccluder). Before returning a hit, both Pick overloads
consult the occluder's NearestWallT value; any candidate entity whose
ray parameter exceeds the nearest-wall intersection is filtered out.
The occluder is wired from GameWindow using the loaded PhysicsDataCache
cell structs. Entities behind walls from the camera's perspective are no
longer selectable. Screen-rect occlusion tests verify the filter across
several hit/miss scenarios.
Superseded 2026-07-17: The full retail render-coupled polygon picker (#71) removed this independent collision-BSP occluder. Retail inherits occlusion from the normal portal/viewcone draw traversal; maintaining a second ray against physics polygons could both hide drawn targets and admit undrawn ones. The original commits remain useful history, but this is no longer the runtime mechanism.
#77 — [DONE 2026-05-18 · 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back
Closed: 2026-05-18
Commit: 3be7000 — fix(physics): close #77 — auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place
Component: physics / PlayerMovementController / GameWindow.OnLiveMotionUpdated / CreateObject.ServerMotionState
Resolution. Two coupled bugs sharing a root in
PlayerMovementController.DriveServerAutoWalk + BeginServerAutoWalk.
-
Walk-vs-run misclassification (the user-visible "always runs at walk range" half).
BeginServerAutoWalkdecided_autoWalkInitiallyRunning = (initialDist − distanceToObject) >= 1.0f, forcing run at any chase past ~1.6 m. ACE's wire-level walk-vs-run answer is the MovementParameters CanCharge bit (0x10), whichCreature.SetWalkRunThresholdsets when server-side player→target distance ≥WalkRunThreshold/2(= 7.5 m default). Retail'sMovementParameters::get_command(decomp0x0052aa00,acclient_2013_pseudo_c.txt:307946+) gates the run path on CanCharge first; the inner walk_run_threshold check practically always walks given ACE's 15 m default. The hardcoded 1.0 m threshold pushed run into the 3-5 m walk-range the user reported should walk. -
Velocity leak in turn-in-place phase (the user-visible "overshoots and snaps back" half). When the auto-walked body crossed the destination,
desiredYawflipped ~180°,walkAligneddropped to false, and theif (!moveForward) return true;branch returned without zeroing body velocity. The body kept the prior frame's running velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slid 4-5 m past the target before the turn-around rotation completed.
Changes:
CreateObject.ServerMotionState.CanCharge: new bool prop reading bit 0x10 ofMoveToParameters. Cross-ref ACEMovementParams.CanCharge = 0x10.PlayerMovementController.BeginServerAutoWalk: replaces the unusedwalkRunThresholdparameter withbool canCharge; sets_autoWalkInitiallyRunning = canCharge.PlayerMovementController.DriveServerAutoWalkturn-in-place branch: calls_motion.DoMotion(Ready, 1.0)and zeros body horizontal velocity (preserving Z for gravity). No-op for initial-turn with a stationary body; fixes overshoot-recovery and settling cases.GameWindow.OnLiveMotionUpdated: passesupdate.MotionState.CanChargethrough;[autowalk-begin]trace now showscanCharge=instead ofwalkRunThresh=.GameWindow.InstallSpeculativeTurnToTarget: predicts ACE's CanCharge from local distance using ACE's exact 7.5 m rule, so the speculative install agrees with the wire-triggered overwrite that arrives moments later.
Verification. Build green; all targeted test projects pass cleanly (Core.Net 294/294, UI.Abstractions 419/419, App 10/10; Core 1073 passed / 8 pre-existing failures unchanged). Visual-verified at Holtburg 2026-05-18: walk-range NPC click walks + Use fires + dialogue appears, walk-range F-key pickup walks + no overshoot + item enters inventory, far-range pickup (8-10 m+) still runs.
Lesson archived: memory/feedback_autowalk_cancharge_bit.md. When
ACE already encodes a decision on the wire (CanCharge IS the walk-vs-run
answer), relay it — don't reinvent the bucket with a locally-computed
threshold.
#56 — [DONE 2026-05-12 · 8735c39] ParticleHookSink ignores CreateParticleHook.PartIndex; multi-emitter scripts collapse to entity root
Closed: 2026-05-12 Commit chain (newest first):
8735c39— feat(vfx #C.1.5b): GpuWorldState fires activator for dat-hydrated entities (4 new fire-sites + 5 integration tests; also picks up EnvCell statics & exterior stabs as a side-effect of the activator-guard relaxation)5ca5827— feat(vfx #C.1.5b): activator handles dat-hydrated entities + per-part transforms (resolver returnsScriptActivationInfo(ScriptId, PartTransforms); keys by ServerGuid OR entity.Id; GameWindow resolver lambda upgraded; 4 existing + 3 new tests)11521f4— fix(vfx #56):ParticleHookSinkappliesCreateParticleHook.PartIndextransform (new_partTransformsByEntityside-table;SpawnFromHooktransforms offset throughpartTransforms[PartIndex]before applying entity rotation; 2 new tests + 2 existing pass)f3bc15e— feat(vfx #C.1.5b):SetupPartTransformshelper for per-part anchor transforms (walksPlacementFrames[Resting]→[Default]→ first-available; 4 tests)1e3c33b— docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
Component: vfx / ParticleHookSink + EntityScriptActivator + GpuWorldState + SetupPartTransforms
Resolution. Two-slice fix that also folded in slice 2 of the C.1.5 phase work. Slice A (the #56 fix proper): precomputed per-part Matrix4x4 array at activator-spawn time via the new SetupPartTransforms.Compute(setup) helper, threaded through EntityScriptActivator → ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms) (mirrors the existing _rotationByEntity side-table pattern), applied inside SpawnFromHook as partLocal = Transform(offset, partTransforms[PartIndex]) before the existing world-rotation step. Backwards-compatible: entities without registered part transforms fall through to identity (pre-fix behavior). Slice B (folded in same phase, makes the fix matter for slice 2 visual gates): dropped the activator's ServerGuid==0 early-return guard. Activator now keys by entity.ServerGuid when non-zero, else entity.Id — collision-free because dat-hydrated entity IDs live in the 0x40xxxxxx (interior) / 0x80xxxxxx (scenery) / 0xC0xxxxxx ranges, all disjoint from server guids. GpuWorldState fires the activator from 4 new sites: AddLandblock + AddEntitiesToExistingLandblock (Far→Near promotion) for OnCreate, RemoveLandblock + RemoveEntitiesFromLandblock (Near→Far demotion) for OnRemove. Live entities are filtered out by ServerGuid != 0 on the AddLandblock path so pending-bucket merges don't double-fire OnCreate.
Reality discovery folded into spec §3: the handoff doc's §4 Q1/Q2 (synthetic-ID scheme + new walker class) were mooted by finding that GameWindow.BuildInteriorEntitiesForStreaming already hydrates EnvCell StaticObjects as WorldEntity instances with stable entity.Id. No new walker, no synthetic IDs.
Verification. Build green. 77 Vfx+Meshing+Activator+Streaming tests pass (4 new for SetupPartTransforms + 2 new for ParticleHookSink + 4 updated + 3 new for activator + 5 new for GpuWorldState integration). 8 pre-existing Physics/Input failures unchanged (verified by stash-and-rerun on Task 4). Visual verification 2026-05-12: Holtburg Town network portal (entity 0x7A9B405B, script 0x3300126D) — swirl no longer ground-buried, emitters distributed across the arch; Holtburg Inn fireplace flames over the firebox; cottage chimney smoke; spell cast on +Acdream cast-anim particles — all match retail.
Acceptance reproducer: the C.1.5a verification log captured portal A entity 0x7A9B405B swirl compressed to a partly-ground-buried point. Post-fix at the same portal, the swirl extends through the arch in retail-matching shape.
#53 — [DONE 2026-05-11 · f928e66] A.5/tier1-redo: entity-classification cache retry
Closed: 2026-05-11 Commit chain (newest first):
f928e66— incomplete-entity flag must persist across same-entity tuples (mid-list null-renderData)c55acdc— skip cache populate when classification is incomplete (drudge fix)95ebbf3— key cache by(entityId, landblockHint)tuple to defeat ID collision71d0edc— namespace stab Ids globally (0xC0LLBB01..) for Tier 1 cache safety4df1914— clarifyDebugCrossCheck's wiring statusf16604b— DEBUG cross-check + tripwire + 2 tests489174f— wireInvalidateLandblockcallback at LB demote/unload1d1afcd— wireInvalidateEntityat live-entity despawnf7e38c2— cache-hit fast path must fire per-entity, not per-tuple0cbef3c— cache-hit fast path + dispatcher integration tests00fa8ae— cachePopulatemust flush at entity boundary, not per-MeshRef tuple2f489a8— cache-miss populate on first frame for static entities28513ea— optionalCachedBatchcollector +restPoseparam onClassifyBatchesa65a241— injectEntityClassificationCacheintoWbDrawDispatcher60fbfce— plumblandblockIdthrough_walkScratcha171e70,aea4460,694815c,773e970— cacheInvalidateLandblock/InvalidateEntity/Populate/ skeleton+first testc02405c— extractGroupKeyto namespace-scopeinternal2f8a574— implementation plan4abb838— mutation audit + cache design spec
Component: rendering / WbDrawDispatcher / EntityClassificationCache / LandblockLoader
Resolution. New EntityClassificationCache keyed by (entityId, landblockHint) tuple in src/AcDream.App/Rendering/Wb/EntityClassificationCache.cs. The dispatcher routes static entities (NOT in _animatedEntities) through the cache — first-frame slow-path populates flat CachedBatch[] (one entry per (partIdx, batchIdx) with the part-relative RestPose and resolved BindlessTextureHandle); subsequent-frame cache hits skip classification entirely and append cached.RestPose * entityWorld to each matching group. Animated entities bypass. Invalidation fires from RemoveLiveEntityByServerGuid for real despawns (0xF747), directly from the in-place 0xF625 appearance mutation, and from RemoveEntitiesFromLandblock (per-LB, Near→Far demote + unload).
Perf result. Entity dispatcher cpu_us median ~1200 µs, p95 ~1500 µs at horizon-safe + High preset on AMD Radeon RX 9070 XT @ 1440p. Pre-Tier-1 baseline was ~3500m / ~4000p95. ~66% reduction in median, ~63% in p95. Well under the A.5 spec budget (median ≤ 2.0 ms, p95 ≤ 2.5 ms). No BUDGET_OVER flag observed.
Verification. Build green; full suite 1711 passed / 8 pre-existing physics/input failures unchanged; N.5b sentinel 112/112; visual gate confirmed via +Acdream test character (NPCs animate, lifestone renders, multi-part buildings + scenery + Nullified Statue of a Drudge on top of the Foundry all render fully — no airborne geometry, no Z-fighting, no missing parts, no wrong textures).
Lessons surfaced during implementation (4 bug-fix iterations):
-
Audit must verify ID uniqueness for cache keys. The original mutation audit verified
Position/Rotation/MeshRefsstability post-spawn but didn't verifyentity.Idwas globally unique. Stabs fromLandblockLoader.BuildEntitiesFromInforestarted atnextId = 1per landblock → cross-LB collisions. Scenery (0x80LLBB00 + localIndex) and interior (0x40LLBB00 + localCounter) overflow at >256 items/LB. Cache key collision produced "buildings up in the air with wrong textures." Fixed by namespacing stab Ids (71d0edc) then by changing cache key to(entityId, landblockHint)tuple (95ebbf3) — defensive against ALL future hydration paths. -
Per-tuple iteration with per-entity cache state is a recurring trap. Three separate bugs caught by code review or visual gate hit this same root cause:
- Populate fired per-tuple → multi-MeshRef entities lost all but the last MeshRef's batches (
00fa8ae). - Cache hit fired per-tuple → multi-MeshRef entities drew N× copies, severe Z-fighting (
f7e38c2). - Incomplete-flag reset fired per-tuple → mid-list null-MeshRef trees populated partial cache, branches never rendered (
f928e66).
The fix pattern in all three: track previous entity Id (
prevTupleEntityId/lastHitEntityId); execute per-entity logic only on actual entity-change detected against that tracker, not unconditionally per tuple. - Populate fired per-tuple → multi-MeshRef entities lost all but the last MeshRef's batches (
-
Async mesh loading interacts with cache populate. WB's
ObjectMeshManager.PrepareMeshDataAsyncdecodes meshes off the main thread. If a MeshRef's GfxObj is still decoding at first-frame visibility,TryGetRenderDatareturns null and the slow path skips it. Without the drudge fix (c55acdc), the cache populated a partial classification and cache hits served it forever — even after the missing mesh loaded. With the fix, the dispatcher trackscurrentEntityIncompleteper entity and drops the populate scratch when any MeshRef returned null; the slow path retries every frame until all meshes load. -
A/B diagnostic env-var paid for itself.
ACDREAM_DISABLE_TIER1_CACHE=1forces every static entity through the slow path. Used twice during debugging to instantly differentiate "bug is in the cache" vs "bug is elsewhere entirely." Kept in tree (read once inWbDrawDispatcherctor) for future cache investigations.
Memory. See ~/.claude/projects/C--Users-erikn-source-repos-acdream/memory/project_tier1_cache.md for the audit-gap and per-tuple-vs-per-entity pattern documented for future cache work.
#54 — [DONE 2026-05-10 · bf31e59] A.5/jobkind-plumbing: far-tier worker loads full entity layer then strips
Closed: 2026-05-10
Commits: bf31e59 (factory signature change to 2-arg + back-compat overload + far-tier early-out)
Component: streaming / LandblockStreamer
Resolution. LandblockStreamer.cs primary ctor now takes Func<uint, LandblockStreamJobKind, LoadedLandblock?> so the factory can branch on the job kind. A back-compat overload preserves the old single-arg signature for existing test code (5 ctor sites in LandblockStreamerTests.cs resolved to the overload with no test changes). BuildLandblockForStreaming(uint, JobKind) in GameWindow.cs early-outs for LoadFar with a heightmap-only path (_dats.Get<LandBlock>(landblockId) + Array.Empty<WorldEntity>()); near-tier path is unchanged. The Bug A post-load entity strip in LandblockStreamer.HandleJob is retained as a Debug.Assert + Release safety net. Per-LB worker cost on far-tier dropped from ~tens of ms (LandBlockInfo + scenery + interior) to ~sub-ms (single LandBlock dat read).
Verification. Build green; 1688/1696 tests pass (8 pre-existing physics/input failures unchanged); 30 streaming-targeted tests (LandblockStreamer + StreamingController + StreamingRegion) all green via the back-compat overload.
#52 — [DONE 2026-05-10 · e40159f] A.5/lifestone-missing: Holtburg lifestone not rendering
Closed: 2026-05-10
Commits: e40159f (alpha-test discard removal + cull state restoration + uDrawIDOffset uniform)
Component: rendering / WbDrawDispatcher / shaders
Resolution. Three independent root causes regressed with the WB rendering migration (Phase N.5 retirement amendment, commit dcae2b6, 2026-05-08). The original ISSUE #52 hypothesis (Bug A far-tier strip catching the lifestone) was wrong — the lifestone is server-spawned (WCID 509, Setup 0x020002EE) and never goes through the far-tier strip. Real causes:
-
Alpha-test discard.
mesh_modern.fragtransparent pass discarded fragments withα >= 0.95. The lifestone crystal core surface0x080011DEdecoded with α≥0.95 across its visible surface, so 100% of the crystal's fragments were discarded — invisible. The original N.5 §2 rationale ("high-α belongs in opaque pass") doesn't hold for surfaces dat-flagged transparent: those pixels can't reach the opaque pass at all. Fix: remove the high-α discard from the transparent pass; keepα < 0.05as a fragment-cost optimization. -
Cull state regression. Legacy
StaticMeshRendererhad Phase 9.2'sEnable(CullFace) + Back + CCWsetup at the top of its translucent pass (commit6f1971a, 2026-04-11) — fix for "lifestone crystal one face missing" reported at the time. Whendcae2b6deleted the legacy renderer, the newWbDrawDispatchernever inherited that GL state, so closed-shell translucents composited back-faces over front-faces in iteration order underDepthMask(false). Fix: re-establish Phase 9.2's exact setup at the top of Phase 8. -
uDrawIDOffsetindexing bug.gl_DrawIDARBresets to 0 at the start of eachglMultiDrawElementsIndirectcall. The transparent pass starts at byte offset_opaqueDrawCount * stridein the indirect buffer, but the vertex shader readBatches[gl_DrawIDARB]directly — so transparent draws read fromBatches[0..transparentCount)(the OPAQUE section) instead ofBatches[opaqueCount..end). The lifestone crystal's apparent texture flickered to whatever opaque batch sorted to index 0 each frame; with the player character in view, this often appeared as a lifestone wearing the player's body / face textures. Fix: adduniform int uDrawIDOffsettomesh_modern.vert, changeBatches[gl_DrawIDARB]toBatches[uDrawIDOffset + gl_DrawIDARB], and set the uniform per-pass inWbDrawDispatcher(0 for opaque,_opaqueDrawCountfor transparent). Mirrors WorldBuilder'sBaseObjectRenderManager.cs:845.
Verification. User-confirmed visually via +Acdream test character at the Holtburg outdoor lifestone (Z=94 platform). Tests 1688/1696 passing (8 pre-existing physics/input failures unchanged). N.5b conformance sentinel 94/94 clean.
Lesson. The WB rendering migration's "lift legacy state into the new dispatcher" was incomplete in two non-obvious ways: (a) GL state setup that lived inside legacy per-pass blocks, and (b) shader uniforms that the legacy per-draw flow didn't need but the multi-draw-indirect flow does. Future WB-migration work should systematically diff the legacy renderer's GL setup + shader I/O against the new dispatcher's. The uDrawIDOffset bug was particularly hidden because it only manifested for entities that mixed transparent draws with the visible opaque sort order — single-pass content (pure opaque or pure transparent) was unaffected.
#13 — [DONE 2026-05-10 · d3b58c9..078919c] PlayerDescription trailer past enchantments
Closed: 2026-05-10
Commits: d3b58c9 (scaffold) → 6587034 (rename nit) → becbde6 (OptionFlags+Options1) → 9a0dfe0 (TrailerTruncated + diag) → f7a5eea (Shortcuts) → 8cbb991 (HotbarSpells) → 75e8e26 (DesiredComps) → b17dc3b (SpellbookFilters) → 98eebef (Options2) → d9a5e40 (strict Inventory+Equipped) → 91693ea (heuristic GAMEPLAY_OPTIONS walker) → 58095d8 (combined fixture test) → 078919c (ItemRepository wiring)
Component: net / player-state
Plan: docs/superpowers/plans/2026-05-10-issue-13-pd-trailer.md
Resolution. PlayerDescriptionParser now walks every trailer
section through Inventory + Equipped, ported faithfully from holtburger
events.rs:503-625 + shortcuts.rs:13-34. The trickiest piece —
gameplay_options — uses a 4-byte-aligned forward heuristic
(TryHeuristicInventoryStart) that probes candidate offsets with a
strict (inventory + equipped consume to EOF) test, mirroring
holtburger's find_inventory_start_after_gameplay_options.
The trailer walk is wrapped in its own inner try/catch (separate from
the outer parse-wide catch) so a malformed trailer cannot destroy the
already-extracted attribute / skill / spell / enchantment data. A new
Parsed.TrailerTruncated flag lets callers distinguish a clean parse
from a graceful-degradation parse (set true if the inner catch fires;
log under ACDREAM_DUMP_VITALS=1).
GameEventWiring's PlayerDescription handler now registers each
inventory entry with ItemRepository.AddOrUpdate(...) and applies
MoveItem(...) for equipped entries so paperdoll picks up
CurrentlyEquippedLocation at login. The acceptance criterion
"ItemRepository.Count after login > 0" is now exercised by
PlayerDescription_RegistersInventoryEntries_InItemRepository in
GameEventWiringTests.
12 tasks, 13 commits, +9 PD parser tests + 1 wiring test (20 PD tests
total, 282 Net.Tests pass). Code-review nits during the run produced
two refactor commits: Shortcut → ShortcutEntry rename to avoid a
homograph with the CharacterOptionDataFlag.Shortcut flag bit
(6587034); TrailerTruncated flag + diagnostic logging
(9a0dfe0).
Forward-looking notes (low priority, no follow-up issues filed):
WeenieClassId = inv.ContainerTypefor inventory entries is a placeholder;CreateObjectoverwrites it with the real weenie class later in the login sequence.- The 10,000 count cap throws
FormatExceptionon validation failure, which the inner catch treats the same as truncation. If a future diagnostic UI needs to distinguish "EOF mid-section" from "garbage count rejected", splitTrailerTruncatedinto two flags. For now theACDREAM_DUMP_VITALS=1log message gives the developer enough signal.
Files: src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs,
src/AcDream.Core.Net/GameEventWiring.cs,
tests/AcDream.Core.Net.Tests/PlayerDescriptionParserTests.cs,
tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs.
#51 — [DONE 2026-05-09 · da56063 + N.5b SHIP] WB's terrain-split formula diverges from retail's FSplitNESW
Closed: 2026-05-09
Commit: da56063 (black-terrain fix; landed within Phase N.5b — see
docs/superpowers/plans/2026-05-09-phase-n5b-terrain-modern.md for the
ship commit chain)
Component: terrain math / Phase N.5b
Resolution: Path C. Phase N.5b lifted terrain rendering onto the
modern path (bindless atlas + glMultiDrawElementsIndirect) WITHOUT
adopting WB's TerrainUtils.CalculateSplitDirection. The pre-implementation
divergence test (tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs)
confirmed the two formulas disagree on 49.98% of sweep cells —
fundamentally incompatible with our shared physics + visual mesh, which
both rely on retail's FSplitNESW (constants 0x0CCAC033 / 0x421BE3BD /
0x6C1AC587 / 0x519B8F25).
Path C: keep retail's FSplitNESW formula via LandblockMesh.Build →
TerrainBlending.CalculateSplitDirection; mirror WB's TerrainRenderManager
architectural pattern (single global VBO/EBO + slot allocator + bindless
atlas + multi-draw indirect) but feed it acdream's mesh. Modern dispatcher
(TerrainModernRenderer) replaces TerrainChunkRenderer (deleted in T9
along with TerrainRenderer + terrain.vert/.frag).
Path A (substitute WB's formula) was killed by the divergence test. Path B (fork-patch WB's renderer to use retail's formula) was rejected for permanent maintenance burden. Path C ships the architectural pattern while preserving retail-formula compliance.
Visual mesh and physics both still consume retail's FSplitNESW; they
remain in lockstep, no triangle-Z hover. The N.6 / N.7 sequencing
implication this issue carried (substitute physics math only when the
visual mesh migrates) is moot — neither side ever switches to WB's
formula.
Files added:
src/AcDream.App/Rendering/TerrainModernRenderer.cssrc/AcDream.Core/Terrain/TerrainSlotAllocator.cssrc/AcDream.App/Rendering/Shaders/terrain_modern.vertsrc/AcDream.App/Rendering/Shaders/terrain_modern.fragtests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs(the test that killed Path A)
Files deleted (T9):
src/AcDream.App/Rendering/TerrainChunkRenderer.cssrc/AcDream.App/Rendering/TerrainRenderer.cssrc/AcDream.App/Rendering/Shaders/terrain.vertsrc/AcDream.App/Rendering/Shaders/terrain.frag
#43 — [DONE 2026-05-05 · 9e4772a] Slope staircase on observed player remotes (anim-only fallback ignored slope)
Closed: 2026-05-05
Commit: 9e4772a
Component: motion (PositionManager.ComputeOffset queue-empty fallback)
Resolution: Grounded player remotes showed a ~5 Hz Z staircase when
running up/down hills. PositionManager.ComputeOffset has two modes:
queue-active (3D direction toward server's broadcast position, Z
follows naturally) and queue-empty / head-reached (the CSequence
root-motion delta rotated into world). Every locomotion cycle bakes Z=0 in body-local,
so the world result has Z=0 too. With server UPs at ~5 Hz and
catchUpSpeed = 2× maxSpeed, body chases each waypoint in ~100ms (Z
ramps), then sits in root-motion-only mode for ~100ms (Z flat) until the
next UP. Visible 5 Hz staircase.
Fix mirrors retail's CTransition::adjust_offset contact-plane
projection (named-retail acclient_2013_pseudo_c.txt:272296-272346),
applied at the queue-empty boundary instead of inside the sweep.
ComputeOffset gains an optional Vector3? terrainNormal; when
the root-motion fallback runs and the supplied normal is non-trivial,
rootMotionWorld -= N × dot(rootMotionWorld, N). XY motion gains a
Z component proportional to slope × forward speed; body Z follows the
terrain mesh between UPs. No-op on flat ground (N ≈ +Z, dot ≈ 0) so
no regression to L.3 M2's flat-ground verification.
GameWindow.TickAnimations grounded-remote path samples
PhysicsEngine.SampleTerrainNormal (a thin public wrapper over the
existing internal SampleTerrainWalkable) at the body's current XY
each tick and passes it to ComputeOffset.
Two unit tests in PositionManagerTests: 30° east-tilted slope
(asserts (3.0, 0, −1.732) for 4 m/s east motion over 1s — body
descends along slope) + flat-ground no-op (asserts unchanged
behaviour with N = +Z).
Verified via launch-slope-verify.log over a 34m vertical traversal:
9,193 queue-empty-with-non-zero-offset.Z ticks on slopes (the path
that previously stair-cased), 26,497 sloped-normal ticks total, zero
#42 regressions.
Diagnostic kept in tree: ACDREAM_SLOPE_DIAG=1 enables the
[SLOPE] per-tick trace (bodyZ before/after, offset, queue active,
sampled cpN.Z) for future regression hunts.
#31 — [DONE 2026-04-29] Low outdoor cell id can go stale after transition movement
Closed: 2026-04-29
Commit: (this commit)
Resolution: ResolveWithTransition now refreshes outdoor cell ownership
from the resolved world position while the sphere sweep runs. Intra-landblock
24m outdoor seams update the low cell id, and full-cell callers crossing a
landblock seam get the destination landblock prefix plus the correct outdoor
low cell.
#34 — [DONE 2026-04-29] Missing routine local/server correction diagnostic
Closed: 2026-04-29
Commit: (this commit)
Resolution: Added ACDREAM_DUMP_MOVE_TRUTH=1, which logs local resolved
position/contact/cell, outbound movement fields, server UpdatePosition echo,
and local/server correction delta for the player in grep-friendly
move-truth OUT / move-truth ECHO lines.
#30 — [DONE 2026-04-29] AutonomousPosition contact byte is too often grounded
Closed: 2026-04-29
Commit: (this commit)
Resolution: GameWindow now derives the movement contact byte from
MovementResult.IsOnGround and passes it explicitly to both MoveToState.Build
and AutonomousPosition.Build. Added packet tests proving both builders encode
an explicit airborne contact byte.
#27 — [DONE 2026-04-26] Cloud meshes appeared missing or faint vs retail
Closed: 2026-04-26
Commit: 4678b3e fix(sky): apply per-Surface Translucency + Luminosity for retail-faithful weather
Resolution: Resolved as a side-effect of the Bug A fix. The original observation came from a session where every sky mesh got effEmissive = 1.0 (saturated vTint to white), which made stars/clouds look full-bright instead of time-of-day-tinted. Fix 2 corrected the emissive default to sub.SurfLuminosity so cloud surfaces (Lum=0.0) now run through the ambient+diffuse vertex-lit path and pick up keyframe tint. Fix 1 separately plumbed surface.Translucency to the shader, picking up the 0.25 translucency on cloud surface 0x08000023 (75% opacity). Visual verification under Phase 0 of the followup plan: clouds and colors now match retail at LCG-picked DayGroups across the day cycle.
#1 — [DONE 2026-04-26] Rain falls only to horizon, not to the player's feet
Closed: 2026-04-26
Commits: 3e0da49 (sky pass split + retail -120m Z offset), 4678b3e (Surface.Translucency + Luminosity correctness), d95a8d2 (legacy emitter delete)
Resolution: Two-part fix. First, rain rendering was completely re-architected to match retail's LScape::draw pattern at 0x00506330 — sky pass before the landblock loop (RenderSky), weather pass after (RenderWeather). Weather meshes now overlay terrain instead of being painted over. Camera anchored inside the rain cylinder via the retail-correct -120m Z offset (constant 0xc2f00000 in GameSky::UpdatePosition at 0x00506dd0). Second, the per-Surface Translucency float (rain = 0.5) and Luminosity float (rain = 0.1484) were both being ignored by the renderer; plumbed end-to-end so streaks contribute at retail-correct intensity instead of 6.7× too bright. Legacy camera-attached particle emitter (UpdateWeatherParticles + BuildRainDesc + BuildSnowDesc) deleted; world-space mesh is the only path now. Snow rides the same fix automatically. Filed alongside two follow-up issues from the visual-verify session: #27 (cloud rendering parity), #28 (aurora/northern lights).
#26 — [DONE 2026-04-26] Stars rendered as a square in one corner of the sky
Closed: 2026-04-26
Commit: 7b88fde fix(sky): drive wrap mode from mesh UV range — fixes Bug B (stars-as-square)
Resolution: SkyRenderer's wrap-mode heuristic was GL_CLAMP_TO_EDGE unless TexVelocity != 0, which mis-classified the inner sky/star layer 0x010015EF (UVs in [0.398, 4.602], TexVel=0). Most of the dome sampled the texture's edge texels; only the small region where UVs fell in [0,1] showed actual texture content. Fixed by computing NeedsUvRepeat per submesh from the actual UV range during GfxObjMesh.Build() and driving the wrap-mode choice from that flag plus the existing scrolling check. Outer dome 0x010015EE/F0/F1/F2 (UVs strictly in [0,1]) keeps CLAMP_TO_EDGE so no seam regression. Probe tools/StarsProbe/ (commit 991fb9a) committed alongside as the diagnostic that found this.
#25 — [DONE 2026-04-26] Phase K.3 — Settings panel + click-to-rebind UI
Closed: 2026-04-26
Commit: (this commit)
Resolution: SettingsPanel with click-to-rebind UX (modal capture
via InputDispatcher.BeginCapture, Esc cancels, conflict prompt with
Yes/No, draft / Save / Cancel semantics), F11 toggle + ImGui
MainMenuBar entry, per-action / per-section / reset-all-defaults
buttons. Roadmap + ISSUES + memory crib + CLAUDE.md updated.
#24 — [DONE 2026-04-26] Phase K.2 — auto-enter player mode + MMB mouse-look
Closed: 2026-04-26
Commit: af74eac
Resolution: Auto-enter player mode at login (one-shot guard
reusing the existing Tab handler logic); MMB-hold mouse-look
(CameraInstantMouseLook — cursor-locked camera + character yaw
drive together); Tab → ChatPanel.FocusInput(); DebugPanel
"Toggle Free-Fly Mode" button.
#23 — [DONE 2026-04-26] Phase K.1c — retail-default keymap + JSON persistence
Closed: 2026-04-26
Commit: da18910
Resolution: ~149 retail-faithful bindings byte-precise to
docs/research/named-retail/retail-default.keymap.txt;
%LOCALAPPDATA%\acdream\keybinds.json with merge-over-defaults
migration; acdream debug F-keys relocated to Ctrl+F*.
#22 — [DONE 2026-04-26] Phase K.1b — cut handlers over to dispatcher
Closed: 2026-04-26
Commit: 256e962
Resolution: Drop the legacy mouse-X-character-yaw path; fix
WantCaptureMouse gating; single input path via the multicast
InputDispatcher.
#21 — [DONE 2026-04-26] Phase K.1a — input architecture skeleton
Closed: 2026-04-26
Commit: 84512d3
Resolution: Action enum, multicast InputDispatcher with scope
stack, KeyChord / Binding / KeyBindings, Silk.NET adapters;
parallel to existing handlers (no behavior change).
#20 — [DONE 2026-04-25] CombatChatTranslator — retail-faithful combat-text formatters
Closed: 2026-04-25
Commit: 3d26c8e
Resolution: Retail-faithful combat-text formatters into ChatLog ("You hit drudge for 50 slashing damage"). Subscribes to CombatState's DamageTaken / DamageDealtAccepted / EvadedIncoming / MissedOutgoing / AttackDone / KillLanded events; templates ported verbatim from holtburger panels/chat.rs:221-308.
#19 — [DONE 2026-04-25] TurbineChat codec (0xF7DE) + ChatChannelInfo
Closed: 2026-04-25
Commit: ca968fc
Resolution: Full 0xF7DE codec with three payload variants (EventSendToRoom, RequestSendToRoomById, Response), UTF-16LE strings with variable-length prefix, SetTurbineChatChannels (0x0295) parser, unified ChatChannelInfo (Legacy + Turbine variants), TurbineChatState. Correction (Campaign CH slice CH3, 2026-08-09): the "ACE doesn't run a TurbineChat server" note above was FALSE. ACE has a complete TurbineChat implementation (TurbineChatHandler.cs, 387 lines), on by default (use_turbine_chat = true), and our own launch logs have shown parsed SetTurbineChatChannels room ids since at least 2026-05-21. See docs/research/2026-08-09-chat-side-channels-vs-ace.md §1.
#18 — [DONE 2026-04-25] Holtburger inbound chat parity + Windows-1252 codec
Closed: 2026-04-25
Commit: ff5ed9e
Resolution: EmoteText (0x01E0) / SoulEmote (0x01E2) / ServerMessage (0xF7E0) / PlayerKilled (0x019E) parsers + WeenieError routing through GameEventWiring. Global codec switch from Encoding.ASCII to Encoding.GetEncoding(1252); matches retail + holtburger; accented names round-trip correctly.
#17 — [DONE 2026-04-25] ChatPanel input field + slash commands
Closed: 2026-04-25
Commit: f14296c
Resolution: ChatPanel gains Enter-to-submit input field; ChatInputParser recognises /say /t /tell /r /g /f /a /m /p /v /cv /lfg /trade /role /society /olthoi; ChatVM tracks LastIncomingTellSender for /r reply.
#16 — [DONE 2026-04-25] LiveCommandBus + WorldSession chat senders
Closed: 2026-04-25
Commit: 8e6e5a0
Resolution: Real ICommandBus impl + WorldSession.SendTalk / SendTell / SendChannel wrappers + SendChatCmd record + ChannelResolver legacy-id mapping per holtburger.
#15 — [DONE 2026-04-25] DebugPanel migration
Closed: 2026-04-25
Commit: 56037a4
Resolution: Migrates the 473-LOC StbTrueTypeSharp DebugOverlay to an ImGui DebugPanel with collapsing-headers + checkbox diagnostics + combat-event tail. Deletes DebugOverlay.cs; TextRenderer + BitmapFont kept for future HUD-in-world (D.6 damage floaters, name plates).
#14 — [DONE 2026-04-25] IPanelRenderer widget extension
Closed: 2026-04-25
Commit: b131514
Resolution: Adds 14 widget signatures (TextColored / Checkbox / Combo / InputTextSubmit / BeginTable / etc.) to IPanelRenderer + ImGuiPanelRenderer impl. Foundation for I.2 DebugPanel and I.4 ChatPanel input.
#7 — [DONE 2026-04-25] PlayerDescription parser stops after spells (enchantment block parsed)
Closed: 2026-04-25
Commit: feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow
Resolution: Extended PlayerDescriptionParser past the spell block to parse the Enchantment trailer per holtburger events.rs:462-501. Added EnchantmentEntry record with full wire payload (16 fields including the StatMod triad — type/key/val) + EnchantmentBucket (Multiplicative / Additive / Cooldown / Vitae per EnchantmentMask). Parsed now exposes IReadOnlyList<EnchantmentEntry> Enchantments. GameEventWiring routes each entry through the new Spellbook.OnEnchantmentAdded(ActiveEnchantmentRecord) overload with StatModType / StatModKey / StatModValue / Bucket populated. 2 new parser tests cover the enchantment block schema + Vitae singleton.
The remaining trailer sections (options / shortcuts / hotbars / inventory / equipped) are not yet parsed; filed as #13. Stopping after enchantments is intentional — it covers the highest-value section (issue #6 lights up) and avoids the heuristic gameplay_options walker that #13 needs.
#12 — [DONE 2026-04-25] Capture full Enchantment wire payload (StatMod) on ActiveEnchantmentRecord
Closed: 2026-04-25
Commit: feat(net): #7 PlayerDescriptionParser — enchantment block walker + StatMod flow
Resolution: Closed alongside #7 in the same commit. ActiveEnchantmentRecord extended with optional StatModType, StatModKey, StatModValue, Bucket fields. Spellbook got an OnEnchantmentAdded(ActiveEnchantmentRecord) overload that accepts the full record. EnchantmentMath.GetMod aggregator now consumes the StatMod data: multiplicative bucket (1) → multiplier ×= val; additive bucket (2) → additive += val; vitae bucket (8) → multiplier ×= val (applied last, matching retail CEnchantmentRegistry::EnchantAttribute semantics). 5 new EnchantmentMath StatMod-aware tests cover: multiplicative buffs aggregate, additive buffs sum, stat-key mismatch is filtered out, vitae applies multiplicatively, family-stacking picks the higher spell-id buff.
2026-07-31 live-update closeout: ParseMagicUpdateEnchantment
(0x02C2) now parses the complete record, including start time, DegradeModifier,
degrade limit, last time degraded, StatMod type/key/value, and bucket
classification. GameEventWiring maps that immutable wire record into the
same ActiveEnchantmentRecord shape as PlayerDescription. The end-to-end
test sends an actual 0x02C2 payload through dispatch and proves the resulting
StatMod changes the local player's effective skill without relogging.
#6 — [DONE 2026-04-25 architecture; data flowing as of #12] Vital max ignores enchantment buffs + vitae
Closed: 2026-04-25
Commit: feat(player): #6 fold enchantment buffs into vital max via EnchantmentMath
Resolution: Ported CEnchantmentRegistry::EnchantAttribute (PDB 0x00594570) as EnchantmentMath.GetMod(IEnumerable<ActiveEnchantmentRecord>, SpellTable, statKey) returning (Multiplier, Additive). Family-stacking dedup via SpellTable.Family (only one buff per family bucket wins, by highest spell-id as a generation proxy). Spellbook.GetVitalMod(statKey) delegates. LocalPlayerState.GetMaxApprox reworked to apply (unbuffed × mult) + add with retail's min-vital clamp (>= 5 if base ≥ 5 else >= 1, matches CreatureVital::GetMaxValue at PDB 0x0058F2DD). Stat-key constants (MaxHealth=1, MaxStamina=3, MaxMana=5) verified against docs/research/named-retail/acclient.h line 37287-37301.
Data path complete: both PlayerDescription and live 0x02C2 updates carry
the full StatMod into ActiveEnchantmentRecord; the shared aggregator applies
it immediately to attributes, vitals, skills, movement, and retained UI.
6 new EnchantmentMathTests cover: empty list returns Identity, no-table-entries returns Identity, stat-key constants match ACE enum, Identity is (1, 0), family-stacking dedup, family=0 (no-bucket) treated as separate.
#11 — [DONE 2026-04-25] Spell metadata loader (spells.csv → SpellTable)
Closed: 2026-04-25
Commit: feat(spells): #11 SpellTable — hydrate metadata from spells.csv at startup
Resolution: Added SpellMetadata record + SpellTable CSV loader (hand-rolled RFC 4180-ish parser for the quoted Description column with embedded commas). Wired into Spellbook constructor as optional metadata source; Spellbook.TryGetMetadata(spellId, out) returns the static record when found. GameWindow loads data/spells.csv from bin output at construction (file copied via <None Include> in AcDream.App.csproj from docs/research/data/spells.csv). Falls back to SpellTable.Empty + console warning if the file is missing (e.g. tooling contexts). 10 new tests covering: empty table, header-only, simple row, quoted description with commas, blank lines skipped, bad spell-id rows skipped, lookup hit/miss, RFC 4180 escaped-quote parsing.
Superseded 2026-07-15: Production now projects all 6,266 records from the
installed end-of-retail SpellTable DID 0x0E00000E. The 3,956-row CSV remains
only as a historical fixture; AP-17 is retired. See
docs/research/2026-07-15-retail-spell-catalog-pseudocode.md.
#9 — [DONE 2026-04-25] Address-correction sweep on acclient_function_map.md
Closed: 2026-04-25
Commit: docs(research): #9 sweep acclient_function_map.md against PDB symbols
Resolution: Wrote tools/pdb-extract/check_function_map.py that cross-checks 63 hand-curated entries against docs/research/named-retail/symbols.json. Findings: zero entries matched address-and-name exactly (confirms ~0x800-0xC10 byte delta vs the binary that produced our Ghidra chunks — different build revision). 38 entries corrected by PDB name lookup; 25 entries either lack PDB symbol records (inlined / non-public) or had wrong class assignments (e.g. 0x5387C0 claimed as CTransition::find_collisions was actually CPolygon::polygon_hits_sphere). Updated acclient_function_map.md with corrected addresses, kept legacy addresses in a "Was" column for traceability, added a top-of-file sweep summary.
#10 — [DONE 2026-04-25] Wire KillerNotification (0x01AD)
Closed: 2026-04-25
Commit: docs(issues): #8/#9/#11 filed; #10 wired (KillerNotification)
Resolution: Orphan parser at GameEvents.ParseKillerNotification existed but was never registered for dispatch in GameEventWiring.cs. Added a combat.OnKillerNotification(victimName, victimGuid) method on CombatState that fires a new KillLanded event, then registered the handler. One-line dispatch + 12-line CombatState method + one regression test fixture in GameEventWiringTests.
#8 — [DONE 2026-04-25] pdb-extract tool: PDB → symbols.json + types.json
Closed: 2026-04-25
Commit: tools(pdb-extract): #8 PDB -> symbols.json + types.json sidecar
Resolution: Pure-Python (no deps) MSF 7.00 PDB parser at tools/pdb-extract/pdb_extract.py. Reads refs/acclient.pdb (Sept 2013 EoR build), extracts S_PUB32 records from the symbol stream + named class/struct types from TPI, and writes JSON sidecars to docs/research/named-retail/:
symbols.json— 18,366 named functions (address+ demangledname+ rawmangled)types.json— 5,371 named class/struct records (name+size+kind)
Best-effort MSVC C++ demangler handles the common ?Method@Class@@<sig> patterns + ctors (??0) + dtors (??1); operator overloads and vtables left mangled. Spot-check verified: CEnchantmentRegistry::EnchantAttribute resolves to 0x00594570 exactly as the discovery agent reported. Runtime <1s.
Regen workflow: py tools/pdb-extract/pdb_extract.py refs/acclient.pdb. The committed JSON outputs are stable + ~3 MB combined; ripgrep/jq on them is faster than re-parsing.
#5 — [DONE 2026-04-25] VitalsPanel stamina/mana bars always null
Closed: 2026-04-25
Commit: feat(player): #5 PlayerDescription parser — Stam/Mana via attribute block
Resolution: First attempt (commit d42bf57) used AppraiseInfoParser for PlayerDescription (0x0013) — wrong wire format. ACE source confirmed via GameEventPlayerDescription.WriteEventBody: PlayerDescription is hand-written (DescriptionPropertyFlag-driven property hashtables, vector flags, attribute block, skills, spells, options/inventory tail) — distinct from IdentifyObjectResponse (0x00C9)'s AppraiseInfo.Write. Pivoted to a real port: new PlayerDescriptionParser.cs that walks property hashtables (Int32/Int64/Bool/Double/String/Did/Iid + Position) gated on the property flags, then reads vector flags + has_health + the attribute block where vitals 7/8/9 carry ranks/start/xp/current. Also redesigned LocalPlayerState to track per-vital snapshots (replacing the sentinel-API of attempt 1) plus per-attribute snapshots, with GetMaxApprox applying the retail formula vital.(ranks+start) + attribute_contribution (Endurance/2 for Health, Endurance for Stamina, Self for Mana). Live verified: +Acdream shows three bars; ~95% reading on Stam/Mana traced to active buff multipliers (filed as #6). Wire-port also added PrivateUpdateVital (0x02E7) + PrivateUpdateVitalCurrent (0x02E9) for delta updates per holtburger UpdateVital. ~700 LOC C#, 30+ new tests.
#59 — WorldPicker 5m fixed-radius could over-pick at tight thresholds (M1-deferred polish)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
WorldPicker.Pick used a hardcoded 5m sphere around every candidate regardless of the entity's actual size, relying on the ServerGuid==0 skip filter plus closest-wins logic to avoid mis-picks. User confirms this is resolved. Files: src/AcDream.Core/Selection/WorldPicker.cs.
#117 — Aperture-shaped see-through: doors/interiors visible through terrain hills and through nearer buildings
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Doors/interiors were visible through terrain hills and through nearer buildings because the far-Z stencil punch erased the depth of nearer occluders at aperture pixels, letting later-drawn dynamics paint over them. Fixed in commit 478c549; the issue's own title already recorded the user's 2026-06-11 re-gate confirmation ("Yes solved").
#133 — Teleport into a dungeon snaps the player BEFORE the dungeon landblock streams in → lands at the old landblock's frame (ocean), not the dungeon
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Teleporting into a dungeon snapped the player onto the OLD (Holtburg) landblock before the destination dungeon streamed in, dropping them into the ocean instead of the dungeon. Fixed by Phase G.3a: TeleportArrivalController holds the position snap until the destination landblock/cell hydrates (7947d7a/aca4b46/f22121b), plus the validated-claim landblock-prefix fix (2ce5e5c) and a login-spawn recenter fix (47ae237) for the sibling case of logging in already inside a dungeon. A follow-up streaming-collapse fix (2026-06-14) also resolved the low-FPS/grey-barrier symptoms discovered at the same gate.
#78 — Outdoor geometry (stabs + terrain mesh) visible inside EnvCells
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Standing inside a building, outdoor terrain and stab geometry rendered through the floor/walls because acdream enforced indoor visibility via three inconsistent gates instead of retail's single PView gate. This was the founding bug behind the full render-pipeline redesign (Option A, one DrawInside(viewer_cell) traversal — see docs/research/2026-05-31-render-architecture-reset-handoff.md), closed as part of the broader #119/#128 render arc (2026-06-12); see project_render_pipeline_digest.md.
#103 — Phase A8.F portal-frame indoor rendering broken at runtime (visual-gate failure)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
The A8.F two-pipe (inside/outside) portal-frame renderer (ACDREAM_A8_INDOOR_BRANCH=1) was abandoned wholesale rather than fixed in place — Phase U (Unified Render Pipeline, 2026-05-30) deleted the broken RenderInsideOut two-pipe path and replaced it with the single unified retail PView portal-visibility pipeline that the render digest now treats as canonical. See docs/research/2026-05-30-unified-render-pipeline-decision-and-handoff.md.
#79 — Indoor lighting: spurious spot lights on walls
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Torch point-lights on interior walls in Holtburg Inn showed as spurious spot-light-like patches not matching retail's falloff/direction. User confirms this is resolved, most likely as part of the broader A7 indoor-lighting umbrella work (#93/#80/#154) that corrected LightManager candidate-pool scoping and per-light parameter handling. Files: src/AcDream.Core/Lighting/LightInfoLoader.cs, mesh_modern.frag accumulateLights.
#82 — Some slope terrain lit incorrectly
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Some terrain slopes in Holtburg were lit differently than retail, suspected to be a terrain-normal / landblock-edge normal-blending divergence between WorldBuilder's split formula and retail's FSplitNESW. User confirms this no longer reproduces. Files: TerrainModernRenderer.cs, terrain_modern.frag.
#154 — Dungeon interiors still read too dim vs retail (torch-sparse stretches + per-vertex bake)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
After the initial indoor-lighting fixes (0d8b827/57c2ab7) dungeons were brighter than the old flat-0.2 ambient but still trailed retail, especially in torch-sparse corridors. User confirms this is now resolved — consistent with the same-day A7 dungeon-lighting root-cause work (the "463>128 light cap" fix in the most recent commit history) that closed the sibling #93/#80 lighting issues.
#83 — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Walking down into cellars and on 2nd floors was broken (stuck/falling); root cause was TryFindIndoorWalkablePlane synthesizing a fresh ContactPlane every frame instead of retaining the previous frame's plane like retail. User confirms this is resolved, consistent with the later A6.P4 per-cell collision architecture (shipped 2026-06-11) and the subsequent #137/#171/#182 physics fixes that completed the retail-faithful collision port this issue was blocked on.
#88 — Indoor static objects vibrate (bookshelves, open furnaces)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session). Static cell objects (bookshelves, open furnaces) showed per-frame transform jitter, suspected sub-step state corruption or floating-point drift in per-part transform recomputation. User confirms this no longer reproduces, likely resolved alongside the A6.P4 physics architecture and subsequent entity-transform stabilization work.
#89 — Port BSPQuery.SphereIntersectsCellBsp for retail-faithful CheckBuildingTransit
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
CellTransit.CheckBuildingTransit used a radius-less point-in-BSP test instead of retail's sphere-vs-BSP CCellStruct::sphere_intersects_cell, making outdoor→indoor entry fire ~sphereRadius deeper into doorways than retail. User confirms this is resolved, consistent with the A6.P4 per-cell shadow-list collision architecture (shipped 2026-06-11) which ported retail-faithful building-transit checks. Files: src/AcDream.Core/Physics/CellTransit.cs, BSPQuery.cs.
#134 — Player "lags downward" instead of gliding along a dungeon ramp edge
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session). Running along a dungeon ramp's edge produced a downward "lag" instead of a slide along the slope tangent, surfaced by the #133 connector-cell physics fix exercising the ramp's collision for the first time. User confirms this is resolved, consistent with the later CSphere/CCylSphere collision-family ports (#172, #182) and the general slide-response work tracked under #32/#116.
#46 — Retail observer of acdream sees blippy / laggy movement
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
A retail client observing acdream's locally-driven +Acdream character saw stepped/blippy movement, suspected AutonomousPosition heartbeat cadence or MoveToState state-change-detection mismatches on the outbound path. User confirms this is resolved — matches the L.2b outbound wire-parity work per project_retail_motion_outbound.md.
#122 — Windows oscillate between background and the correct outside view when entering houses
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Windows flickered between the background/skybox and the correct outside view while entering houses (the #109 oscillation family localized to windows at the outdoor→interior root flip). User confirms this is resolved, consistent with the render-pipeline redesign (Option A, one DrawInside(viewer_cell)) that closed the broader #119/#128 flap family on 2026-06-12.
#123 — Buildings transiently disappear when running close past them
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session). Whole buildings transiently vanished when the player ran close past them at the outdoor root, suspected frustum pre-gate or stencil-punch interaction at close range. User confirms this is resolved, consistent with the render-pipeline redesign (Option A) that closed the broader #119/#128 flap family on 2026-06-12.
#179 — Lightning flash has no indoor gate (dormant until weather strobes ship)
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
mesh_modern.frag added the lightning-flash term to every fragment (including sealed-dungeon interiors) with no indoor gating, latent until weather strobes ship. User confirms this is resolved — the flash term is now gated for playerInsideCell frames (or zeroed at the SceneLightingUbo build), matching retail's flat indoor ambient with no storm terms.
#65 — Local player doesn't turn to face target on close-range Use
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
ACE's close-range Use path sends a MovementType=8 TurnToObject motion that acdream's OnLiveMotionUpdated didn't handle, so the local player completed the Use without visibly turning to face the target. User confirms this is resolved, consistent with the R4/R5 MoveToManager arc that ported retail's TurnTo handling for local and remote entities (superseding the older RemoteMoveToDriver).
#66 — Local + remote rotation: player flips back, NPCs don't turn
Status: DONE (2026-07-09, user-confirmed via memory during an issues-triage pass — not independently re-verified this session).
Two related rotation bugs: the local player's facing snapped back after auto-walk arrival, and NPCs didn't turn to face the player on MovementType=8 TurnToObject motions (acdream only handled MovementType=6 MoveToObject). This issue explicitly superseded #65 as the broader TurnToObject-handling ticket. User confirms both are resolved, consistent with the R4/R5 MoveToManager arc's retail-faithful TurnTo port for local and remote entities.
#96 — Per-tick PhysicsEngine.ResolveWithTransition CP seed (retail divergence)
Status: DONE (2026-07-09, investigated this session).
#96 was never a plain bug to fix — by 2026-05-22 (commits 892019bc/f8d669be) the project determined the per-tick ContactPlane seed at PhysicsEngine.cs is load-bearing (BSP step_up on the last stair step depends on it) and formally reclassified it as an accepted retail divergence rather than a defect. It's tracked today as register row IA-1 in docs/architecture/retail-divergence-register.md (retail: CTransition::init, pc:272547) rather than as an open bug. The code today still matches the issue's description exactly (verified at PhysicsEngine.cs:979-1010), so nothing was "fixed" in the traditional sense — the issue was superseded by the divergence-register entry as its permanent home.
#60 — obstruction_ethereal retail downstream path not ported (M2 combat-HUD impact)
Status: DONE (2026-07-09, investigated this session).
Fixed by 3361a8d7 and dc1e9270 (2026-06-24). CollisionExemption.ShouldSkip now requires both ETHEREAL_PS and IGNORE_COLLISIONS_PS for the Gate-1 exempt; ETHEREAL-alone now sets SpherePath.ObstructionEthereal and flows through BSP/Sphere/Cylinder consume sites matching retail pc:276782/276806/276989/321692/324573. Divergence-register row AD-7 retired same commit. ObstructionEtherealTests.cs passes 11/11.
#68 — Remote players don't stop running animation on auto-walk arrival
Status: DONE (2026-07-09, investigated this session).
The mechanism named as the suspect in #68 (RemoteMoveToDriver's arrival handling not flipping the animation cycle to Ready) was deleted wholesale in commit 7016b26c (R4-V4, 2026-07-03), replaced by a verbatim-ported retail MoveToManager for every remote. The same day, commit c2dc1a88 (R4-V5) fixed exactly the class of bug #68 describes: StopCompletely (which MoveToManager's arrival path calls) wasn't reaching the entity's AnimationSequencer at all — it's now wired through IInterpretedMotionSink.StopCompletely() → MotionTableDispatchSink → sequencer.PerformMovement(StopCompletely()), confirmed live-wired for remotes in GameWindow.EnsureRemoteMotionBindings. No commit references "#68" explicitly and no user visual re-verification of this specific scenario is on record post-fix — recommend a spot-check (retail player auto-walking to an NPC, watched from acdream) if it resurfaces.
#227 — [DONE] Drop WB fork patch by switching to PrepareEnvCellGeomMeshDataAsync
Status: DONE (2026-07-09, investigated this session).
Superseded by Phase O (2026-05-21, dropped the WorldBuilder.Shared/Chorizite.OpenGLSDLBackend project references entirely — there's no more forked submodule to patch/revert) and Phase A8 (2026-05-28, shipped EnvCellRenderer.cs which already calls the narrow PrepareEnvCellGeomMeshDataAsync(geomId, environmentId, cellStructure, surfaces) entry point at EnvCellRenderer.cs:355, using the bit-32-tagged synthetic geom id the issue proposed). ObjectMeshManager.cs's bounds/type dispatch now branches on resolution type before calling TryGet<T>, so the blind TryGet<Setup> bug is structurally gone. EnvCellRenderer remains the live production cell-rendering path under Phase U as of commit 6aabe0b5 (Jul 6 2026). This historical item was renumbered from a duplicate #87 heading to #227 during the 2026-07-20 documentation audit; the original indoor-cell issue retains #87.
#126 — Outdoor spawn claim on a building roof is grounded THROUGH the roof to terrain (transparent-interior spawn)
Status: DONE (2026-07-09, investigated this session).
Fixed same-day as filed (2026-06-11) by commit 120aeff7 "RETAIL-CORRECTED: restores commit the server Z — retail never re-derives position from surfaces" (superseding an earlier same-day attempt b94a7e80 that the user caught as retail-divergent). The outdoor zero-delta-restore branch in src/AcDream.Core/Physics/PhysicsEngine.cs now commits the claim's Z verbatim (matching retail's CPhysicsObj::SetPositionInternal, 0x00515bd0) instead of unconditionally grounding to terrain, eliminating the roof-to-interior warp. Verified present, unmodified, at HEAD.
#4 — Sky horizon-glow disabled (fog-mix skipped on sky meshes)
Status: DONE (2026-07-09, investigated this session).
The literal bug as filed — fog-mix skipped on sky meshes — is no longer true. Commit 97fc1b51 (2026-04-27) re-enabled sky fog using a retail-cited 3D range-fog formula (docs/research/2026-04-23-sky-fog.md, which root-caused retail's actual horizon-glow mechanism: sky domes saturate to WorldFogColor by design because their intrinsic radius sits near/past keyframe FogEnd — same fog path as terrain, no special sky-only rule needed). Author noted user visual verification against retail screenshots; the fix has shipped unmodified for 2.5+ months through several subsequent commits. Follow-up: the SKY_FOG_FLOOR=0.2 mitigation clamp layered on top still lacks a divergence-register row.
#81 — Static building stabs don't react to atmospheric lighting changes
Status: DONE (2026-07-09, investigated this session).
Fixed by the A7 lighting rework, specifically A7 Fix D (0980bea4, cf627933, c62da825, b7d655bc, 2026-06-18/19). That work split the previously-unified lighting shader into an explicit object-path (uLightingMode=0) vs EnvCell-interior-path (uLightingMode=1): building exterior shells (stabs, IsBuildingShell/ParentCellId==null) are object-path entities and now read the same per-frame SceneLighting UBO (sun direction/color + ambient, rebuilt every frame from the live SkyKeyframe) that terrain and scenery already consumed — so stabs now darken/brighten in lockstep with the day/night cycle by construction. No dedicated visual re-check specific to stabs-vs-daycycle is on record, but the code mechanism the issue blamed no longer exists.
#33 — Live entity collision shape collapses to one cylinder
Status: DONE (2026-07-09, investigated this session).
The described mechanism (live entities collapsing to one root-centered cylinder) no longer exists. RegisterLiveEntityCollision (GameWindow.cs:4244) now builds a multi-shape list via ShadowShapeBuilder.FromSetup — one shape per CylSphere, Sphere, and BSP-bearing Part — and registers via RegisterMultiPart (A6.P4 Task 7, ca9341c2). The retail CSphere and CCylSphere collision families were ported verbatim in 78e57581 (sphere primitive), 6ab26989 (fix #172, CCylSphere family), and 96ae2740 (fix #182, CSphere family, retires TS-45). The remaining setup.Radius single-cylinder path is now a deliberately-audited fallback used only when no other shape data exists. tests/AcDream.Core.Tests/Physics/ShadowShapeBuilderShapeSourceTests.cs covers both a multi-shape object and a live-creature case.
#216 — Inventory drags into the 3-D world could only drop items
Status: DONE (2026-07-13, 30d29450, user-confirmed connected gate).
Dragging the starter-dungeon exit token onto its NPC now world-picks at the
actual release point, executes the retail GiveToTarget path without optimistic
inventory mutation, receives ACE's authoritative removal, and completes the
quest teleport out of the dungeon.
#316 — The player arm's LANDING TRANSITION block never publishes the collision shadow
Status: OPEN Severity: UNKNOWN pending one measurement (see "The open question") — either a ~33 ms shadow lag (cosmetic, invisible in practice) or the #184 invisible-but-solid class (real). Do not act on it before measuring. Filed: 2026-08-04 Component: physics / collision / remote presentation
Found by the OnPosition-collapse contract scoping
(docs/research/2026-08-04-onposition-collapse-contract.md), which neither
C4 route 4b-3 review round caught. Confirmed independently by reading the
block.
Description: LiveEntityNetworkUpdateController.OnPosition's player-guid
LANDING TRANSITION block (the !rmState.Body.InContact hard-snap) does all of:
Interp.Clear(), the body position/orientation snap,
TryArmConstraintAfterOperation, the render-entity sync
(entity.SetPosition / ParentCellId / Rotation),
EnsureRemoteMotionBindings, and the landing probe — then returns. It never
calls LiveEntityShadowPublisher.TryPublishRemote. The three publish sites in
the file all sit outside this block, and the NPC-guid copy's equivalent
scenario DOES publish through its arm tail.
So a landing player-remote's body and render entity move to the authoritative landing pose while its collision shadow stays at the pre-snap position.
This also contradicts the file's own #184 Slice 2b comments, which assert "player shadows follow the resolved body exactly like NPCs". Those comments are the stale-comment class the campaign has hit six slices running.
The open question (measure before fixing): the per-tick remote physics
commit syncs shadows on translation/orientation/cell change, so this may
self-heal on the next tick (~33 ms) rather than persisting. Whether it does
decides whether this is cosmetic or the #184 class. Resolve by landing a
remote player observer and sampling the shadow entry position against the body
across the landing frame and the following tick — ACDREAM_PROBE_REMOTE_LANDING
already instruments the packet side of exactly this edge.
Why it is NOT fixed in the collapse: the collapse is behaviour-preserving by contract; adding a publish would be a behaviour change smuggled into a refactor, and its severity is unmeasured. Split-on-discovery: own commit, with a dual-guid test, after the measurement.
Post-collapse update (2026-08-04): the OnPosition collapse dissolved the
standalone player-guid LANDING TRANSITION block into the unified remote
routing tail's AirborneSnap arm. The defect this row describes is
UNCHANGED and now lives as an explicit, commented, guid-gated skip at that
arm's shadow-publish step (arm is RemoteContactArm.AirborneSnap && IsPlayerGuid(...) in LiveEntityNetworkUpdateController.OnPosition) —
preserved verbatim, not fixed, per this row's own resolution above. Covered
by LandingPacket_PlayerGuid_QueueClearedNoShadowPublish_316Preserved /
LandingPacket_CreatureGuid_ShadowPublishedQueueNotCleared in
tests/AcDream.App.Tests/Physics/LiveEntityNetworkOnPositionCollapseMatrixTests.cs.
#319 — A player-parented child never receives a canonical cell (ParentInstanceSequence hardcoded 0)
Status: FIX IMPLEMENTED, awaiting the connected acceptance gate (§7 of the contract) and commit — NOT YET COMMITTED in this worktree. Do not mark DONE until the gate runs and the change lands. Severity: LOW for the user (no observable symptom — verified, not assumed), HIGH for process (it defeats route 7's own connected gate; see below) Filed: 2026-08-05 Component: physics / entity lifetime / equipped children
Fix summary (implementation, revised after the dual review — retail PASS,
architecture FAIL/6 MAJORs, both 2026-08-05). Both CreateObject-carried
producers (EquippedChildRenderController.OnSpawn for a raw CreateObject and
OnCreateParentAccepted for the same-generation CreateParentUpdate
envelope — the contract named only OnSpawn; OnCreateParentAccepted has the
identical structural defect and was fixed alongside it) now route through
AcceptLateBoundCreateObjectRelation: if the parent's live snapshot is known
at accept time (always true in production — see A6 below), stage the relation
with the parent's live InstanceSequence; otherwise log a loud refusal with
no state mutation (a deferred-relation queue was tried here and REMOVED after
the review; see A6). ParentAttachmentState.CanCommitIncarnation is a pure,
side-effect-free precondition checked BEFORE either half of a parent-attach
commit mutates anything (moved there by architecture review finding A1: the
original shape checked from inside CommitProjection, reached only AFTER the
canonical commit had already landed, so a mismatch tore the transaction —
canonically parented, no committed relation, a staged relation blocking
Resolve forever). It logs and returns false rather than throwing (Route
3's N3 principle: a possibly-transient condition must not be fatal on a host
that must survive long endurance sessions) — wired from both the App producer
and the headless RuntimeLiveEntitySessionController, both now checking it
BEFORE their canonical commit. Two structural gates
(LiveEntityHydrationController.OnLandblockLoaded,
LiveEntityPresentationController.RestoreShadow) now refuse a record with a
committed parent, closing the two call sites the contract's §3.1/§3.2 flagged
as inert-only-because-the-cell-is-zero (§3.2's premise was corrected by
architecture finding A4: the gate is a live behavior change for
CREATURE-parented children, route 7's D1 already re-cells them nonzero — see
the connected gate's Half B watch item). A6 — the deferred-relation
question, decided: an initial revision queued a CreateObject-carried
relation whose parent was not yet addressable and adopted the parent's live
incarnation once it arrived. Both independent reviews proved this queue was
structurally unreachable in production for BOTH producers —
RuntimeEntityObjectLifetime.RegisterEntityCore's EnqueueDeferredCreate
gate defers the ENTIRE CreateObject (both wire shapes) before either producer
ever runs — while it carried three latent defects of its own (a missing child
POSITION_TS gate, a placeholder-incarnation collision with the generation
filters, unbounded mid-session accumulation), exercised only by a test that
bypassed production routing. Deleted rather than fixed in place: dead code
carrying three defects is a worse trade than a loud refusal for a case the
layer above already guarantees cannot happen. New ledger-convergence tests
(dual-parent-class: child removal, parent removal, full teardown) close the
gap this decision would otherwise have left untested. Full contract:
docs/research/2026-08-05-issue-319-contract.md.
Follow-up filed as #320 (the local player's canonical cell does not track
ordinary movement — deliberately NOT bundled into this fix). Register:
AP-142 clause (f), AP-132 clarifying sentence, new row AP-146.
Regressed by: cd3129e9 (C4 route 7), which un-masked a pre-existing latent
bug rather than creating it.
Root cause. EquippedChildRenderController.cs:134 hardcodes
ParentInstanceSequence: 0 for a CreateObject carrying a parent. Correct for
creatures and statics, which genuinely are sequence 0. WRONG for players: ACE
sets a player's ObjectInstance to Character.TotalLogins
(Player_Networking.cs:37), which acdream parses into
RuntimeEntityRecord.Incarnation. The relation is therefore filed under
(playerGuid, 0) while the record carries TotalLogins, and BOTH route-7
write sites key on the record's real incarnation:
- D1 attach re-cell —
RuntimeEntityObjectLifetime.cs:1537parent.Incarnation == parentInstanceSequence→ false. - D2 propagation —
RuntimeEntityDirectory.cs:478-480ChildrenAttachedToParent(guid, current.Incarnation)→ empty, forever.
TryCommitParent never validates the sequence, so the attach succeeds and
equipment: attached prints normally. Silent.
Why route 7 owns it. The deleted TickChild call reached
RebucketLiveEntity → CommitRebucket → SetFullCell keyed on the CHILD guid
alone, sourced from the parent's App-side ParentCellId — structurally immune
to a wrong parent key, and it tracked the local player exactly. Its replacement
RebucketLiveEntityPresentationOnly deliberately never commits canonical cell
(LiveEntityRuntime.cs:1026-1028).
Scope is wider than the local player: every REMOTE player's equipment too.
Proven by class rather than anecdote — every probe-firing parent across both
captured gate logs is 0x7… (static) or 0x8… (dynamic), i.e. sequence 0; the
sole 0x5… player parent is the sole failure.
User-visible consequence: NIL, verified. Rendering has an explicit fallback
(LiveRenderProjectionJournal.cs:270-277); attached children are structurally
excluded from spatial roots, physics/projectile worksets, the collision
retirement sweep, radar, and WorldPicker; VFX redirect to the parent;
landblock unload parks rather than destroys. Headless bots do not misreport.
THE PROCESS FINDING, which outranks the defect. Route 7's still-owed
connected gate accepts a session "only if cause=propagate lines appear". A
zero-cell player child emits NO line, so the defect's signature is ABSENCE —
which the criterion reads as "not exercised" rather than "broken". Two captured
gate logs contain this defect and neither flags it. The criterion is
unfalsifiable in the presence of the bug it exists to catch, which is worse
than having no gate, because it manufactures confidence. Corrected in the
closeout handoff: the gate must now assert a POSITIVE — the equipped child's
FullCellId equals the parent's after a crossing — not merely count probe
lines.
Do NOT rush the fix; it has more blast radius than the bug.
- The player's canonical cell does not track the player during ordinary
movement (only login activation, inbound Position/ForcePosition, and
teleport write it; WASD passes a landblock id that
LiveEntityRuntime.cs:935-938explicitly preserves the old cell for). So correcting the key ALONE yields a stale cell, not a correct one. - Three sites are inert only because the cell is zero and would wake on a fix:
the hydration
projectionCellId != 0filter (LiveEntityHydrationController.cs:551-554, opens a two-writer window),RestoreShadow(LiveEntityPresentationController.cs:216-236, installs a broadphase row route 7 says should not exist), andRuntimeInitialCreateResidenceState.Begin'sFullCellId != 0refusal (:583).
Full analysis, with the headless test's structural immunity explained:
2026-08-05-local-player-child-propagation.md
#321 — DatSoundCacheTests concurrent-decode-dedup fails under full-suite load
Status: OPEN Severity: LOW (test-only so far; no production symptom observed) Filed: 2026-08-05 Component: content / audio cache
Surfaced during C5a's commit-1 standalone verification: a concurrent
decode-dedup fact in DatSoundCacheTests failed once under full-suite load in
AcDream.Core.Tests and passed cleanly when re-run standalone.
Filed separately ON PURPOSE. It is NOT #302 (PortalProjectionTests
GC-allocation assertion, App.Tests) and NOT #308 (NakEmissionTests.LossSoak_…
wall-clock deadline, Core.Net.Tests). The standing handoff rule is that those
two must never be conflated; a THIRD load-sensitive failure absorbed into "the
flake class" is exactly how a real intermittent defect gets dismissed as noise.
What is actually unknown: whether this is a test-harness race (two threads
racing the dedup latch in the fixture) or a genuine thread-safety defect in the
decode cache itself. The distinction matters — DatCollection is already
recorded in project memory as NOT thread-safe, and an audio decode cache racing
under load would be the same family rather than a coincidence.
First step, before treating it as noise: run AcDream.Core.Tests alone
under repeat/stress to see whether it reproduces without full-suite load. If it
only fails under load it is scheduling pressure; if it reproduces in isolation
under repetition, it is a real race and should be escalated out of LOW.
Do not add a retry, a Skip, or a delay to make it green — this campaign's
standing rule is no workarounds without explicit approval, and a masked race is
strictly worse than a red test.
#322 — Two callers compute the same two pre-placement flags from the same two inputs
Status: OPEN Severity: LOW (internal refactor debt; NOT a retail divergence) Filed: 2026-08-05 (C5b review, finding S1 — the successor #275 closed without filing) Component: Runtime / inbound Position
Description. Since C5b (735f0a72) both accepted-Position callers derive
retail's two pre-placement writes — installPlacementFrame and clearParent —
from the identical pair (disposition, hasAnimations), in two separate places:
InboundPhysicsStateController.TryApplyPositioncomputes them inline, pre-merge, from the retained snapshotold.RuntimeInitialCreateContinuationExecutor.ApplyPositionActionreads them offRuntimeAuthoritativePositionRouteClassifier'sApplyPlacementFrameBeforeRouting/UnparentBeforeRouting, built throughRuntimeAcceptedPositionRouteRequests.Build.
The two are pinned EQUAL by test rather than by a shared code path
(InboundPhysicsStateControllerTests.MergedPrePlacementFieldsMatchTheClassifiedRouteFlags,
whose oracle is the production classifier fed through the production Build
since the C5b review's L3 fix). That is deliberate: it keeps each computation
separately sabotage-verifiable. It is still two copies of one rule.
Why this is not #275. #275 was the BEHAVIOURAL unification and is closed:
the merge no longer passes unconditional true/true, and it no longer derives
FullCellId from bare wire acceptance. What remains is structural only. There
is no behavioural motivation left behind it, which is precisely why it needs
its own ID rather than an open-ended "eventual cutover" pointer.
Acceptance (either is fine, pick at the time): (a) wire the continuation executor into the steady-state path so there is one caller, or (b) extract the two-flag derivation into one function both callers call — but if (b), the pin test must be re-argued, since a shared path makes the current merge-vs-classifier sabotage discrimination vacuous.
Do not widen TryApplyPosition's signature to take a full
RuntimeAuthoritativePositionRoute as a shortcut: the whole point of C5b's
finding is that these two flags need no route, no playerDistance and no
CommittedCellId, because retail decides both ahead of MoveOrTeleport
(SmartBox::HandleReceivedPosition @0x00453FD0 — Gate A @0x0045400C returns
@0x0045409D before unset_parent @0x00454129 and before the HasAnims
SetPlacementFrame gate @0x00454137).
#323 — A far-snap store can silently stale a pending initial-create completion receipt
Status: OPEN Severity: LOW (narrow, self-healing within one broadcast interval; no observed live symptom) Filed: 2026-08-05 (C5b review, finding D2 — found while establishing that C5b did NOT weaken the guard; this gap predates C5b) Component: App / placement projection
Description. LiveEntityRuntime.TryApplyInitialCreateCompletionPresentation
declines a stale ExecutorCompleted receipt on two terms:
record.FullCellId != token.ExactCellId and
record.Canonical.PlacementCommitVersion != token.PlacementCommitVersion.
Together those cover every owner that can move the receipt's facts — the
receipt carries the canonical body's pose and cell at publish
(RuntimeSetPositionState.PublishExecutorCompletion), and only a Runtime
SetPosition commit/withdrawal (every one of which calls
AdvancePlacementCommit; the only caller family is RuntimeSetPositionState)
or a rebucket can move them.
Except one. RuntimeRemotePlacementDriveController.StoreAcceptedDestinationPose
writes body.Position and body.Orientation directly on the far-snap
Refused/Contention/RejectedPreparation arm (AP-138 item (1)'s
"store, because the resolve never ran"). It bumps no placement commit and
moves no cell. If an ExecutorCompleted receipt for that entity is still
sitting behind an unacknowledged receipt on the shared placement FIFO when
that store lands, the receipt drains with a pose that is now older than the
body's, and entity.SetPosition(projection.WorldPosition) snaps the render
entity back.
Why the FIFO can be non-empty at that moment. PublishExecutorCompletion
dispatches synchronously, but RuntimePlacementProjectionSubscription.OnPlacement
applies a receipt only when it is the FIFO head, and
RuntimePlacementPresentationSink.TryApply deliberately refuses (leaves at the
head) any Place/Withdraw for an entity still holding an initial-create
residence, for the drive controller's per-frame pump to consume. So one
entity's conductor-owned receipt can hold another entity's ExecutorCompleted
behind it across network packets.
Not established: whether the combination is actually reachable in play — it needs a ≥96 m far-snap classification for an entity whose initial-create completion is still queued, and the far arm's refusal reasons are themselves narrow. Reported rather than fixed for exactly that reason.
Do NOT fix it by adding PositionAuthorityVersion to the guard. That was
the C5b reviewer's proposed shape and it is wrong: the merge bumps that version
on every accepted Position including ones that move nothing, so the guard would
decline receipts whose facts are still true, on the entity's FIRST
world-visible moment — skipping the pose write and
RebucketLiveEntityPresentationOnly while TryPublishPlace still publishes.
The correct shape, if this is ever confirmed reachable, is to make the store
arm advertise itself (a body-pose authority version, or routing the store
through a commit-versioned seam) so the receipt can see it.
Superseded text, for the record. The comment at the guard used to say only "A newer move superseded this receipt's facts after the drain." It now carries the full argument and cites this issue.