fix(combat): synchronize auto-target presentation
Make the retained toolbar consume retail's payload-free selection notice from canonical live state so a reentrant Auto Target replacement cannot be erased by the outer clear. Restrict automatic acquisition to the requested hostile non-player monster policy and record the intentional PK edge divergence. Co-Authored-By: Codex <codex@openai.com>
This commit is contained in:
parent
8be933fc94
commit
0f2d98c501
11 changed files with 225 additions and 7 deletions
|
|
@ -46,6 +46,38 @@ Copy this block when adding a new issue:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## #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
|
## #204 — Replacement corpses replay the death transition and stand back up
|
||||||
|
|
||||||
**Status:** DONE — 2026-07-12, user visually confirmed multiple corpses remain fallen
|
**Status:** DONE — 2026-07-12, user visually confirmed multiple corpses remain fallen
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Intentional architecture (IA) — 16 rows
|
## 1. Intentional architecture (IA) — 17 rows
|
||||||
|
|
||||||
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -57,6 +57,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
| IA-15 | D.2b gameplay UI is our own `UiHost`/`UiRoot` retained tree, not a byte-port of Keystone. `RetailUiRuntime` owns the production import/mount graph; `RetailWindowManager`/typed handles centralize registry, raise, focus/capture cleanup, lifecycle events, reverse-order grouped controller teardown, removable Silk input subscriptions, and schema-v2 per-character/per-resolution layout persistence; `RetailWindowFrame` is the single production/Studio mount contract for imported-chrome and shared-wrapper windows. Production LayoutDesc imports include vitals `0x2100006C`, chat `0x21000006`, toolbar `0x21000016`, character `0x2100002E`, inventory `0x21000023` plus mounted `0x21000024/22/21`, and radar `0x21000074`. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/RetailWindowManager.cs`; `src/AcDream.App/UI/RetainedPanelControllerGroup.cs`; `src/AcDream.App/UI/UiHost.cs`; `src/AcDream.App/UI/RetailWindowLayoutPersistence.cs`; `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/Layout/LayoutImporter.cs`; binding supply in `GameWindow.cs` | Keystone has no matching PDB/decomp, so we preserve its observable ElementDesc/state/input behavior from DAT, named client call sites, and live evidence while using modern retained ownership. Real RenderSurfaces and imported element geometry remain the visual oracle. | Persistence is behaviorally reconstructed from `saveui/loadui` semantics rather than Keystone internals; lifecycle edge cases remain constrained by conformance tests instead of a byte-port | Production LayoutDesc objects; `docs/research/2026-06-15-layoutdesc-format.md`; Keystone behavior notes in `docs/research/retail-ui/` |
|
||||||
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
| IA-17 | Toolbar chrome is toolkit-supplied through the central `RetailWindowFrame` mount (`UiCollapsibleFrame` 8-piece bevel) because LayoutDesc `0x21000016` carries no baked frame. It also supports a toolkit-defined collapse-to-one-row (bottom-edge resize snapping between a row-1-only and a two-row height, row-2 visibility tied to the stop) — retail's real collapse is keystone.dll (no decomp) and the DAT stacks both rows always. | `src/AcDream.App/UI/Layout/RetailWindowFrame.cs`; `src/AcDream.App/UI/UiCollapsibleFrame.cs`; toolbar policy in `GameWindow.cs`; spec: `docs/superpowers/specs/2026-06-20-d2b-toolbar-collapse-design.md` | The central mount now owns wrapper geometry/registration uniformly; border-over-content prevents the row-2 right cap from poking through | The collapse stops remain a toolkit reconstruction rather than a byte-port of Keystone behavior | gmToolbarUI WM chrome (keystone.dll, no PDB); no bevel ids in LayoutDesc 0x21000016 (toolbar dump) |
|
||||||
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
| IA-18 | Effect overlay tile (enum 0x10000005) is a `ReplaceColor` SURFACE SOURCE — pure-white pixels in the composited drag icon are replaced PER-PIXEL with the same (x,y) pixel of the effect tile (the SURFACE overload `SurfaceWindow::ReplaceColor` 0x004415b0), preserving the tile's texture/gradient; the tile itself is NOT blitted as an additional layer. This IS faithful retail behavior. **Anti-regression: do NOT re-implement this as a blit layer NOR as a flat-color replace (it is a per-pixel surface copy).** | `src/AcDream.App/UI/IconComposer.cs` (`ReplaceWhiteFromSurface`) | Faithful port of `IconData::RenderIcons` @407614 → the SURFACE overload `ReplaceColor` 0x004415b0 (`dst[x,y]=src[x,y]` where `dst==white`); confirmed via clean Ghidra decompile + named decomp + visual (the Energy Crystal's blue is a gradient, 2026-06-17). | A blit-layer or flat-color re-implementation would show the wrong effect look (no gradient) — the visual-verification regression that retired the mean-color approximation | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407524; `ReplaceColor` SURFACE overload 0x004415b0:71656; `docs/research/2026-06-17-stateful-icon-RESOLVED.md` |
|
||||||
|
| IA-19 | Automatic combat acquisition is narrowed to attackable non-player monsters. Retail `AutoTarget` falls back to `SelectNext(SELECTION_TYPE_COMPASS_ITEM)`, whose combat filter can also admit attackable enemy players in compatible PK states. | `src/AcDream.Core/Combat/CombatTargetPolicy.cs`; consumer `src/AcDream.App/Rendering/GameWindow.cs` | Explicit product direction: Auto Target must never select NPCs, players, pets, or other objects; manual player-selection commands remain available | In PK play, Auto Target will not acquire an otherwise valid hostile player as retail would; the player must be selected manually | `ClientCombatSystem::AutoTarget @ 0x0056BC80`; `CPlayerSystem::SelectNext @ 0x0055F9A0`; `ClientCombatSystem::ObjectIsAttackable @ 0x0056A600` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -497,7 +497,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
|
||||||
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
|
||||||
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
|
||||||
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
|
||||||
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the right-to-left red charge meter, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
|
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the right-to-left red charge meter, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
|
||||||
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
|
||||||
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
- **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green.
|
||||||
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
- **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars.
|
||||||
|
|
|
||||||
|
|
@ -470,7 +470,10 @@ include dungeons.
|
||||||
retail's wire-driven description-before-enter-world lifecycle: the corpse's
|
retail's wire-driven description-before-enter-world lifecycle: the corpse's
|
||||||
authoritative Dead state is retained and `HandleEnterWorld` strips its
|
authoritative Dead state is retained and `HandleEnterWorld` strips its
|
||||||
Ready→Dead transition before the first rendered tick. Corpse persistence was
|
Ready→Dead transition before the first rendered tick. Corpse persistence was
|
||||||
visually confirmed live on 2026-07-12. See
|
visually confirmed live on 2026-07-12. The follow-up #205 correction makes
|
||||||
|
the toolbar consume the final reentrant Auto Target selection and restricts
|
||||||
|
automatic candidates to hostile non-player monsters; live gate passed
|
||||||
|
2026-07-12. See
|
||||||
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`.
|
`docs/research/2026-07-12-death-and-auto-target-pseudocode.md`.
|
||||||
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,37 @@ quality, so its existing closest eligible creature scan implements the
|
||||||
fallback branch. The important lifecycle behavior is exact: authoritative
|
fallback branch. The important lifecycle behavior is exact: authoritative
|
||||||
death clears selection, and the SelectionChanged consumer reacquires only
|
death clears selection, and the SelectionChanged consumer reacquires only
|
||||||
when Auto Target is enabled in Melee/Missile combat.
|
when Auto Target is enabled in Melee/Missile combat.
|
||||||
|
|
||||||
|
### Selection-notice reentrancy
|
||||||
|
|
||||||
|
Retail's selection notice has no selected-object payload:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACCWeenieObject::SetSelectedObject(newId):
|
||||||
|
selectedID = newId
|
||||||
|
CM_UI::SendNotice_SelectionChanged()
|
||||||
|
|
||||||
|
CM_UI::SendNotice_SelectionChanged():
|
||||||
|
for each registered handler:
|
||||||
|
handler.RecvNotice_SelectionChanged()
|
||||||
|
|
||||||
|
gmToolbarUI::HandleSelectionChanged():
|
||||||
|
id = ACCWeenieObject::selectedID // read current global state
|
||||||
|
clear and repopulate selected-object strip from id
|
||||||
|
```
|
||||||
|
|
||||||
|
If `ClientCombatSystem::RecvNotice_SelectionChanged` runs first for a clear,
|
||||||
|
its `AutoTarget()` may select a replacement reentrantly. A later toolbar
|
||||||
|
handler reads the replacement from global state; it does not receive and apply
|
||||||
|
the stale outer clear. acdream's retained toolbar must likewise read the
|
||||||
|
canonical `SelectionState.SelectedObjectId` when notified rather than use the
|
||||||
|
captured transition payload.
|
||||||
|
|
||||||
|
### acdream automatic monster narrowing
|
||||||
|
|
||||||
|
Retail's combat-mode `SelectNext(COMPASS_ITEM)` rejects candidates that fail
|
||||||
|
`ClientCombatSystem::ObjectIsAttackable @ 0x0056A600`; that predicate rejects
|
||||||
|
friendly NPCs, pets, corpses, and non-creatures but can accept enemy players in
|
||||||
|
compatible PK states. By explicit product direction, acdream narrows automatic
|
||||||
|
acquisition further to non-player hostile monsters. Manual player selection
|
||||||
|
remains separate. This intentional PK edge divergence is register IA-19.
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,14 @@ presentation, target indicator, use/pickup, PreviousSelection, and plugins all
|
||||||
read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event
|
read or write this same owner. Plugins use `IPluginHost.Selection`; plugin event
|
||||||
failures are isolated from the client and other plugins.
|
failures are isolated from the client and other plugins.
|
||||||
|
|
||||||
|
Retail `CM_UI::SendNotice_SelectionChanged @ 0x00479F50` carries no object-id
|
||||||
|
payload. Notice consumers such as `gmToolbarUI::HandleSelectionChanged` read the
|
||||||
|
current global selection when invoked. Preserve that rule in retained UI: a
|
||||||
|
handler registered earlier can retarget reentrantly (Auto Target on a death
|
||||||
|
clear), so applying the captured outer transition afterward produces stale UI
|
||||||
|
even though `SelectionState` is correct. Selected-object presentation must read
|
||||||
|
`SelectionState.SelectedObjectId` at notification time. Issue #205, 2026-07-12.
|
||||||
|
|
||||||
`AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration:
|
`AcDream.App.UI.InteractionState` separately owns temporary pointer orchestration:
|
||||||
`None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a
|
`None`, `Use`, `Examine`, or `UseItemOnTarget(sourceGuid)`. Item target mode is a
|
||||||
projection of this state, not a second selected-object field. Keep selection in
|
projection of this state, not a second selected-object field. Keep selection in
|
||||||
|
|
|
||||||
|
|
@ -11964,7 +11964,7 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
private uint? GetSelectedOrClosestCombatTarget()
|
private uint? GetSelectedOrClosestCombatTarget()
|
||||||
{
|
{
|
||||||
if (_selection.SelectedObjectId is { } selected && IsLiveCreatureTarget(selected))
|
if (_selection.SelectedObjectId is { } selected && IsLiveHostileMonsterTarget(selected))
|
||||||
return selected;
|
return selected;
|
||||||
|
|
||||||
if (!_persistedGameplay.AutoTarget)
|
if (!_persistedGameplay.AutoTarget)
|
||||||
|
|
@ -11984,7 +11984,7 @@ public sealed class GameWindow : IDisposable
|
||||||
if (!_persistedGameplay.ViewCombatTarget
|
if (!_persistedGameplay.ViewCombatTarget
|
||||||
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|
|| !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
|
||||||
|| _selection.SelectedObjectId is not uint selected
|
|| _selection.SelectedObjectId is not uint selected
|
||||||
|| !IsLiveCreatureTarget(selected)
|
|| !IsLiveHostileMonsterTarget(selected)
|
||||||
|| !_entitiesByServerGuid.TryGetValue(selected, out var target))
|
|| !_entitiesByServerGuid.TryGetValue(selected, out var target))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|
@ -12496,7 +12496,7 @@ public sealed class GameWindow : IDisposable
|
||||||
float bestDistanceSq = float.PositiveInfinity;
|
float bestDistanceSq = float.PositiveInfinity;
|
||||||
foreach (var (guid, entity) in _entitiesByServerGuid)
|
foreach (var (guid, entity) in _entitiesByServerGuid)
|
||||||
{
|
{
|
||||||
if (!IsLiveCreatureTarget(guid))
|
if (!IsLiveHostileMonsterTarget(guid))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
float distanceSq = System.Numerics.Vector3.DistanceSquared(
|
||||||
|
|
@ -12545,6 +12545,17 @@ public sealed class GameWindow : IDisposable
|
||||||
return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
return (LiveItemType(guid) & AcDream.Core.Items.ItemType.Creature) != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsLiveHostileMonsterTarget(uint guid)
|
||||||
|
{
|
||||||
|
if (!IsLiveCreatureTarget(guid))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return AcDream.Core.Combat.CombatTargetPolicy.IsHostileMonster(
|
||||||
|
_playerServerGuid,
|
||||||
|
Objects.Get(_playerServerGuid),
|
||||||
|
Objects.Get(guid));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
/// True if the selected-object strip should show a Health meter for <paramref name="guid"/>.
|
||||||
/// Exact projection of retail's <c>IsPlayer() || pet_owner ||
|
/// Exact projection of retail's <c>IsPlayer() || pet_owner ||
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,16 @@ public sealed class SelectedObjectController : IRetainedPanelController
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSelectionTransition(SelectionTransition transition)
|
private void OnSelectionTransition(SelectionTransition transition)
|
||||||
=> ApplySelection(transition.SelectedObjectId);
|
{
|
||||||
|
// Retail's CM_UI::SendNotice_SelectionChanged (0x00479F50) carries no
|
||||||
|
// selected-id payload. gmToolbarUI::HandleSelectionChanged therefore
|
||||||
|
// reads the live ACCWeenieObject::selectedID when its notice handler
|
||||||
|
// runs. This matters when an earlier handler (ClientCombatSystem::
|
||||||
|
// AutoTarget) selects a replacement reentrantly: the outer "cleared"
|
||||||
|
// notice must render that replacement, not stale transition data.
|
||||||
|
_ = transition;
|
||||||
|
ApplySelection(_selection.SelectedObjectId);
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
42
src/AcDream.Core/Combat/CombatTargetPolicy.cs
Normal file
42
src/AcDream.Core/Combat/CombatTargetPolicy.cs
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Combat;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Eligibility policy for automatic monster acquisition.
|
||||||
|
/// </summary>
|
||||||
|
public static class CombatTargetPolicy
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true only for a live-data creature that is attackable, is not a
|
||||||
|
/// player, and is not a pet. Friendly NPCs and non-creatures are rejected.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The attackability core is retail
|
||||||
|
/// <c>ClientCombatSystem::ObjectIsAttackable @ 0x0056A600</c>. Retail
|
||||||
|
/// <c>AutoTarget @ 0x0056BC80</c> falls back to
|
||||||
|
/// <c>SelectNext(SELECTION_TYPE_COMPASS_ITEM)</c>, whose combat filter also
|
||||||
|
/// uses ObjectIsAttackable. acdream deliberately narrows that set to
|
||||||
|
/// non-player monsters for its automatic acquisition policy; explicit
|
||||||
|
/// player-selection commands remain separate.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool IsHostileMonster(
|
||||||
|
uint playerId,
|
||||||
|
ClientObject? player,
|
||||||
|
ClientObject? candidate)
|
||||||
|
{
|
||||||
|
if (candidate is null
|
||||||
|
|| candidate.ObjectId == playerId
|
||||||
|
|| (candidate.Type & ItemType.Creature) == 0
|
||||||
|
|| (candidate.PublicWeenieBitfield.GetValueOrDefault()
|
||||||
|
& SelectedObjectHealthPolicy.BfPlayer) != 0
|
||||||
|
|| candidate.PetOwnerId != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return SelectedObjectHealthPolicy.ObjectIsAttackable(
|
||||||
|
playerId,
|
||||||
|
player,
|
||||||
|
candidate.ObjectId,
|
||||||
|
candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -171,6 +171,38 @@ public class SelectedObjectControllerTests
|
||||||
Assert.Empty(textChild.LinesProvider());
|
Assert.Empty(textChild.LinesProvider());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReentrantAutoTargetNotice_RendersCurrentReplacementNotStaleClear()
|
||||||
|
{
|
||||||
|
const uint Dead = 0xAA00u;
|
||||||
|
const uint Replacement = 0xAA01u;
|
||||||
|
var (layout, nameEl, _, _) = FakeLayout();
|
||||||
|
var h = new Harness();
|
||||||
|
h.NameMap[Dead] = "Dead Drudge";
|
||||||
|
h.NameMap[Replacement] = "Drudge Prowler";
|
||||||
|
h.StackMap[Dead] = 1u;
|
||||||
|
h.StackMap[Replacement] = 1u;
|
||||||
|
|
||||||
|
h.Selection.Select(Dead, SelectionChangeSource.World);
|
||||||
|
// CombatTargetController is registered before the retained toolbar in
|
||||||
|
// production. Its clear handler selects a replacement reentrantly.
|
||||||
|
h.Selection.Changed += transition =>
|
||||||
|
{
|
||||||
|
if (transition.SelectedObjectId is null)
|
||||||
|
h.Selection.Select(Replacement, SelectionChangeSource.System);
|
||||||
|
};
|
||||||
|
h.Bind(layout);
|
||||||
|
|
||||||
|
h.Selection.Clear(
|
||||||
|
SelectionChangeSource.System,
|
||||||
|
SelectionChangeReason.CombatTargetDied);
|
||||||
|
|
||||||
|
Assert.Equal(Replacement, h.Selection.SelectedObjectId);
|
||||||
|
var lines = nameEl.Children.OfType<UiText>().Single().LinesProvider();
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Equal("Drudge Prowler", lines[0].Text);
|
||||||
|
}
|
||||||
|
|
||||||
// ── H1: Select a health target — meter does NOT show on select alone ─────
|
// ── H1: Select a health target — meter does NOT show on select alone ─────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
46
tests/AcDream.Core.Tests/Combat/CombatTargetPolicyTests.cs
Normal file
46
tests/AcDream.Core.Tests/Combat/CombatTargetPolicyTests.cs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
using AcDream.Core.Combat;
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Combat;
|
||||||
|
|
||||||
|
public sealed class CombatTargetPolicyTests
|
||||||
|
{
|
||||||
|
private const uint PlayerId = 0x50000001u;
|
||||||
|
|
||||||
|
private static ClientObject Creature(
|
||||||
|
uint id,
|
||||||
|
uint flags = 0,
|
||||||
|
uint petOwner = 0) => new()
|
||||||
|
{
|
||||||
|
ObjectId = id,
|
||||||
|
Type = ItemType.Creature,
|
||||||
|
PublicWeenieBitfield = flags,
|
||||||
|
PetOwnerId = petOwner,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AutomaticAcquisition_AcceptsOnlyAttackableNonPlayerMonster()
|
||||||
|
{
|
||||||
|
var player = Creature(PlayerId, SelectedObjectHealthPolicy.BfPlayer);
|
||||||
|
var monster = Creature(0x50000010u, SelectedObjectHealthPolicy.BfAttackable);
|
||||||
|
var friendlyNpc = Creature(0x50000011u);
|
||||||
|
var pet = Creature(0x50000012u, petOwner: PlayerId);
|
||||||
|
var hostilePlayer = Creature(
|
||||||
|
0x50000013u,
|
||||||
|
SelectedObjectHealthPolicy.BfPlayer
|
||||||
|
| SelectedObjectHealthPolicy.BfPlayerKiller);
|
||||||
|
player.PublicWeenieBitfield |= SelectedObjectHealthPolicy.BfPlayerKiller;
|
||||||
|
var attackableDoor = new ClientObject
|
||||||
|
{
|
||||||
|
ObjectId = 0x50000014u,
|
||||||
|
Type = ItemType.Misc,
|
||||||
|
PublicWeenieBitfield = SelectedObjectHealthPolicy.BfAttackable,
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(CombatTargetPolicy.IsHostileMonster(PlayerId, player, monster));
|
||||||
|
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, friendlyNpc));
|
||||||
|
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, pet));
|
||||||
|
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, hostilePlayer));
|
||||||
|
Assert.False(CombatTargetPolicy.IsHostileMonster(PlayerId, player, attackableDoor));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue