feat(ui): D.5.3/B.1 — UiRoot payload injection + cursor drag ghost (AP-47)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1672eaa620
commit
595c4bdac4
3 changed files with 97 additions and 3 deletions
|
|
@ -97,7 +97,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Documented approximation (AP) — 42 rows
|
## 3. Documented approximation (AP) — 43 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 |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
|
|
@ -146,6 +146,7 @@ accepted-divergence entries (#96, #49, #50).
|
||||||
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
|
| AP-42 | `UiMenu` item model is flat (label + opaque payload, single-level popup); retail `UIElement_Menu::MakePopup @0x46d310` supports hierarchical nested submenus via recursive popup chain | `src/AcDream.App/UI/UiMenu.cs` | The chat talk-focus menu is single-level (14 rows, 2 columns, no submenu); hierarchy is latent and unreachable through the chat window — no behavioral difference in the current usage | A future menu with nested submenus would render flat (only the top-level items drawn, no drill-down) | `UIElement_Menu::MakePopup` @0x46d310 |
|
||||||
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
| AP-45 | `PublicUpdatePropertyInt (0x02CE)` sequence byte parsed-past but not honored; last update wins (no freshness check against sequence number) | `src/AcDream.Core.Net/Messages/PublicUpdatePropertyInt.cs` | Loopback ACE rarely reorders; latest-wins matches `PrivateUpdateVital`/`UpdatePosition`'s existing non-sequence behavior. Sequence tracking added when needed alongside TS-26. | A reordered 0x02CE on a real network could apply a stale UiEffects value — item icon temporarily shows the wrong effect state, corrected on next update | `PublicUpdatePropertyInt` sequence byte (ACE GameMessagePublicUpdatePropertyInt) |
|
||||||
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
|
| AP-46 | Health-meter gate approximation: retail shows the health meter for `IsPlayer() || pet_owner || ClientCombatSystem::ObjectIsAttackable()` (full PK/faction logic); acdream's `GameWindow.IsHealthBarTarget` uses the server PWD bits `BF_ATTACKABLE (0x10)` OR `BF_PLAYER (0x8)` | `src/AcDream.App/Rendering/GameWindow.cs` (`IsHealthBarTarget`) → `SelectedObjectController` | The PWD `BF_ATTACKABLE`/`BF_PLAYER` bits distinguish monsters + players (bar) from friendly/vendor NPCs (name-only) for the M1.5 dev loop; the pet case and the full ObjectIsAttackable PK/faction refinement (free-PK, PK-vs-PK, PKLite) are not ported | A PK/faction edge (e.g. a hostile-flagged player whose `BF_ATTACKABLE` is unset, or a pet) could show/hide the bar where retail differs — no impact on the non-PK PvE dev loop | `ClientCombatSystem::ObjectIsAttackable` acclient_2013_pseudo_c.txt:375385; `BF_ATTACKABLE` acclient.h:6437 |
|
||||||
|
| AP-47 | Cursor drag ghost reuses the full composited `m_pIcon` at reduced alpha (`GhostAlpha=0.6`) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay) | `src/AcDream.App/UI/UiRoot.cs` (`DrawDragGhost`/`GhostAlpha`) → `src/AcDream.App/UI/UiItemSlot.cs` (`GetDragGhost`) | Cosmetic only — the dragged item is still unambiguously identifiable; building the second underlay-less composite is deferred polish; the ghost is item-agnostic in UiRoot via `GetDragGhost()` | The ghost shows the opaque type-default underlay backing rather than retail's underlay-less translucent copy — a subtle look difference while dragging, no functional effect | `IconData::RenderIcons` acclient_2013_pseudo_c.txt:407594-407625 (m_pDragIcon path); deep-dive §3.2/§5.5 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,9 +141,25 @@ public sealed class UiRoot : UiElement
|
||||||
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
// beats even rect backgrounds. Faithful to retail's root-level MakePopup.
|
||||||
ctx.BeginOverlayLayer();
|
ctx.BeginOverlayLayer();
|
||||||
DrawOverlays(ctx);
|
DrawOverlays(ctx);
|
||||||
|
DrawDragGhost(ctx);
|
||||||
ctx.EndOverlayLayer();
|
ctx.EndOverlayLayer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Translucency of the cursor-following drag ghost. AP-47: we reuse the
|
||||||
|
/// item's full composited icon at this alpha rather than retail's dedicated
|
||||||
|
/// underlay-less m_pDragIcon.</summary>
|
||||||
|
private const float GhostAlpha = 0.6f;
|
||||||
|
|
||||||
|
/// <summary>Paint the drag ghost at the cursor. The texture comes from the source
|
||||||
|
/// element's <see cref="UiElement.GetDragGhost"/> so UiRoot stays item-agnostic; the
|
||||||
|
/// ghost is NOT a tree element, so it never intercepts hit-tests.</summary>
|
||||||
|
private void DrawDragGhost(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
if (DragSource?.GetDragGhost() is not { } g || g.tex == 0) return;
|
||||||
|
ctx.DrawSprite(g.tex, MouseX - g.w / 2f, MouseY - g.h / 2f, g.w, g.h,
|
||||||
|
0f, 0f, 1f, 1f, new Vector4(1f, 1f, 1f, GhostAlpha));
|
||||||
|
}
|
||||||
|
|
||||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||||
|
|
||||||
public void OnMouseMove(int x, int y)
|
public void OnMouseMove(int x, int y)
|
||||||
|
|
@ -185,7 +201,7 @@ public sealed class UiRoot : UiElement
|
||||||
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
||||||
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
||||||
{
|
{
|
||||||
BeginDrag(Captured, payload: null);
|
BeginDrag(Captured);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (DragSource is not null)
|
if (DragSource is not null)
|
||||||
|
|
@ -447,8 +463,13 @@ public sealed class UiRoot : UiElement
|
||||||
|
|
||||||
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
||||||
|
|
||||||
private void BeginDrag(UiElement source, object? payload)
|
private void BeginDrag(UiElement source)
|
||||||
{
|
{
|
||||||
|
// Pull the payload from the source; a null payload (e.g. an empty item cell)
|
||||||
|
// CANCELS the drag — retail's ItemList_BeginDrag only arms an occupied cell.
|
||||||
|
var payload = source.GetDragPayload();
|
||||||
|
if (payload is null) { _dragCandidate = false; return; }
|
||||||
|
|
||||||
DragSource = source;
|
DragSource = source;
|
||||||
DragPayload = payload;
|
DragPayload = payload;
|
||||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||||
|
|
|
||||||
|
|
@ -131,4 +131,76 @@ public class DragDropSpineTests
|
||||||
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DropReleased, Data0: 0, Payload: SomePayload()));
|
||||||
Assert.Null(h.LastDrop);
|
Assert.Null(h.LastDrop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full UiRoot chain: arming + use-vs-drag ─────────────────────────────
|
||||||
|
// A bound, hit-testable slot inside a list, sized for the hit-test.
|
||||||
|
private static (UiRoot root, UiItemList list, UiItemSlot cell) RootWithBoundSlot(uint itemId)
|
||||||
|
{
|
||||||
|
var root = new UiRoot { Width = 800, Height = 600 };
|
||||||
|
var list = new UiItemList(_ => (1u, 1, 1)) { Left = 0, Top = 0, Width = 32, Height = 32 };
|
||||||
|
// Tests don't run OnDraw (which sizes the cell), so size the cell explicitly.
|
||||||
|
list.Cell.Width = 32; list.Cell.Height = 32;
|
||||||
|
if (itemId != 0) list.Cell.SetItem(itemId, 0x99u);
|
||||||
|
root.AddChild(list);
|
||||||
|
return (root, list, list.Cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BeginDrag_arms_whenPayloadNonNull()
|
||||||
|
{
|
||||||
|
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||||
|
root.OnMouseMove(20, 10); // >3px → promote to drag
|
||||||
|
Assert.Same(cell, root.DragSource);
|
||||||
|
Assert.IsType<ItemDragPayload>(root.DragPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BeginDrag_doesNotArm_whenPayloadNull_emptySlot()
|
||||||
|
{
|
||||||
|
var (root, _, _) = RootWithBoundSlot(0u); // empty cell → GetDragPayload null
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||||
|
root.OnMouseMove(20, 10);
|
||||||
|
Assert.Null(root.DragSource); // never armed
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Click_withoutDrag_firesUse()
|
||||||
|
{
|
||||||
|
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||||
|
bool used = false;
|
||||||
|
cell.Clicked = () => used = true;
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 10, 10); // no move → Click emitted
|
||||||
|
Assert.True(used);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CompletedDrag_doesNotFireUse()
|
||||||
|
{
|
||||||
|
var (root, _, cell) = RootWithBoundSlot(0x5001u);
|
||||||
|
bool used = false;
|
||||||
|
cell.Clicked = () => used = true;
|
||||||
|
root.OnMouseDown(UiMouseButton.Left, 10, 10);
|
||||||
|
root.OnMouseMove(20, 10); // promote to drag
|
||||||
|
root.OnMouseUp(UiMouseButton.Left, 20, 10); // FinishDrag, NOT Click
|
||||||
|
Assert.False(used);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── no-handler / orphan-cell DragEnter defaults to Reject (review carry-forward) ──
|
||||||
|
[Fact]
|
||||||
|
public void DragEnter_orphanCell_noList_defaultsToReject()
|
||||||
|
{
|
||||||
|
var cell = new UiItemSlot(); // no parent list → FindList() null
|
||||||
|
cell.OnEvent(new UiEvent(0u, cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.Reject, cell.DragAcceptVisual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DragEnter_listWithoutHandler_defaultsToReject()
|
||||||
|
{
|
||||||
|
var list = new UiItemList(_ => (1u, 1, 1)); // no RegisterDragHandler
|
||||||
|
list.Cell.OnEvent(new UiEvent(0u, list.Cell, UiEventType.DragEnter, Payload: SomePayload()));
|
||||||
|
Assert.Equal(UiItemSlot.DragAcceptState.Reject, list.Cell.DragAcceptVisual);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue