Records the round that shipped item-cell tooltips, the world-object hover tooltip, and the #411 cursor-swap fix: #409's write-up gains a "hover-feedback completion round" section covering all three items with live-verification notes; #411 is closed with the corrected decomp reading; register row TS-85 is narrowed to reflect the two newly-ported SetTooltip call sites (UIElement_UIItem::UpdateTooltip, UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound) and the still-open ones (spellcasting endowment/cast-button/favorite/submenu, map notes, character-panel attribute/skill info). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
18401 lines
1.1 MiB
18401 lines
1.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](plans/2026-04-11-roadmap.md).
|
||
|
||
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/*.md` note.
|
||
|
||
## Conventions
|
||
|
||
- Sequential integer IDs (`#1`, `#2`, …). Commits that close an issue reference the ID in the message (e.g. `fix #3: periodic TimeSync parsing`).
|
||
- `Status` is `OPEN`, `IN-PROGRESS`, or `DONE`. 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.
|
||
|
||
## #411 — Hover feedback over interactive UI elements: no cursor swap, and item cells have no rollover state
|
||
|
||
**Status:** CLOSED 2026-08-16 at the #409 hover-feedback completion round — the
|
||
user's answer to the open question below ("the POINTER changes, like it already
|
||
does over world NPCs") is CONFIRMED by decomp, not merely a memory to trust:
|
||
`UIElement_SmartBoxWrapper::FindObject @0x004E5430` calls
|
||
`SmartBox::set_found_object(itemID)` UNCONDITIONALLY whenever the hovered UI
|
||
element casts to `UIElement_UIItem` — not gated on target mode, as the port plan
|
||
below (written before this finding) assumed it would need to be. Fixed with a
|
||
ONE-LINE widening of `CursorFeedbackController.Update(UiRoot)`'s existing
|
||
(too-narrow) item-hover special case; `ResolveGlobalKind` needed no changes,
|
||
since it already read the "found" flag unconditionally across every mode. See
|
||
docs/ISSUES.md #409's own "hover-feedback completion round" write-up (item 3)
|
||
for the full derivation and `CursorFeedbackControllerTests.
|
||
UpdateFromRoot_HoveringAnItemSlot_ShowsFoundCursor_In{OrdinaryPeaceMode,CombatMode}`
|
||
for the pin. The rollover-STATE half of this investigation (item 3 below,
|
||
`UiItemSlot` has no `HoverEnter`/`HoverLeave`) was NOT in scope for the pointer
|
||
question and remains unaddressed if the user separately wants the highlight —
|
||
file a fresh issue if so; this closure covers only the pointer-swap question the
|
||
lead's scope addition asked about.
|
||
|
||
**Severity:** LOW (cosmetic/affordance; no gameplay impact)
|
||
**Depends on:** nothing — the hover dispatch it needs is already correct (see #409's
|
||
live-failure round, which proved `UiRoot.UpdateHover` selects the right widget).
|
||
|
||
**Original investigation (below), kept for its still-valid layer 1/2/3 breakdown —
|
||
only the "never fires over an inventory item" conclusion for layer 2 was
|
||
incomplete; see the closure note above for the corrected reading.**
|
||
|
||
**Retail mechanism, derived from `docs/research/named-retail/acclient_2013_pseudo_c.txt`.**
|
||
There are THREE separate hover-feedback layers, and the DAT decides which one applies:
|
||
|
||
1. **Per-element cursor** — `UIElementManager::CheckCursor @0x0045ABF0`, called from
|
||
`SwitchMouseOver @0x0045B5F8` whenever the entered element changes. It takes
|
||
`m_pElementWithMouseCapture` if it `HasCursor()`, else `m_pElementLastEntered` if it
|
||
`HasCursor()`, and calls `SetCursor(elem->m_cursorDID, m_cursorHotX, m_cursorHotY, 0)`;
|
||
otherwise it restores `m_defaultCursorDID` with the "is default" flag 1.
|
||
`UIElement::HasCursor @0x00464980` is just `m_cursorDID != INVALID_DID`, and the ONLY
|
||
writer of `m_cursorDID` in the whole binary is `UIElement::SetCursor @0x0045FF50`,
|
||
whose ONLY caller is `MediaMachine::Update_Cursor @0x00465A80` — i.e. this layer is
|
||
100% authored `MediaDescCursor` state media, never game code.
|
||
**MEASURED (exhaustive raw scan of every `ElementDesc` in every `LayoutDesc`,
|
||
template roots and nested children included): exactly 101 authored cursor media,
|
||
on element types 9 (Resizebar) and 2 (Dragbar) only, using only 5 cursor DIDs —
|
||
`0x06006119`, `0x06006126`, `0x06006127`, `0x06006128`, `0x06005E66`.** Those five
|
||
are precisely what `RetailCursorCatalog.TryGetWindowControlCursor` already
|
||
hardcodes. **So NO inventory item, item list, button, or option row authors a
|
||
per-element cursor in retail, and acdream is not missing a cursor swap for them.**
|
||
What acdream IS missing here is the general seam: `UiElement.StateCursors` is parsed
|
||
(`LayoutImporter.ReadState`) and stored (`UiElement.SetStateCursors`) but has ZERO
|
||
consumers — the window move/resize cursors reach the screen through the hardcoded
|
||
catalog instead. Porting `CheckCursor` properly means driving the cursor off the
|
||
hovered element's own `StateCursors` and deleting the hardcode.
|
||
|
||
2. **Global "found" cursor** — `ClientUISystem::UpdateCursorState @0x00564630` picks the
|
||
`...Found` variant of whichever cursor the current combat/target/busy mode selects,
|
||
keyed ONLY on `SmartBox::get_found_object_id() != 0`. The only writer is
|
||
`UIElement_SmartBoxWrapper::FindObject @0x004E545D` — the 3D viewport's world pick.
|
||
So this layer never fires over an inventory item either.
|
||
|
||
3. **Per-element rollover STATE** — `UIElementManager::SwitchMouseOver @0x0045B560` calls
|
||
`m_pElementLastEntered->MouseOverTop(1)` on enter and `(0)` on leave.
|
||
`UIElement::MouseOverTop @0x004615D0` sets `__bitfield164` bit 0 and broadcasts
|
||
element message `0x1B`; the media machine then swaps the element to its rollover
|
||
media. `UIElement_Button::MouseOverTop @0x004721F0` and
|
||
`UIElement_Field::MouseOverTop @0x00472360` override it (the Field override is the
|
||
drag-drop accept/reject state pair, states 9/10).
|
||
|
||
**Most likely what the user is seeing.** Because layers 1 and 2 are measurably not
|
||
involved for an inventory item, "the cursor should light up" is most likely layer 3 —
|
||
the item cell's own rollover highlight. acdream's `UiButton` honors rollover
|
||
(`RolloverEnabled`, dat property `0x13`, `UiButton.cs:462` + its HoverEnter/HoverLeave
|
||
handling at `UiButton.cs:831/835`), but **`UiItemSlot` has no hover handling at all** —
|
||
no HoverEnter/HoverLeave, no rollover state. That is the concrete gap to close first.
|
||
|
||
**Open question that needs one user observation (or a retail side-by-side).** Whether
|
||
the retail behavior the user remembers is (a) the item cell highlighting, or (b) the
|
||
mouse pointer bitmap actually changing. If it is (b), the mechanism is NOT any of the
|
||
three above for a UI item and needs a fresh derivation — do not guess. Ask before
|
||
porting.
|
||
|
||
**Port plan (in dependency order).**
|
||
1. Give `UiItemSlot` a HoverEnter/HoverLeave rollover state (retail
|
||
`UIElement::MouseOverTop @0x004615D0` bit 0 + message `0x1B`), sourced from the
|
||
cell's own authored rollover media where it has one.
|
||
2. Replace `RetailCursorCatalog.TryGetWindowControlCursor`'s five hardcoded DIDs with a
|
||
real `CheckCursor @0x0045ABF0` port off `UiElement.StateCursors` + a capture-wins
|
||
precedence, driven from `UiRoot`'s existing hover-change edge (the same edge #409's
|
||
tooltip dwell already uses). This is behavior-neutral for the 101 measured elements
|
||
and removes a hardcode; it is the prerequisite for any future DAT that authors a
|
||
cursor somewhere new.
|
||
3. Only if the user confirms (b) above: derive the item-hover cursor mechanism fresh.
|
||
|
||
**Files:** `src/AcDream.App/UI/UiItemSlot.cs`, `src/AcDream.App/UI/UiRoot.cs`
|
||
(`UpdateHover`), `src/AcDream.App/UI/RetailCursorCatalog.cs`,
|
||
`src/AcDream.App/UI/CursorFeedbackController.cs`, `src/AcDream.App/UI/UiElement.cs`
|
||
(`StateCursors`, today unconsumed), `src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||
(`ReadState`'s `MediaDescCursor` read).
|
||
|
||
---
|
||
|
||
## #410 — Client-wide VJustify (vertical text justification) enum mapping + unauthored default are wrong (retail default is Top, not Center)
|
||
|
||
**Status:** OPEN
|
||
**Severity:** MEDIUM (silently mispositions every DAT-imported `UiText` that
|
||
relies on the unauthored default, or that authors a raw vertical-
|
||
justification value other than 1 — currently invisible unless two
|
||
elements' boxes are close/overlapping the way the Skills info-box panes
|
||
are, but could affect vertical alignment anywhere client-wide)
|
||
|
||
Found during Campaign CC gate round 1 re-test 2's R3-3 investigation
|
||
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`). The
|
||
Skills page's info-box title (`0x100003fb`) and description (`0x100003fc`)
|
||
panes author NO dat property `0x15` (vertical justification) — live-DAT-
|
||
probe-confirmed absent on both — so both fall to whatever this port's
|
||
unauthored default resolves to, currently `VJustify.Center`
|
||
(`ElementReader.cs`'s `VJustify` field default and
|
||
`ElementReader.cs`/`DatWidgetFactory.cs`'s import-time mapping switches).
|
||
|
||
Byte-traced against retail:
|
||
|
||
- `UIElement_Text::UIElement_Text` (ctor) `@0x004685ff`: unconditionally
|
||
sets `this->m_eVerticalJustification = 4` (and
|
||
`m_eHorizontalJustification = 2` at `@0x004685f5`) BEFORE any dat
|
||
property is applied — i.e. retail's real unauthored default is the raw
|
||
value **4**, not whatever a "sensible default" might suggest.
|
||
- `UIElement_Text::CalcJustification` `@0x00467260`: the ACTUAL enum
|
||
semantics, shared by both the horizontal and vertical branches via one
|
||
`ecx_5` comparison — `ecx_5 == 1` → **Center**; `ecx_5 == 3 || ecx_5 == 5`
|
||
→ the FAR edge (**Right** for horizontal, **Bottom** for vertical); any
|
||
OTHER value (0, 2, 4, ...) → `edi = 0`, the NEAR edge (**Left** for
|
||
horizontal, **Top** for vertical).
|
||
|
||
Cross-referencing: the ctor's own vertical default of 4 resolves via this
|
||
real semantic table to **Top**, not Center. This port's
|
||
`ElementReader.cs:507`'s import-time switch (`2u=>Top, 4u=>Bottom,
|
||
_=>Center`) and `DatWidgetFactory.cs:704`'s build-time switch are BOTH
|
||
wrong relative to the real table — only raw value `2` (coincidentally
|
||
falling into the correct "near edge" bucket) and `1` (Center, matching the
|
||
`_=>Center` catch-all by coincidence) currently resolve correctly; `0`,
|
||
`3`, `4`, and `5` all resolve to the wrong bucket. The `ElementInfo.VJustify`
|
||
field default (`VJustify.Center`) is ALSO wrong — it should be `Top` to
|
||
match the ctor's real resolved value.
|
||
|
||
**Why this is filed instead of fixed here:** the blast radius is
|
||
client-wide — every DAT-imported `UiText` that reaches the
|
||
`Centered`/`RightAligned`/`OneLine` static paths or the multi-line
|
||
honored-justification path (`_honorDatVerticalJustification`, set
|
||
unconditionally by `ConfigureDatState` for every DAT-imported text
|
||
element) is affected, including already-shipped, visually-verified,
|
||
FROZEN surfaces (vitals numbers, chat, main game UI, Options panel) that
|
||
may be relying on the CURRENT (wrong) Center default for their existing
|
||
correct-looking vertical alignment. Flipping the shared default/mapping
|
||
without a full client-wide regression sweep risks reintroducing
|
||
regressions in surfaces this session has no budget to re-verify. R3-3's
|
||
own fix (`CharacterCreationSkillsPage`'s constructor) scopes the
|
||
correction to ONLY the two Skills info-box panes via an explicit
|
||
`VerticalJustify = VJustify.Top` post-construction assignment — a
|
||
targeted, decomp-grounded correction that does not touch the shared
|
||
mapping.
|
||
|
||
**Fix direction when this issue is picked up:** (1) correct
|
||
`ElementReader.cs`'s import-time switch AND `DatWidgetFactory.cs`'s
|
||
build-time switch to the real table above (`1=>Center, 3 or 5=>Bottom,
|
||
else=>Top`) for BOTH horizontal and vertical justification (audit the
|
||
horizontal switch too — it currently special-cases `0u or 2u=>Left`
|
||
instead of "everything except 1/3/5"; likely benign today since 2 is the
|
||
only unauthored horizontal default in practice, but should be corrected
|
||
for the same reason); (2) flip `ElementInfo.VJustify`'s field default to
|
||
`Top`; (3) fix `ElementReader.cs:435`'s `Merge` sentinel
|
||
(`derived.VJustify != VJustify.Center ? derived : base_`) to use the NEW
|
||
default (`Top`) as the "unset" sentinel instead, or restructure to a
|
||
nullable/explicit-override tracking shape so the merge doesn't rely on a
|
||
magic default value at all; (4) a full client-wide live-DAT sweep of every
|
||
Type-12/Button element that authors OR omits property `0x15`/`0x14`,
|
||
cross-checked against a fresh full visual pass of chat, main game UI,
|
||
Options, and every chargen page (this port's own `CharacterCreationSkillsPage`
|
||
override from R3-3 should be REMOVED once the shared default is corrected,
|
||
since it would then be redundant); (5) the exact same audit for the
|
||
horizontal `HJustify` mapping while in this code, since it shares the
|
||
`CalcJustification` function and the same class of latent bug.
|
||
|
||
## #409 — Client-wide UI tooltip system is unshipped (GF-16, deferred out of Campaign CC gate round 1)
|
||
|
||
**Status:** CODE-COMPLETE 2026-08-16; review-fix round F1-F11, the LIVE-FAILURE round, and the hover-feedback completion round (item-cell tooltips + world-object hover tooltip) all landed same day. The live-failure round's own fix is LIVE-VERIFIED (Options -> Character tab tooltip observed on a real connected client, screenshot evidence); the hover-feedback completion round's three items are automated-gate-verified (unit + live-DAT) but the user's connected gate for THOSE items specifically is still owed — see that round's own "Live-verify all three" note.
|
||
**Severity:** LOW-MEDIUM (cosmetic/discoverability — no gameplay impact, but retail shows a tooltip on hover for authored elements client-wide and acdream showed none before this fix)
|
||
|
||
**2026-08-16 re-derivation + port.** Full re-derivation from
|
||
`docs/research/named-retail/acclient_2013_pseudo_c.txt` corrected two things
|
||
the original GF-16 filing below got wrong from a shallower pass: **`P0x47`
|
||
is NOT a "tooltip behavior enum" — it is the element-desc id WITHIN the
|
||
popup LayoutDesc (`P0x48`) to instantiate as the popup's root**
|
||
(`UIElementManager::StartTooltip @0x0045DE90` passes it straight to
|
||
`LayoutDesc::AccessElementDesc`), and **`P0x4A` is read off the freshly
|
||
INSTANTIATED popup's own root element, not the hovering trigger element**
|
||
(it names that popup's text-child id). A live-DAT sweep (installed EoR
|
||
build) found **430 elements author at least one of the five trigger
|
||
properties** (243 with literal `P0x49` `StringInfo` text this port shows;
|
||
the other 187 have no literal text and show nothing — see register row
|
||
TS-85 for the honest scope of what's missing there), superseding the
|
||
original "~253" estimate.
|
||
|
||
**2026-08-16 LIVE-FAILURE round (user gate on build `1.0.3-tt.a`: "tooltips do not appear
|
||
anywhere except one on the paperdoll").** Root-caused, fixed, and live-verified the same
|
||
day. TWO findings, both measured, neither of them a broken hover/hit-test:
|
||
|
||
1. **The dominant root cause: the presenter read only the AUTHORED text.**
|
||
`RetailTooltipPresenter.OnTooltipShow` gated on `widget.AuthoredTooltipText`
|
||
(`P0x49`) alone. Retail's `UIElement::StartTooltipAtMouse @0x00460D70` takes the
|
||
RUNTIME `m_TTText` first (`@0x00460DA3` `StringInfo::IsValid` -> `@0x00460DAA`
|
||
verbatim copy) and only falls back to `InqProperty(0x49)` at `@0x00460DDF`.
|
||
acdream ALREADY had the runtime layer — `UiElement.GetTooltipText()`, populated by
|
||
`CharacterOptionsPageController:477`, `ChatOptionsPageController:502`,
|
||
`ConfigOptionsPageController` (5 sites), `KeyboardConfigController:477`,
|
||
`SocialAllegiancePageController:412`, `SocialFellowshipPageController:458`, and
|
||
`UiCheckboxBitfield64:216` — but nothing consulted it. **Live-DAT measured:** the
|
||
Options toggle-row checkbox (`0x2100002B` template root `0x10000218`, checkbox leaf
|
||
`0x10000219`) authors `P0x47=0x10000397 P0x48=0x21000041 P0x4B=true` and an EMPTY
|
||
`P0x49` — the popup locator and the on-bit are authored, only the text arrives at
|
||
runtime, exactly as retail's `UIOption_CheckboxBitfield64::CreateChildren
|
||
@0x00485E65` stamps its `siTooltip` array. Re-measured across every LayoutDesc: all
|
||
187 no-literal-text tooltip elements author BOTH locator ids, i.e. the whole set is
|
||
runtime-text targets. FIXED: `RetailTooltipPresenter.ResolveTooltipText` now uses
|
||
retail's order, and the `P0x48`-absent fallback to the element's own LayoutDesc
|
||
(`@0x00460E7E`, `this->m_layout->m_DID`) is ported through the new
|
||
`UiElement.SourceLayoutDid` threaded from `LayoutImporter.Build`'s new
|
||
`sourceLayoutDid` parameter. (`RowTemplateResolver`'s build delegate carries no
|
||
layout id, so social row templates leave `SourceLayoutDid` at 0 — they author
|
||
`P0x48` anyway, so no live case needs the fallback there.)
|
||
|
||
2. **The "243 showable" number was never an in-world number.** A grouped re-sweep of
|
||
the same 243 found them concentrated in CHARACTER-CREATION layouts (`0x21000038`
|
||
heritage/profession/skills/appearance/town/summary tabs, `0x21000047` attributes,
|
||
`0x21000049`/`0x2100004C` appearance+skills, `0x21000005`/`0x2100000F`/`0x21000068`
|
||
the shared appearance page, `0x21000046` heritage picks). The INVENTORY window
|
||
(`0x21000023`) and paperdoll (`0x21000024`) author exactly TWO between them:
|
||
`0x100001D6` "Drag clothing and armor here to wear them" (the doll drag mask,
|
||
`PaperdollController.DollDragMaskId`) and `0x100005BE` "When this option is chosen,
|
||
you will see explicit equipment slots instead of a portrait" (the Slots button).
|
||
**That first one IS the user's single working tooltip** — confirmed live. So the
|
||
paperdoll was never a differential against a broken mechanism; it was the only
|
||
authored-text tooltip in the panel being hovered. Reachability was also measured
|
||
and is NOT a problem: 238 of the 243 build as real, non-`ClickThrough` hover
|
||
targets (230 `UiButton`, 6 `UiScrollbar`, 2 `UiField`; the 5 misses are Type-12
|
||
prototypes the importer skips by design).
|
||
|
||
**Live verification (2026-08-16, connected `testaccount`/`+Acdream`, Release,
|
||
`ACDREAM_RETAIL_UI=1`):** hovering Options -> Character -> "Vivid Targeting Indicator"
|
||
now shows "Enable this option to apply a targeting indicator around selected objects
|
||
and monsters for better visual reference"; a temporary hover probe confirmed the hover
|
||
target is element `0x10000219` with `runtime=True`. Hovering the paperdoll still shows
|
||
"Drag clothing and armor here to wear them". Hovering an inventory ITEM still shows
|
||
nothing — that is `UIElement_UIItem::UpdateTooltip @0x004E1CB0` (retail shows the item
|
||
NAME, `"%d %s"`-prefixed when the stack is > 1), which stays deferred: acdream's
|
||
`UiItemSlot` is constructed programmatically at 6+ sites and carries neither the
|
||
`P0x47` popup locator nor a name source, so porting it is its own slice, not a
|
||
one-line seam. Register TS-85 is narrowed accordingly and now enumerates all 15
|
||
`SetTooltip` call sites split into ported / no-acdream-analog.
|
||
|
||
**2026-08-16 review-fix round (F1-F11), same day.** An Opus review of the
|
||
port above returned architectural PASS-with-findings / retail-fidelity FAIL
|
||
with twelve findings; F1-F11 landed in one fix commit (F12 was info-only).
|
||
Highlights: the popup now offsets +32px from the mouse on both axes
|
||
(`StartTooltip @0x00459700`, was landing flush at the cursor); the dwell
|
||
timer now anchors to mouse-IDLE like retail's `m_lastMouseMoveTime`, not
|
||
hover-enter, and is gated off while the mouse has capture
|
||
(`CheckTooltip @0x0045B6E0`); `ReleaseCapture` no longer tears down an
|
||
already-shown tooltip's fired latch (it only restarts the idle deadline,
|
||
matching `ReleaseMouseCapture @0x0045D2B0`); the popup no longer mounts an
|
||
empty bevel artifact when its text child fails to resolve
|
||
(`StartTooltip @0x0045DE90`'s `DynamicCast` gate); the popup and its text
|
||
child now null their anchor policy before resizing, mirroring the sibling
|
||
`RetailMessageDialogView`; the auto-resize now applies retail's
|
||
`ResizeTo @0x00463C30` max/min width/height clamps; and a session reset now
|
||
also clears `UiRoot`'s own hover/tooltip-fired latch (not just the
|
||
presenter's popup element) so a post-reset hover re-shows immediately. The
|
||
review's biggest correction was to the ORIGINAL port's own framing of what
|
||
it deferred: "191 elements rely on retail's dynamic `InqProperty(0x49)`
|
||
override" was FALSE — retail's own base `InqProperty` reads the same
|
||
authored property bags this port already reads, so most no-literal-text
|
||
elements show nothing in retail too. Register row TS-85 is rewritten (not
|
||
just re-counted) with the real gap: the `m_TTText`/`SetTooltip` runtime-text
|
||
family, headed by the `P0xD0` truncated-text auto-tooltip
|
||
(`UIElement_Text::RecalculateTruncation @0x00466F80`) — sized and found
|
||
disproportionate to port in the same round (it needs a per-line-position
|
||
truncation model this port's `UiText` doesn't have), so it stays deferred,
|
||
honestly described. Layout `0x21000041` holds four 30x30 popup skins
|
||
(`0x10000487`/`0x10000395`/`0x10000397`/`0x10000398`), each a four-piece
|
||
bevel frame around one shared Type-12 text child `0x10000396` — confirmed
|
||
by `TooltipLiveDatTests`.
|
||
|
||
**Shipped:** the six-property data layer (`ElementInfo`/`UiElement`
|
||
`Tooltip*`/`AuthoredTooltip*` fields, `ElementReader`,
|
||
`DatWidgetFactory.ResolveTooltipText`, `LayoutImporter.BuildWidget`); the
|
||
hover-dwell/auto-hide/dismissal state machine (`UiRoot.Tick`'s existing
|
||
`CheckTooltip` port gained `TooltipShow`/`TooltipHide` C# events, a
|
||
per-element `P0x50` delay-override consult, and dismissal wiring at every
|
||
retail-confirmed teardown site — hover-target change, owner-element
|
||
removal, duration timeout); `RetailTooltipPresenter` (owned by
|
||
`RetailUiRuntime`, mounted alongside `RetailDialogFactory`) — builds the
|
||
popup via the SAME `LayoutImporter`/dat-lock seam dialogs use, auto-resizes
|
||
by the retail measured-vs-authored-text delta (word-wrapped at the display
|
||
width via the existing `UiText.WrapWords` primitive), positions at the
|
||
mouse cursor clamped to the display, keeps itself topmost over dialogs via
|
||
its own later per-tick `BringToFront` (register AD-106), and gates on both
|
||
the global `Misc.TooltipEnable` preference (client-local, `SettingsStore`'s
|
||
new `MiscSettings` section — retail's OWN 2013 Config tab authors no
|
||
visible row for it either, confirmed by the OP campaign's own research, so
|
||
no new options-panel row was added) and the widget's own `P0x4B`. **No
|
||
click-dismissal was ported** — `UIElementManager::MouseDownEvent
|
||
@0x0045DB60` calls the SAME `SwitchMouseOver` hover-change check that
|
||
already drives dismissal, and it no-ops when the hit-tested element hasn't
|
||
changed, so retail itself does not dismiss a tooltip by clicking its own
|
||
owner.
|
||
|
||
**Deferred (register TS-85, rewritten at the F3 review round):** the
|
||
`m_TTText`/`SetTooltip` runtime-text family headed by the `P0xD0`
|
||
truncated-text auto-tooltip (187 of 430 tooltip-property-authoring elements
|
||
have no literal text and show nothing; an unmeasured subset of those would
|
||
show retail's truncation tooltip instead) and the `P0x3D` per-element
|
||
wrap-width override (zero elements author one today). **Deferred (register
|
||
AD-106, two honest additions at the F10 review round):** the topmost-z-order
|
||
mechanism is a sibling-with-later-reraise adaptation, not retail's literal
|
||
separate presentation layer — the guarantee is versus dialogs/screens ONLY
|
||
(the overlay popup layer and the drag ghost still paint above regardless),
|
||
and the per-tick `BringToFront` re-raise chain now has four rungs
|
||
(`CharacterManagementUiController`, `CharacterCreationUiController`,
|
||
`RetailDialogFactory`, `RetailTooltipPresenter`) — bounded and enumerable
|
||
today, but a design smell worth flagging.
|
||
|
||
**Gate note (5-10 min, `ACDREAM_RETAIL_UI=1`) — REWRITTEN at the live-failure
|
||
round, because the original note only listed chargen surfaces and so could not
|
||
have caught the in-world gap the user's gate found.** Hover and hold still; a
|
||
small box should appear after ~0.25 s and vanish when you move to a different
|
||
control.
|
||
|
||
*In-world (this is what the live-failure fix added — check these FIRST):*
|
||
(1) Options (F11) -> **Character** tab, any checkbox row (e.g. "Vivid Targeting
|
||
Indicator") — a full help sentence. Same for the **Chat** and **Config** tabs'
|
||
rows, sliders and dropdowns. (2) Options -> Configure Keyboard, any key button.
|
||
(3) Social panel (F3/F4) -> the Allegiance/Fellowship checkboxes. (4) Inventory
|
||
-> hover the paperdoll figure ("Drag clothing and armor here to wear them") and
|
||
the "Slots" button.
|
||
|
||
*Chargen (already worked before this round):* (5) Appearance page rotate arrows
|
||
("Rotate left."/"Rotate right.") and any color swatch; (6) the hair/eyes/nose
|
||
spin arrows — longer text, should WRAP rather than run off-screen; (7) the
|
||
Heritage/Profession/Skills/Town/Summary tab buttons.
|
||
|
||
Confirm also: the box sits offset down-right of the cursor (retail's +32px on
|
||
both axes), never runs off the edge of the window even near a corner, and
|
||
disappears on its own after ~10 s if you hold still without moving away. No
|
||
click-to-dismiss is expected — only moving off the control, or a very long
|
||
hold, closes it.
|
||
|
||
**2026-08-16 hover-feedback completion round.** Closes the two items the
|
||
live-failure round explicitly deferred (item-cell tooltips, and the #411
|
||
pointer question), plus the world-object hover tooltip the user's gate notes
|
||
called out separately.
|
||
|
||
1. **Inventory/shortcut/paperdoll item-name tooltips — SHIPPED.**
|
||
`UIElement_UIItem::UpdateTooltip @0x004E1CB0` is called from
|
||
`UIItem_Update` (an item-DATA-CHANGE refresh, not a hover handler — the
|
||
trigger that actually SHOWS it is the generic `CheckTooltip` dwell timer,
|
||
same as any other tooltip-bearing element). Re-derived and closed the gap
|
||
the live-failure round left open ("acdream's `UiItemSlot` carries neither
|
||
the `P0x47` popup locator nor a name source"): live-DAT sweep of the
|
||
shared UIItem cell-template catalog (`ItemListCellTemplate.CatalogLayoutId`,
|
||
`0x21000037`) found ALL 47 UIItem-type (class `0x10000032`) prototypes —
|
||
inventory's cell, every toolbar slot, every paperdoll/armor slot skin —
|
||
resolve the IDENTICAL popup locator (`P0x47=0x10000395`/`P0x48=0x21000041`)
|
||
through catalog inheritance, with no literal text authored on any of them
|
||
(`TooltipLiveDatTests.UiItemCatalog_EveryPrototype_SharesTheSamePopupLocator`).
|
||
`UiItemSlot` now hardcodes that pair and exposes `GetTooltipText()` via a
|
||
new per-instance `TooltipTextResolve` delegate, wired at every physical-
|
||
item construction site — `InventoryController` (main-pack cell + grid
|
||
cells), `ExternalContainerController`, `PaperdollController` (closes the
|
||
`gmPaperDollUI::UpdateItemSlotTooltip @0x004A52EF` row too — same cell
|
||
class, same fix), `VendorUiController` (shop/buying/selling lists),
|
||
`SecureTradeUiController`, `ToolbarController`. Text is
|
||
`ClientObject.GetTooltipDisplayName()` (new Core method): `GetAppropriateName()`
|
||
prefixed with the stack count via `"{count} {name}"` when `StackSize > 1`,
|
||
matching `UpdateTooltip`'s exact `NAME_APPROPRIATE` + `"%d %s"` sprintf.
|
||
`UiCatalogSlot` (spell/component catalog cells — a DIFFERENT `UiItemSlot`
|
||
subclass) is unaffected; it already overrides `GetTooltipText()` with its
|
||
own `Label`.
|
||
|
||
2. **World-object hover tooltip (NPCs, players, signs, chests, portals) —
|
||
SHIPPED.** NOT the UI-element dwell-timer path — retail's mechanism is
|
||
`UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @0x004E5AD0`,
|
||
fed every frame by `FindObject @0x004E5430`/`Global_Loop @0x004E5620`
|
||
using the current mouse position regardless of input focus. It fires
|
||
IMMEDIATELY (no dwell wait) on the found-object id CHANGING, gated by the
|
||
`PlayerModule::ShowTooltips` character option (`CharacterOptionId.ShowTooltips`
|
||
— already modeled in `CharacterOptionTable`, default true), with text
|
||
`ACCWeenieObject::GetObjectName(id, NAME_APPROPRIATE, 0)` — the SAME name
|
||
call as item tooltips, but WITHOUT the item-cell's separate stack-count
|
||
prefix (a real, decomp-confirmed asymmetry: a ground pile of arrows shows
|
||
"Arrows", not "20 Arrows"). Ported as `RetailTooltipPresenter.
|
||
UpdateWorldHoverTooltip`, driven by the SAME world-hover pick
|
||
`CursorFeedbackController`'s own found-cursor already uses
|
||
(`WorldSelectionQuery.PickAtCursor`, `includeSelf: true`) and the SAME
|
||
`ClientObjectTable`-backed name resolver `SocialAllegiancePageController`'s
|
||
`ResolveWorldObjectName` already established as this codebase's pattern.
|
||
Queried only when no UI element is hovered (this port's reading of
|
||
`FindObject`'s `m_pElementLastOver` check, narrowed from retail's literal
|
||
"raycast even under non-item UI chrome" — see the class's own doc note).
|
||
**Own player is included** (`includeSelf: true`, the same precedent the
|
||
cursor feedback wiring already set) — no decomp evidence was found either
|
||
confirming or excluding self from the found-object pipeline, so this
|
||
follows the established local precedent rather than guessing fresh; flag
|
||
if that reads wrong in the live gate. **The exact popup skin is an
|
||
inference, not a measured value** — an exhaustive live-DAT sweep found
|
||
`UIElement_SmartBoxWrapper` (class `0x10000030`) has NO authored
|
||
`ElementDesc` anywhere installed (unlike every other tooltip trigger, it
|
||
is evidently constructed directly by `gmGamePlayUI`'s own mode setup, not
|
||
from a walkable LayoutDesc — `TooltipLiveDatTests.
|
||
SmartBoxWrapper_HasNoAuthoredElementDesc_AnywhereInstalled`), so its real
|
||
`P0x47`/`P0x48` cannot be read off the DAT the way the item catalog's can.
|
||
This port reuses the SAME pair every other game-code `SetTooltip` caller
|
||
in this family resolves to — the best-evidenced choice, called out in
|
||
register row TS-85 rather than silently assumed exact.
|
||
|
||
3. **#411 resolved: retail DOES swap the pointer over inventory items,
|
||
unconditionally — the earlier investigation's "never fires over an
|
||
inventory item" finding was INCOMPLETE, not wrong about what it checked.**
|
||
The original #411 scan (below) correctly found no PER-ELEMENT authored
|
||
cursor on item cells and correctly found `SmartBox::get_found_object_id()`
|
||
is written only by `UIElement_SmartBoxWrapper` — but it had not yet traced
|
||
`FindObject @0x004E5430` far enough: when the currently-hovered UI element
|
||
(`m_pElementLastOver`) casts to `UIElement_UIItem` (class `0x10000032`),
|
||
`FindObject` calls `SmartBox::set_found_object(itemID, 0xFFFFFFFF)`
|
||
directly and returns WITHOUT running the 3D raycast — UNCONDITIONALLY, not
|
||
gated on target mode. `ClientUISystem::UpdateCursorState @0x00564630`
|
||
computes its "found" flag ONCE at the top of the function
|
||
(`ebx = SmartBox::get_found_object_id() != 0`, `@0x00564642`) and every
|
||
later branch (default/melee-missile/magic/use/examine/use-target/busy)
|
||
reads that SAME flag — so hovering an occupied item cell shows the
|
||
cursor's "...Found" variant in EVERY mode, not only during an active
|
||
`UseTarget` selection. `CursorFeedbackController.Update(UiRoot)` already
|
||
had the item-hover special case wired (from an earlier round) but
|
||
incorrectly gated it to `TargetMode.UseTarget` only; that one-line gate is
|
||
now removed — `ResolveGlobalKind`'s existing found/not-found branching
|
||
needed no changes at all, since it already read the snapshot's
|
||
`HoverTargetGuid` unconditionally across every mode. Live-DAT-independent
|
||
(pure decomp + unit fixture), so no DAT sweep was needed for this part;
|
||
two new `CursorFeedbackControllerTests` pin the widened behavior in
|
||
ordinary peace mode and in combat mode.
|
||
|
||
**Live-verify all three on the connected client** (session-config launch,
|
||
graceful close per the usual rules): hover an inventory item — a name
|
||
tooltip should appear (with a count prefix for a stack) AND the mouse
|
||
pointer should swap to its "found" variant; hover an NPC/creature — a name
|
||
tooltip should appear immediately (no perceptible delay) if "Show Tooltips"
|
||
is on; hover a sign/chest/portal similarly.
|
||
|
||
---
|
||
|
||
**Original GF-16 filing (superseded by the re-derivation above; kept for
|
||
investigation history).**
|
||
|
||
Found during Campaign CC gate round 1's Batch D root-cause investigation
|
||
(`docs/research/2026-08-16-campaign-cc-gate-round1-findings.md`, GF-16
|
||
"Hover tooltips missing on all pages"). Explicitly out of Batch D's own
|
||
scope — Batch D fixed the chargen 3D preview backdrop (GF-7/GF-14) only;
|
||
GF-16 is a CLIENT-WIDE mechanism, not a chargen-scoped one, and needs its
|
||
own gate round the same way GF-12's frame carve-out and #408's
|
||
importer-wide honor did.
|
||
|
||
Retail's tooltip pipeline (decomp anchors from the Batch D
|
||
investigation):
|
||
|
||
- `UIElement::StartTooltipAtMouse @0x00460D70` — the per-element entry
|
||
point; fired from mouse-hover dispatch.
|
||
- `UIElementManager::StartTooltip @0x0045DE90` and a second call site
|
||
`@0x00459700` — the manager-level owner that actually builds/positions
|
||
the tooltip popup element and starts its show/delay timer.
|
||
- Layout DID `0x21000041` — the authored tooltip popup LayoutDesc (not yet
|
||
imported/mounted by `LayoutImporter`/`RetailUiRuntime`).
|
||
- Element properties `P0x47`/`P0x48`/`P0x49`/`P0x4A`/`P0x4B` — the five
|
||
per-element tooltip-text/behavior properties `UIElement::OnSetAttribute`
|
||
reads (exact semantics per property still need re-derivation when this
|
||
issue is picked up — the investigation only confirmed the property IDs,
|
||
not their individual meanings).
|
||
- Measured **~253 authored elements client-wide** carry at least one of
|
||
those five properties (a scope comparable to #408's 1,083-element sweep,
|
||
though a different property family).
|
||
- User-facing config: `Misc_TooltipEnable`/`Misc_TooltipDelay` prefs (the
|
||
Options-panel-adjacent settings that gate whether tooltips show at all
|
||
and how long the hover dwell is before one appears).
|
||
|
||
Fix direction, mirroring #408's own "own gate round" shape: (1) grep-named
|
||
first on all four decomp anchors above and re-derive the exact show/hide/
|
||
position/delay state machine (`StartTooltipAtMouse` → `StartTooltip` →
|
||
popup lifecycle) before writing any pseudocode; (2) import/mount layout
|
||
`0x21000041` through the existing `LayoutImporter`/`RetailUiRuntime`
|
||
pipeline; (3) wire client-wide mouse-hover dispatch (likely through the
|
||
existing `InputDispatcher`/`UiRoot` hover-tracking, if any already exists,
|
||
or a new hover-timer owner otherwise) to read the five P0x47-P0x4B
|
||
properties per hovered element; (4) honor `Misc_TooltipEnable`/
|
||
`Misc_TooltipDelay` from `RuntimeCharacterOptionsState`/
|
||
`CharacterOptionTable` (Campaign OP's existing option-storage owner); (5)
|
||
a live-DAT sweep of the ~253 elements (same shape as #408's per-LayoutDesc
|
||
enumeration) before claiming full coverage, since a partial per-page
|
||
implementation would repeat the "accumulate a bigger partial table"
|
||
mistake #306 already named for a different subsystem; (6) its own
|
||
connected visual gate — hovering a representative sample across multiple
|
||
screens (chargen, main game UI, chat, Options) side-by-side with retail.
|
||
|
||
## #408 — General importer-wide honor of dat property 0x3B (Invisible) is unshipped (1,083 elements client-wide)
|
||
|
||
**Status:** OPEN
|
||
**Severity:** LOW-MEDIUM (cosmetic — extra/leaked elements render where retail hides them; no gameplay/wire impact)
|
||
|
||
Found while fixing GF-13 (Campaign CC gate round 1, Batch A, 2026-08-16):
|
||
acdream's `LayoutImporter`/`DatWidgetFactory` never read dat property
|
||
`0x3B` (Invisible — `BoolBaseProperty`), which retail's
|
||
`UIElement::OnSetAttribute @0x00462d80` case 8
|
||
(`GetPropertyName()-0x33==8`) honors on EVERY element via
|
||
`SetVisible(value==0)`. The blast-radius sweep this fix's investigation
|
||
ran found **1,083 elements client-wide** author `P0x3B=true` — far
|
||
beyond the two chargen-Summary GM labels (`0x10000403`
|
||
"Non-Admin"/`0x10000494` "Non-Envoy") the user actually reported.
|
||
|
||
The fix (`fix(chargen): Campaign CC gate round 1 Batch A`) added the data
|
||
plumbing everywhere (`ElementInfo.Invisible`, read in
|
||
`ElementReader.ApplyCanonicalLegacyProjection`; `UiElement.AuthoredInvisible`,
|
||
set in `LayoutImporter.BuildWidget`) but deliberately does NOT act on it
|
||
in the shared importer path — only `CharacterCreationUiController`
|
||
(`HideAuthoredInvisibleElements`) walks its own mounted subtree and
|
||
hides what it finds, chargen-scoped only. Register row AP-230 records
|
||
the split.
|
||
|
||
Honoring the flag client-wide (setting `UiElement.Visible = false`
|
||
directly in `LayoutImporter.BuildWidget` when `info.Invisible` is true,
|
||
or an equivalent central chokepoint) is straightforward, but 1,083
|
||
elements is its own visual-regression surface: any one of them could be
|
||
an element some OTHER screen currently relies on being visible despite
|
||
authoring the flag (e.g. a state-conditional visibility toggle that
|
||
happens to leave `0x3B=true` on its default/direct state while a
|
||
controller separately manages `Visible` at runtime). This needs its own
|
||
sweep — dump the 1,083 ids grouped by owning LayoutDesc/screen, spot-check
|
||
a representative sample per screen against retail, then flip the
|
||
importer-wide switch with a dedicated visual gate — not a one-line
|
||
change folded into an unrelated fix.
|
||
|
||
Fix direction: (1) enumerate the 1,083 ids per LayoutDesc (a live-DAT
|
||
probe test, similar to `SpewBoxLayoutDumpDiagnostic`); (2) for each
|
||
distinct screen/LayoutDesc, confirm honoring the flag doesn't hide
|
||
something the runtime currently manages visibility of dynamically at that
|
||
SAME element id (would double-drive `Visible`); (3) flip the honor in
|
||
`LayoutImporter.BuildWidget` (mirroring the chargen-scoped code path
|
||
already proven live) and delete `CharacterCreationUiController`'s own
|
||
narrow `HideAuthoredInvisibleElements`/AP-230 in the same commit; (4) run
|
||
a full-client visual matrix, not just chargen.
|
||
|
||
## #407 — Windowed resolution offering starves on RDP/virtual displays (video-mode gating)
|
||
|
||
**Status:** DONE (`e601a496`, 2026-08-16 — same gate round, user-directed immediate fix)
|
||
**Severity:** MEDIUM (windowed usability on remote/virtual displays)
|
||
|
||
Found live during the CC gate over RDP: the Config Resolution dropdown
|
||
offered exactly two entries — `1920x1080` and the desktop's own
|
||
`2056x1290` — because the RDP virtual display's driver advertises only
|
||
those two video modes (measured via `EnumDisplaySettings`: the physical
|
||
2560x1440 monitor's mode list is not visible to the remote session at
|
||
all; the two secondary virtual displays expose only `800x600`).
|
||
`DisplayModeCatalog` (#391) honestly curates what the monitor
|
||
enumerates — the defect is the DESIGN conflation: the WINDOWED size
|
||
offering is gated on fullscreen-capable video modes, but a windowed
|
||
client needs no video mode — any size that fits the desktop is
|
||
displayable. On a physical monitor the conflation is invisible (rich
|
||
mode list); on RDP it collapses to nothing below 1920.
|
||
|
||
Fix direction: split the offering by target state. The dropdown offers
|
||
(static modern ladder entries that fit the desktop) ∪ (curated hardware
|
||
modes), ascending; the windowed apply (a plain Size write) accepts any
|
||
offered entry ≤ desktop; the fullscreen apply keeps the hardware-catalog
|
||
validation + `GlfwDisplayModeSwitcher`'s monitor-mode-list hard guard
|
||
UNCHANGED (a fullscreen pick of a non-hardware mode refuses safely,
|
||
log-and-stay per #388 — the #392 apply-result seam is that family's
|
||
existing follow-up). #391's "an offered mode is by construction a
|
||
supported one" invariant narrows to the fullscreen half and must be
|
||
re-documented; register IA-22 (user-directed curation) gets the same
|
||
amendment. Immediate workaround (confirmed live): drag-resize the
|
||
windowed client — resize events rebuild the swapchain (#387) and the
|
||
retail UI rescales from its 800x600 authored canvas.
|
||
|
||
## #406 — CLOSED: Launcher records a crashed client as `exited{code:0,reason:"graceful"}`
|
||
|
||
**Status:** DONE (this commit, 2026-08-16)
|
||
**Severity:** MEDIUM (diagnosis-misleading, not data-loss)
|
||
|
||
Found while diagnosing #405: the client process died with exit code
|
||
`0xE0434352` (.NET unhandled exception, stack on stderr), but the
|
||
launcher's session status stream recorded `{"e":"exited","code":0,
|
||
"reason":"graceful"}` — the exact opposite of what happened.
|
||
|
||
Root cause was NOT in the launcher's process supervision (its own
|
||
OS-level exit-code read was always correct) — it was in the CLIENT's own
|
||
self-report. `GameWindow.Dispose()` (`src/AcDream.App/Rendering/GameWindow.cs`)
|
||
runs unconditionally via `Program.cs`'s `using var window = new
|
||
GameWindow(...)` even when invoked mid-unwind of an exception that
|
||
escaped `Run()`'s Silk.NET frame loop — the resource-shutdown transaction
|
||
itself can converge cleanly (nothing it tears down touches the crash),
|
||
so `CompleteShutdown` had no way to tell "normal `Run()` return" from "an
|
||
exception is propagating through me right now" and always wrote the
|
||
hardcoded `exited{code:0,reason:"graceful"}`. Fixed by latching
|
||
`_runFailure` in `Run()`'s existing `catch (Exception failure)` block
|
||
(right before the `throw;` that already existed for the
|
||
`_constructionCleanup.RetainFrom(failure)` ledger) and consulting it from
|
||
a new `ReportExited` method that is now the ONE call site for the
|
||
terminal status write: `exited{code:1,reason:"crashed"}` when a crash was
|
||
observed, `exited{code:0,reason:"graceful"}` on a real graceful
|
||
Dispose(), `exited{code:1,reason:"shutdown-incomplete"}` unchanged for a
|
||
non-crash teardown failure. `"crashed"` is a new value for the already-
|
||
free-text `reason` field (§LA1's `exited{code,reason}` vocabulary pins
|
||
the EVENT name, not an enum of `reason` strings — `StatusEventParser`
|
||
already round-trips any string there) so no wire-contract amendment was
|
||
needed. Pinned as a source-shape test (`GameWindowCrashStatusTests`) since
|
||
`GameWindow` cannot be constructed without a live GPU/window. **Precedence
|
||
(F15, gate round 1 closeout, 2026-08-16):** `ReportExited`'s `_runFailure`
|
||
check runs FIRST and returns immediately, so a crash ALWAYS wins over an
|
||
incomplete shutdown for the same session: if `Run()` observed an
|
||
exception AND the resource-shutdown transaction subsequently failed to
|
||
converge (`report.Status != Complete`), the reported reason is still
|
||
`"crashed"`, never `"shutdown-incomplete"`. The teardown failure itself is
|
||
not lost -- `Console.Error.WriteLine` still logs the blocked stage and
|
||
every cleanup failure right before `ReportExited` runs -- but the ONE
|
||
terminal status event a launcher/monitoring consumer reads only ever
|
||
carries one reason per session, and a crash is judged the more actionable
|
||
of the two.
|
||
|
||
Sibling gap fixed in the same commit: the launcher previously discarded
|
||
the child's stdout/stderr entirely, which is why diagnosing this exact
|
||
crash required a manual console re-run. Added
|
||
`BoundedProcessOutputCapture` (`src/AcDream.Launcher.Core/Launching/`) —
|
||
a 2 MiB-capped, additive-only sink mirroring `SessionStatusWriter`'s
|
||
open-append-flush-close-per-write posture (a long-lived write handle is
|
||
NOT actually concurrently readable on Windows even with
|
||
`FileShare.Read` — confirmed by isolated repro) — wired into BOTH
|
||
`SystemChildProcess` (`ProcessStartInfo.RedirectStandardError` +
|
||
`ErrorDataReceived`; used on Linux for every child and on Windows for
|
||
graphical/non-console children, i.e. exactly this bug's own App/GUI
|
||
scenario) and `WindowsSystemChildProcess` (a real native pipe via a new
|
||
`CreateChildOutputPipe`, mirroring the existing stdin pipe in the
|
||
opposite direction, drained on a background pump thread; used on Windows
|
||
for console-capable children, i.e. Headless). The capture path is opt-in
|
||
via a new `LauncherProcessSpec.StderrLogPath` (null = behave exactly as
|
||
before) threaded through `SessionConfigComposer` → `client.err.log`
|
||
beside `status.jsonl` in the per-session directory →
|
||
`LauncherExecutableSet.CreatePlaySpec`/`CreateProbeSpec` →
|
||
`LauncherOrchestrator`. Real end-to-end tests
|
||
(`LauncherProcessSupervisorTests`) spawn an actual child via both code
|
||
paths and assert the captured file.
|
||
|
||
## #405 — CLOSED: chargen/summary preview leases missing Transfer killed every retail-UI window load
|
||
|
||
**Status:** DONE (`fix #405` commit, 2026-08-16 — Campaign CC gate round 1)
|
||
**Severity:** CRITICAL (client unusable via launcher/retail-UI path)
|
||
|
||
`LivePresentationCompositionPhase.CompletePresentation`'s lease-transfer
|
||
ladder never gained `chargenPreviewLease?.Transfer()` (CC6b-MOUNT) nor
|
||
`summaryPreviewLease?.Transfer()` (CC5, faithfully duplicating the same
|
||
miss). Both resources rode into the published result beside the
|
||
paperdoll/appraisal siblings, but `CompositionAcquisitionScope.Complete()`
|
||
saw two acquired-unpublished leases and threw
|
||
`InvalidOperationException: Composition phase completed with unpublished
|
||
resources: chargen preview viewport, summary preview viewport` on EVERY
|
||
real window load with retail UI mounted — the client died ~1.7 s after
|
||
start, before connecting. Five review rounds read past it because no
|
||
automated suite executes the transfer ladder (it needs a live GPU
|
||
window; `LivePresentationCompositionTests` covers scope mechanics only)
|
||
and no graphical launch happened between CC6b-MOUNT's landing and the
|
||
user's gate. Follow-up test-coverage gap: a composition-level fake-GPU
|
||
harness that drives `ComposeCore` through `scope.Complete()` would have
|
||
caught this and remains unbuilt — weigh it against the E6 deterministic
|
||
suite patterns before CC's campaign close. Verified fixed by a live
|
||
launch: `started → connected → characterList`, graceful close.
|
||
|
||
## #404 — ChargenSkillScoreResolver duplicates ChargenTableReader's own SkillTable read
|
||
|
||
**Status:** OPEN (post-CC cleanup follow-up)
|
||
**Severity:** LOW
|
||
**Filed:** 2026-08-16 (Campaign CC CC5 re-review residual round, nit 3)
|
||
**Component:** `src/AcDream.App/Composition/InteractionRetainedUiComposition.cs`
|
||
(`ChargenSkillScoreResolver` construction, `:670-672`),
|
||
`src/AcDream.Content/CharGen/ChargenTableReader.cs` (`:41`, `:61`)
|
||
|
||
`ChargenSkillScoreResolver`'s constructor takes its OWN independent read of
|
||
the global SkillTable (portal.dat `0x0E000004`) at composition time
|
||
(`InteractionRetainedUiComposition.cs:670-672`,
|
||
`d.Dats.Get<SkillTable>(0x0E000004u)`), beside `ChargenTableReader`'s
|
||
own already-established read of the SAME table
|
||
(`ChargenTableReader.cs:41` names the id, `:61` reads it) — which discards
|
||
the DAT's `SkillFormula` field entirely (`ChargenTableReader.Project` only
|
||
projects `TrainedCost`/`SpecializedCost` per skill into
|
||
`ChargenSkillCost`, never `SkillBase.Formula`). Two independent reads of
|
||
the same DAT file are harmless today (both are read-only, one-shot, under
|
||
the DAT lock) but are a duplicate-source-of-truth smell: if the two readers
|
||
ever diverge (a caching change, a future write path), nothing enforces they
|
||
stay in sync.
|
||
|
||
**Fix direction:** project `SkillFormula` (and `MinLevel`, needed by
|
||
`RetailSkillFormula.CalculateChargenScore`'s gate) into `ChargenOptions`
|
||
alongside the existing `GlobalSkillCostsBySkillId` — `ChargenTableReader`
|
||
already walks every `SkillBase` in the table
|
||
(`ChargenTableReader.Project`'s `globalSkillCosts` loop) so adding the
|
||
formula/MinLevel costs no new DAT read, just a wider projection type. Then
|
||
`ChargenSkillScoreResolver` becomes pure arithmetic over `ChargenOptions`
|
||
it already receives from the caller, with no `SkillTable`/DAT dependency of
|
||
its own, and its constructor-time DAT read goes away entirely.
|
||
|
||
**Acceptance:** one SkillTable read at composition time (through
|
||
`ChargenTableReader`), not two; `ChargenSkillScoreResolver` (or its
|
||
replacement) takes `ChargenOptions`/a projected formula table instead of a
|
||
raw `SkillTable`; existing `RetailSkillFormulaTests`/`ChargenTableReaderInstalledDatTests`
|
||
coverage still passes.
|
||
|
||
## #403 — Consolidate RetailAnimationCyclePlayback into LiveEntityAnimationPresenter's legacy branch
|
||
|
||
**Status:** OPEN (post-CC consolidation follow-up)
|
||
**Severity:** LOW
|
||
**Filed:** 2026-08-15 (Campaign CC slice CC6b-PRE review fix round, F5)
|
||
**Component:** `src/AcDream.Core/Physics/RetailAnimationCyclePlayback.cs`,
|
||
`src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs`
|
||
|
||
`RetailAnimationCyclePlayback` (advance-with-wrap + lerp/slerp) is a Core,
|
||
pure, unit-tested extraction of the SAME algorithm
|
||
`LiveEntityAnimationPresenter.Present`'s legacy (no-`AnimationSequencer`)
|
||
branch already carries inline for NPC idle cycles
|
||
(`CurrFrame += legacyAdvanceSeconds * Framerate` with the same modulo wrap,
|
||
plus its own private `TryResolvePartFrame` doing the same frame-bracket
|
||
lerp/slerp). The chargen preview (`ChargenPreviewAnimator`) consumes the
|
||
new shared type; the two implementations were deliberately left
|
||
un-consolidated at CC6b-PRE — `LiveEntityAnimationPresenter` is live,
|
||
heavily-tested, in-flight production entity-rendering code with zero
|
||
relation to the preview-only feature that motivated the extraction, so
|
||
touching it was judged out of that slice's blast radius.
|
||
|
||
That decision has no tracked owner. Someone should, in a dedicated pass
|
||
after Campaign CC closes: redirect `LiveEntityAnimationPresenter`'s inline
|
||
copy through `RetailAnimationCyclePlayback` (a behavior-preserving
|
||
mechanical swap — same formulas, same order of operations) and delete the
|
||
duplicate. Verify byte-identical output first (a differential test against
|
||
the pre-change behavior over a representative NPC idle set) before landing.
|
||
|
||
**Acceptance:** one call site for the advance-with-wrap + lerp/slerp
|
||
algorithm; `LiveEntityAnimationPresenter`'s legacy branch calls
|
||
`RetailAnimationCyclePlayback` instead of reimplementing it; no behavior
|
||
change to any currently-animated NPC.
|
||
|
||
## #402 — Flaky test: Streaming.LandblockBuildFactoryTests.Build_UsesTheSuppliedSharedReaderGate
|
||
|
||
**Status:** OPEN (flake, not a regression)
|
||
**Severity:** LOW (test-infra noise; no known production defect)
|
||
**Filed:** 2026-08-15 (Campaign CC slice CC4 review fix round, R2 — noticed
|
||
while running the full App.Tests suite repeatedly for the F1/R1
|
||
FixedCanvasSize arbiter gate)
|
||
**Component:** `tests/AcDream.App.Tests/Streaming/LandblockBuildFactoryTests.cs`
|
||
|
||
`Build_UsesTheSuppliedSharedReaderGate` fails intermittently in full-suite
|
||
runs (observed roughly 2 of 5 runs) but passes reliably when run in
|
||
isolation (`--filter FullyQualifiedName~Build_UsesTheSuppliedSharedReaderGate`).
|
||
The test was last touched at `82f8d4f8` (2026-07-25, Slice I7's parsed-
|
||
collision-graph removal) — unrelated to any Campaign CC/CC4 chargen work,
|
||
which never touches streaming/collision code. Symptom pattern (passes
|
||
isolated, flakes under full-suite parallelism) points at shared mutable
|
||
state or a timing assumption racing another test class rather than the
|
||
factory logic itself; not yet root-caused.
|
||
|
||
**Fix direction:** re-run the full suite a few times to reproduce and
|
||
capture the failure's actual assertion/exception (not just "sometimes
|
||
red"), then check `LandblockBuildFactoryTests`'s fixture for anything
|
||
shared across test classes (static state, a shared reader/gate instance,
|
||
file-system paths) that a parallel xUnit collection could race.
|
||
|
||
**Acceptance:** the flake is reproduced with a captured failure detail,
|
||
root-caused, and fixed (or the test is isolated into its own collection if
|
||
the root cause is unavoidable cross-test parallelism); full-suite runs stop
|
||
intermittently failing on this test.
|
||
|
||
## #401 — RetailUi should default ON (opt-out), not per-path forced
|
||
|
||
**Status:** OPEN (product-default decision)
|
||
**Severity:** MEDIUM (recurrence risk)
|
||
**Filed:** 2026-08-15 (Campaign LA gate-round-2 batch review, F2)
|
||
**Component:** `src/AcDream.App/RuntimeOptions.cs`
|
||
|
||
`RetailUi` still parses opt-IN from `ACDREAM_RETAIL_UI` (default false), and
|
||
`6e1c0967` forces it true on exactly one call site (the session-config
|
||
launch path). Any other product entry point — including CLAUDE.md's
|
||
documented plain `dotnet run` dev launch — still boots world rendering with
|
||
zero interface, the same trap one caller later. The ImGui frontend is gone
|
||
(Campaign V), so `RetailUi == false` means "no UI at all"; the review
|
||
confirmed nothing legitimately needs that in a product or test path.
|
||
|
||
**Fix direction:** invert the flag — retail UI on by default,
|
||
`ACDREAM_RETAIL_UI=0` as the dev opt-OUT — and delete the per-path forcing
|
||
in `RuntimeOptions.FromSessionConfig`. Sweep launch scripts/docs
|
||
(CLAUDE.md's launch command, test-script env listings) for stale
|
||
`ACDREAM_RETAIL_UI=1` mentions in the same change. Also pin the currently
|
||
untested "explicit `ACDREAM_RETAIL_UI=0` alongside a session config is
|
||
ignored" behavior — or make the inversion moot it.
|
||
|
||
**Acceptance:** every launch path shows the retail UI unless explicitly
|
||
opted out; the forcing is gone; docs updated.
|
||
|
||
## #400 — Character select: Credits button is ghosted; retail opens gmCreditsUI
|
||
|
||
**Status:** OPEN (post-LA polish)
|
||
**Severity:** LOW
|
||
**Filed:** 2026-08-15 (Campaign LA gate round 2, char-select findings batch)
|
||
**Component:** `src/AcDream.App/UI/Layout/CharacterManagementUiController.cs`
|
||
|
||
Retail's character-management screen routes the Credits button
|
||
(`0x100003A3`, listbox-base offset 6 in
|
||
`gmCharacterManagementUI::ListenToElementMessage @0x004ed5a0`) to
|
||
`QueueUIMode(0x10000005)` → `gmCreditsUI` (`Register @0x0047a69e`) — a
|
||
scrolling credits screen. acdream ghosts the button (visible, disabled,
|
||
no invented action — the same treatment as Create Character). Porting
|
||
`gmCreditsUI` is its own small screen (authored layout, scroll behavior,
|
||
return-to-select) and is deliberately out of Campaign LA's scope.
|
||
|
||
**Acceptance:** Credits opens the ported retail credits screen and
|
||
returns to character select; button re-enabled.
|
||
|
||
## #399 — Launcher: no test ever constructs MainWindow, so code-behind defects reach the user gate
|
||
|
||
**Status:** DONE (this commit, Campaign LA UI-test slice) — closed via
|
||
`tests/AcDream.Launcher.Tests/MainWindowViewTests.cs`.
|
||
**Severity:** HIGH (process class: this gap let #398 — a crash on every
|
||
modal open/close — pass 14,012 green tests and reach the user gate)
|
||
**Filed:** 2026-08-15 (found while launching the launcher for the LA11 gate)
|
||
**Component:** tests/AcDream.Launcher.Tests
|
||
|
||
`tests/AcDream.Launcher.Tests` is ViewModel-only — its csproj has no
|
||
Avalonia headless package, and no test instantiates `MainWindow` or any
|
||
view. `LauncherWindowViewModelTests` proved the modal state machine while
|
||
the code-behind that consumes it was never executed once, which is exactly
|
||
how #398's null `x:Name` fields survived every automated gate.
|
||
|
||
**Fix landed.** Added `Avalonia.Headless.XUnit` 12.1.1 to the launcher test
|
||
project (its net10.0 dependency group targets **xunit v3**, so the project
|
||
migrated `xunit` 2.9.3 → `xunit.v3` 3.2.2 — a drop-in swap; all 54
|
||
pre-existing `[Fact]`/`[Theory]`/`Assert.*` tests compiled and passed
|
||
unchanged, only two call sites needed `TestContext.Current.CancellationToken`
|
||
per the new `xUnit1051` analyzer). `TestAppBuilder`
|
||
(`tests/AcDream.Launcher.Tests/TestAppBuilder.cs`) wires
|
||
`[assembly: AvaloniaTestApplication]` to a headless `AppBuilder.Configure<App>()`
|
||
so FluentTheme (declared in the real `App.axaml`) is live for every test.
|
||
`MainWindowViewTests.cs` adds 12 `[AvaloniaFact]`/`[AvaloniaTheory]` tests:
|
||
an explicit non-null check of every `x:Name` field the code-behind
|
||
dereferences, a reflection sweep over every `x:Name` in the markup (so a
|
||
future named control without a matching non-null field fails loudly), and
|
||
one open+close round trip per `ProfileEditorKind` (all seven, including
|
||
`Remove`) plus the first-run wizard and the update prompt — each pumping
|
||
`Dispatcher.UIThread.RunJobs()` so the `Dispatcher.UIThread.Post` callback
|
||
in `OnViewModelPropertyChanged`/`FocusActiveModal` actually executes, not
|
||
just gets queued. A dedicated test proves the `_focusBeforeModal != null`
|
||
restore branch (not just the `ProfilesTree.Focus()` fallback) also runs
|
||
clean, anchored on a real focusable button since `ProfilesTree` (a
|
||
`TreeView`) has `Focusable="False"` under FluentTheme — its own tab stops
|
||
are `TreeViewItem` rows, so the close-path assertions check "no exception
|
||
escaped" rather than "focus landed on ProfilesTree" (that would be a false
|
||
expectation, not the bug this issue is about).
|
||
|
||
**Falsification (required evidence).** Reverting `MainWindow`'s constructor
|
||
to `AvaloniaXamlLoader.Load(this)` and rerunning: **12 failed / 0 passed**
|
||
— 10 tests throw `System.NullReferenceException` at
|
||
`AcDream.Launcher.MainWindow.FocusActiveModal` (propagating cleanly out of
|
||
`Dispatcher.UIThread.RunJobs()`, confirming dispatcher exceptions are not
|
||
swallowed), the 2 reflection tests fail on an explicit
|
||
"`x:Name 'ProfilesTree' was null after construction`" message. Restoring
|
||
`InitializeComponent()`: **12 passed / 0 failed**. Full launcher suite:
|
||
**66 passed / 0 failed** (Windows and native Ubuntu/WSL, both post-fix).
|
||
`tests/AcDream.Launcher.Core.Tests`: 317/317 unaffected.
|
||
|
||
**CI.** `.github/workflows/headless-portability.yml`'s `portable-launcher`
|
||
job already runs `dotnet test tests/AcDream.Launcher.Tests/...` on both
|
||
`windows-latest` and `ubuntu-latest` with no display setup — no workflow
|
||
change was needed, since `Avalonia.Headless` requires no real windowing
|
||
system (confirmed directly: the new tests pass unmodified under WSL/native
|
||
Linux with no `DISPLAY` or Xvfb).
|
||
|
||
**Acceptance (met):** a headless view test fails against the pre-#398 code
|
||
(`AvaloniaXamlLoader.Load`) and passes after, and runs in the portable
|
||
Windows+Ubuntu CI lane alongside the existing launcher tests.
|
||
|
||
## #398 — Launcher: fatal startup/dispatcher exceptions are reported without a stack
|
||
|
||
**Status:** DONE (`e1e94697`)
|
||
**Severity:** MODERATE (diagnosability)
|
||
**Filed:** 2026-08-15 · **Closed:** 2026-08-15
|
||
**Component:** `src/AcDream.Launcher/Program.cs`
|
||
|
||
`Program.Main`'s top-level guard printed only `ex.Message` before returning
|
||
74 — the `MainWindow` NullReferenceException fixed at `d54b8a78` surfaced
|
||
with no file, line, or frame, and diagnosis required temporarily editing
|
||
the guard and rebuilding.
|
||
|
||
**Fix landed (`e1e94697`).** `TryWriteCrashReport` writes the full
|
||
exception chain plus non-identifying host facts (UTC, OS, RID, assembly
|
||
version) to `<DataDirectory>/crash-reports/launcher-crash-<utc>.log`;
|
||
stderr stays terse and names the path; the reporter itself never throws.
|
||
When option parsing is the failure, the caller's `--data-dir` is still
|
||
honored via a positional, validation-free read — the first implementation
|
||
fell back to the machine's real data root and broke LA11's process-local
|
||
roots during an isolated run (observed live, then fixed in the same
|
||
commit). Verified: forced startup failure writes the report inside the
|
||
isolated root with the full stack; the real root stays empty.
|
||
|
||
**Redaction, stated exactly (deliberate narrowing of the filed
|
||
acceptance):** the report never serializes the command line, environment,
|
||
or process state, but exception TEXT may quote an option name or path.
|
||
The gate-round-1 review (F1) corrected the original by-construction claim:
|
||
the launcher DOES hold credentials (`ProfileEditorDialogViewModel`,
|
||
`AccountProfile.Password`, `StartRequest.Password`); the true invariant is
|
||
narrower — no code path interpolates a credential VALUE into an exception
|
||
message. That invariant is now PINNED by
|
||
`MainWindowViewTests.CrashReportNeverContainsAStoredPassword`: a real
|
||
STJ parse failure over a profiles document containing a known password,
|
||
corrupted after the credential so the parser consumed the value, must
|
||
produce a crash file with the stack and without the password. If that test
|
||
ever fails, this sink needs the status-stream's credential scanning.
|
||
|
||
## #397 — Windows: LauncherProcessSupervisor.Stop has no reliable graceful-stop signal for a no-window console host
|
||
|
||
**Status:** IN-PROGRESS — the isolated process-group implementation and real
|
||
Windows fixtures are complete; the LA11 connected acceptance row remains
|
||
required before closure.
|
||
**Severity:** MODERATE (a hard-killed `AcDream.Headless` leaves the ACE
|
||
account session stuck for several minutes — a documented project landmine;
|
||
see CLAUDE.md "Logout-before-reconnect")
|
||
**Filed:** 2026-08-14 (Campaign LA plan §LA3 review-fix round, finding F3)
|
||
**Component:** Launcher.Core / process supervision
|
||
|
||
**Implementation checkpoint.** `LauncherProcessSupervisor.Stop` attempts
|
||
`ILauncherChildProcess.TryRequestGracefulStop` before `CloseMainWindow` and
|
||
the timeout/kill fallback. Linux retains its K4-proven targeted `SIGINT`.
|
||
On Windows, console-capable launcher specs now use a narrow no-shell
|
||
`CreateProcessW` seam with `CREATE_NEW_PROCESS_GROUP`, a suspended start, and
|
||
an explicit inherited-handle list that preserves only redirected stdin plus
|
||
stdout/stderr. A consoleless Avalonia parent briefly allocates and hides a
|
||
console for the creation transaction, detaches after the new group inherits
|
||
it, and later attaches only long enough to send
|
||
`GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, childProcessGroupId)`. Each such
|
||
child is therefore both the root of its own process group and, for the normal
|
||
Explorer-launched case, attached to its own console. Graphical children opt
|
||
out and retain the ordinary `Process`/`WM_CLOSE` path.
|
||
|
||
Two real Windows fixture gates cover both a console parent and a consoleless
|
||
WinExe parent. They prove exact complex argv, redirected stdin, receipt of a
|
||
targeted CTRL_BREAK marker, exit code 0 before timeout, no supervisor `Kill`,
|
||
and a sibling process group that remains running until it receives its own
|
||
targeted break. Safe-handle cleanup, early-failure termination, and the
|
||
Linux SIGINT gate remain covered by the Launcher.Core suite.
|
||
|
||
**Acceptance for closing this issue:** automated process-group and targeted-
|
||
signal coverage is complete. Keep the issue IN-PROGRESS until the LA11 live
|
||
connected row proves `AcDream.Headless` exits gracefully and ACE clears the
|
||
session immediately (not after the ~3-minute stale-session window) when
|
||
stopped through `LauncherProcessSupervisor.Stop` on Windows, matching the
|
||
Linux SIGINT behavior.
|
||
|
||
## #396 — Configure Keyboard: no capture-instruction dialog on a mapping-button click
|
||
|
||
**Status:** ROOT-CAUSED + FIXED — pending the user's visual re-gate of the
|
||
dialog itself. The follow-up crash (`2a81e813`: the wait root's retail class
|
||
type 0x19 was unmapped in DatWidgetFactory, so the first mapping-button
|
||
click threw out of OnClick and killed the client) was live-verified fixed
|
||
2026-08-14 — the user exercised the mapping-button path, no crash.
|
||
Filed 2026-08-14 at the OP8 re-gate (user report: "nothing happens when I
|
||
press an option button", with the retail screenshot showing the instruction
|
||
dialog). The OP8 port armed `InputDispatcher.BeginCapture` with no visible
|
||
feedback; retail's `UIOption_ActionKeyMap::InitiateBinding @0x004899D0`
|
||
opens a type-2 WAIT dialog (`OpenMapWarnDialog @0x00488A00`: queue key
|
||
`0x10000001`, text `ID_ActionKeyMap_MapInstructions` from table `0x23000004`
|
||
with the row's action label as its ACTION variable — "The next key you press
|
||
or mouse button that you click will be mapped to the '…' action. … Press the
|
||
ESC key to cancel.") BEFORE registering the key handler, and refuses to arm
|
||
capture if the dialog cannot open. Fix: `RetailWaitDialogView` (wait root
|
||
`0x31` — same authored popup/message pair `0x3D`/`0x3E` as the confirmation
|
||
root, live-DAT probed) + `RetailDialogFactory.MakeWait` +
|
||
`KeyboardConfigController.Bindings.Open/CloseCaptureInstructions`, closed on
|
||
key hit or ESC through the capture callback. Retail's own text supplies the
|
||
ESC line; ESC handling stays in the dispatcher's modal capture (a dialog-side
|
||
cancel would race it).
|
||
|
||
## #395 — Configure Keyboard: key captions show raw enum spellings, not retail's localized key names
|
||
|
||
**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate.
|
||
Filed 2026-08-14 at the OP8 re-gate (user report: acdream shows
|
||
"Shift+ShiftLeft" where retail shows "SKIFT" on their Swedish layout).
|
||
`DescribeChord` printed Silk enum spellings; retail's
|
||
`CInputManager_WIN32::GetNameFromKey @0x00687F40` resolves each control
|
||
through `GetNameFromKey_Internal @0x00687800`: DAT string-table override by
|
||
ELF hash of the DIK name (key table enum 4 → `0x2300000A`, meta enum 5 →
|
||
`0x2300000B` — GetDIDByEnum category 4, live-probed; the shipped tables
|
||
author exactly `DIK_LCONTROL` → "Left Ctrl" and `DIK_LMENU` → "Left Alt"),
|
||
else the OS keyboard layout's own name, with modifier prefixes joined by the
|
||
authored `ID_KeyDescDelimiter` ("+", `0x23000007`) and a bare modifier-key
|
||
binding showing only the key name (retail's walk-mode row is meta-mode 0).
|
||
Fix: `RetailKeyNames` (the pipeline port) + `PlatformKeyNameProvider`
|
||
(Win32 `GetKeyNameTextW` — register row AD-96 for the
|
||
DirectInput-vs-GetKeyNameText adaptation and the non-Windows fallback).
|
||
|
||
## #394 — Configure Keyboard: row captions render in the debug bitmap font, not the authored 18px serif
|
||
|
||
**Status:** ROOT-CAUSED + FIXED (this commit) — pending the user's re-gate.
|
||
Filed 2026-08-14 at the OP8 re-gate (user side-by-side screenshot: acdream's
|
||
"Move Forward" label vs retail's serif). The controller-synthesized row
|
||
caption (`BuildActionRow`'s composed `UiText`) never set `DatFont`, so it
|
||
fell back to the debug bitmap font; the authored action-row template
|
||
(`0x21000009` element `0x1000002F`, retail type `UIOption_ActionKeyMap`)
|
||
carries `FontDid 0x4000000A` — the 18px serif retail draws the label with
|
||
(live-DAT probed: header `0x1000002E` = `0x4000000F` 30px gothic, key
|
||
buttons `0x10000030-32` = `0x40000001` 18px serif — the buttons already
|
||
resolved their authored font through the production template build; only the
|
||
synthesized caption was wrong). Fix: `Bind` takes `resolveTemplateFont`,
|
||
resolved once per template pair from the row template's own authored FontDid
|
||
and applied to the caption `UiText`. Probe evidence:
|
||
`KeyboardConfigLiveMountProbeTests.ProbeKeyboardFontsAndKeyNameStrings`
|
||
(env-gated, kept).
|
||
|
||
## #393 — Texture detail options: retail's "High Resolution Textures" toggle + Landscape/Environment TextureDetail mip-skip
|
||
|
||
**Status:** OPEN — filed 2026-08-14 from the highres-texture verification
|
||
(post-M4 nice-to-have; perf/nostalgia option, no gameplay impact).
|
||
acdream today loads `client_highres.dat` unconditionally and always picks
|
||
`Textures[0]` — retail's MAXIMUM texture detail, verified end-to-end (live
|
||
path AND the baked `acdream.pak`; the 2026-08-14 investigation's evidence
|
||
chain). Retail additionally offers two knobs acdream has no equivalent for:
|
||
|
||
1. **"High Resolution Textures" toggle** (change-notice string
|
||
`ID_Option_HighResChange`; `CLCache::LoadHighResDat @0x004FA250` only
|
||
runs when armed). Off = `client_highres.dat` never loads; lookups use
|
||
the portal-resident versions. acdream shape: a Config-tab option that
|
||
skips the highres fallback in `DatCollectionAdapter.TryGet` (portal
|
||
class: `_portal || _highRes`, `DatCollectionAdapter.cs:90`) and
|
||
`MeshExtractor`'s explicit highres fallbacks (`MeshExtractor.cs:399,767`
|
||
— which currently THROW on a miss; the off-path must degrade the way
|
||
retail does, NOT throw). **Research prerequisite:** what retail falls
|
||
back to for surfaces whose ONLY copy lives in highres.
|
||
2. **Texture detail levels** (`Render_LandscapeTextureDetail` /
|
||
`Render_EnvironmentTextureDetail` UIPreferences; the pick
|
||
`@0x0044C3C8`): the enum is an INDEX into the texture's source-level
|
||
(mip) chain — detail 0 keeps every level, detail N drops the N largest
|
||
(`i_1 = m_num - esi_1` keeps levels from index N;
|
||
`RenderTexture::ShouldDropHighDetail` can force it under memory
|
||
pressure). acdream shape: skip/downsample the top N levels at Vulkan
|
||
texture upload — works for live uploads and pak payloads alike (the
|
||
downsample is at upload, not bake; NO re-bake needed).
|
||
|
||
Also carried from the same investigation: a one-off enumeration proving
|
||
portal/highres id sets are disjoint (the "portal wins on overlap"
|
||
`TryResolvePreferred` corner — no overlap evidence exists, a ~20-line
|
||
tool run closes it).
|
||
|
||
## #392 — A refused/failed fullscreen enter leaves `fullscreen: true` persisted against a windowed client
|
||
|
||
**Status:** OPEN — filed 2026-08-13 from the #376/#388 blast review (M4).
|
||
The save path persists the Full Screen flag BEFORE the apply runs; when
|
||
the state-aware apply then refuses (mode not offered / catalog absent) or
|
||
the native switch fails, the client stays windowed while settings.json and
|
||
the Config checkbox keep saying fullscreen — a silent flag/reality
|
||
divergence that survives restarts (it re-creates #377's stuck-flag
|
||
recovery shape, minus the crash). The proper fix is an apply-result seam:
|
||
`IRuntimeDisplayWindowTarget.Apply` reports what actually took effect and
|
||
`RuntimeSettingsController` reconciles the stored `DisplaySettings` —
|
||
NOT a store write-back from inside the target (layering). Small,
|
||
self-contained; part of the display block's tail.
|
||
|
||
## #391 — Resolution list: offer only modern modes from the monitor's real mode list (user-directed curation)
|
||
|
||
**Status:** DONE 2026-08-13 (this commit) — display block slice 2, pending
|
||
the user's gate. `DisplayModeCatalog` (App/Rendering) enumerates the
|
||
window's monitor via Silk (`IMonitor.GetAllVideoModes`), curates through
|
||
the pure `Curate` rule (modern families 16:9/16:10/21:9/32:9 ±2.5%,
|
||
≥1280 wide, fits the desktop, desktop mode always included, refresh-rate
|
||
duplicates collapsed, ascending), and is installed once at `GameWindow`
|
||
load. The Config Resolution row takes the curated list + the desktop-mode
|
||
Defaults value through two new optional `Bind` parameters; fixture
|
||
callers keep the static ladder (800x600 now removed from it — the ladder
|
||
itself passes the curation rule, pinned by test). Register row IA-22
|
||
carries the deliberate deviation (retail listed every adapter mode and
|
||
authored 800x600 as the default). The same catalog is the designated
|
||
mode-validation source for #376/#388. Original filing below.
|
||
|
||
**Original filing:** OPEN — filed 2026-08-13, user-directed ("we should only
|
||
support modern resolutions. Not any old format"). Today's Resolution
|
||
dropdown offers a list that includes legacy 4:3 modes (800x600 was
|
||
pickable) and modes the desktop cannot host (3840x2160 on a 2560x1440
|
||
desktop, which silently clamps). Replace it with: enumerate the actual
|
||
monitor mode list (GLFW glfwGetVideoModes — the same enumeration #388's
|
||
fullscreen mode-validation needs, one shared source), filter to modern
|
||
widescreen families (16:9/16:10/21:9, sensible minimum size), and for
|
||
windowed picks offer only sizes that fit the desktop work area. Retail
|
||
showed every adapter mode including 4:3 — curating the list is a
|
||
deliberate deviation; add its register row (intentional architecture,
|
||
user-directed) in the implementing commit. Part of the display block
|
||
(#376/#377/#388/#389/#390).
|
||
|
||
## #390 — UI windows stranded off-screen when the resolution shrinks (no retail reposition/clamp on display change)
|
||
|
||
**Status:** DONE 2026-08-13 (this commit) — display block slice 3, pending
|
||
the user's gate. Retail's mechanism was pulled from the decomp FIRST
|
||
(`docs/research/2026-08-13-retail-ui-display-change.md`): a display change
|
||
runs the UI cascade (`UIElementManager::RefreshEvent @0x0045C530` →
|
||
`UIElement::UpdateForParentSizeChange @0x00462640`), which unconditionally
|
||
re-applies every floating window's own clamping `MoveTo`
|
||
(`x = max(0, min(x, parentW − selfW))`, top-left priority), then reloads
|
||
the per-resolution auto layout (global message 0xE) — no proportional
|
||
moves, no resets, saves only via `@saveui`. Port:
|
||
`RetailWindowLayoutPersistence.ClampAllToScreen()` (the cascade clamp, no
|
||
I/O, every attached window — including floating chats, which retail
|
||
leaves unclamped: register row AD-91) + `RetailUiRuntime.Draw`'s two-step
|
||
screen-size edge detector (change frame → clamp; first stable frame →
|
||
one `RestoreAll(saveBack:false)` per-resolution reload — no store writes
|
||
from live changes). The login restore path already carried retail's exact
|
||
clamp math (`Apply`); the live trigger was the missing half. Original
|
||
filing below.
|
||
|
||
**Original filing:** OPEN — filed 2026-08-13 (user gate report: "If I go from a high
|
||
resolution to a low, the GUI will be outside of the screen and I have to
|
||
resize the window to get it"). Floating retail-UI windows keep absolute
|
||
pixel positions across resolution changes; a panel parked at x=2000 on a
|
||
2560-wide window is unreachable after a pick down to 1280. Retail keeps
|
||
windows reachable across display changes — the exact retail mechanism
|
||
(clamp-into-bounds vs proportional reposition vs per-resolution layout
|
||
sets) must be pulled from the named decomp (UIElementManager /
|
||
UIRegionManager display-change handling) BEFORE implementing; do not
|
||
invent a clamp rule. Part of the display work block with #376/#377/#388/
|
||
#389.
|
||
|
||
## #389 — World-camera FOV is an invented aspect-independent constant; retail is SmartboxFOV (vFOV = gameFOV / (aspect − 0.1))
|
||
|
||
**Status:** DONE 2026-08-13 (this commit) — display block slice 1, pending
|
||
the user's feel gate. `RetailFieldOfView` ports the law + the
|
||
`Render::SetFOVRad` (0, π) acceptance gate verbatim;
|
||
`CameraController` owns `GameFovRadians` (default 90°) and recomputes
|
||
every camera's aspect + applied FOV on `SetAspect`/`SetGameFov`/
|
||
`EnterChaseMode`/`RestoreState`; `ApplyFieldOfView` converts the stored
|
||
degrees exactly as retail's option setter does; the four π/3 camera
|
||
constants are deleted; `DisplaySettings.Default.FieldOfView` is retail's
|
||
registered 90. **The same seam fixed a second latent squish bug: SetAspect
|
||
never propagated to the CHASE cameras at all — a mid-session resize left
|
||
the play camera on its creation-time aspect, drawing the world stretched
|
||
onto the new viewport.** The user's stored settings.json was migrated
|
||
60→90 by hand (the stale pre-port default). Register AD-89 retired in
|
||
this commit. Original filing below.
|
||
|
||
**Original filing:** OPEN — filed 2026-08-13 (user gate report: "meant to run on an
|
||
old aspect ratio... modern screens feels weird... some resolutions feels
|
||
like it is just squished"). Decomp-verified retail law:
|
||
`Render::SetFOVRad(SmartBox::m_fGameFOV / (RenderDevice::m_ViewportAspectRatio − 0.1))`
|
||
(two sites, `0x00452b2f` and `0x00453b14`; the same expression feeds a
|
||
`tan` at `0x00451c0e`), with `m_fGameFOV` defaulting to **π/2 = 90°**
|
||
(`0x00454649`) and set in DEGREES by the Field of View option
|
||
(`× 0.0174533`, `0x00451e6a`). Net effect: retail holds the HORIZONTAL
|
||
view roughly constant (~85–90°) across aspect ratios and narrows the
|
||
vertical FOV on wide screens (16:9 → vFOV ≈ 53.6°, 4:3 → ≈ 73°). acdream
|
||
instead hardcodes `ChaseCamera.FovY = π/3 = 60°` (FlyCamera likewise)
|
||
with no aspect coupling and no retail anchor — wider aspects balloon the
|
||
horizontal view and every aspect gets a different feel than retail.
|
||
Fix: port the smartbox formula into the world cameras' aspect path
|
||
(recompute applied vFOV on every SetAspect), drive `gameFOV` from the
|
||
Config tab's Field of View value in retail's degree mapping (default 90),
|
||
and delete the π/3 constant. Register row AD-89 tracks the divergence
|
||
until the port lands.
|
||
|
||
## #388 — CRASH: unhandled GlfwException "Failed to set video mode: Graphics mode not supported" during a fullscreen-state resolution/settings apply; and fullscreen/maximized windows silently ignore resolution picks
|
||
|
||
**Status:** DONE 2026-08-13 (this commit, with #376) — display block slices
|
||
5+6, pending the user's physical-display gate and the pair's dual Opus
|
||
review. `SilkRuntimeDisplayWindowTarget.Apply` is now the state-aware
|
||
machine: a fullscreen target is a VALIDATED native mode switch
|
||
(`GlfwDisplayModeSwitcher.TryEnterFullscreen` — mode must be in the #391
|
||
catalog, refresh = the monitor's highest for that WxH); a windowed target
|
||
while fullscreen leaves via the native exit (which sets the client size
|
||
itself); a raw `Size` write NEVER happens against a fullscreen window (on
|
||
GLFW that is a video-mode request — this crash's mechanism); every failure
|
||
is a logged no-throw with the window left usable. Live-verified on this
|
||
machine: `display: fullscreen mode switch 1920x1080@300` →
|
||
`vulkan: swapchain recreated 1920x1080` → graceful exit, desktop restored.
|
||
The old Silk borderless `WindowState` path is deleted from the apply.
|
||
Original filing below.
|
||
|
||
**Original filing:** OPEN — filed 2026-08-13 from the user's live gate session
|
||
(log: scratchpad `rdp-verify.log` / task b7rd8zkd4, exit 29 after an
|
||
unhandled `Silk.NET.GLFW.GlfwException`). Two distinct facts from the
|
||
same session, both in the #376/#377 fullscreen family:
|
||
|
||
1. **The crash.** With the persisted display state carrying
|
||
`fullscreen: true`, a settings apply attempted a GLFW video-mode
|
||
change ("Failed to set video mode: Graphics mode not supported").
|
||
The first failure surfaced as `settings: display save failed:
|
||
PlatformError...` (caught), then a second fired as an UNHANDLED
|
||
exception from GLFW's error callback and killed the process
|
||
mid-session. Likely path: `SilkRuntimeDisplayWindowTarget.Apply`
|
||
writing `_window.Size`/`WindowState` while the window is in Silk
|
||
fullscreen — GLFW size writes on a fullscreen window are video-mode
|
||
requests, and an unsupported mode (816x639 was in flight) is fatal
|
||
through Silk's throwing error callback. Root-cause exactly before
|
||
fixing; the crash also re-saved `fullscreen: true`, arming the #377
|
||
startup crash on the NEXT launch (recovered by hand-editing
|
||
settings.json back to false, the documented #377 recovery).
|
||
2. **The silent no-op.** Earlier in the same log, five consecutive
|
||
resolution picks (2560x1440 / 3840x2160 / 1366x768 / 800x600 x2, all
|
||
"window was 2056x1290") produced NO framebuffer-resize event and NO
|
||
swapchain recreation — the Size writes were silently ignored by the
|
||
window state the client was in (fullscreen/maximized family). This is
|
||
the concrete mechanism behind the user's "the resolution does not
|
||
change" report in that state. In plain windowed state the same
|
||
session's own picks DID work end-to-end (`pick 800x600 → event
|
||
800x600 → swapchain recreated 800x600 ok=True`, #387's chain).
|
||
|
||
Both fold into the promoted fullscreen work block (#376 native
|
||
glfwSetWindowMonitor + mode-list validation, #377 startup crash): the
|
||
Apply path must become state-aware — windowed pick = window resize
|
||
(shipped, #387); fullscreen pick = validated native mode switch; never
|
||
a raw Size write against a fullscreen window.
|
||
|
||
## #387 — Window resize never recreates the Vulkan swapchain: resolution picks stretch the image instead of changing the pixel count
|
||
|
||
**Status:** DONE 2026-08-13 (this commit) — pending the user's re-check.
|
||
User report (verbatim shape): "It looks like it is doing now is just
|
||
stretching the window, not changing the pixel count when I change the
|
||
resolution." Confirmed real and root-caused the same session.
|
||
|
||
**ROOT CAUSE — the resize event never reached the swapchain.** Campaign V
|
||
slice V11 deleted the GL `SilkFramebufferViewportTarget` and left
|
||
`NullFramebufferViewportTarget` on the assumption that the driver's
|
||
OUT_OF_DATE/SUBOPTIMAL acquire/present results would drive
|
||
`VulkanGraphicsContext`'s frame-boundary swapchain recreation whenever the
|
||
window resized. That assumption is driver-dependent and spec-insufficient:
|
||
a conformant driver may keep presenting the stale-extent swapchain scaled
|
||
to the new window indefinitely — which is exactly what this machine's
|
||
Windows AMD driver does. Result: `OnFramebufferResize` updated only the
|
||
camera aspect; the swapchain (and every render pass sized from its extent,
|
||
UI included) stayed at the old pixel count and the presentation engine
|
||
stretched it — for Options resolution picks AND manual window-edge drags
|
||
alike. **Fix:** `SwapchainRecreateViewportTarget` (the Vulkan
|
||
implementation of the existing `IFramebufferViewportTarget` seam) arms
|
||
`VulkanGraphicsContext.RequestRecreate()` on every resize event; the next
|
||
`PrepareFrame` rebuilds the swapchain at the live `FramebufferSize`, so
|
||
event bursts collapse into one recreation and stale events cannot install
|
||
a stale extent. Regressed by
|
||
`tests/AcDream.App.Tests/Composition/SwapchainRecreateViewportTargetTests.cs`
|
||
(target contract + the controller→target end-to-end seam with the
|
||
minimised-gate case). **Re-check:** pick a smaller Resolution — the window
|
||
should shrink AND the image should re-render crisp at the new pixel count
|
||
(UI elements occupy proportionally more of the window, retail-style), not
|
||
scale down blurrily; same for a window-edge drag.
|
||
|
||
## #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); re-gate USER-PASSED 2026-08-14.
|
||
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`'s `OnConfirmationRequest` (wired this slice — it
|
||
was `null` pre-FA6, so headless bots dropped every confirmation
|
||
regardless) never fires: no `[fa6-diag] OnConfirmationRequest received`
|
||
line 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 the `FindPlayerByName`
|
||
history above, and is not evidence either way).
|
||
- No `[weenie-error]` line appears after the swear either (the two
|
||
`0x051D` lines 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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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, NOT REPRODUCIBLE on current code (2026-08-13, display
|
||
block slice 4 attempt). Three consecutive `fullscreen: true` launches on
|
||
the exact current binary (post-#387/#389/#391) all reached in-world
|
||
cleanly at the 2560x1440 desktop mode with the swapchain following
|
||
(`377-crash-repro.log` + runs 2–3; framebuffer 1280x720 → 2560x1440 →
|
||
`swapchain recreated 2560x1440 ok=True`), versus "deterministic" at
|
||
filing on 2026-08-11. Deltas since filing: #387 rewired the resize event
|
||
into swapchain recreation (changing startup-resize interleaving), the
|
||
GlfwException topology guard landed in `TryGetActiveMonitorRefreshHz`,
|
||
and the filing-day session was mid RDP/console topology handoff — the
|
||
issue's own suspected trigger. Disposition: stays OPEN awaiting
|
||
recurrence (the #387 evidence log lines now record the full chain if it
|
||
ever fires again); the structural protection — never querying/acting on
|
||
a monitor mid-mode-transition — lands with #388's state-aware apply,
|
||
which designs this crash class out rather than catching it. 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:** DONE 2026-08-13 (this commit, with #388) — display block slice
|
||
5, pending the user's physical-display gate. `GlfwDisplayModeSwitcher`
|
||
ports retail's `Device::ForceDisplayResolution` semantics through native
|
||
`glfwSetWindowMonitor` (the same `IWindow.Native.Glfw` handle path #348's
|
||
cursor cache proved; primary monitor, matching retail's primary display
|
||
device), validated against the #391 mode catalog before any attempt, with
|
||
the monitor's highest refresh rate for the picked WxH and the windowed
|
||
placement remembered for the exit path. Live-verified: a real
|
||
1920x1080@300 mode switch, swapchain following, graceful restore.
|
||
Original filing below.
|
||
|
||
**Original filing:** 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:** DONE — re-gate USER-PASSED 2026-08-14: the OP8 first look
|
||
showed captioned tabs/buttons inside the frame (this issue's own defects);
|
||
its three NEW findings — wrong caption font, raw enum key names, missing
|
||
capture dialog — are #394/#395/#396, filed + fixed the same day.
|
||
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):
|
||
|
||
1. **The missing string resolver** ("buttons lacked text" + "no text in
|
||
tabs"): `RetailUiRuntime.MountKeyboardConfig`'s main
|
||
`LayoutImporter.Build` call was the ONE mount in that class not
|
||
passing `strings.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 own `resolveString` lookups (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.
|
||
2. **Parked row-template prototypes** ("buttons outside the window" +
|
||
"overlapping text"): `gmKeyboardUI` (`0x21000009`) authors its
|
||
ListBox row templates — the header text `0x1000002E` and the action
|
||
row `0x1000002F` carrying the three 100x32 key buttons — as ordinary
|
||
TOP-LEVEL siblings of the screen, referenced by dat property `0x64`
|
||
(the template list). Retail never instantiates template-list elements
|
||
as live widgets (`AddItemFromTemplateList` clones from the desc — the
|
||
same re-import our `UiTemplateListBox.TemplateResolver` performs), but
|
||
`LayoutImporter.ImportInfos` built 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 —
|
||
`0x10000211` is 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:** DONE — re-gate USER-PASSED 2026-08-14 (pass-1 re-check round).
|
||
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:** DONE — blank-tabs half fixed (`c3ed32fb` probe + fix at
|
||
`057d8cd7`); the Gameplay-buttons half re-gate USER-PASSED 2026-08-14
|
||
(pass-1 re-check round). 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.
|
||
|
||
<!-- original filing -->
|
||
**(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`.
|
||
|
||
<!-- original filing -->
|
||
**(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 <house type>" 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):**
|
||
1. First quarantine: `RuntimeLocalPlayerFrameController.AdvanceBeforeNetwork:93`
|
||
unconditionally re-assigned `controller.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).
|
||
2. Second quarantine (one layer deeper): the frame controller takes the
|
||
`ObjectClockDisposition == Suspend` branch and calls
|
||
`SuspendObjectUpdate` → `EnsurePublishedForRuntimeOperation` throws —
|
||
`_host.CanAdvancePlayer` is true while the controller is unpublished.
|
||
3. Root condition (at the time): believed to be "the session's world never
|
||
hydrates" from the `entities: 0` artifact — 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.RunAsync` instead drives its ticks through
|
||
`await Task.Delay(delay, _timeProvider, cancellationToken).ConfigureAwait(false)`
|
||
— a console app has no `SynchronizationContext`, 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, NARROWED 2026-08-16 at Campaign CC gate round 1 Batch C
|
||
Commit 2 — the BUILD half of this issue's own "fix shape" recommendation is
|
||
now DONE. `LayoutImporter.BuildWidget` gained a `UiText`/`UiField`
|
||
media-bearing-child carve-out (mirroring `UiMeter`'s own text-overlay
|
||
carve-out, EXACTLY the shape this issue proposed) as part of a chargen
|
||
description-box fix; the client-wide blast-radius sweep that fix's own
|
||
tests run
|
||
(`LayoutImporterMediaBearingChildSweepTests.MediaBearingChildSweep_EnumeratesEveryAffectedType12Element`)
|
||
independently re-confirmed `0x1000048C` under `0x10000011` in layout
|
||
`0x2100006F` as one of the affected elements — it now builds as a real
|
||
widget instead of being silently swallowed. **Still open:** no controller
|
||
binds or drives its visible state (STILL the original ask — what triggers
|
||
retail's "new text" indicator, and what it does on click, remains
|
||
un-researched); this issue stays open for that behavioral half.
|
||
|
||
**Where:** `src/AcDream.App/UI/Layout/ChatWindowController.cs` (behavior,
|
||
still missing); `src/AcDream.App/UI/Layout/LayoutImporter.cs`
|
||
(`BuildWidget`'s new `UiText or UiField` carve-out — CLOSED the build half);
|
||
`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):**
|
||
|
||
1. **Nondeterministic on the SAME binary.** Two launches at ~08:51/08:53
|
||
reached `auto-entered player mode` and `event=complete`; every launch from
|
||
~08:56 onward stalls identically. No code change flips it: a control run
|
||
without `ACDREAM_RETAIL_UI`, a run with the uncommitted focus-crash fix
|
||
reverted (pure committed tree), and post-ACE-restart runs all stall.
|
||
2. **Not the server.** Loopback capture (35 s, `login-capture.pcap` in the
|
||
session scratchpad): ACE sends `PlayerCreate` (0xF746, from :9001) and the
|
||
player guid `0x5000000A` appears in 48 payloads. A retail client logs into
|
||
the same ACE fine (user-confirmed).
|
||
3. **`ACDREAM_PROBE_PARK=1` shows a park storm:** 19 bodies (remotes
|
||
0x8xxxxxxx + generated statics 0x709xxxxx in landblocks 0x0904/0x0905) all
|
||
park with `cause=unplaceable`, `eligible=True captured=True`, and **zero
|
||
restores** over 30+ s. `ACDREAM_PROBE_PLACEMENT_FAIL=1` emits nothing.
|
||
4. **The player guid appears in ZERO park lines** — the local player's route-1
|
||
Place edge never executes at all, so `PlayerMovementController` never
|
||
publishes, `PlayerModeAutoEntry.IsPlayerControllerReady` never 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:
|
||
|
||
```csharp
|
||
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:** CLOSED 2026-08-09 (`c6bc2bf7`). NOT a flake and NOT timing —
|
||
the "load-sensitive" framing was wrong: it failed deterministically on
|
||
EVERY Debug `dotnet test` run since the test landed (`090b0354`
|
||
introduced the test and the tripwire in the same commit; the
|
||
parallel-agent-load correlation was just that Debug runs are rare — the
|
||
gate config is Release). Mechanism: the test feeds a far-tier factory
|
||
returning Near payload on purpose to prove the strip safety net, but
|
||
`LandblockStreamer.HandleJob` is documented "fail loud in Debug builds
|
||
and strip in Release" — its `Debug.Assert` fires on exactly that input,
|
||
the VSTest host translates the assert into a thrown
|
||
`DebugAssertException` (its message says so verbatim) instead of killing
|
||
the testhost, and the worker catch folds it into a `Failed` completion,
|
||
so `Assert.IsType<Loaded>` fails. Release compiles the assert out
|
||
(`[Conditional("DEBUG")]`) and strips — hence "never reproduces in
|
||
clean-room Release". Fix: the test now pins BOTH halves of the
|
||
config-divergent contract via `#if DEBUG` (Debug expects the loud
|
||
`Failed` carrying the assert text; Release keeps the strip assertions).
|
||
Production code unchanged; LandblockBuildOriginTests 11/11 in both
|
||
configs.
|
||
|
||
## #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 `332045c7` returns 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:
|
||
1. The running process loaded `AcDream.Core.dll` from somewhere other than
|
||
`src/AcDream.App/bin/Release/net10.0/` — the byte check confirmed the probe
|
||
string is in THAT copy, not that the process loaded it.
|
||
2. 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.
|
||
3. 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):**
|
||
1. **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.
|
||
2. If retail visibly slides: the remaining suspect is UPSTREAM of the
|
||
walkable response — whether retail's `find_cell_list` broadphase 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 returns `collisionNormalValid=True` with
|
||
**`collN=(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.
|
||
|
||
**2026-08-16 recurrence (Campaign #409 tooltip review-fix round).** The Opus
|
||
reviewer of `a377b9bf` hit this same assertion failing TWICE under
|
||
full-solution load, unrelated to any #409 tooltip code; passes standalone
|
||
26/26. Joining the same running known-flake set the CC7 gate row already
|
||
names (`docs/plans/2026-08-15-character-creation-campaign.md`, CC7 row) for
|
||
Core.Net `NakEmissionTests.LossSoak_...` / Content `DecodedTextureCacheTests`
|
||
/ App `SocialPanelLiveMountProbeTests.ProbeLiveMountShapes` /
|
||
`RuntimeCollisionReportingStateTests.WarmedSteadyContactRefreshDoesNotAllocate` —
|
||
a full-solution gate hitting exactly one of these five allocation/timing-
|
||
sensitive tests, with a clean standalone or immediate-rerun pass, is this
|
||
known class, not a new regression.
|
||
|
||
---
|
||
|
||
## #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:**
|
||
1. The GUARD IS CORRECT — it refused to project an entity 28.8 km from its
|
||
geometry. Do not weaken it.
|
||
2. 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 by `r·(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 the `collision`
|
||
flag 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` / `_stepDownHeight` are
|
||
**initialised to `0.4f`** (`PlayerMovementController.cs:159-160`).
|
||
- The `StepUpHeight` property's own doc comment says the authoritative source
|
||
is the player's `Setup.StepUpHeight`, set by
|
||
**`PlayerModeController.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 to `0.4f`) and
|
||
`RuntimeSetPositionMoverPreparation.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`](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
|
||
|
||
1. Walks **up** a rock face onto a plateau fine, then **cannot pass at the top** — wedged, position frozen.
|
||
2. Jumping is **"swallowed half way by the rock"** — the body sinks into the visual geometry.
|
||
3. **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=none` is then a measurement, not an inference,
|
||
and `cpSrc=` 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=coincident` **refutes (b)** for that object outright;
|
||
`no-physics-bsp` / `empty-physics-bsp` / `displaced` / `extent-mismatch`
|
||
each name a specific data defect.
|
||
- **`ACDREAM_WIRE_MESH=1`** upgrades 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:
|
||
|
||
1. cheap reject: the part's `CGfxObj::physics_sphere` centre through `Position::localtolocal` (`0x0052cb5a`), tested against the portal plane with `eps = F_EPSILON + radius` (`0x0052cb65`);
|
||
2. on pass, the ADMITTING test is box-vs-plane: `CPhysicsPart::GetBoundingBox` @0x0050d600 (`0x0052cbdd`) → `BBox::LocalToLocal` @0x005b1e60 (`0x0052cbf9`) → `Plane::intersect_box` @0x005aa170 (`0x0052cc05`);
|
||
3. if the side differs from `portal_side`: `other_cell_id == 0xFFFFFFFF` (`0x0052cc1e`) sets the “leads outside” flag; otherwise `CCellPortal::GetOtherCell` @0x0053ba30 (`0x0052cc2b`) — a THISCALL on the portal record (`ecx` set at `0x0052cc18`) taking ONE explicit argument, `cellarray->do_not_load_cells` (`0x0052cc27 mov eax,[edi+4]`; the CELLARRAY layout is +0 `added_outside`, +4 `do_not_load_cells`, +8 `num_cells`, +0xc `cells`, cross-checked against `find_bbox_cell_list`'s `0x00510fc8`/`0x00510fcf` zeroing and `add_all_outside_cells`' `0x0053336c` read 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. Then `BBox::LocalToLocal` into the destination and `CCellStruct::box_intersects_cell` @0x00533910 → `BSPTREE` @0x0053c880 gates the add (`0x0052cc5a`);
|
||
4. 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:
|
||
|
||
```csharp
|
||
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
|
||
|
||
1. ~~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.Capture` already bounds the per-cell list.
|
||
2. If it is kept, it must measure from where the geometry is: `ShadowEntry`
|
||
needs the `BoundsCenter` that `ShadowShape` now carries, and `deltaToCurr`
|
||
must be taken against `obj.Position + rotate(obj.BoundsCenter, obj.Rotation)`.
|
||
3. Cylinder rows are unaffected (`BoundsCenter == Zero` by 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
|
||
|
||
1. A landing (or the spawn settle that compresses it, `SpawnPlacementSettler`)
|
||
reaches `OBJECTINFO::validate_walkable` with the OBJECTINFO `CONTACT` bit
|
||
clear, so it calls `set_collision_normal` with the **terrain** plane normal.
|
||
Verified in the PDB-paired binary: `0x0050d251 test byte ptr [ebp+4],1 /
|
||
jne` then `0x0050d261 test eax,eax` (`step_down`) then
|
||
`0x0050d26c call set_collision_normal`. acdream `TransitionTypes.cs`
|
||
`ValidateWalkable` matches (`!oi.Contact && !sp.StepDown`).
|
||
2. `CTransition::validate_transition` unconditionally converts it:
|
||
`0x0050ac19 test eax,eax / 0x0050ac21 je / 0x0050ac30 call
|
||
set_sliding_normal`. acdream matches.
|
||
3. `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.
|
||
4. `SetPositionInternal` persists it as `SLIDING_TS`
|
||
(`0x005154c2` / `0x005154e1`); `get_object_info` re-seeds it next frame
|
||
(`0x00511d44 test / 0x00511d4f call init_sliding_normal`). acdream matches.
|
||
5. `CTransition::adjust_offset` sees `dot(offset, sliding) < 0` and projects
|
||
the step onto the crease `cross(sliding, contact)` — a purely horizontal,
|
||
purely cross-slope axis. An exactly-up-slope offset has zero component on
|
||
it.
|
||
6. The sweep aborts at step 0 and reports failure:
|
||
`0x0050c0ed test ebx,ebx / jne 0x0050c089` -> `cmp [esp+14h],1 / jne` ->
|
||
`xor eax,eax`. Retail returns `i != 0 && state == OK` — **byte-identical to
|
||
acdream's `FindTransitionalPosition`** (Binary Ninja typed this function
|
||
`void` and dropped the return value; the disassembly settles it).
|
||
7. 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.
|
||
- `RemoteRampHarness` now 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:
|
||
|
||
1. **A headless remote-motion tick does not exist.** `RuntimeRemotePhysicsUpdater`
|
||
is Runtime-HOMED but App-DRIVEN (constructed only at
|
||
`RemotePhysicsUpdater.cs:46`, ticked only from
|
||
`LiveEntityAnimationScheduler.cs:351`), and `GetOrCreateRemoteMotion` has
|
||
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.
|
||
2. **Live collision-asset publication does not exist headless-side.** The
|
||
headless `PhysicsDataCache` holds only the per-landblock static closure;
|
||
there is no `IPreparedCollisionSource` on-demand pull
|
||
(graphical: `LiveCollisionAssetPublisher`). Without it,
|
||
`_physicsBspBounds` is null for live-entity parts, BSP dispatch never
|
||
fires, and doors/chests/statues/portals get the wrong shape or none.
|
||
3. **Degrade resolution:** the graphical route resolves collision part ids
|
||
through `GfxObjDegradeResolver` slot-0 walking; raw `setup.Parts` is the
|
||
wrong id for every humanoid part.
|
||
4. **Shadow lifetime hangs off FIVE edges, not two:** wire delete, pickup
|
||
(`TryApplyPickup` leaves a permanent invisible collider from a looted
|
||
item), same-guid generation supersession (`RetireCanonicalOnly` path has
|
||
no unregister → duplicate phantom), GENERATION RESET
|
||
(`HeadlessGenerationResetHost.RetireEntityProjection` is an empty no-op
|
||
and `ResetSessionPhysics` does not clear `ShadowObjects` — registrations
|
||
leak across reconnects), and hidden/withdrawn suspension.
|
||
5. **The K-ledger cannot see any of this:** `RuntimePhysicsOwnershipSnapshot.IsConverged`
|
||
checks 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.
|
||
6. **Ordering + retry:** registration must follow `ProjectSpawn` (a throw
|
||
after `ApplyAcceptedSpawn` leaves 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.
|
||
7. **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`](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:
|
||
|
||
1. the wire heading is applied instead of the body's being preserved
|
||
(`InboundPhysicsStateController.ApplyAcceptedPosition:846-856` is
|
||
`ForcePosition`-gated);
|
||
2. the entity is unparented, and may have a placement frame installed
|
||
(`clearParent: !force`, `installPlacementFrame: !force && !hasAnimations`);
|
||
3. local velocity is zeroed (`:882-885`, the `TeleportAdvanced` arm);
|
||
4. TELEPORT_TS advances and `OfferTeleportDestination` is 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:
|
||
|
||
1. **The predicate exists twice.** Besides `PhysicsTimestampGate.cs:199`,
|
||
`RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority`
|
||
independently requires
|
||
`authority.PreviousTeleportSequence == authority.AcceptedTeleportSequence`
|
||
for a `ForcePosition` — the same narrowing, encoded downstream. Widening
|
||
one without the other turns the newly-admitted packets into
|
||
`RejectedAuthority` routes, which is a third behaviour, worse than either.
|
||
2. **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.PreviousTeleport` and `.Teleport`
|
||
would be equal and STALE while the wire carried a newer value: a shape no
|
||
consumer has seen. `IsFreshTeleportStart`, the drive controller's
|
||
`previousTeleport` argument, and J6.3's F751/Position teleport
|
||
correlation all read that pair.
|
||
3. **Reachability has to be established before the fix, not assumed.** ACE's
|
||
two `ObjectForcePosition` bumps (`Player.cs:1148` PKLite re-placement,
|
||
`Player_Tick.cs:488` z-hack correction) do not themselves bump the
|
||
teleport sequence — but `PositionPack` serialises 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`
|
||
(`OnPositionUpdated` and siblings), constructed only at
|
||
`src/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):**
|
||
|
||
1. The graphical route performs presentation recovery the no-window route has
|
||
no analogue for (`RequiresSpatialProjectionRecovery`, the equipped-child
|
||
`ChildUnparentDisposition` arm, `LiveEntityHydrationController`).
|
||
2. 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.
|
||
3. Ordering constraints are load-bearing and already documented as
|
||
measurements, not intentions — AP-138's route-2 first-submit
|
||
`CurrentCellId` observation 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.
|
||
4. `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`](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):**
|
||
|
||
1. 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.
|
||
2. `Rebucketed` entity-delta publication cadence: today the player NEVER
|
||
publishes a `Rebucketed` delta during WASD (the preserve path early-outs
|
||
at `CommitRebucket`'s `previous == fullCellId`,
|
||
`RuntimeEntityObjectLifetime.cs:1965-1972`); an exact-cell commit would
|
||
publish per EnvCell crossing — audit every consumer before shipping.
|
||
3. The accepted-Position classification inputs for the LOCAL player (route 2
|
||
and the 4b-3 `PreMergeCommittedCellId` measurement at
|
||
`TryApplyPosition:1801-1814`): a fresh committed cell changes the
|
||
pre-merge population on live correction paths AP-136/AP-138 spent four
|
||
review rounds pinning.
|
||
4. 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.
|
||
5. The `isOrdinaryRoot` family (`LiveEntityRuntime.cs:915-918`, `:3213`,
|
||
`:3323`) and the animation-scheduler local-player exclusion
|
||
(`LiveEntityAnimationScheduler.cs:183-227`).
|
||
6. **First verification step, unresolved by the #319 investigation or its
|
||
fix:** whether the local player is a `RuntimePhysicsState` spatial 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 into
|
||
`ParkCollisionResidents`. 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:
|
||
|
||
1. No end-to-end composition test exercises the full portal-arrival →
|
||
canonical commit → presentation-suffix → `PhysicsEngine.ShadowObjects`
|
||
chain for the LOCAL player specifically (existing tests cover pieces —
|
||
the canonical commit, the presentation sink's `TryApply`, the drive
|
||
controller — but not the full composed path with a real
|
||
`RuntimePlacementPresentationSink` wired to a real `PhysicsEngine`).
|
||
2. No test asserts the local-player collision SHADOW lands at the
|
||
destination. The discriminating assertion for that future test:
|
||
`PhysicsEngine.ShadowObjects` must hold a row at the destination cell/
|
||
position, not just `LocalPlayerShadowState`'s internal dedup cache — see
|
||
the register row (AP-131 amendment, filed alongside this issue) for the
|
||
asymmetry this exposes: `LocalPlayerShadowState.Set` updates the dedup
|
||
cache without publishing to `ShadowObjects`, self-healing only on the
|
||
local player's first subsequent movement tick.
|
||
3. 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`](research/2026-08-04-c4-route-6-contract.md),
|
||
[`2026-08-04-c4-routes-6-7-scoping.md`](research/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`](research/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.
|
||
|
||
1. Walk the observed character to a landblock boundary so a remote sits in a
|
||
landblock the observer has not streamed, forcing a `DeferredCell` park.
|
||
2. Confirm the remote no longer vanishes permanently — the pre-fix symptom was
|
||
invisible AND intangible for the rest of the session.
|
||
3. 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.
|
||
4. **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=True` **and a
|
||
later** `[park-restore] … residency=True` for 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 to `record.FullCellId` before
|
||
submitting.
|
||
5. **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_ForcePositionParkIsNotRestored` pins the
|
||
opposite: route 2 dispatches only for `ForcePosition`, so the ordinary
|
||
`Apply` that 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.**
|
||
6. 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`](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`](plans/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 12 `ParentCellId` writers 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 for `CObjCell::IsInView` particle gating — "live/interior entities
|
||
normally use `ParentCellId` instead." `f24532ad` began setting it on live
|
||
entities at rebucket and placement projection
|
||
(`LiveEntityRuntime.cs:855,1101,1238`). Because
|
||
`EntityEffectPoseRegistry.UpdateRoot:163` resolves
|
||
`EffectCellId ?? ParentCellId`, the field now WINS for live entities, while
|
||
12+ production sites still write `ParentCellId` alone. 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 by
|
||
`ShouldDrawParticles` @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 by
|
||
`LiveEntityLightControllerTests.Refresh_FollowsCurrentTopLevelRootAndCell`.
|
||
**Landed 2026-08-03 (S2).** The audit found 14 cell writers: only 3 rebucket
|
||
(and so repaired `EffectCellId` by accident), while 11 do not — including the
|
||
hottest paths, `RemotePhysicsUpdater:239,294` and
|
||
`LiveEntityOrdinaryPhysicsUpdater:107` (every physics tick from the snapshot)
|
||
and `LocalPlayerProjectionController:79` (the local player every frame). So a
|
||
moving entity updated its cell constantly while `EffectCellId` stayed frozen.
|
||
The consumers also disagreed: `EntityEffectPoseRegistry` preferred
|
||
`EffectCellId`, while `WbDrawDispatcher.TryGetEntityCell` and the remote
|
||
spawn seed preferred `ParentCellId`. Fix: `WorldEntity.VisibilityCellId`
|
||
(`ParentCellId ?? EffectCellId`) is the single accessor every consumer
|
||
resolves through; `LiveEntityRuntime`'s three live-entity `EffectCellId`
|
||
writes 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_FollowsCurrentTopLevelRootAndCell` is back to moving the entity by
|
||
`ParentCellId` alone — its original pre-`f24532ad` form — 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 all `failures=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.** `670f307c` gave Runtime its own world
|
||
frame (`RuntimePhysicsState.ObserveLocalWorldFrame`), which rebases the
|
||
instant an accepted Position carries `TeleportAdvanced`. App's
|
||
`LiveWorldOriginState` rebases only when
|
||
`StreamingOriginRecenterCoordinator.Advance` observes
|
||
`IsOriginRecenterRetirementComplete` — 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 bug `670f307c` fixed, 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 at
|
||
`DatLiveEntityProjectionMaterializer`'s landblock→world conversion — the
|
||
App-side counterpart of `TryGetWorldFrameOffset`. 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: `BeginOriginRecenter`
|
||
detaches 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). Instead
|
||
`LiveWorldOriginState.EnsureAgreesWithRuntimeFrame` is 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 in `01f4791e`. 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).** `RetryWorldFrameUnavailable` splits the
|
||
world-frame park from the Setup park, so a missing frame stops reporting
|
||
itself as an asset problem; call sites now test `IsRetryable()` instead of
|
||
one reason, so a future reason cannot be silently demoted to a rejection.
|
||
The operation retains its `RuntimeSetPositionParkReason`, and
|
||
`RuntimeSetPositionOwnershipSnapshot` reports
|
||
`ParkedAwaitingSetupCollisionCount` / `ParkedAwaitingWorldFrameCount` /
|
||
`ParkedPlacementCount`. `ObserveLocalPlayerCreate` records the accepted
|
||
local-player Create even when it carries no landblock, and
|
||
`ThrowIfWorldFrameUnreachable` makes that contradiction terminal instead of
|
||
an infinite silent retry.
|
||
**Deliberately NOT folded into `IsConverged`:** #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.json` is
|
||
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 zero `world frame is unreachable` failures 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`](research/2026-08-03-c4-route-2-implementation-plan.md);
|
||
contract: [`2026-08-03-c4-route-2-contract.md`](research/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) drove
|
||
`PlayerMovementController.BlipPosition` — a raw `PhysicsBody.SnapToCell`
|
||
with no transition, no collision, no contact-plane resolve, no
|
||
`FullCellId`/`PlacementCommitVersion` advance — while the generic tail
|
||
(`LiveEntityNetworkUpdateController.cs`) independently wrote the
|
||
render-facing `WorldEntity` from 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 bug `670f307c` fixed.
|
||
**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 SAME
|
||
`RuntimeSetPositionState.TryBeginExclusiveAuthoredPlacement` +
|
||
`TryPrepareAndSubmitAuthoredPlacement` transaction every other placement
|
||
uses (retail `CPhysicsObj::SetPositionSimple` @0x005162B0, flags `0x1012`,
|
||
called from `SmartBox::BlipPlayer` @0x00453940), reconciles the
|
||
controller's render-lerp/cell state
|
||
(`PlayerMovementController.CommitCanonicalForcePositionFrame`, replacing
|
||
the deleted `BlipPosition`), and fires the ack strictly AFTER that commit —
|
||
never before it. `LocalForcePositionTransaction.cs` and
|
||
`HeadlessSessionWorldProjection.BlipLocalPlayer` are 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 existing
|
||
`RuntimePlacementPresentationSink`, the same seam every other placement
|
||
already uses).
|
||
**Two named behaviour changes, both retail-exact per this session's
|
||
verification:** (1) the outbound `AutonomousPosition` ack is now an OUTPUT
|
||
of the committed route, not a step alongside it — retail's
|
||
`cmdinterp->SendPositionEvent()` @0x00454091 runs after
|
||
`SmartBox::BlipPlayer` @0x00454074 returns, and the deleted transaction's
|
||
trailing `isCurrent()` 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 — every
|
||
`CPhysicsObj::ConstrainTo` call in `HandleReceivedPosition`
|
||
(@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, commit `7719d25b`) was an unbacked deviation for this exact
|
||
branch — it was correct for retail's "Player, normal" branch that Slice P5
|
||
was modeling in general, but `SmartBox::BlipPlayer` is not on that branch.
|
||
#167's own historical write-up (below) is superseded for its `BlipPosition`
|
||
half by this entry.
|
||
**New behaviour (retail fidelity gain, not a regression):** the ForcePosition
|
||
now runs retail's REAL `SetPosition` collision 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's
|
||
`BlipPlayer` has never lifted the origin by a sphere radius. The real dat
|
||
human Setup `0x02000001`'s foot sphere is `(0,0,0.475) r=.48`
|
||
(`Ts46SphereListConformanceTests.cs:35-39`), whose bottom sits at
|
||
`origin + 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
|
||
measured `Z = 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-time `Forget` cancels 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
|
||
measures `InContact=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 renamed
|
||
`DeferredCell_ParksThenCommitsAndNeverDoubleAcksAfterTheCollisionGenerationWakes`
|
||
and no longer pins the current zero-ack count with an assertion (a pinning
|
||
`Assert.Empty` would fail — and read as a regression — the day Contact
|
||
correctly starts flipping); it captures the ack count once and asserts
|
||
only that further `Advance()` 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 — see
|
||
[`2026-08-03-c4-route-2-review-findings.md`](research/2026-08-03-c4-route-2-review-findings.md)
|
||
for the full R1-R9 list:** (R1, HIGH) the DeferredCell park could not
|
||
survive in production — `RuntimeEntityObjectLifetime.TryApplyPosition`
|
||
Forgets 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.Advance`
|
||
now 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 lost `BlipLocalPlayer`'s collision-
|
||
neighborhood re-centering; restored via the new
|
||
`IRuntimeDirectWorldProjection.CenterOnAcceptedForcePosition`. (R3, HIGH)
|
||
the headless login-window ForcePosition fallback was dropped; restored.
|
||
(R4, MEDIUM-HIGH) the force-ack was stealing a `Place` receipt the
|
||
presentation sink had legitimately declined for its own retry contract;
|
||
removed. (R5, MEDIUM-HIGH) corrected the `Rejected` status doc's false
|
||
"no SetPosition ran" claim. (R6, MEDIUM) `_pending` could leak forever on
|
||
a mid-session cancellation (now caught by the same R1 detection) and
|
||
`SubmitAndResolve` could silently overwrite a still-live pending (now
|
||
guarded, throws on the invariant violation). (R9, LOW hygiene) a stale
|
||
`BlipPosition` doc cref, `HeadlessSessionHost._currentSession` never
|
||
cleared on teardown, and the streaming observer/pose-dirty side effects
|
||
firing for a `Rejected`/`Contention` status 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 of
|
||
[`2026-08-03-c4-route-2-review-findings.md`](research/2026-08-03-c4-route-2-review-findings.md):**
|
||
round 1 bolted the re-issue onto ad-hoc per-branch `_pending` bookkeeping,
|
||
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.SettlePending` is the sole terminal
|
||
writer of `_pending`, `RetainPending` the sole outstanding writer, and
|
||
`AbandonPending` the sole teardown writer. Its ONE decision input is the
|
||
terminal operation token's `PositionAuthorityVersion`
|
||
(`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 outbound
|
||
`AutonomousPosition`, never two); advanced with the newest accepted event
|
||
still a ForcePosition ⇒ re-issue it, re-classified from the current record
|
||
via the newly recorded `_newestForce` observation (fixes **B1**: a
|
||
correction blocked by a woken park's retained completion is no longer lost);
|
||
advanced with the newest accepted event now an ordinary `Apply` ⇒ clear, no
|
||
re-issue (fixes **N2**: the round-1 shape reused the stale force route and
|
||
would have applied `Teleport|Slide` + an ack retail never sends to an
|
||
ordinary pose, skipping that branch's `ConstrainTo`). 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, because `SmartBox::BlipPlayer`
|
||
@0x00453940 DISCARDS `CPhysicsObj::SetPositionSimple`'s
|
||
@0x005162B0 `enum SetPositionError` (other retail callers test it,
|
||
`== OK_SPE` @0x0055605D) and returns `void`, after which
|
||
`SmartBox::HandleReceivedPosition` @0x00453FD0 runs
|
||
`cmdinterp->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-blocked `Contention`; a re-issue marker that never begins), and —
|
||
begun but displaced — where a newer force supersedes the packet before its
|
||
settle, since `SettlePending` nulls `_pending` without 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's
|
||
`SmartBox::BlipPlayer` is 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 legacy `TryApplyPosition` caller is route 4's job, not
|
||
route 2's).
|
||
- **#286 — OPEN — headless never calls `RetryPending` on its placement
|
||
projection subscription.** `RuntimePlacementProjectionSubscription.RetryPending`
|
||
has exactly one caller in the tree, `GraphicalSessionEventRoute.cs:109,113`;
|
||
`HeadlessSessionEventRoute` constructs the subscription
|
||
(`HeadlessSessionEventRoute.cs:22`) but nothing pumps its retry. C4 route
|
||
2's R4 fix deliberately stopped the force-ack from consuming a `Place` the
|
||
sink declined, on the contract that the subscription's OWN retry re-offers
|
||
it — so on headless a declined `Place` would 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.Advance` has no
|
||
reentrancy latch.** Its template `RuntimeFirstEntryDriveController.DriveAll`
|
||
guards with `_driving` (`RuntimeFirstEntryDriveController.cs:61,130,132,146`);
|
||
the accepted-position drive does not, even though `Advance` can now re-enter
|
||
`SubmitAndResolve` through the `SettlePending` re-issue. No live re-entrant
|
||
path exists today (the funnel's recursion is bounded at one level and every
|
||
host pumps `Advance` from 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.BlipLocalPlayer` became
|
||
`CenterOnAcceptedForcePosition`.** (a) The deleted method also restored
|
||
`controller.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.OnServerEcho` no 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:25`
|
||
and `src/AcDream.Core/Physics/PhysicsBody.cs:442` both 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 `SendPositionImmediately` never 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:
|
||
`RuntimeInitialCreateContinuationExecutor` consumes
|
||
`SendPositionImmediately` only 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
|
||
after `BlipPlayer`, 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 (a `DeferredCell` park
|
||
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`](plans/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 through
|
||
`RuntimePlacementPresentationSink` / `TryApplyRuntimePlacementPlace` and
|
||
asserts the `WorldEntity` moved. 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 `Withdraw` receipts the
|
||
sink may have declined.** `RuntimeAcceptedPositionDriveController.SubmitAndResolve`'s
|
||
`DeferredCell` branch drains the head of the placement FIFO while it is a
|
||
`Withdraw` for this entity and calls `AcknowledgeProjection` on it
|
||
(`RuntimeAcceptedPositionDriveController.cs:709-716`). That is the exact
|
||
receipt-stealing shape R4 removed for `Place`: the R4 fix's whole argument is
|
||
that `RuntimePlacementProjectionSubscription` deliberately 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. The `Withdraw` drain was left
|
||
unchanged in round 1 because it is copied verbatim from
|
||
`RuntimeFirstEntryDriveController.TryCompleteContinuationPlacement`, but the
|
||
same asymmetry applies — that controller's residence guarantees the decline,
|
||
this one's does not. Fix shape: decide whether a declined `Withdraw` is
|
||
reachable for this route and either drop the drain (letting the
|
||
subscription's retry own it, as `Place` now 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.Advance` consumes
|
||
the acknowledged placement and immediately calls `ReconcileAndAcknowledge`
|
||
(`:452`), and only then enters `SettlePending`, 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 outbound
|
||
`AutonomousPosition`. `ReconcileAndAcknowledge`'s own
|
||
`record.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`.** When `SettlePending`'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 —
|
||
but `PendingCount` (`:259`) and the ownership ledger registered at `:255-256`
|
||
both 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`.**
|
||
`SubmitAndResolve` returns `RuntimeAcceptedPositionExecutionStatus.Contention`
|
||
for 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 next `Advance` pump, while true `Contention` may
|
||
have recorded nothing at all (register row AD-62 shape (iv)). Hosts cannot
|
||
distinguish them — `LiveEntityNetworkUpdateController` branches 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 is `GameMessagePublicUpdatePropertyInt` (0x02CE)
|
||
carrying `PropertyInt.PlayerKillerStatus`(134) = `PKLite`(0x40)
|
||
(`Player.cs:1153` -> `Player_Properties.cs:1122-1132` ->
|
||
`WorldObject_Networking.cs:1413-1442`). The PWD `ObjectDescriptionFlag` bits
|
||
are recomputed only inside serialization, and `EnqueueBroadcastUpdateObject`
|
||
(`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 in
|
||
`Properties.Ints[134]`) but never translate it: `ClientObject.PublicWeenieBitfield`
|
||
has 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 via `EntityCollisionFlags.cs:133-139` /
|
||
`LiveSessionEventRouter.cs:419-442`, target via
|
||
`LiveEntityCollisionBuilder.cs:151-153` — so `CollisionExemption.cs:117-122`
|
||
("4c. both PKLite -> collide") never fires and `:125` exempts.
|
||
**Retail is the exact port we are missing:** `ACCWeenieObject::OnStatUpdated`
|
||
@0x0058DF20 `case 0x86:` calls `PublicWeenieDesc::SetPlayerKillerStatus`
|
||
@0x005AC7C0, which rewrites `pwd._bitfield` in place — PK `|0x20`, PKLite
|
||
`|0x2000000`, Free `|0x200000`, mutually exclusive, else clear all three.
|
||
`IsPKLite` @0x0058C8A0 reads `(_bitfield >> 0x19) & 1`. Entry points are
|
||
`Handle_Qualities__PrivateUpdateInt` @0x00558FD0 (0x02CD, self) and
|
||
`Handle_Qualities__UpdateInt` @0x00558D60 (0x02CE, remote).
|
||
Fix shape: port `SetPlayerKillerStatus` as a bitfield rewrite on PropertyInt
|
||
134, applied from BOTH routes. `RecomputePvpStatus` already reacts to
|
||
`ObjectUpdated` so the mover side follows for free; the TARGET side needs a
|
||
second edge because shadow-registry `EntityCollisionFlags` are 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, logging
|
||
`combat: attack ignored; no creature target found`,
|
||
`LiveCombatAttackOperations.cs:187`).
|
||
`CombatTargetPolicy.IsHostileMonster:31-33` rejects any candidate carrying
|
||
`SelectedObjectHealthPolicy.BfPlayer` before reaching `ObjectIsAttackable`,
|
||
so the PKLite pool match at `SelectedObjectHealthPolicy.cs:70-71` is
|
||
unreachable for player targets.
|
||
**Retail has ONE predicate for monsters and players, with no player
|
||
exclusion:** `ClientCombatSystem::ExecuteAttack` @0x0056BB70 gates
|
||
unconditionally on `ObjectIsAttackable` @0x0056A600, which checks creature
|
||
type, the `0x200000` bits, then `IsPlayer(): (bothPK) || (bothPKLite)`, else
|
||
`BF_ATTACKABLE` with pets excluded. We already have that predicate ported
|
||
verbatim and correctly at `SelectedObjectHealthPolicy.cs:41-78` — it is
|
||
simply unreachable.
|
||
**DO NOT fix by relaxing `IsHostileMonster`'s automatic-acquisition
|
||
scope.** `IsHostileMonster` also 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.** Retail `ClientCombatSystem::
|
||
UpdateTargetTracking` @0x0056A950 (pc:375691-375696) gates
|
||
`CameraSet::TrackTarget` on the SAME `ObjectIsAttackable` predicate as
|
||
`ExecuteAttack` @0x0056BB98, not the narrow monster-only policy. The
|
||
camera performs no acquisition of its own — it only tracks whatever the
|
||
player already selected — so `WorldSelectionQuery.GetCombatCameraTargetPoint`
|
||
must route through the wide predicate exactly like explicit-target
|
||
admission. (Landed: `GetCombatCameraTargetPoint` now calls
|
||
`IsAttackableTarget`.)
|
||
Fix shape: SPLIT explicit-target admission AND the combat camera
|
||
(-> `ObjectIsAttackable`/`IsAttackableTarget`, retail-exact) from
|
||
auto-acquisition (-> keep `IsHostileMonster`, 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:32` already admits
|
||
`BfPlayer`) and the vivid target indicator.
|
||
Correct model to copy: spells already work on PKLite players because
|
||
`RetailSpellTargetPolicy.cs:40-46` treats `BF_PLAYER` as an ACCEPT and never
|
||
calls `ObjectIsAttackable` — the client checks target-TYPE compatibility and
|
||
lets the server arbitrate PK legality (retail
|
||
`ClientMagicSystem::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:103` checks only the TARGET's `IsImpenetrable`, and the
|
||
class doc at `:33-39` claims "retail's pseudo-C only checks the target's
|
||
`IsImpenetrable()`; acdream follows retail." The pseudo-C at pc:276824-276827
|
||
has TWO short-circuit branches — mover `state & IS_IMPENETRABLE (0x80)` OR
|
||
target `IsImpenetrable()` — 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]` and `PublicWeenieBitfield` can disagree
|
||
inside one `ClientObject`. LOW.** `ClientObjectTable.UpdateIntProperty:792-796`
|
||
is the only entry point that mirrors PropertyInt 134 (PlayerKillerStatus) into
|
||
the PWD bitfield. `UpsertProperties:750-767` (PlayerDescription 0x0013) and
|
||
`UpdateProperties:728-741` (IdentifyObjectResponse) write
|
||
`Properties.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 — and `LiveSessionEventRouter.RecomputePvpStatus:426-429`
|
||
reads 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 rewrites `pwd._blipColor` on `case 0x5f` (95 = RadarBlipColor) and
|
||
`pwd._radar_enum` on `case 0x85` (133 = RadarBehavior), verified at
|
||
`acclient_2013_pseudo_c.txt:408381-408391`. acdream handles neither, and
|
||
`RadarSnapshotProvider.cs:85,134` reads 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.WarmedSteadyContactRefreshDoesNotAllocate` is a FOURTH, load-sensitive flake — distinct from #302, #308 and #321. LOW.**
|
||
`tests/AcDream.Runtime.Tests/Physics/RuntimeCollisionReportingStateTests.cs:2381` asserts
|
||
`GC.GetAllocatedBytesForCurrentThread()` is EXACTLY 0 across a warmed 10,000-iteration
|
||
steady-contact refresh loop. Observed failing once on 2026-08-06 inside a full-solution
|
||
`dotnet test AcDream.slnx -c Release -m:1` run (measured 2,944 bytes), then passing on the
|
||
immediate full-suite retry, on both of two isolated `AcDream.Runtime.Tests` project 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 exact `GC.GetAllocatedBytesForCurrentThread()` assertion,
|
||
sensitive to JIT tiering and background GC on the measuring thread) but it is a different
|
||
test in a different assembly — #302 is `AcDream.App.Tests`, this is
|
||
`AcDream.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 in `AcDream.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 only `RuntimeCollisionReportingState` handling, registers no
|
||
shadow inside the measurement, and touches none of `CellTransit` / `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_ReusesExactResultArray`
|
||
is flaky. LOW.** Measured 1 failure in 6 consecutive isolated runs of
|
||
`AcDream.App.Tests` at `88348f67`, and once in a full-suite run that passed on
|
||
two immediate retries. The test asserts on
|
||
`GC.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_LedgersConverge`
|
||
is a SECOND, load-sensitive flake — distinct from #302. LOW.**
|
||
`tests/AcDream.Core.Net.Tests/Transport/` — a wall-clock-driven randomized
|
||
packet-loss soak with a `DateTime.UtcNow < deadline` loop. Observed failing
|
||
twice on 2026-08-03/04, **both times only inside a full-solution run**, and
|
||
0 failures in 4 consecutive isolated runs of `AcDream.Core.Net.Tests` alone.
|
||
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 a `GC.GetAllocatedBytesForCurrentThread()` assertion in
|
||
`AcDream.App.Tests` sensitive to JIT tiering; this one is a wall-clock
|
||
deadline in `AcDream.Core.Net.Tests` sensitive 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; read `claude-memory/project_network_transport_digest.md` before
|
||
touching it.
|
||
|
||
- **#303 — OPEN — `LiveEntityPvpBitfieldSync` lives in App but touches only
|
||
Runtime-owned state. INFO/shape.**
|
||
`src/AcDream.App/Physics/LiveEntityPvpBitfieldSync.cs` reads
|
||
`RuntimeEntityObjectLifetime.Objects` and writes
|
||
`RuntimePhysicsState.Engine.ShadowObjects` — both Runtime-owned since J5.5.
|
||
Moving it beside `RuntimeEntityPvpBitfieldSnapshotSync` would leave one
|
||
subscriber and one owner. No coverage gap today (headless has no
|
||
`LiveEntityCollisionBuilder` and 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.GetSelectedOrClosestCombatTarget`
|
||
has no production caller. LOW/shape.** Grep confirms only tests reach it
|
||
(`GetSelectedOrClosestCombatTarget:114-121`); no `GameplayInputActionRouter`
|
||
or other App wiring calls it. The #298 fix widened it correctly (explicit
|
||
selection now checks `IWorldSelectionQuery.IsAttackableTarget` instead of
|
||
`IsHostileMonster`), matching `CombatAttackTargetSource`'s live path
|
||
defensively, but it currently exists only to keep the (also unused-in-
|
||
production) `IsAttackableTarget` member exercised by four `IWorldSelectionQuery`
|
||
fakes. Fix shape: delete the dead method (and, if nothing else calls
|
||
`IsAttackableTarget` through 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.GetSelectedOrClosestTarget` has
|
||
the same player-exclusion bug #298 fixed for graphical hosts.
|
||
MEDIUM.** `src/AcDream.Headless/Hosting/HeadlessGameplayOperations.cs:235-244`
|
||
checks explicit selection via `RuntimeHostileTargetQuery.IsHostile`
|
||
(`src/AcDream.Runtime/Gameplay/RuntimeHostileTargetQuery.cs:73-101`), which is
|
||
the monster-only `CombatTargetPolicy.IsHostileMonster` gate — structurally
|
||
identical to the bug #298 fixed in `CombatAttackTargetSource`/
|
||
`SelectionInteractionController`. A headless bot that explicitly selects a
|
||
compatible-PK player and attacks will fall through to `SelectClosestTarget()`
|
||
(since `AutoTarget` is hardcoded `true` at `:128`) instead of attacking the
|
||
selected player. Pre-existing (not introduced by #298 — confirmed by the
|
||
same `9966b531`/`3361a8d7`/`2644d1d5`/`0f2d98c5` diff 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 an `ObjectIsAttackable`-backed
|
||
explicit-admission query to `RuntimeHostileTargetQuery` (or a sibling) and
|
||
route `GetSelectedOrClosestTarget`'s explicit branch through it, leaving
|
||
`FindClosest`/auto-acquisition on the narrow policy. Filed from the #298
|
||
review.
|
||
|
||
## WeenieError message-mapping coverage — 2026-08-03
|
||
|
||
- **#306 — OPEN — `WeenieErrorMessages` should map every code retail's
|
||
`HandleFailureEvent` switch 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.cs` maps 60 of 378
|
||
`WeenieError` enum values (`src/AcDream.Core/Physics/WeenieError.cs`);
|
||
the raw `WeenieError 0xNNNN[: param]` fallback is the norm for any code
|
||
a player actually triggers outside chat/channel/tell/allegiance
|
||
chatter. Target state: `WeenieErrorMessages` handles 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 at
|
||
`docs/research/named-retail/acclient_2013_pseudo_c.txt:382616`+. Each
|
||
case constructs a literal UTF-16 string and calls
|
||
`ClientSystem::AddTextToScroll`. **The pseudo-C dump truncates long
|
||
string literals at their declared array bound mid-sentence** (e.g.
|
||
`data_7d32c0`'s declared `wchar16 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 at `C:\Users\erikn\Downloads\acclient.exe` (imagebase
|
||
0x400000, verify pairing with `tools/pdb-extract/check_exe_pdb.py`
|
||
first) via VA → RVA → file-offset mapping — the technique is written
|
||
up in `claude-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 `, 0` right 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 address `0x00571dc5` — a decompiler/codegen artifact from the
|
||
compiler lowering one sparse switch into several contiguous
|
||
range-checked jump tables chained together (each guarded by its own
|
||
`if (arg2 > X) { if ((arg2 - Y) <= Z) switch(arg2) {...} }`). One
|
||
example, confirmed by dumping its jump table
|
||
(`acclient_2013_pseudo_c.txt:385851-385971`): `arg2 > 0x4e8` then
|
||
`(arg2 - 0x4e9) <= 0xaa` selects a 171-entry table covering codes
|
||
`0x4e9`–`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**, spanning
|
||
`0x17`–`0x593` in 41 contiguous runs with gaps between them. Codes
|
||
that appear in none of the six sub-switches (and are not one of the
|
||
five `AbortAutomaticAttack`-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:**
|
||
|
||
1. **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.Format` has no
|
||
colour concept today; `ChatLog.OnWeenieError` emits a plain
|
||
`ChatEntry` with `Kind: ChatKind.System` and no colour field.
|
||
Before porting the full table, check whether `ChatEntry`/`ChatVM`
|
||
can express a per-entry colour at all — if not, that's a
|
||
prerequisite sub-task, not a detail to skip.
|
||
2. **`AbortAutomaticAttack` side effect — verified true.**
|
||
`HandleFailureEvent` opens 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`, block
|
||
`0x005719b1`-`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 — `0x43` has 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 — likely
|
||
`RuntimeActionState`'s combat-attack owner per J5.3 — and the
|
||
equivalent abort call) rather than be bundled silently into the
|
||
string-table commit.
|
||
3. **`%s`-parameterised (WithString) cases.** A meaningful fraction of
|
||
the 339 cases build their string via
|
||
`PStringBase<unsigned short>::sprintf(..., u"...%s...")` using
|
||
`arg3->m_charbuffer` (the event's string parameter) rather than a
|
||
bare literal — e.g. case `0x4e9`/`0x4ea` (`acclient_2013_pseudo_c.txt:382636-382653`).
|
||
These map to `WeenieErrorWithString` on our side
|
||
(`WeenieErrorMessages.WithStringTemplates`, `_`-placeholder
|
||
convention). When porting, classify each case as plain-literal
|
||
(→ `NoParamTemplates`) or `%s`-templated (→ `WithStringTemplates`)
|
||
— do not assume the split matches `WeenieError` vs
|
||
`WeenieErrorWithString` enum 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 `WeenieErrorMessages` claims to
|
||
map and asserts `Format(code, ...)` never returns the
|
||
`WeenieError 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_7dXXXX` address referenced by a `case` in 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 and
|
||
`data_7dXXXX` symbol) and
|
||
`tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs`.
|
||
|
||
## C4 accepted-position authority — 2026-08-03
|
||
|
||
- **#307 — DONE (2026-08-03) — `AcceptedPhysicsTimestamps.PreviousTeleport` was
|
||
always 0 on the live Position path, silently dropping every local-player
|
||
ForcePosition correction after the character's first teleport.**
|
||
`InboundPhysicsStateController.TryApplyPosition` called its private
|
||
`Current(gate, teleportAdvanced: …)` helper without the `previousTeleport`
|
||
argument, which defaulted to a literal `0`. The only site that populated it
|
||
was the deferred initial-create path `TryAcceptDeferredPosition`, which is
|
||
why `RuntimeInitialCreateContinuationExecutor` was correct and every newer
|
||
consumer was not.
|
||
|
||
**Live blast radius.** Shipped in C4 route 2 (`9966b531`).
|
||
`LiveEntityNetworkUpdateController.cs` and
|
||
`RuntimeLiveEntitySessionController.cs` feed the value into
|
||
`RuntimeAcceptedPositionDriveController.TryExecuteAcceptedLocalPosition`,
|
||
where `RuntimeAuthoritativePositionRouteClassifier.ValidAcceptedAuthority`
|
||
requires `PreviousTeleportSequence == AcceptedTeleportSequence` for a
|
||
`ForcePosition` disposition — 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).
|
||
With `Previous` pinned 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-safe `TeleportRegressed` check would
|
||
also have fired against the 0, rejecting ordinary `Apply` positions too.
|
||
|
||
**Fix.** Capture `previousTeleport = gate.TeleportTimestamp` BEFORE
|
||
`TryAcceptPositionEvent` mutates it (the exact shape
|
||
`TryAcceptDeferredPosition` already used) and pass it through. The zero
|
||
default on `Current` is removed outright — the parameter is now `ushort?`
|
||
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_ReportsThePreEventTeleportStamp`
|
||
and
|
||
`…LocalPlayerForcePositionAfterATeleport_ClassifiesAsAnAcceptedForceCorrection`
|
||
(the second drives the real classifier and fails with `RejectedAuthority`
|
||
against 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) commits
|
||
`settle.Position` but never reads `settle.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-570` is 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) `/ls` works; (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.**
|
||
`EntityEffectController` now 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 at `f24532ad`.
|
||
- **#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
|
||
at `radius=12`** where pre-fix it was a hardcoded `1`, 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 set
|
||
`ACDREAM_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 under `radius=12`.
|
||
**Fixed across `3aab05b0` (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's `PreFetchCells` also requires each
|
||
landblock's `LandBlockInfo` and 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"`
|
||
over `src/` returns nothing. The correct premise is the configured
|
||
*streaming/fog* window, `QualitySettings.FarRadius`: at the shipped `High`
|
||
preset 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.IsRenderNeighborhoodResident` demanded `IsNearTier` for
|
||
every ring member, so any radius above `NearRadius` was unsatisfiable and
|
||
would have held the reveal forever; and
|
||
`RuntimeWorldTransitState.AcknowledgeDestinationReadiness` re-derived and
|
||
asserted `indoor ? 0 : 1`, so a changed radius failed
|
||
`invalid-readiness-shape` and 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 in
|
||
`PhysicsEngine.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 at
|
||
`UserPreferences::RegisterPreference` @0x0054ECBE and pushed into
|
||
`SmartBox::set_mid_radius` @0x00453180. acdream's structural analogue is the
|
||
quality preset's `NearRadius`/`FarRadius` pair, 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_cells` is latched, retail reports
|
||
`ECM_DDD::SendNotice_RuntimeDDDStatus(active, remaining, total)` @0x00692870
|
||
into `gmPowerbarUI::RecvNotice_RuntimeDDDStatus` @0x004DA5C0, which drives a
|
||
powerbar progress bar with an "N of M" cell count (string id
|
||
`ID_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.cs` each hardcode it with no config path. Retail's
|
||
`Render::zfar` is statically initialised to **4000.0** (byte-verified at
|
||
`0x0081EC88`), and the only writers are `GameSky::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 `01f4791e` and
|
||
`670f307c` broke 28 tests in one commit (`f24532ad` added 2 more), while the
|
||
Runtime suite lost 13 — 12 of them in `RuntimeRemoteFirstEntryStateTests`,
|
||
the exact conductor `670f307c` gated. 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 returns
|
||
`RetrySetupUnavailable` until 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 own `LiveWorldOriginState` (initialized from the player
|
||
spawn; `Recenter` called only from
|
||
`StreamingOriginRecenterCoordinator.Advance` at 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`): `EffectCellId` is now populated at materialization and
|
||
wins over `ParentCellId` in `EntityEffectPoseRegistry.UpdateRoot`, and
|
||
projectile classification reads the canonical body's own
|
||
`CellPosition.ObjCellId` instead of deriving it from the sidecar. Two
|
||
rendering fixtures still modelled the pre-change shape. New
|
||
`RuntimeWorldFrameTests` pins 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 @ 0x00455820` sets
|
||
`blocking_for_cells` until `LScape::PreFetchCells @ 0x00505660` has walked
|
||
the configured `mid_radius` square and each required
|
||
`CLandBlock::PreFetchCells` / `CLandBlockInfo::PreFetchCells` building and
|
||
connected EnvCell dependency is available. While blocked,
|
||
`SmartBox::UseTime @ 0x00455410` checks 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 during `TAS_TUNNEL_CONTINUE`
|
||
before 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 `GameWindow` decomposition 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 in
|
||
[`docs/architecture/code-structure.md`](architecture/code-structure.md).
|
||
- **Active M4 prelude:** resume
|
||
[`plans/2026-07-23-world-interaction-completion.md`](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:** `#116` slide response, `#273` tight-gap
|
||
collision clearance, and deferred restricted-house gate `#274`;
|
||
`#153` closed 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:** `#226` retail 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:** `#228` records 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 `a9a822f2`
|
||
with one presentation-independent `GameRuntime` shared by graphical and
|
||
no-window hosts. Slice K closed through `776482da` with 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`](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. `#248` came out of the V3 audit.
|
||
- **Deferred Linux graphical track:** L0 completed at `66f114b2`; L1's backend
|
||
selection/capability implementation checkpoint landed at `11501d52`.
|
||
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](README.md) 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-6 `hasSphere1`) — reverting it locally
|
||
produced byte-identical replay output; its site is provably unreached
|
||
by either mined trajectory (`hit1` never 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:** `IsOpaque` counts `Base1ClipMap` as opaque
|
||
(`WbDrawDispatcher.cs` `IsOpaque`, 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`) BEFORE `FlushLandscapeAlpha`
|
||
(`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:
|
||
|
||
1. **No confirmed retail CONSUMER of `WATER_CONTACT_TS` was found.** The
|
||
write site (`CPhysicsObj::SetPositionInternal`, pc:283459-283483) is
|
||
confirmed; a full xref scan for READS of bit 0x8 on `transient_state`
|
||
was 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::
|
||
SetPositionInternal` once reachable, or a live cdb capture.
|
||
2. **`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.
|
||
3. **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`](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`. `RetailChaseCameraTests` sets `AlignToSlope` and
|
||
`CollideCamera` to `false`, and three classes set `UseRetailChaseCamera` to
|
||
`false` — all *away from* their defaults. Read by `RetailChaseCamera.Update`,
|
||
`CameraController.Active`, `CameraFrameController`, `WorldRenderFrameBuilder`
|
||
and `MouseLookController`.
|
||
- `AcDream.Core.Rendering.RenderingDiagnostics.ProbeFlapEnabled` — written by
|
||
`CornerFloodReplayTests` and `Issue181WallPressEquilibriumTests`.
|
||
- `System.Console.Out` — redirected via `Console.SetOut` by 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:274` `RetireFence`
|
||
- `src/AcDream.App/Rendering/GpuFrameFlightController.cs:474` `SilkGpuFenceApi.Wait`
|
||
- `src/AcDream.App/Rendering/GameWindowLifetime.cs:419` shutdown's `frame 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_ReusesGlyphTableWithoutAllocating`
|
||
- `AcDream.App.Tests.Rendering.RenderFrameProductTests.WarmProductBuildAndBorrowAllocateNothing`
|
||
- `AcDream.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 across `scene.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:
|
||
|
||
1. **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.
|
||
2. **First-call cost inside the window.** `ArchRenderSceneTests` warmed the
|
||
`ApplyRegister` arm and measured the `ApplyUpdate` arm, so the measured call
|
||
was the first ever into that code: its tier-0 JIT, type loads and static
|
||
initialisation all landed inside. `RenderFrameProductTests` warmed 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:
|
||
|
||
1. Callers go on to `glDeleteTexture` a texture whose bindless handle is still
|
||
resident, which `GL_ARB_bindless_texture` leaves undefined. Six other caches in
|
||
the tree (`TextureCache`, `CompositeTextureArrayCache`,
|
||
`StandaloneBindlessTextureCache`, `TerrainAtlas`) do call `MakeNonResident`
|
||
first, so the omission is inconsistent as well as unsafe.
|
||
2. 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 `Multisample` dimension of `GlRenderStateCache`. Mistyping
|
||
that comparison would leave the whole suite green — that is exactly the
|
||
regression that forced the V4a revert (`9aaf97e7`). Add a positive test plus
|
||
`Assert.False(changes.Multisample)` in the existing negative cases.
|
||
- Add an encoding guard: an `.editorconfig` with `charset = utf-8` and a
|
||
`.gitattributes` text 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`](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`](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`](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`](research/2026-05-06-issue-38-render-interp-pseudocode.md);
|
||
[`docs/research/2026-07-19-r6-complete-root-frame-pseudocode.md`](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`](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`](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`](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 plugin `saw 37085 entities total` at 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.txt` for how
|
||
`CMotionInterp`/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):**
|
||
1. 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).
|
||
2. 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 with `adjust_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.
|
||
3. **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=Y` on 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) — NOT `0178/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: root `01C1`
|
||
(top) ⊇ {015F,015E}; root `015F` drops `01C1`; root `015E` drops both `015F` AND `01C1`.
|
||
The up-cell dies at the **CLIP, never the side test** (root `015F`→`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) → `ClipPortalAgainstView`
|
||
rejects 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) draws `cell_draw_list` (the portal flood), and `polyClipFinish` ALSO
|
||
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
|
||
relax `ClipToRegion`'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):**
|
||
1. First attempt (log): `use-deferred seq=624` fired (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.
|
||
2. Retail control (capture `door-use3.pcapng`): retail sent
|
||
`[0xF7B1][seq][0x36][guid]` ×5 — ACE responded EVERY time with door
|
||
`0xF74C` UpdateMotion + `0xF74B` SetState (ETHEREAL toggling 0x1001C ↔
|
||
0x10018), broadcast to BOTH clients. Message format identical to ours.
|
||
3. acdream re-try (capture `door-use4.pcapng` + log): 2× DoubleClick + 4× R
|
||
— picks land, but ZERO `[B.4b] use` lines 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:
|
||
1. 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.
|
||
2. After the jump/run sequence: the LAST player `pending=False` completion
|
||
is at the first `MovementJump Press` (log line 371); from there to the
|
||
end (line 939) every player MOTIONDONE reports `pending=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.
|
||
3. With MotionsPending() true, `BeginTurnToHeading`/`BeginMoveForward`
|
||
(retail 0x00529b90 `if (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=0x06` arms, 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 →
|
||
`_pendingPostArrivalAction` never fires → use eaten with zero feedback.
|
||
4. Retail contrast: the #170 live cdb drain trace showed retail's queue
|
||
stays SHALLOW (add_to_queue == MotionDone, drained same-tick); our
|
||
`CheckForCompletedMotions` completes ~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 fix `427332ac` removed 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:
|
||
|
||
1. **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 to `PositionManager::StickTo` → `StickyManager::adjust_offset`
|
||
(0x00555430): per-tick 0.3 m edge-gap + facing tracking against the moving
|
||
target. Our `MoveToManager.BeginNextNode` invokes the `StickTo` seam — unbound
|
||
— so attackers complete-and-freeze at stale arrival poses until the next wire
|
||
re-arm.
|
||
2. **Arrival radii are zero.** ACE/retail arrive edge-to-edge
|
||
(`cylinder_distance`, own + target radii from the PartArray/setup —
|
||
`ACE PhysicsObj.MoveToObject` reads `GetRadius()/GetHeight()` from the TARGET).
|
||
acdream binds `getOwnRadius: () => 0f` (GameWindow `EnsureRemoteMotionBindings`,
|
||
the explicit R5-V3 pin) AND `RouteServerMoveTo` never sets
|
||
`MovementStruct.Radius/Height` → both radii 0 → every attacker closes ~one
|
||
body-radius deeper than retail → dogpile.
|
||
3. 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:
|
||
1. **Wrong/uniform attack animations** → **#159 is DONE (`2de5a011`) but was a
|
||
RED HERRING for this creature.** The Mite Scamp's attacks are wire
|
||
`0x62/0x63/0x64` = `AttackHigh1/Med1/Low1`, which live in the ALREADY-CORRECT
|
||
low block — they were never misnumbered, and `CombatAnimationPlanner` isn't
|
||
even wired into the runtime dispatch (the live path is `AnimationCommandRouter`
|
||
→ `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 because `pending_motions`
|
||
completes+re-queues) and/or the sequencer not selecting per-command frames.
|
||
2. **Over-frequency / stuck attack** — motion trace: ACE streams attack UMs
|
||
(`mt=0x00 cmd=0x62/0x63/0x64 spd=0.97`) on top of the `mt=0x06` chase;
|
||
acdream's `pending_motions` completes+re-queues in a tight MOTIONDONE loop
|
||
(`pending=True` spam) → over-plays. R3/R4 pending_motions/MotionDone area.
|
||
3. **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:
|
||
1. **Every inbound UM fires the `unpack_movement` head interrupt**
|
||
(`remoteMot.Motion.InterruptCurrentMovement`, GameWindow ~4893) which is bound
|
||
to `MoveTo.CancelMoveTo(ActionCancelled)` (GameWindow ~4319). So each `mt-0`
|
||
attack UM **cancels the active chase MoveTo.**
|
||
2. `MoveToInterpretedState` → `ApplyInterpretedMovement(cancelMoveTo:true)`
|
||
installs the UM's `ForwardCommand`, 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").
|
||
The `ServerActionStamp` 15-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 labelled `ACDREAM_DUMP_MOTION=1` capture
|
||
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-6 `MoveToRunRate`, 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 vs
|
||
`RunAnimSpeed × dispatched speed`; check what speed ACE's MoveToChain
|
||
actually steps at (Player_Move.cs / Creature GetRunRate usage).
|
||
**Where:** GameWindow `TickRemoteMoveTo` + the L.3 M2 queue chase vs
|
||
`MotionTableDispatchSink` 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 = 0` write 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. See
|
||
`Issue165RemoteWallPenetrationDiagnosticTests.SingleLargeTickJumpThroughObstacle_IsStillBlockedAtSurface`
|
||
and
|
||
`DoorCollisionApparatusTests.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`'s `false` at `CellTransit.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 as `SetPosition`) 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`: with `0xC95A` unregistered, resolve returns `currentCellId`
|
||
unchanged). 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 `_liveCenter` render-only + `get_block_offset` as 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: workflow `wf_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):**
|
||
1. **Drop the stale source center landblock from physics at the teleport recenter** (`GameWindow.OnLivePositionUpdated`, `differentLandblock` branch → `_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.Resolve` NO-LANDBLOCK verbatim, `:605`) until the destination streams in.
|
||
2. **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 (`IsSpawnCellReady` hold). Gate suppression during the hold kept (`DungeonStreamingGate`).
|
||
3. **Clear a dangling `CellGraph.CurrCell` when 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 is `X=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` (property `0x6f`) 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`) `ZLevel`s (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 of `gmToolbarUI::HandleSelectionChanged`); `SelectionChanged` event on `GameWindow`; `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 wire `PluralName` selected by `NAME_APPROPRIATE`; reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`; 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 Core `StackSplitQuantityState` feeds the controls plus selected-source merge, container split (`0x0055`), and ground split (`0x0056`). `GetObjectSplitSize @ 0x00586F00` ensures 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 Core `ItemManaState`; valid matching responses reveal/fill authored meter `0x100001A2`, 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 `SelectedObjectHealthPolicy` ports `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` and the toolbar's outer player/pet short-circuit. CreateObject now parses second-header `PetOwner (0x8)` after MaterialType/Cooldown fields and carries it through `WorldSession`/`WeenieData` into `ClientObject`. 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:
|
||
1. ~~**Chat text colors**~~ — **CLOSED 2026-08-09, Campaign CH slice CH1.** `ChatWindowController.RetailChatColor(ChatKind)` (the old best-effort per-`ChatKind` map) is deleted; coloring now keys off the entry's retail wire `LogTextType` through the exact 34-entry `RetailChatColorTable`, a faithful port of `ChatInterface::BuildChatColorLookupTable @0x004F31C0` with every RGBA float read from the PDB-paired binary's `.data` section — see `docs/research/2026-08-09-chat-retail-color-table.md` and `docs/plans/2026-08-09-chat-parity-campaign.md`.
|
||
2. **Buttons** — the chat buttons (Send, Max/Min, and the channel "Chat ▸" menu button) want visual polish: **pressed / hover state feedback** (`UiButton` currently draws only its default-state sprite; the dat carries `Normal`/`Pressed`/`Highlight` states 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_SPAWNS` trace shows ACE DOES re-send `CreateObject` for 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 subsequent `GpuWorldState.AddLandblock` record-replace for the same landblock, OR the per-instance render data (`WbEntitySpawnAdapter.OnCreate`) isn't built/registered, OR a render-root/visibility gate while `CurrCell` re-acquires.
|
||
- **(B) Own avatar stops rendering after a couple of round-trips.** The player entity (persistent, rescued+re-injected via `GpuWorldState.DrainRescued`→`AppendLiveEntity` at `GameWindow` ~: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), so `EntityClassificationCache` (keyed on `Id`) is ALWAYS a miss for them — never the cause. *(Side-finding, separate minor bug: the cache has a demote-vs-unload invalidation asymmetry — `RemoveEntitiesFromLandblock` fires `_onLandblockUnloaded` (`:~495`) but `RemoveLandblock` does 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, every `ServerGuid!=0` with `MeshRefs>0` → dynamic) + `DrawDynamicsLast` draw them.
|
||
- **The ACTUAL cause: re-delivery is unreliable / absent.** `notan/+Je` walk-around run: after teleport-out, `live:spawn` doors = **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 earlier `testaccount2` run 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.2f` and `InitPath` places the head sphere center at
|
||
`height − radius` = 0.72 — the top 0.63 m of a 1.83 m character had NO
|
||
collision. The dat human Setup 0x02000001 (dumped in
|
||
`HumanSetup_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::transition` 0x00512dc0 →
|
||
`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 in
|
||
`WindowShaft_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):
|
||
1. 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+ε).
|
||
2. 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`).
|
||
3. **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_up` 0x0050b6cc 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.
|
||
4. Offline repro: `Issue137CorridorSeamReplayTests` (3 tests, currently
|
||
`Skip="#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 our `TransitionalInsert` attempt 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):**
|
||
1. **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 in
|
||
`CellStruct.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.
|
||
2. **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=no` with 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 is
|
||
`validate_transition` (0x0050ac21); the BSP/sphere layer never writes it,
|
||
and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1)
|
||
is success-only. Our BSPQuery Contact-branch full-hit responses were
|
||
STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the
|
||
real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve,
|
||
`ok=True` in 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 real
|
||
`Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440,
|
||
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 existing `EnterDungeonCollapse`
|
||
early (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 the `EnvCell` dat `SeenOutside` flag
|
||
(the same flag the hydrated `ObjCell.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 `PortalSpace` hold the observer follows the
|
||
recentered destination (not the frozen position); `_lastLivePlayerLandblockId` is 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 trip `ExitDungeonExpand`.
|
||
Adversarially reviewed (3 lenses); register row AP-36 amended. Tests in
|
||
`StreamingControllerDungeonGateTests` (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`](../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 = 4`
|
||
is the *actual* termination mechanism for cyclic graphs. The
|
||
`if (nview.Polygons.Count > before)` re-enqueue-on-growth guard is a
|
||
near-no-op because `CellView.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 `OutsideView` polygons and
|
||
216-poly `CellView`s 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`):
|
||
- `AddToCell` 433050 — `esi[0x11]` update-count/slice watermark on the cell
|
||
- `InitCell` — per-cell timestamp init
|
||
- `AddViewToPortals` 433446 — 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 the
|
||
`MaxReprocessPerCell` cap + re-enqueue-on-growth guard with a
|
||
per-cell slice watermark; honest-limitation comment lives at the
|
||
`MaxReprocessPerCell` declaration.
|
||
- `src/AcDream.App/Rendering/PortalView.cs` — `CellView.Add` currently
|
||
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:
|
||
1. The `cellTransform` applied to physics in
|
||
`_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)`
|
||
at `GameWindow.cs:5384` includes the `+0.02f` Z 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.
|
||
2. 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`](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`](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` — `ResolveCellId` outdoor-then-indoor branch logic
|
||
- `src/AcDream.Core/Physics/CellTransit.cs:235-325` — `FindCellList` / `BuildCellSetAndPickContaining` containment test
|
||
- `src/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`](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`](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=4` per cell (normal outdoor).
|
||
- Post-teleport: `visibleCells=135-145` per cell at landblock 0x0007 + spurious cells from 0x020A and 0x0408 (different worldOrigins, i.e. different dungeons entirely).
|
||
- Cell-transit chain: `0xA9B40003 -> 0x00070143 reason=teleport` is 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 owns `visibleCells`)
|
||
- Check `EnvCellRenderManager` + `VisibilityManager` in `references/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.FindEnvCollisions` indoor 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`](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 b3ce505 stopgap
|
||
|
||
**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` — `GetNearbyObjects` indoor branch
|
||
- `src/AcDream.Core/Physics/TransitionTypes.cs:2180+` — `FindObjCollisions` caller
|
||
|
||
**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) Contact` branch 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.2` in 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 and `SphereIntersectsSolidInternal` correctly rejects.
|
||
- Retail succeeds because its `check_cell` transitions to cottage main floor cell 0xA9B40146 during the ascent, where the cellar's ceiling polygon is absent. Our `check_cell` stays at cellar 0xA9B40147.
|
||
|
||
Full slice 5 evidence + sharpened next-step pickup at [`docs/research/2026-05-22-a6-p3-slice5-handoff.md`](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`](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.cs`](tests/AcDream.Core.Tests/Physics/Issue98CellarUpReplayTests.cs) loads the three cottage/cellar cell fixtures (captured live via the new `ACDREAM_DUMP_CELLS` probe) 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`](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_walkable` accepts 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 `ContactPlane` writes 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_edge` fires ONCE in 35K BPs. Acdream uses it heavily.
|
||
|
||
**Fix targets** (priority order, from the comparison doc):
|
||
1. (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.AdjustOffset` slope projection + `Transition.DoStepUp` WalkInterp handling.
|
||
2. Cottage-cell candidacy uses wrong sphere reference (pre-step-up vs step-lifted center).
|
||
3. `find_crossed_edge` over-use in our walkable acceptance path.
|
||
4. (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):**
|
||
1. Port retail's cell-array hysteresis from `CObjCell::find_cell_list`.
|
||
2. Modify `ResolveCellId` to prefer the previous tick's CellId when the sphere is close to (but slightly outside) the previous cell's CellBSP volume.
|
||
3. Modify `CheckBuildingTransit` similarly for building-shell transitions.
|
||
4. 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 inside `Transition.AdjustOffset` (PhysicsDiagnostics.LogStepWalkAdjust + four branch tokens). Reveals which projection branch fires per call.
|
||
- `8daf7e7` — captured findings note at [`docs/research/2026-05-23-a6-stepwalkadjust-findings.md`](docs/research/2026-05-23-a6-stepwalkadjust-findings.md) + log snapshot at `docs/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-plane` branch 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). 101 `stepdown-reject` hits in the capture vs 1 acceptance.
|
||
- **Shape 1 fix attempted (`0cb4c59`, reverted in `402ec10`):** Added `PhysicsGlobals.ContactPlaneFlatThreshold = 0.99f` and gated `BSPQuery.AdjustSphereToPlane`'s two `SetContactPlane` call sites by `worldNormal.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 in `contact=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_plane` on sloped polygons doesn't match retail behavior. Either retail synthesizes a flat CP from a sloped contact (the `step_sphere_down:321203` `Plane::Plane(&plane, esi, &point)` codepath — `esi` may be a synthesized direction, not the polygon's normal), OR retail's gate is upstream of `set_contact_plane` (the polygon never reaches CP-setting in the first place), OR our `OnWalkable` tracking is over-coupled to `ContactPlaneValid` in 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:
|
||
|
||
1. **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.
|
||
2. **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.
|
||
3. **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`](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`](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). `CellDumpSerializer` correctly 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 via `RegisterStairRampGfxObj`.
|
||
- **Finding:** `CellDumpSerializer.Hydrate` sets `BSP=null` per its xmldoc — so the indoor BSP collision path is skipped for hydrated fixtures. Harness wraps cells with a synthetic one-leaf BSP via `AttachSyntheticBsp` to fire the indoor path.
|
||
- **Finding:** `PhysicsBody` seeding requires BOTH `ContactPlane*` AND `WalkablePolygon*` fields. The engine at `PhysicsEngine.cs:665-673` only calls `SpherePath.SetWalkable(...)` if `body.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`](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=N` outbound
|
||
(verified in `launch.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:
|
||
|
||
1. **Timing of `_liveSession` field assignment.** The new code assigns
|
||
`_liveSession` inside `WireLiveSessionEvents` before subscriptions
|
||
run, and again after CreateAndWire returns. The original code set
|
||
`_liveSession` once at the inline `new WorldSession(...)` site. A
|
||
subtle ordering bug between subscriptions and `_liveSession`'s
|
||
externally visible state may matter.
|
||
2. **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 of `WireLiveSessionEvents`).
|
||
3. **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 `_liveSession` field
|
||
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.
|
||
4. **Some non-subscription side effect** in `WireLiveSessionEvents`
|
||
that 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` — `TryStartLiveSession` split
|
||
+ new `WireLiveSessionEvents` method
|
||
|
||
**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](references/holtburger/crates/holtburger-core/src/client/simulation.rs))
|
||
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](src/AcDream.App/Input/PlayerMovementController.cs)
|
||
— 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](src/AcDream.Core/Ui/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](src/AcDream.Core/Physics/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`):
|
||
1. Per-part sphere reject via `CGfxObj::drawing_sphere`.
|
||
2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every
|
||
visual polygon; closest-t polygon hit wins over any sphere hit.
|
||
|
||
Commit B's Stage A
|
||
([WorldPicker.cs](src/AcDream.Core/Selection/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](src/AcDream.App/UI/Layout/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:
|
||
1. **B.6 slice 2 (2026-05-14):** inbound MoveToObject parsing + `BeginServerAutoWalk` wiring at `GameWindow.cs:3389` — body auto-walks toward the server-supplied destination.
|
||
2. **B.6 #75 refactor (`f035ea3`, 2026-05-16):** `ApplyAutoWalkOverlay → DriveServerAutoWalk` drives the body directly from path data, no input synthesis. The `MoveToState` leak that previously cancelled ACE's `MoveToChain` callback is gone; the chain runs uninterrupted and `TryUseItem` / `TryPickUp` fires 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](references/holtburger/crates/holtburger-core/src/client/simulation.rs)
|
||
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:
|
||
```csharp
|
||
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`](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` / `IsDoorName` helpers, spawn-time `AnimationSequencer` registration branch in `OnLiveEntitySpawnedLocked`, `_doorSequencers` dict, `[door-cycle]` diagnostic in `OnLiveMotionUpdated`, `TickAnimations` loop 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](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` — `OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `OnLiveStateUpdated` ServerGuid→Id translation; `_entitiesByServerGuid` reverse-lookup dict
|
||
- `src/AcDream.UI.Abstractions/Input/InputDispatcher.cs` — double-click detection
|
||
- `src/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:**
|
||
1. Investigate ACME's full per-vertex filter set (road + building + anything else)
|
||
and port them as a coherent unit, not piecemeal.
|
||
2. 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.
|
||
3. 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` — `GenerateInternal` is the active path
|
||
- `src/AcDream.Core/World/WbSceneryAdapter.cs` — adapter used by `GenerateInternal`
|
||
- `references/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):**
|
||
|
||
1. The displacement-noise math in `SceneryGenerator` differs from
|
||
retail's `chunk_005A0000` LCG by a constant or a sign flip. Audit
|
||
`eeee4c5` claimed "all MATCH" against the decomp, but a runtime
|
||
trace would prove or disprove.
|
||
2. 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.
|
||
3. The `obj.Align != 0` path 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.
|
||
4. Slope filter could reject a cell retail accepts (or vice versa),
|
||
pushing trees into adjacent cells.
|
||
5. Region-table / `SceneInfo` lookup might select a different
|
||
scenery list for the cell type.
|
||
|
||
**Investigation plan (gold-standard, per `project_retail_debugger.md`):**
|
||
|
||
1. Run the existing `ACDREAM_DUMP_SCENERY_Z=1` diagnostic to capture
|
||
acdream's full per-spawn (gfx, world XY, scale, partT) for
|
||
landblock `0xA9B3FFFF`.
|
||
2. Attach cdb to a live retail client at the same Holtburg spot
|
||
(`tools/pdb-extract/check_exe_pdb.py` confirms PDB pairs with
|
||
v11.4186). Set a breakpoint on `CLandBlock::get_land_scenes` (or
|
||
the inner `chunk_005A0000` placement function); capture every
|
||
`(gfxObjId, worldX, worldY, scale, heading)` retail emits for
|
||
the same landblock.
|
||
3. 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`](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 point
|
||
- `chunk_005A0000.c` — referenced retail source per `SceneryGenerator.cs` comments
|
||
- [`docs/research/named-retail/symbols.json`](docs/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`](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`](src/AcDream.Core/World/SceneryGenerator.cs:204)
|
||
returns `LocalPosition.Z = obj.BaseLoc.Origin.Z` (just the
|
||
ObjectDesc's BaseLoc Z offset, no terrain). [`GameWindow.cs:4642`](src/AcDream.App/Rendering/GameWindow.cs:4642)
|
||
adds the terrain ground Z:
|
||
|
||
```csharp
|
||
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):**
|
||
|
||
1. **Per-GfxObj origin convention.** Most AC tree GfxObjs are
|
||
authored with local origin at the trunk base (mesh vertices
|
||
have `Z >= 0` measured up from the origin). A few species
|
||
may be authored with origin at bbox-center or visual top —
|
||
for those, `finalZ = groundZ + BaseLoc.Z` plants 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.
|
||
|
||
2. **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:4643` uses 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.
|
||
|
||
3. **Same close-degrade story as #47, applied to scenery.** Some
|
||
tree GfxObjs have `DIDDegrade` tables; 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`](src/AcDream.Core/World/SceneryGenerator.cs:204) — BaseLoc.Z passthrough
|
||
- [`src/AcDream.App/Rendering/GameWindow.cs:4632-4655`](src/AcDream.App/Rendering/GameWindow.cs:4632) — groundZ resolution + finalZ assembly
|
||
- [`src/AcDream.Core/Physics/TerrainSurface.cs`](src/AcDream.Core/Physics/TerrainSurface.cs) — physics sampler (AC2D split-direction formula)
|
||
- `SampleTerrainZ` (private, in GameWindow.cs) — bilinear fallback
|
||
- [`src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs`](src/AcDream.Core/Meshing/GfxObjDegradeResolver.cs) — close-degrade resolver if hypothesis 3 confirmed; would need scenery-scope expansion (drop the `IsIssue47HumanoidSetup` gate 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](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 gate `if (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` —
|
||
`PlanFromVelocity` thresholds (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 investigation
|
||
- `docs/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 workflow
|
||
- `docs/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 `UmGraceSeconds` window 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:
|
||
|
||
```c
|
||
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`:
|
||
|
||
```csharp
|
||
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 to
|
||
> `return 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):**
|
||
|
||
1. **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).
|
||
|
||
2. **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 pass `isOnGround:
|
||
!rm.Airborne` (= false for airborne). Check whether the
|
||
`stepUpHeight` / `stepDownHeight` parameters are unconditionally
|
||
used inside `ResolveWithTransition` regardless of the
|
||
`isOnGround` flag.
|
||
|
||
3. **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` —
|
||
`ResolveWithTransition` and any internal `CTransition` /
|
||
`find_valid_position` helpers. 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 from `transition()`)
|
||
- `SpherePath` initialization
|
||
- The verbatim retail depenetration logic for airborne bodies
|
||
|
||
If our port differs from retail in this region, that diff is likely
|
||
the bug.
|
||
|
||
**Repro:**
|
||
|
||
1. Launch acdream + retail client side-by-side connected to local ACE.
|
||
2. Have retail char stand still on outdoor terrain at any position X.
|
||
3. Jump in place.
|
||
4. 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.cs`
|
||
- `src/AcDream.Core/Physics/RemoteMotionCombiner.cs`
|
||
- `src/AcDream.App/Physics/RemotePhysicsUpdater.cs`
|
||
- `src/AcDream.App/Rendering/GameWindow.cs`
|
||
- `tests/AcDream.Core.Tests/Physics/HumanoidMotionTableRootMotionTests.cs`
|
||
|
||
**Research:**
|
||
|
||
- `docs/research/2026-07-17-remote-root-motion-reconciliation-pseudocode.md`
|
||
- `docs/research/2026-05-02-remote-entity-motion/resolved-via-cdb.md`
|
||
- named retail `CPhysicsObj::UpdatePositionInternal @ 0x00512C30`,
|
||
`CPartArray::Update @ 0x00517DB0`, `add_motion @ 0x005224B0`, and
|
||
`InterpolationManager::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):
|
||
|
||
1. `PositionManager.ComputeOffset` is 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
|
||
retail `UpdatePositionInternal @ 0x00512c30`.
|
||
2. `ResolveWithTransition` is **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:
|
||
|
||
1. **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 `UpdatePosition` arrival. Body never follows
|
||
the terrain mesh between UPs.
|
||
|
||
2. **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 branch
|
||
- `src/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-03` chronological commit log + visual verification
|
||
- `docs/research/2026-05-03-remote-anim-cycle/investigation-prompt.md`
|
||
for 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 `UpdatePosition` queue 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` — `PhysicsTick` constant
|
||
- `src/AcDream.App/Input/PlayerMovementController.cs:448-456` — `_physicsAccum` gate
|
||
- `src/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 to
|
||
`Setup.PlacementFrames[Resting]` instead of `Animation.PartFrames[0]`)
|
||
→ stub still visible.
|
||
- **Backface culling / mesh winding.** `ACDREAM_NO_CULL=1` (disable
|
||
`glCullFace` entirely) → stub still visible.
|
||
- **Palette overlay (SubPalettes).** `ACDREAM_NO_PALETTE_OVERLAY=1`
|
||
(skip `ComposePalette`) → 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.1` confirmed
|
||
`+Y = forward` in 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 `0x0100120D` after 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's `StaticObjectManager.cs:256-258` and retail decomp's
|
||
`Frame::combine` at `0x00518FD0`.
|
||
|
||
**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 `0x0100120D` are `SidesType=0` (ST_SINGLE), all surfaces are `Base1Image` — NO ST_DOUBLE polygons we'd be missing, NO surfaces lacking the `Type & 6` bits that retail's `DrawPolyInternal` skips.
|
||
- **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's `newPalette.Colors[j + offset]`.
|
||
- **The `*8` wire 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
|
||
1. ACE sends incomplete SubPalette ranges (retail-original would cover the full palette)
|
||
2. Retail does *additional* client-side compute that ACE pre-resolves wrongly
|
||
3. The base palette `0x0400007E` itself is supposed to have coat colors at those indices in retail's interpretation (different palette decode)
|
||
|
||
**Next investigation (deferred):**
|
||
|
||
- Diff ACE's `WorldObject_Networking.cs` CharGen ObjDesc construction against retail's
|
||
`ClothingTable::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.cs` is the diagnostic harness — extend it.
|
||
|
||
**Files (diagnostic env vars committed for next-session reuse):**
|
||
|
||
- ~~`src/AcDream.App/Rendering/InstancedMeshRenderer.cs:210-275`
|
||
— `ACDREAM_NO_CULL` env var~~ (file deleted in N.5 ship amendment)
|
||
- `src/AcDream.App/Rendering/GameWindow.cs` — `ACDREAM_HIDE_PART=N`
|
||
hides specific humanoid part; `ACDREAM_DUMP_CLOTHING=1` dumps
|
||
AnimPartChanges + TextureChanges + per-part Surface chain coverage.
|
||
- `src/AcDream.App/Rendering/TextureCache.cs:159-204` — `DecodeFromDats`
|
||
is the texture decode entry. Compare against
|
||
`references/WorldBuilder-ACME-Edition/.../TextureHelpers.cs`.
|
||
|
||
**Reproduction:**
|
||
|
||
```powershell
|
||
$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
|
||
|
||
1. 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.
|
||
2. 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:
|
||
1. Retail's `OBJECTINFO::kill_velocity` rarely fires in normal play —
|
||
gated on `last_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).
|
||
2. 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 in `PlayerMovementController` via
|
||
`_physicsAccum`. Render still runs at 60+ Hz; only the physics
|
||
integration step is 30Hz.
|
||
3. 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_collisions` pc: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 by
|
||
`src/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 corrected `OnWalkable` would 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`, retail
|
||
`CTransition::adjust_offset` 0x0050a370 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:
|
||
|
||
1. `RuntimeRemotePhysicsUpdater.Tick` asserted
|
||
`TransientState |= Contact | OnWalkable` on every tick a remote was not
|
||
flagged airborne. Retail writes CONTACT_TS from
|
||
`collision_info.contact_plane_valid` (`CPhysicsObj::SetPositionInternal`
|
||
@0x00515330, 0x00515430) and routes ON_WALKABLE_TS through
|
||
`set_on_walkable` (@0x00511310) purely on
|
||
`contact_plane.N.z >= PhysicsGlobals::floor_z` (0x00515465-0x0051548E).
|
||
With both bits forced, `calc_acceleration` (@0x00510950) returned zero
|
||
acceleration and `calc_friction` (@0x0050EE70) — whose entire body sits
|
||
inside `transient_state & 2` — could not engage either.
|
||
2. The same block zeroed `Body.Velocity`. Retail's `MoveOrTeleport`
|
||
(@0x00516330) never reads or writes a remote's wire velocity at all; the
|
||
zeroing discarded both the authoritative `0xF74E` vector and everything
|
||
gravity had accumulated.
|
||
3. The tick consumed only `ResolveResult.Position/CellId/IsOnGround` and called
|
||
`HandleAllCollisions` bare — the TAIL of `SetPositionInternal` without its
|
||
prefix. The sweep's own `InContact`/`OnWalkable` were never committed, and
|
||
the landing edge was decided from `IsOnGround`, which is `inContact || ...`
|
||
and is therefore TRUE on a steep contact. The tick now runs the full
|
||
`PhysicsObjUpdate.CommitSetPositionTransition` sequence (contact prefix ->
|
||
`set_on_walkable` edge -> `handle_all_collisions` @0x005154FE), gated on
|
||
`Ok && candidateMoved` exactly like `PlayerMovementController` and retail
|
||
`UpdateObjectInternal` (pc:283657).
|
||
4. Both landing blocks cleared `PhysicsStateFlags.Gravity`. Retail never
|
||
toggles GRAVITY_PS on a ground edge: the `CPhysicsObj` constructor seeds it
|
||
(state `0x400C08` @0x00512508) and `set_state` (@0x00514DD0) assigns the
|
||
description's state wholesale, post-processing only lighting/nodraw/hidden.
|
||
The matching `State |= Gravity` in 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 `LeaveGround` dispatch is new and unbounded.** The
|
||
`previousOnWalkable && !finalOnWalkable` arm calls
|
||
`MotionInterpreter.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 with `get_state_velocity()`
|
||
plus a jump Z, then `RemoveLinkAnimations` + `apply_current_movement`
|
||
re-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 in
|
||
`claude-memory/project_physics_collision_digest.md`: a per-stats-refresh
|
||
`ReportExhaustion` re-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
|
||
the `feedback_latent_bug_masked_by_fallback` shape: 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 on `Ok && candidateMoved`, and
|
||
`rm.Airborne = !rm.Body.OnWalkable` is assigned only inside it, while the
|
||
packet-side landing block no longer clears `Airborne` either. 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 reports `MATCH` / `MISMATCH (expected GUID = …)` against our
|
||
`refs/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 `.cdb` script, PowerShell wrapper pattern, watchouts
|
||
(PDB name conventions, `;` parsing, kill-target-on-detach behavior,
|
||
high-hit-rate lag).
|
||
- **Step `-1` added 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_object`
|
||
ratio = 0.61). Drove the L.5 fix in PlayerMovementController.
|
||
- `OBJECTINFO::kill_velocity` rarely 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:**
|
||
1. 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).
|
||
2. The PES script-hook system (`CallPESHook::Execute` →
|
||
`CPhysicsObj::CallPES`) drives those emitters periodically, ~150
|
||
times per minute on average.
|
||
3. 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 CallPES
|
||
- `CreateParticleHook::Execute` @ `0x00526ec0` — particle-creation hook
|
||
- `CPhysicsObj::CallPES` @ `0x00511af0`
|
||
- `CPhysicsObj::create_particle_emitter` @ `0x0050f360`
|
||
- `GameSky::CreateDeletePhysicsObjects` @ `0x005073c0`
|
||
- `LongNIHash<ParticleEmitter>` instance — emitter registry
|
||
- `CelestialPosition.pes_id` @ struct offset +0x004 — populated by
|
||
`SkyDesc::GetSky` but consumed downstream of `GameSky` (via the
|
||
hook system, not GameSky itself)
|
||
|
||
**Implementation outline:**
|
||
1. Decomp dive: read `CallPESHook::Execute`, `CreateParticleHook::Execute`,
|
||
`CPhysicsObj::CallPES`, and `GameSky::CreateDeletePhysicsObjects`
|
||
(and any cell/region weather handlers that spawn the dynamic 47).
|
||
2. Identify what triggers `CreateParticleHook` for sky objects — is it
|
||
inside `CreateDeletePhysicsObjects`, the region/weather change handler,
|
||
or somewhere else?
|
||
3. Port the persistent-emitter creation path: when a cell loads or
|
||
weather/time changes, instantiate the appropriate ParticleEmitters
|
||
on celestial objects.
|
||
4. Port the PES timeline driver — periodic dispatch from a script
|
||
timeline into our equivalent `CallPES`.
|
||
5. Port the actual PES script execution (rate of emission, particle
|
||
parameters, etc.) into our particle system.
|
||
6. 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 wiring
|
||
- `src/AcDream.Core/World/SkyDescLoader.cs` — already parses pes_id
|
||
- `src/AcDream.Core/Particles/*` — particle system foundation
|
||
- `src/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 split
|
||
- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash/fog/lightning coloration path
|
||
- `src/AcDream.Core/World/SkyDescLoader.cs` — keep `PesObjectId` parsed for diagnostics, not render playback
|
||
|
||
**Research:**
|
||
- `docs/research/2026-04-28-pes-pseudocode.md` — C.1 correction: `CelestialPosition.pes_id` copied but ignored by GameSky
|
||
- `docs/research/2026-04-23-sky-pes-wiring.md` — earlier decompile trace reached the same no-sky-PES conclusion
|
||
- `docs/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 consumed
|
||
- `src/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:
|
||
|
||
```c
|
||
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:**
|
||
1. Keep `SkyObject.PesObjectId` parsed for diagnostics only.
|
||
2. Compare retail/acdream material state for the active sky/weather GfxObj/Setup ids (`0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`).
|
||
3. Trace the named retail sky/weather draw path for texture transforms, translucency, diffusion, luminosity, and any non-GameSky weather effect dispatch.
|
||
4. Only add a new runtime visual path once the decompile has an actual caller.
|
||
|
||
**Decomp pointers:**
|
||
- `SkyDesc::GetSky` named retail `0x00501ec0` — copies `SkyObject.default_pes_object` into `CelestialPosition.pes_id`.
|
||
- `GameSky::CreateDeletePhysicsObjects` named retail `0x005073c0` — creates/updates sky objects from `gfx_id`, does not read `pes_id`.
|
||
- `GameSky::MakeObject` named retail `0x00506ee0` — calls `CPhysicsObj::makeObject(gfx_id, 0, 0)`, no PES.
|
||
- `GameSky::UseTime` named retail `0x005075b0` — updates frame/luminosity/diffusion/translucency, no PES.
|
||
|
||
**Files:**
|
||
- `src/AcDream.Core/World/SkyDescLoader.cs` — carries `PesObjectId` for 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:
|
||
|
||
1. `TranslucencyKindExtensions.FromSurfaceType` now applies retail's Translucent-override at `D3DPolyRender::SetSurface` (decomp 425246-425260) — surface `0x08000023` (Type=`0x10114` = `B1ClipMap | Translucent | Alpha | Additive`) is now correctly classified as `AlphaBlend` instead of `Additive`.
|
||
2. `SkyRenderer.EnsureSetupUploaded` now loads `0x020xxxxx` Setup IDs (e.g. `0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`) which were silently dropped. Setup parts are flattened via `SetupMesh.Flatten` and 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 `0x010001EC` part 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 override
|
||
- `src/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`.** Setup
|
||
`0x02000001` has 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 `+Je` and 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..38` depending 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 = 0` for both humans and drudges → all
|
||
parts use Vector3.One scale.
|
||
|
||
**Working hypotheses (next session):**
|
||
|
||
1. **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.
|
||
2. **Lighting setup.** Cell ambient may be too low, leaving back-
|
||
facing surfaces in flat shadow. Compare `uCellAmbient` value
|
||
against retail's behaviour at the same time-of-day.
|
||
3. **Anti-aliasing.** Retail may use MSAA; acdream window may not.
|
||
Polygon edges in acdream would be visibly stair-stepped, reading
|
||
as "more faceted" / blockier.
|
||
4. **Surface flags interpretation.** Specific Surface.Type bits for
|
||
character textures (skin, fabric) may need handling acdream
|
||
doesn't yet do (e.g. `SmoothShade` flag, or a mip bias).
|
||
|
||
**Diagnostic infrastructure landed this session** (env-var-gated, no
|
||
runtime cost when off):
|
||
|
||
- `ACDREAM_DUMP_CLOTHING=1` extended:
|
||
- `setup.Parts.Count`, `flatten.Count`, `APC` count on header line
|
||
- `ParentIndex[]` array dump
|
||
- `DefaultScale[]` array dump
|
||
- `IdleFrame.Frames[]` per-part Origin + Orientation (first 17 parts)
|
||
- `EMIT part=NN gfx=0xXX subMeshes=N tris=N` per part
|
||
- `TOTAL tris=N meshRefs=N` per entity
|
||
|
||
**Files (suspect surface area for next investigation):**
|
||
|
||
- `src/AcDream.Core/Meshing/SetupMesh.cs` — Flatten composition
|
||
- `src/AcDream.Core/Meshing/GfxObjMesh.cs` — polygon emission +
|
||
vertex normal handling (line 142)
|
||
- `src/AcDream.App/Rendering/Shaders/mesh.frag` — lighting eq
|
||
- `src/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:
|
||
1. 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.
|
||
2. 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_side2` vs `Render::FrameCurrent`). A
|
||
below-grade eye gets NO terrain — through the door retail shows sky.
|
||
WB renders the world with face culling DISABLED frame-globally (WB
|
||
`GameScene.cs:841` — editor heritage), and `TerrainModernRenderer.Draw`
|
||
set 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.Draw` now 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):**
|
||
1. 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).
|
||
2. **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):**
|
||
1. intermittent transparent interior when ENTERING the hilltop cottage;
|
||
2. 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);
|
||
3. 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` (Ghidra
|
||
`0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON`
|
||
(= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON
|
||
<= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return
|
||
COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq`
|
||
(0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5`
|
||
obscured it). Fixed at `TransitionTypes.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=UnitZ` default is RETAIL-FAITHFUL.**
|
||
Ghidra `validate_transition` (`0x0050aa70`) does exactly our
|
||
`TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0)
|
||
set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the
|
||
faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our
|
||
`collision_normal_valid` was 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):**
|
||
|
||
1. **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_Tick22760` now pins the blocking
|
||
invariant only.
|
||
|
||
2. **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-era `slide_sphere` work the engine slides
|
||
in-frame (Z reaches the 1.92 target on frame 1). Retail's cached-normal
|
||
mechanism (`CPhysicsObj::get_object_info` pc: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_sphere` first-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:
|
||
|
||
1. **Shape-1 re-attributed — it is NOT the degenerate-offset guard
|
||
threshold.** Retail's guard kills slides under ~1.4 cm (`|offset|² <
|
||
0.000199999995` at `0x537735`); 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 recorded
|
||
`cn=(0,0,1)` (ground), live retail `cn=(0,+1,0)` (the door face).
|
||
Strong lead: `TransitionTypes.cs:3701-3702` — on a blocked move with
|
||
no valid collision normal we DEFAULT `cn = 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.
|
||
|
||
2. **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 uses `contact_plane` OR
|
||
`last_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.
|
||
|
||
3. **Candidate epsilon-squaring divergence (real, but explains neither
|
||
shape).** Retail compares SQUARED quantities (`|cross|²` @`0x5375a5`,
|
||
`|offset|²` @`0x537735`) against `0.000199999995` (≈0.0002, NON-squared);
|
||
our port compares against `EpsilonSq = 0.0002²` (line 3105 + the
|
||
`dirLenSq >= EpsilonSq` branch @3098) — potentially ~10⁴× too small.
|
||
DO NOT change this without cdb confirmation: the BN `test ah, 0x5`
|
||
branch 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's `BSPTree.cs`, and current source) found the
|
||
ROUTING question (does retail's first airborne wall-contact frame reach
|
||
`slide_sphere`?) answerable from static structure alone: retail's
|
||
dispatch never calls `slide_sphere` on a genuine first-airborne-frame
|
||
FOOT-sphere hit — `Path 6` sets `Collide` without repositioning, the
|
||
retry routes to `Path 4` (`find_walkable`), which for a sheer vertical
|
||
wall finds no candidate, and `Phase 3`'s `sp.Collide` block then hard-
|
||
stops with the wall's real normal. A confirming instrumentation run
|
||
(probes on which `BSPQuery.cs` path fires + whether `FindWalkableInternal`
|
||
finds a candidate, added in `5e2be19b`) reproduced exactly this sequence
|
||
for the D4 fixture once TS-4's shortcut was removed (`5e2be19b`) — Path 6
|
||
→ Path 4 (`changed=false`) → Phase 3 `Collided` with `StepUpNormal`. 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`/`Adjusted` instead of
|
||
retail's direct `Collided`+`SetCollisionNormal`) is CONFIRMED as a real,
|
||
independently-decomp-confirmed divergence (pc:323824-323834, ACE
|
||
`BSPTree.cs:221-230`) and is now fixed in both `BSPQuery.cs` and
|
||
`FlatBspQuery.cs` (`db2889af`). **But re-running
|
||
`Diagnostic_Tick22760_DumpEngineInternals` after the fix shows NO
|
||
CHANGE** (harness still `cn=(0,0,1)` vs live `cn=(0,+1,0)`). New
|
||
dispatch-entry probes (`[path-dispatch]`, `[path5-diag]`, permanent,
|
||
gated on `ProbeIndoorBspEnabled`) traced the ACTUAL tick-22760 call
|
||
sequence: the mover is GROUNDED (`Contact` bit set in the seeded
|
||
snapshot), so it never reaches Path 6 at all. It dispatches Path 5 →
|
||
`StepSphereDown` (Path 3, both `DoStepDown` half-steps fail to find a
|
||
walkable candidate on the door's simplified BSP registration) →
|
||
`EdgeSlideAfterStepDownFailed` → `SpherePath.PrecipiceSlide`, whose
|
||
`find_crossed_edge`-false fallback returns `Collided` with **no**
|
||
collision-normal write. A fresh byte-level read of retail's
|
||
`SPHEREPATH::precipice_slide` (pc:274316-274326, `0x0050cc80`) confirms
|
||
this is byte-exact retail behavior (`if (eax == 0) { walkable = 0;
|
||
return 2; }`, no `set_collision_normal` call) — not a bug; `validate_transition`'s
|
||
`UnitZ` default 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.BuildEngineWithDoorFixture`
|
||
registers the door's raw BSP directly at its captured bounding-sphere
|
||
center rather than via the faithful `ShadowShapeBuilder.FromSetup` +
|
||
`PlacementFrame` transform `BuildFaithfulDoorEngine` uses 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. See
|
||
`docs/research/2026-07-30-ts4-116-oracle-plan.md` Addendum 2 for the
|
||
full trace. **Next step, if picked back up:** re-run the tick-22760
|
||
capture against `BuildFaithfulDoorEngine`'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,
|
||
`CameraOnInteriorSide` culls the exit portal → OutsideView EMPTY →
|
||
`SphereVisibleOutside` culls 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`):**
|
||
1. **`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=3` on a 43-part staircase. Fixed: `0x40XXYY##` ids +
|
||
owner-derived cache hints (`ResolveCacheLandblockHint`).
|
||
2. **`987313a` — knife-edge clip port** (climb strobes, top flap family):
|
||
`ProjectToClip` → exact W=0 eye-plane clip per retail
|
||
`ACRender::polyClipFinish` (0x006b6d00); zero-area in-plane views now
|
||
PROPAGATE (segment-key CanonicalKey) like retail's ClipPortals; the
|
||
`EyeInsidePortalOpening` rescue DELETED (CornerFloodReplay passes
|
||
without it under the W=0 port).
|
||
3. **`1ca412d` + `6a9b529` — entity bounds must cover the mesh** (the
|
||
gaze-dependent vanish: stairs visible climbing down, gone climbing
|
||
up): `WorldEntity.RefreshAabb` was 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-authored `CGfxObj.drawing_sphere` per 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:**
|
||
1. 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.)
|
||
2. A tower CLOSE TO the hill cottage has the MISSING stairs + the
|
||
extraneous water barrel in its middle — this entry (#119) proper.
|
||
3. 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):**
|
||
1. Running UP the tower, the TOP stairs disappear visually but stay
|
||
walkable.
|
||
2. 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.
|
||
- `BuildFromExterior` gained `seedRegion` — 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 `DrawBuildingLookIns` sub-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):**
|
||
1. `[wb-error] Error uploading mesh data for 0x0100321D` — GL
|
||
`InvalidOperation` thrown in `ManagedGLTextureArray..ctor:70`
|
||
(TextureAtlasManager ctor → CreateTextureArrayInternal), caught by
|
||
`UploadMeshData`'s try/catch → returns null. **The drop is STICKY:**
|
||
`_preparationTasks.TryRemove` runs 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).
|
||
2. Same session, `Tick()` → `GenerateMipmaps()` →
|
||
`ManagedGLTextureArray.ProcessDirtyUpdatesInternal:283` threw 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 <type>
|
||
available." summary, then one indented `RadarCoordinates`-formatted
|
||
location line per landblock (skipped for apartments, which have no world
|
||
location, matching retail's `arg2 != 4` gate), then the >400-locations
|
||
truncation notice when `TotalAvailable > 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 own
|
||
`GetData`/`GetPatron`/`GetFirstVassal`/`GetNextVassal` walk. 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`](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:
|
||
1. **Mechanism re-audit vs raw:** the head-interrupt IS unconditional
|
||
(`unpack_movement` 0x00524440 → `interrupt_current_movement` 0x005101f0
|
||
→ `MovementManager::CancelMoveTo(0x36)`), and `MoveToManager::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.
|
||
2. **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.
|
||
3. **The originally-observed glide is no longer reproducible** — killed by
|
||
the R4-V5 fix stack (un-ticked remote MoveToManagers `006cf659`, TS-40
|
||
link-strip `350fb5e3`, #160 run-rate `41006e79`) + #161's apply-pass
|
||
params fix `b1cf0102` (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:
|
||
1. **Apply-pass params:** retail's `apply_interpreted_movement` runs ALL its
|
||
dispatches with `ModifyInterpretedState=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 clobbered
|
||
`InterpretedState.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`'s `current_style` copy (raw 0051e757)
|
||
added to the UM flat-copy. NOTE: the handoff's "HitGround raw @305949"
|
||
was a mislabel — 305949 is inside `move_to_interpreted_state`; real
|
||
HitGround is 0x00528ac0 → `apply_current_movement(0,0)`.
|
||
2. **Landing order:** both GameWindow landing blocks cleared the Gravity
|
||
state bit BEFORE `Motion.HitGround()`, whose verbatim `state & 0x400`
|
||
gate 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).
|
||
3. **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_ExitsFalling`
|
||
lifecycle pin; `AirborneBody_NoCycleDispatches_OnlyTurnStop` assertion
|
||
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** — `UiItemList` clip+scroll via the shared `UiScrollable`; 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 scrollbar `0x100001C7` remains 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 = paperdoll `UiViewport` doll + 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):**
|
||
1. **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.
|
||
2. **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.
|
||
3. **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:
|
||
1. **Membership pick ping-pong** — `CellTransit.BuildCellSetAndPickContaining` used an unordered
|
||
`HashSet` + a pre-pick fork in `FindEnvCollisions`. Ported retail's verbatim ordered `CELLARRAY`
|
||
`find_cell_list` pick (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.)
|
||
2. **Render-root clobbering** — `CellGraph.CurrCell` ("the player's cell", the render root) was
|
||
written by the PER-ENTITY `ResolveWithTransition`/`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: `CurrCell` is now written ONLY by the player
|
||
(`PhysicsEngine.UpdatePlayerCurrCell` via `PlayerMovementController.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`](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`](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`](research/2026-05-25-a6-stairs-cyl-retail-investigation.md).
|
||
**Plan:** [`docs/superpowers/plans/2026-05-25-issue-101-stairs-cyl-phantom.md`](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`.
|
||
|
||
1. **Walk-vs-run misclassification (the user-visible "always runs at walk range" half).**
|
||
`BeginServerAutoWalk` decided `_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), which `Creature.SetWalkRunThreshold`
|
||
sets when server-side player→target distance ≥ `WalkRunThreshold/2`
|
||
(= 7.5 m default). Retail's `MovementParameters::get_command`
|
||
(decomp `0x0052aa00`, `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.
|
||
|
||
2. **Velocity leak in turn-in-place phase (the user-visible "overshoots
|
||
and snaps back" half).** When the auto-walked body crossed the
|
||
destination, `desiredYaw` flipped ~180°, `walkAligned` dropped to
|
||
false, and the `if (!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 of `MoveToParameters`. Cross-ref ACE
|
||
`MovementParams.CanCharge = 0x10`.
|
||
- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
|
||
`walkRunThreshold` parameter with `bool canCharge`; sets
|
||
`_autoWalkInitiallyRunning = canCharge`.
|
||
- `PlayerMovementController.DriveServerAutoWalk` turn-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`: passes
|
||
`update.MotionState.CanCharge` through; `[autowalk-begin]` trace
|
||
now shows `canCharge=` instead of `walkRunThresh=`.
|
||
- `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 returns `ScriptActivationInfo(ScriptId, PartTransforms)`; keys by ServerGuid OR entity.Id; GameWindow resolver lambda upgraded; 4 existing + 3 new tests)
|
||
- `11521f4` — fix(vfx #56): `ParticleHookSink` applies `CreateParticleHook.PartIndex` transform (new `_partTransformsByEntity` side-table; `SpawnFromHook` transforms offset through `partTransforms[PartIndex]` before applying entity rotation; 2 new tests + 2 existing pass)
|
||
- `f3bc15e` — feat(vfx #C.1.5b): `SetupPartTransforms` helper for per-part anchor transforms (walks `PlacementFrames[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 collision
|
||
- `71d0edc` — namespace stab Ids globally (`0xC0LLBB01..`) for Tier 1 cache safety
|
||
- `4df1914` — clarify `DebugCrossCheck`'s wiring status
|
||
- `f16604b` — DEBUG cross-check + tripwire + 2 tests
|
||
- `489174f` — wire `InvalidateLandblock` callback at LB demote/unload
|
||
- `1d1afcd` — wire `InvalidateEntity` at live-entity despawn
|
||
- `f7e38c2` — cache-hit fast path must fire per-entity, not per-tuple
|
||
- `0cbef3c` — cache-hit fast path + dispatcher integration tests
|
||
- `00fa8ae` — cache `Populate` must flush at entity boundary, not per-MeshRef tuple
|
||
- `2f489a8` — cache-miss populate on first frame for static entities
|
||
- `28513ea` — optional `CachedBatch` collector + `restPose` param on `ClassifyBatches`
|
||
- `a65a241` — inject `EntityClassificationCache` into `WbDrawDispatcher`
|
||
- `60fbfce` — plumb `landblockId` through `_walkScratch`
|
||
- `a171e70`, `aea4460`, `694815c`, `773e970` — cache `InvalidateLandblock` / `InvalidateEntity` / `Populate` / skeleton+first test
|
||
- `c02405c` — extract `GroupKey` to namespace-scope `internal`
|
||
- `2f8a574` — implementation plan
|
||
- `4abb838` — 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):**
|
||
|
||
1. **Audit must verify ID uniqueness for cache keys.** The original mutation audit verified `Position`/`Rotation`/`MeshRefs` stability post-spawn but didn't verify `entity.Id` was globally unique. Stabs from `LandblockLoader.BuildEntitiesFromInfo` restarted at `nextId = 1` per 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.
|
||
|
||
2. **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.
|
||
|
||
3. **Async mesh loading interacts with cache populate.** WB's `ObjectMeshManager.PrepareMeshDataAsync` decodes meshes off the main thread. If a MeshRef's GfxObj is still decoding at first-frame visibility, `TryGetRenderData` returns 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 tracks `currentEntityIncomplete` per entity and drops the populate scratch when any MeshRef returned null; the slow path retries every frame until all meshes load.
|
||
|
||
4. **A/B diagnostic env-var paid for itself.** `ACDREAM_DISABLE_TIER1_CACHE=1` forces 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 in `WbDrawDispatcher` ctor) 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:
|
||
|
||
1. **Alpha-test discard.** `mesh_modern.frag` transparent pass discarded fragments with `α >= 0.95`. The lifestone crystal core surface `0x080011DE` decoded 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.05` as a fragment-cost optimization.
|
||
|
||
2. **Cull state regression.** Legacy `StaticMeshRenderer` had Phase 9.2's `Enable(CullFace) + Back + CCW` setup at the top of its translucent pass (commit `6f1971a`, 2026-04-11) — fix for "lifestone crystal one face missing" reported at the time. When `dcae2b6` deleted the legacy renderer, the new `WbDrawDispatcher` never inherited that GL state, so closed-shell translucents composited back-faces over front-faces in iteration order under `DepthMask(false)`. Fix: re-establish Phase 9.2's exact setup at the top of Phase 8.
|
||
|
||
3. **`uDrawIDOffset` indexing bug.** `gl_DrawIDARB` resets to 0 at the start of each `glMultiDrawElementsIndirect` call. The transparent pass starts at byte offset `_opaqueDrawCount * stride` in the indirect buffer, but the vertex shader read `Batches[gl_DrawIDARB]` directly — so transparent draws read from `Batches[0..transparentCount)` (the OPAQUE section) instead of `Batches[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: add `uniform int uDrawIDOffset` to `mesh_modern.vert`, change `Batches[gl_DrawIDARB]` to `Batches[uDrawIDOffset + gl_DrawIDARB]`, and set the uniform per-pass in `WbDrawDispatcher` (0 for opaque, `_opaqueDrawCount` for transparent). Mirrors WorldBuilder's `BaseObjectRenderManager.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`](../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.ContainerType` for inventory entries is a
|
||
placeholder; `CreateObject` overwrites it with the real weenie class
|
||
later in the login sequence.
|
||
- The 10,000 count cap throws `FormatException` on 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", split `TrailerTruncated` into two flags. For now
|
||
the `ACDREAM_DUMP_VITALS=1` log 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.cs`
|
||
- `src/AcDream.Core/Terrain/TerrainSlotAllocator.cs`
|
||
- `src/AcDream.App/Rendering/Shaders/terrain_modern.vert`
|
||
- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
|
||
- `tests/AcDream.Core.Tests/Terrain/SplitFormulaDivergenceTest.cs` (the
|
||
test that killed Path A)
|
||
|
||
**Files deleted (T9):**
|
||
- `src/AcDream.App/Rendering/TerrainChunkRenderer.cs`
|
||
- `src/AcDream.App/Rendering/TerrainRenderer.cs`
|
||
- `src/AcDream.App/Rendering/Shaders/terrain.vert`
|
||
- `src/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` + demangled `name` + raw `mangled`)
|
||
- `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.
|
||
|
||
---
|
||
|
||
<!--
|
||
Example:
|
||
|
||
## #0 — [DONE 2026-04-24 · 593b76f] Sky cube edges visible as cross in daytime sky
|
||
|
||
**Closed:** 2026-04-24
|
||
**Commit:** `593b76f sky(phase-8.1): CLAUDE_TO_EDGE on static sky meshes`
|
||
**Resolution:** Switched to `GL_CLAMP_TO_EDGE` wrap mode for static sky
|
||
meshes; scrolling cloud layers kept `GL_REPEAT`. The 5 dome walls were
|
||
sampling opposite-edge pixels via UV wrap + LINEAR filtering, producing
|
||
visible seam lines that formed a cube outline across the view.
|
||
-->
|
||
|
||
## #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`](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:1537`
|
||
`parent.Incarnation == parentInstanceSequence` → false.
|
||
- D2 propagation — `RuntimeEntityDirectory.cs:478-480`
|
||
`ChildrenAttachedToParent(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.**
|
||
1. 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-938` explicitly preserves the old cell for). So
|
||
correcting the key ALONE yields a stale cell, not a correct one.
|
||
2. Three sites are inert only because the cell is zero and would wake on a fix:
|
||
the hydration `projectionCellId != 0` filter
|
||
(`LiveEntityHydrationController.cs:551-554`, opens a two-writer window),
|
||
`RestoreShadow` (`LiveEntityPresentationController.cs:216-236`, installs a
|
||
broadphase row route 7 says should not exist), and
|
||
`RuntimeInitialCreateResidenceState.Begin`'s `FullCellId != 0` refusal
|
||
(`:583`).
|
||
|
||
Full analysis, with the headless test's structural immunity explained:
|
||
[`2026-08-05-local-player-child-propagation.md`](research/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.TryApplyPosition` computes them inline,
|
||
pre-merge, from the retained snapshot `old`.
|
||
- `RuntimeInitialCreateContinuationExecutor.ApplyPositionAction` reads them off
|
||
`RuntimeAuthoritativePositionRouteClassifier`'s
|
||
`ApplyPlacementFrameBeforeRouting`/`UnparentBeforeRouting`, built through
|
||
`RuntimeAcceptedPositionRouteRequests.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.
|