fix(movement): invalidate burden on enchantment changes

This commit is contained in:
Erik 2026-07-31 10:16:27 +02:00
parent 2b9dfec9d7
commit d6e8b60303
13 changed files with 231 additions and 25 deletions

View file

@ -98,6 +98,43 @@ Copy this block when adding a new issue:
---
## #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)

View file

@ -53,7 +53,11 @@ TS-8 are retired by focused and end-to-end packet tests. #269's
capture-driven slope-slide residual is also closed and user-accepted:
`CTransition::validate_transition` now performs retail's non-OK-only
remembered-plane restore with the preceding `OBJECTINFO::kill_velocity`.
Campaign P now continues with the unfinished live matrix rows.
The matrix then exposed #272: burden was invalidated by base Strength but not
by Strength enchantment add/purge. Runtime movement plus both retained burden
surfaces now use effective Strength and the canonical enchantment-change edge;
automated gates pass and the connected buff/death gate was user-accepted on
2026-07-31. Campaign P then continues with the unfinished live matrix rows.
---

View file

@ -400,6 +400,14 @@ root-caused, retail-ported, and user-accepted in the same session:
and the complete Release suite passes 10,062 tests / 5 skips. The user
accepted repeated uphill runs while pressing into the stair sides. Evidence:
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
- **#272 complete and user-accepted 2026-07-31** —
`CACQualities::InqLoad` consumes enchantment-adjusted Strength through
`InqAttribute`, but Runtime movement and both retained burden displays read
raw Strength and did not share the enchantment invalidation edge. They now
consume `GetEffectiveAttribute(Strength)` and
`Spellbook.EnchantmentsChanged`, so buff, dispel, expiration, and death
purge recompute the same burden state immediately. Focused and full
Runtime/App tests pass.
Matrix rows accepted so far: speed parity, roof slide, downhill bounce,
flat pop, uphill landing, and #269's slope-stop feel

View file

@ -54,11 +54,16 @@ InqLoad(this, &loadOut):
return 1 // always succeeds for CACQualities (has vtable)
```
This EXACTLY matches acdream's existing `IndicatorBarController.UpdateBurden()`
/ `InventoryController.RefreshBurden()` pattern (Strength attribute + prop
0xE6 aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the
wire value is absent) — already ported, already correct, already tested via
the UI. **`AcDream.Core.Items.BurdenMath`
The property/capacity shape matches acdream's
`IndicatorBarController.UpdateBurden()` /
`InventoryController.RefreshBurden()` pattern (Strength attribute + prop 0xE6
aug + prop 5 EncumbranceVal, falling back to `SumCarriedBurden` when the wire
value is absent). A 2026-07-31 connected gate exposed one omitted retail
detail: `InqAttribute` returns the enchantment-adjusted attribute, while all
three acdream burden consumers still read raw `AttributeValue.Current`.
Issue #272 corrects them to `LocalPlayerState.GetEffectiveAttribute(Strength)`
and invalidates burden on the canonical `Spellbook.EnchantmentsChanged` edge.
**`AcDream.Core.Items.BurdenMath`
(`EncumbranceCapacity`/`LoadRatio`/`LoadModifier`) is the SAME formulas at
the SAME addresses.** P1's `EncumbranceSystem` (Physics-namespaced, for
citation clarity next to `MovementSystem`) delegates to `BurdenMath` rather
@ -317,8 +322,10 @@ Runtime (AcDream.Runtime, presentation-free):
- onSkillsUpdated callback -> character.Character.UpdateMovementSkillBase(...)
- NEW: inventory.Objects.{ObjectAdded,ObjectUpdated,ObjectRemoved,ObjectMoved,
ContainerContentsReplaced,Cleared} + LocalPlayer.AttributeChanged(Strength)
+ Spellbook.EnchantmentsChanged
-> recompute burden (Strength + prop 0xE6 aug + prop 5 EncumbranceVal,
SAME shape as IndicatorBarController.UpdateBurden/InventoryController.RefreshBurden)
using effective/enchantment-adjusted Strength
-> character.Character.MovementSkills.UpdateBurden(ratio)
- NEW: character.Character.LocalPlayer.Changed(VitalKind.Stamina)
-> character.Character.MovementSkills.UpdateStamina(current)

View file

@ -99,3 +99,12 @@ InputDispatcher / PlayerMovementController
5 skips. The user accepted repeated uphill stair-side runs on 2026-07-31.
See
`docs/research/2026-07-31-271-stair-side-slide-capture.md`.
- 2026-07-31: #272 burden invalidation. Retail `InqLoad @ 0x0058F130`
obtains Strength through `InqAttribute @ 0x00591A00`, so carry capacity
always uses the enchantment-adjusted value. Runtime movement, the indicator
bar, and the inventory meter must all consume
`LocalPlayerState.GetEffectiveAttribute(Strength)` and react to the same
`Spellbook.EnchantmentsChanged` event. That one event covers spell add,
remove, expiration, dispel, and death purge. Never refresh this through a
UI-only workaround or wait for a base-attribute packet. The connected
buff/death gate was user-accepted on 2026-07-31.

View file

@ -614,9 +614,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
d.Character.Spellbook,
d.Inventory.Objects,
() => d.PlayerIdentity.ServerGuid,
() => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength),
() => late.Session.LinkStatus,
d.ClientTime,
() => late.Session.CurrentSession?.RequestLinkStatusPing(),
@ -656,9 +655,9 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
() => d.PlayerIdentity.ServerGuid,
iconComposer.GetIcon,
iconComposer.GetDragIcon,
() => d.Character.LocalPlayer.GetAttribute(
LocalPlayerState.AttributeKind.Strength)
is { } strength ? (int?)strength.Current : null,
() => d.Character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength),
d.Character.Spellbook,
guid => late.Session.CurrentSession?.SendUse(guid),
(item, container, placement) =>
late.Session.CurrentSession?.SendPutItemInContainer(

View file

@ -91,7 +91,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
_miniGame.OnClick = () => bindings.TogglePanel(RetailPanelCatalog.MiniGame);
_endCharacterSession.OnClick = bindings.RequestEndCharacterSession;
bindings.Spellbook.EnchantmentsChanged += UpdateEnchantments;
bindings.Spellbook.EnchantmentsChanged += OnEnchantmentsChanged;
bindings.Objects.ObjectAdded += OnObjectChanged;
bindings.Objects.ObjectUpdated += OnObjectChanged;
bindings.Objects.ObjectRemoved += OnObjectChanged;
@ -229,6 +229,11 @@ public sealed class IndicatorBarController : IRetainedPanelController
private void OnObjectMoved(ClientObjectMove _) => UpdateBurden();
private void OnContainerContentsReplaced(uint _) => UpdateBurden();
private void OnObjectsCleared() => UpdateBurden();
private void OnEnchantmentsChanged()
{
UpdateEnchantments();
UpdateBurden();
}
private void UpdateBurden()
{
@ -253,7 +258,7 @@ public sealed class IndicatorBarController : IRetainedPanelController
{
if (_disposed) return;
_disposed = true;
_bindings.Spellbook.EnchantmentsChanged -= UpdateEnchantments;
_bindings.Spellbook.EnchantmentsChanged -= OnEnchantmentsChanged;
_bindings.Objects.ObjectAdded -= OnObjectChanged;
_bindings.Objects.ObjectUpdated -= OnObjectChanged;
_bindings.Objects.ObjectRemoved -= OnObjectChanged;

View file

@ -3,6 +3,7 @@ using System.Numerics;
using AcDream.App.UI;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
namespace AcDream.App.UI.Layout;
@ -43,6 +44,7 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
private readonly Func<ItemType, uint, uint, uint, uint, uint> _iconIds;
private readonly Func<ItemType, uint, uint, uint, uint, uint>? _dragIconIds;
private readonly Func<int?> _strength;
private readonly Spellbook? _burdenSpellbook;
private readonly Func<string>? _ownerName;
private readonly UiItemList? _contentsGrid;
@ -96,13 +98,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
Action<uint, uint>? notifyMergeAttempt,
ItemInteractionController? itemInteraction,
Action? onClose,
StackSplitQuantityState? stackSplitQuantity)
StackSplitQuantityState? stackSplitQuantity,
Spellbook? burdenSpellbook)
{
_objects = objects;
_playerGuid = playerGuid;
_iconIds = iconIds;
_dragIconIds = dragIconIds;
_strength = strength;
_burdenSpellbook = burdenSpellbook;
_ownerName = ownerName;
_sendUse = sendUse;
_sendPutItemInContainer = sendPutItemInContainer;
@ -195,6 +199,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated += OnObjectChanged;
_objects.Cleared += OnObjectsCleared;
_selection.Changed += OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged += RefreshBurden;
if (_itemInteraction is not null)
{
_itemInteraction.StateChanged += OnInteractionStateChanged;
@ -244,14 +250,15 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
ItemInteractionController? itemInteraction = null,
Action? onClose = null,
StackSplitQuantityState? stackSplitQuantity = null,
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null)
Func<ItemType, uint, uint, uint, uint, uint>? dragIconIds = null,
Spellbook? burdenSpellbook = null)
=> new InventoryController(layout, objects, playerGuid, iconIds, dragIconIds, strength, selection,
ownerName, datFont,
contentsEmptySprite, sideBagEmptySprite, mainPackEmptySprite,
sendUse, sendPutItemInContainer,
sendStackableSplitToContainer, sendStackableMerge,
notifyMergeAttempt, itemInteraction,
onClose, stackSplitQuantity);
onClose, stackSplitQuantity, burdenSpellbook);
private void OnObjectChanged(ClientObject o)
{
@ -944,6 +951,8 @@ public sealed class InventoryController : IItemListDragHandler, IRetainedPanelCo
_objects.ObjectUpdated -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
_selection.Changed -= OnSelectionChanged;
if (_burdenSpellbook is not null)
_burdenSpellbook.EnchantmentsChanged -= RefreshBurden;
if (_contentsGrid is not null)
{
_contentsGrid.PrimaryItemPressed = null;

View file

@ -122,6 +122,7 @@ public sealed record InventoryRuntimeBindings(
Func<ItemType, uint, uint, uint, uint, uint> ResolveIcon,
Func<ItemType, uint, uint, uint, uint, uint> ResolveDragIcon,
Func<int?> Strength,
Spellbook Spellbook,
Action<uint>? SendUse,
Action<uint, uint, int>? SendPutItemInContainer,
Action<uint, uint, uint, uint>? SendStackableSplitToContainer,
@ -1816,7 +1817,8 @@ public sealed class RetailUiRuntime : IDisposable
notifyMergeAttempt, b.ItemInteraction,
() => CloseWindow(WindowNames.Inventory),
StackSplitQuantity,
b.ResolveDragIcon);
b.ResolveDragIcon,
b.Spellbook);
InventoryPanelController = inventory;
PaperdollController paperdoll = PaperdollController.Bind(
layout, b.Objects, b.PlayerGuid, b.ResolveIcon, b.Selection, b.ItemInteraction,

View file

@ -229,6 +229,10 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
if (kind == LocalPlayerState.AttributeKind.Strength)
RecomputeBurden(inventory, character);
});
SubscribeParameterless(
h => character.Character.Spellbook.EnchantmentsChanged += h,
h => character.Character.Spellbook.EnchantmentsChanged -= h,
() => RecomputeBurden(inventory, character));
// Current-stamina push — CACQualities::InqRunRate/InqJumpVelocity's
// stamina==0 effective-skill-zeroing gate (pseudocode doc §5).
@ -370,8 +374,8 @@ public sealed class LiveSessionEventRouter : ILiveSessionEventRouting
{
uint player = inventory.PlayerGuid();
ClientObject? playerObject = inventory.Objects.Get(player);
int strength = (int)(character.Character.LocalPlayer
.GetAttribute(LocalPlayerState.AttributeKind.Strength)?.Current ?? 0u);
int strength = character.Character.LocalPlayer
.GetEffectiveAttribute(LocalPlayerState.AttributeKind.Strength) ?? 0;
int aug = playerObject?.Properties.GetInt(
(uint)PropertyInt.AugmentationIncreasedCarryingCapacity) ?? 0;
int capacity = EncumbranceSystem.EncumbranceCapacity(strength, aug);

View file

@ -89,6 +89,25 @@ public sealed class IndicatorBarControllerTests
Assert.Equal(expectedState, button.ActiveRetailStateId);
}
[Fact]
public void EnchantmentChange_ReevaluatesBurdenFromEffectiveStrength()
{
var h = CreateHarness(strength: 10);
using IndicatorBarController controller = h.Controller;
h.Objects.UpdateIntProperty(Player, 5u, 1600);
UiButton button = h.Button(IndicatorBarController.BurdenButtonId);
Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId);
h.Strength = 20;
h.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60d, Player, Bucket: 1u));
Assert.Equal(IndicatorBarController.UnencumberedState, button.ActiveRetailStateId);
h.Strength = 10;
h.Spellbook.OnPurgeAll();
Assert.Equal(IndicatorBarController.EncumberedState, button.ActiveRetailStateId);
}
[Fact]
public void Burden_ClickOpensCharacterInformationPanel()
{
@ -205,7 +224,7 @@ public sealed class IndicatorBarControllerTests
spellbook,
objects,
() => Player,
() => strength,
() => harness.Strength,
() => harness.LinkStatus,
() => harness.Time,
harness.ToggledPanels.Add,
@ -222,7 +241,7 @@ public sealed class IndicatorBarControllerTests
public ImportedLayout Layout { get; } = layout;
public Spellbook Spellbook { get; } = spellbook;
public ClientObjectTable Objects { get; } = objects;
public int Strength { get; } = strength;
public int Strength { get; set; } = strength;
public List<uint> ToggledPanels { get; } = [];
public double Time { get; set; }
public LinkStatusSnapshot LinkStatus { get; set; } = new(true, 0d);

View file

@ -3,6 +3,7 @@ using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Selection;
using AcDream.Core.Spells;
using Xunit;
namespace AcDream.App.Tests.UI.Layout;
@ -63,10 +64,12 @@ public class InventoryControllerTests
Action? onClose = null,
SelectionState? selection = null,
StackSplitQuantityState? stackSplitQuantity = null,
ItemInteractionController? itemInteraction = null)
ItemInteractionController? itemInteraction = null,
Func<int?>? strengthProvider = null,
Spellbook? burdenSpellbook = null)
=> InventoryController.Bind(layout, objects, () => Player,
iconIds: (_, _, _, _, _) => 0u,
strength: () => strength, datFont: null,
strength: strengthProvider ?? (() => strength), datFont: null,
ownerName: ownerName is null ? null : () => ownerName,
sendUse: uses is null ? null : g => uses.Add(g),
sendPutItemInContainer: puts is null ? null : (i, c, p) => puts.Add((i, c, p)),
@ -78,7 +81,8 @@ public class InventoryControllerTests
onClose: onClose,
selection: selection ?? new SelectionState(),
stackSplitQuantity: stackSplitQuantity,
itemInteraction: itemInteraction);
itemInteraction: itemInteraction,
burdenSpellbook: burdenSpellbook);
private static UiButton MakeButton(uint id)
{
@ -238,6 +242,37 @@ public class InventoryControllerTests
Assert.Contains("50%", CaptionText(burdenText));
}
[Fact]
public void EnchantmentChange_RefreshesBurdenFromEffectiveStrength()
{
var (layout, _, _, _, meter, burdenText, _, _) = BuildLayout();
var objects = new ClientObjectTable();
var props = new PropertyBundle();
props.Ints[5] = 1600;
objects.UpsertProperties(Player, props);
int effectiveStrength = 10;
var spellbook = new Spellbook();
using InventoryController controller = Bind(
layout,
objects,
strengthProvider: () => effectiveStrength,
burdenSpellbook: spellbook);
Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3);
Assert.Contains("106%", CaptionText(burdenText));
effectiveStrength = 20;
spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
42u, 1u, 60d, Player, Bucket: 1u));
Assert.Equal(1600f / 3000f / 3f, meter.Fill() ?? -1f, 3);
Assert.Contains("53%", CaptionText(burdenText));
effectiveStrength = 10;
spellbook.OnPurgeAll();
Assert.Equal(1600f / 1500f / 3f, meter.Fill() ?? -1f, 3);
Assert.Contains("106%", CaptionText(burdenText));
}
[Fact]
public void Captions_render_known_strings()
{

View file

@ -233,6 +233,74 @@ public sealed class LiveSessionEventRouterTests
router.Dispose();
}
[Fact]
public void StrengthEnchantmentChange_RecomputesBurdenWithoutBaseAttributeUpdate()
{
using var session = NewSession();
const uint playerGuid = 0x50000001u;
var objects = new ClientObjectTable();
var character = new RuntimeCharacterState();
character.InstallSpellMetadata(SpellTable.LoadFromReader(new StringReader(
"Spell ID,Name,Flags [Hex]\n42,Strength Test,0x4\n")));
int movementStatsUpdated = 0;
var router = new LiveSessionEventRouter(
session,
NoOpEntitySink(),
NoOpEnvironmentSink(),
new LiveInventorySessionBindings(
objects,
PlayerGuid: () => playerGuid,
OnShortcuts: null,
OnUseDone: null,
ItemMana: new ItemManaState(),
ExternalContainers: new ExternalContainerState()),
new LiveCharacterSessionBindings(
new CombatState(),
character,
ResolveSkillFormulaBonus: null,
OnSkillsUpdated: null,
OnConfirmationRequest: null,
OnConfirmationDone: null,
ClientTime: () => 0d,
OnMovementStatsUpdated: () => movementStatsUpdated++),
NewSocialBindings());
router.Attach();
character.LocalPlayer.OnAttributeUpdate(
atType: 1u, ranks: 90u, start: 10u, xp: 0u);
var props = new PropertyBundle();
props.Ints[(uint)PropertyInt.EncumbranceVal] = 16500;
objects.UpsertProperties(playerGuid, props);
Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4);
int beforeBuff = movementStatsUpdated;
character.Spellbook.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 42u,
LayerId: 1u,
Duration: 60d,
CasterGuid: playerGuid,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u,
StatModValue: 1.2f,
Bucket: 1u));
Assert.Equal(120, character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength));
Assert.Equal(16500f / 18000f, character.MovementSkills.Burden, precision: 4);
Assert.Equal(beforeBuff + 1, movementStatsUpdated);
int beforePurge = movementStatsUpdated;
character.Spellbook.OnPurgeAll();
Assert.Equal(100, character.LocalPlayer.GetEffectiveAttribute(
LocalPlayerState.AttributeKind.Strength));
Assert.Equal(1.1f, character.MovementSkills.Burden, precision: 4);
Assert.Equal(beforePurge + 1, movementStatsUpdated);
router.Dispose();
}
[Fact]
public void ObjectTablePropertyChange_RecomputesMovementSkillAugmentations()
{