From 1ccf07b705a488f33155766364ece7eddac1d8a2 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 21 Jun 2026 09:28:29 +0200 Subject: [PATCH] =?UTF-8?q?fix(ui):=20D.2b-B=20=E2=80=94=20address=20phase?= =?UTF-8?q?-boundary=20review=20(burden=20%=20saturation=20+=20equipped=20?= =?UTF-8?q?filter)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opus phase-boundary review findings: - I1 (faithfulness): LoadToPercent now computes from the CLAMPED fill (floor(LoadToFill(load)*300)), so the burden % SATURATES at 300% like retail (decomp 176544-176576 clamps arg2 to [0,1] BEFORE the *300). The old floor(load*100) over-read to 400% at 4x capacity. Golden test corrected. - I2 (partition): exclude equipped items (CurrentlyEquippedLocation != None) from the contents grid + selector — a mid-session self-wield routes them through MoveItem(item, WielderGuid=player) into GetContents(player); retail's gm3DItemsUI shows pack contents only. New conformance test. - N1: drop dead 'using System.Collections.Generic' (left after the 383e8b7 cleanup). - N3: AP-48 risk wording (drift can be low OR high vs server EncumbranceVal). Build green; BurdenMath 17, InventoryController/UiMeter 10 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../architecture/retail-divergence-register.md | 2 +- .../UI/Layout/InventoryController.cs | 6 +++++- src/AcDream.Core/Items/ClientObject.cs | 10 ++++++---- .../UI/Layout/InventoryControllerTests.cs | 18 ++++++++++++++++++ .../Items/BurdenMathTests.cs | 6 +++--- 5 files changed, 33 insertions(+), 9 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index ab53fe22..b56d55c5 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -147,7 +147,7 @@ accepted-divergence entries (#96, #49, #50). | 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-47 | Cursor drag ghost reuses the full composited `m_pIcon` (incl. type-default underlay) instead of retail's dedicated `m_pDragIcon` (base + custom-overlay, NO type-default underlay). Opacity now matches retail (full). | `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 carries the opaque type-default underlay backing rather than retail's underlay-less 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 | -| AP-48 | Inventory burden `currentBurden` summed client-side (`ClientObjectTable.SumCarriedBurden`) when the player's wire `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server value via `CACQualities::InqLoad` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from coins/untracked items (e.g. items inside side bags that arrived before their parent, see TS-32) — burden bar reads slightly low vs retail until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | +| AP-48 | Inventory burden `currentBurden` summed client-side (`ClientObjectTable.SumCarriedBurden`) when the player's wire `EncumbranceVal` (PropertyInt 5) is absent; retail reads the server value via `CACQualities::InqLoad` (decomp 0x0058f130). | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) | The wire `EncumbranceVal` is not yet parsed from the server; the carried-Burden sum is the nearest client-side fallback and is correct for items in the simple pack-only case. Retire when B-Wire parses PropertyInt 5. | Drift from the server `EncumbranceVal` either way (untracked items read low; double-tracked or stale-but-present items read high) — e.g. items inside side bags that arrived before their parent (see TS-32) — until B-Wire lands. | `CACQualities::InqLoad` 0x0058f130; ACE PropertyInt.EncumbranceVal=5 | | AP-49 | Carry-capacity augmentation (PropertyInt `0xE6`) not tracked → `bonus=0` → un-augmented `Str×150` capacity. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`RefreshBurden`) caller of `BurdenMath.EncumbranceCapacity` | PropertyInt `0xE6` is not yet parsed from the wire; aug=0 is the correct no-aug default and matches the vast majority of characters. | Augmented characters read slightly low capacity — burden bar shows slightly higher fill than retail for augmented chars; a three-aug char's cap is 150×Str higher per aug, so up to 450 extra points per point of Str. | `EncumbranceSystem::EncumbranceCapacity` decomp 256393 (0x004fcc00); retail `0xE6` PropertyInt augmentation | | AP-50 | Burden meter orientation/direction set programmatically (`Vertical=true`, `FillFromBottom=true`) rather than reading retail's `m_eDirection` property from the LayoutDesc element (property id `0x6f`). | `src/AcDream.App/UI/Layout/InventoryController.cs`; `src/AcDream.App/UI/UiMeter.cs` (`Vertical`/`FillFromBottom`) | The `m_eDirection` property is not yet read by `ElementReader`; bottom-up fill is visually confirmed against retail's burden bar art. Retire when `0x6f` is wired through `ElementReader`→`DatWidgetFactory`. | Wrong fill direction (top-down instead of bottom-up, or horizontal) if a future meter whose art requires a different direction is forced through the same code path. | `UIElement_Meter::DrawChildren` @0x46fbd0; property `0x6f` (`m_eDirection`) in the LayoutDesc | | AP-51 | Main-pack `m_topContainer` cell (element `0x100001C9`) uses a placeholder/empty icon (`tex=0u`), not a weenie-driven backpack icon. | `src/AcDream.App/UI/Layout/InventoryController.cs` (`Populate`) | The player's own container entity has no item `IconId` on the client (it's a character, not an item); retail uses the equipped-pack's icon via the character's equipped-pack `CreateObject`. The equipped-pack DID path is deferred to Sub-phase C. | Main-pack cell renders blank (no icon) where retail shows the equipped backpack art — cosmetic gap visible when F12 is open. | `gmBackpackUI::PostInit` @0x4a8520 (m_topContainer bind); `CreateObject` weenie icon path | diff --git a/src/AcDream.App/UI/Layout/InventoryController.cs b/src/AcDream.App/UI/Layout/InventoryController.cs index 1f0e29ab..187500c4 100644 --- a/src/AcDream.App/UI/Layout/InventoryController.cs +++ b/src/AcDream.App/UI/Layout/InventoryController.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Numerics; using AcDream.Core.Items; @@ -137,6 +136,11 @@ public sealed class InventoryController { var item = _objects.Get(guid); if (item is null) continue; + // Equipped items belong on the paperdoll (Sub-phase C), never in the pack grid or + // the side-bag list. A mid-session self-wield routes an item through + // MoveItem(item, WielderGuid=player), so it transiently lands in GetContents(player); + // retail's gm3DItemsUI shows pack contents only, gated on the wielded location. + if (item.CurrentlyEquippedLocation != EquipMask.None) continue; bool isBag = item.Type.HasFlag(ItemType.Container) || item.ItemsCapacity > 0; var list = isBag ? _containerList : _contentsGrid; if (list is null) continue; diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs index cf08a6b4..2ec5d309 100644 --- a/src/AcDream.Core/Items/ClientObject.cs +++ b/src/AcDream.Core/Items/ClientObject.cs @@ -258,12 +258,14 @@ public static class BurdenMath } /// - /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176576): after - /// arg2 = load/3, the text is floor(arg2 * 300) = floor(load * 100), - /// NOT clamped (reads up to 300%+). + /// Retail gmBackpackUI::SetLoadLevel percent text (decomp 176542-176576): the + /// percent is computed from the CLAMPED fill — arg2 = load/3 is clamped to [0,1] + /// (the 176544-176563 block sets arg2 = 1.0 when fill >= 1) BEFORE + /// floor(arg2 * 300). So the number SATURATES at 300% (it does NOT read 400% at + /// 4x capacity). Equivalent to floor(LoadToFill(load) * 300). /// public static int LoadToPercent(float load) - => (int)System.MathF.Floor(load * 100f); + => (int)System.MathF.Floor(LoadToFill(load) * 300f); public static int ComputeMax(int strength, int bonusBurden) => BurdenPerStrength * strength + strength * bonusBurden; diff --git a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs index 14d6053e..7da7e627 100644 --- a/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/InventoryControllerTests.cs @@ -85,6 +85,24 @@ public class InventoryControllerTests Assert.Equal(0xCu, containers.GetItem(0)!.ItemId); } + [Fact] + public void Equipped_items_are_excluded_from_the_grid_and_selector() + { + var (layout, grid, containers, _, _, _, _, _) = BuildLayout(); + var objects = new ClientObjectTable(); + SeedContained(objects, 0xA, Player, slot: 0); // loose pack item + // A self-wielded item: MoveItem with an equip location indexes it under the player + // (the live wield path), but it must NOT show in the pack grid or the selector. + objects.AddOrUpdate(new ClientObject { ObjectId = 0xD }); + objects.MoveItem(0xD, Player, newSlot: 1, newEquipLocation: EquipMask.MeleeWeapon); + + Bind(layout, objects); + + Assert.Equal(1, grid.GetNumUIItems()); // only the loose item + Assert.Equal(0xAu, grid.GetItem(0)!.ItemId); + Assert.Equal(0, containers.GetNumUIItems()); // equipped item is not a side bag either + } + [Fact] public void Grid_is_six_columns_thirtytwo_px() { diff --git a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs index 25cfa043..7d9bcec7 100644 --- a/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs +++ b/tests/AcDream.Core.Tests/Items/BurdenMathTests.cs @@ -33,8 +33,8 @@ public class BurdenMathTests [InlineData(0f, 0)] [InlineData(0.5f, 50)] [InlineData(1.0f, 100)] - [InlineData(3.0f, 300)] - [InlineData(4.0f, 400)] // percent NOT clamped - public void LoadToPercent_is_floor_load_times_100(float load, int expected) + [InlineData(3.0f, 300)] // 300% = full + [InlineData(4.0f, 300)] // SATURATES at 300% (computed from the clamped fill, decomp 176544-176576) + public void LoadToPercent_saturates_at_300(float load, int expected) => Assert.Equal(expected, BurdenMath.LoadToPercent(load)); }