diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 76a06622..4eda3e76 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -2060,7 +2060,7 @@ See divergence register **AP-59**.
**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-*`. **Still deferred:** the Mana meter (0x100001A2 — owned-item-only; no remote-target mana path yet). Divergence row AP-46.
- **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, reveals authored entry `0x100001A3` and horizontal DAT slider `0x100001A4`, initializes to the full stack, clamps entry edits, and uses the exact 1000-step slider conversion. One Core `StackSplitQuantityState` feeds both controls and the selected-source merge amount. Live visual gate pending.
+- **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 both controls and the selected-source merge amount. Warning-free App Release + 4,691 tests; live visual gate pending.
**Files:** `src/AcDream.App/UI/Layout/ToolbarController.cs` + the selection/WorldPicker state (see `claude-memory/project_interaction_pipeline.md`).
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index d2291a11..44f1674a 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -490,7 +490,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **✓ SHIPPED — Wave 4.3a shortcut-alias boundary.** Drag feedback now preserves retail's neutral/accept/reject distinction. Inventory and paperdoll apply the `(DropItemFlags & 0xE) == 0` physical-item gate, so a toolbar alias can reorder within the toolbar but can never move, unwield, wield, or drop the object it references. Live equipped-helmet gate passed 2026-07-11: inventory drop removed only the shortcut.
- **✓ SHIPPED — Wave 4.3b source-aware toolbar transactions.** Pure Core `ShortcutDropPlanner` ports `gmToolbarUI::HandleDropRelease @ 0x004BE7C0`: fresh inventory drops displace cyclically right (with wrap), shortcut aliases restore to their vacated source, duplicate objects are removed before placement, full bars intentionally lose the displaced entry when retail finds no empty slot, and every transaction is planned before local mutation then emitted in exact Remove/Add order. Full-stack rekey planning preserves the raw spell word. AP-102 retired; 4,646 automated tests pass; live inventory-to-occupied displacement and toolbar reorder gate passed 2026-07-11.
- **✓ SHIPPED — Wave 4.3c remaining drop branches + stack merge.** `UiButton` now has a reusable retained item-drop-target seam; gmToolbarUI's inventory/backpack button accepts only fresh physical items, draws the exact authored green-arrow overlay `0x060011F7`, and sends them to the player container while shortcut aliases remain neutral. Pure Core `StackMergePlanner` ports `IsMergeAttemptLegal @ 0x00586F30` and `AttemptMerge @ 0x005878F0`; inventory drops try legal same-WCID stack merges before normal insertion, clamp `0x0054` amount to destination capacity, immediately broadcast retail's misleadingly named `FullMergingItem` notice to rekey the toolbar, and select the destination. Existing destination shortcuts are removed before rekey. Warning-free Release build and 4,660 tests pass; live stack-merge gate passed 2026-07-11. Backpack-button drop gate remains pending.
-- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls.** `SelectedObjectController` now formats retail's count-first stack name, reveals the authored numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with inventory merge. Exact cdb-pinned 1000-step slider rounding, entry clamping, real-DAT fixture media, and pointer drag have conformance tests. Warning-free App Release build and 4,683 tests pass; live visual gate pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
+- **✓ IMPLEMENTED — Wave 4.4a selected-stack controls.** `SelectedObjectController` formats retail's count-first `NAME_APPROPRIATE` stack name from the wire plural, reveals the authored right-aligned numeric entry and horizontal DAT slider, initializes both to the full stack, refreshes them on selected-object stack changes, and shares one Core split quantity with inventory merge. The thumb owns its raw pointer position so quantity 1 and maximum reach the exact endpoints; entry writes retain retail's `split/max` attribute behavior. Exact cdb-pinned 1000-step rounding, plural fallback, real-DAT media/alignment, and pointer drag have conformance tests. Warning-free App Release build and 4,691 tests pass; live visual gate pending 2026-07-11. Selected-item mana and remaining toolbar buttons stay under AP-101.
- **✓ 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.
- **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.
diff --git a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
index d72904ac..f55903e3 100644
--- a/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
+++ b/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
@@ -507,7 +507,8 @@ HandleSelectionChanged(): // 0x004BF380
if object missing: return
set selected name:
- normal object: name, or `"%d %hs"` (count then name) when stacked
+ normal object: NAME_APPROPRIATE, or `"%d %hs"` (count then appropriate
+ name) when stacked
owned coinstack: formatted coin total/name path
splitSize = 1
@@ -561,6 +562,16 @@ RecvNotice_UpdateItemMana(id, fraction, valid): // 0x004BD0C0
### 5.4 Stack editing
+`ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0` selects
+`PublicWeenieDesc._plural_name` whenever `_stackSize > 1`. If the optional plural
+string is empty, retail copies the singular and appends `s`, or `es` when the
+singular already ends in lowercase `s`. The CreateObject WeenieHeader carries the
+explicit plural as flag `0x00000001`, before ItemsCapacity.
+
+The stack entry's authored DAT horizontal justification is Right. Its number is
+therefore right-aligned against the slider's left edge rather than drawn from the
+entry's left edge.
+
- `RecvNotice_SplitStack(selectedId)` activates and selects all text only when the
selected object still has a stack size greater than one.
- On entry activation, select all text.
@@ -583,6 +594,11 @@ splitSize = clamp(1 + floor(positionMillis * maxSplitSize / 1000),
The apparently odd extra one is verbatim retail behavior. Static cdb disassembly
of the matching v11.4186 binary also pins the stack-name format string at
`0x007B4738` to `"%d %hs"` and the numeric-entry format at `0x007A0184` to `"%d"`.
+While the pointer is moving, the scrollbar retains its own normalized position and
+the toolbar only rewrites the entry text. Selection refresh or entry deactivation
+explicitly writes attribute `0x86 = splitSize/maxSplitSize`. Consequently a pointer
+drag can remain at exact position `0` (quantity 1) or `1` (the maximum), rather than
+being redrawn from the quantized quantity during the drag.
---
diff --git a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
index 1357c28d..ae6aff9e 100644
--- a/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
+++ b/docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md
@@ -535,7 +535,11 @@ binary pinned stack-name `"%d %hs"`, numeric entry `"%d"`, and the slider's exac
`InventoryController` consumes the value only when the dragged source is selected.
The horizontal scrollbar remains the generic retained `UiScrollbar` in scalar mode,
using the DAT track/thumb rather than panel-local drawing. The warning-free App Release
-build and 4,683-test suite are green; the live visual gate follows.
+build and 4,691-test suite are green. Gate polish preserves CreateObject
+`PluralName` through Core.Net into `ClientObject.GetAppropriateName`, honors the
+entry's authored right alignment, and lets the pointer-owned thumb remain at exact
+minimum/maximum endpoints instead of snapping back from the quantized amount. The
+live visual gate follows.
**Wave 4.1 implementation (2026-07-11):** bare `1..9` now use slots 0..8,
Ctrl+`1..9` selects them, Alt+`5..9` uses slots 13..17, and `0` creates a
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 5d45fbe6..176218a2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2120,7 +2120,7 @@ public sealed class GameWindow : IDisposable
handler => Combat.HealthChanged += handler,
handler => Combat.HealthChanged -= handler,
IsHealthBarTarget,
- guid => Objects.Get(guid)?.Name,
+ guid => Objects.Get(guid)?.GetAppropriateName(),
Combat.GetHealthPercent,
Combat.HasHealth,
guid => (uint)(Objects.Get(guid)?.StackSize ?? 0),
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index e4a50387..2e803e88 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -405,6 +405,8 @@ public static class DatWidgetFactory
FocusFieldSprite = focusSprite,
Selectable = selectable,
OneLine = oneLine,
+ Centered = info.HJustify == HJustify.Center,
+ RightAligned = info.HJustify == HJustify.Right,
};
if (info.TryGetEffectiveInteger(0x1Eu, out int maxCharacters))
field.MaxCharacters = maxCharacters;
diff --git a/src/AcDream.App/UI/Layout/SelectedObjectController.cs b/src/AcDream.App/UI/Layout/SelectedObjectController.cs
index 129a00f7..271abef5 100644
--- a/src/AcDream.App/UI/Layout/SelectedObjectController.cs
+++ b/src/AcDream.App/UI/Layout/SelectedObjectController.cs
@@ -99,6 +99,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
private uint? _current;
private string? _currentName;
private double _flashRemaining; // > 0 while the selection overlay is flashing
+ private bool _changingSplitFromSlider;
private bool _disposed;
/// White label color for the name line.
@@ -166,8 +167,8 @@ public sealed class SelectedObjectController : IRetainedPanelController
{
_stackSizeSlider.Visible = false;
_stackSizeSlider.Horizontal = true;
- _stackSizeSlider.ScalarValue = () => _splitQuantity.Ratio;
- _stackSizeSlider.ScalarChanged = _splitQuantity.SetFromSliderRatio;
+ _stackSizeSlider.SetScalarPosition(_splitQuantity.Ratio);
+ _stackSizeSlider.ScalarChanged = OnStackSliderChanged;
}
// Attach a centered UiText child to the name element for the object name display.
@@ -224,7 +225,7 @@ public sealed class SelectedObjectController : IRetainedPanelController
/// (typical host: h => Combat.HealthChanged += h) — drives meter visibility.
/// Returns true for guids that may show a health meter
/// (proxy for retail's IsPlayer() || pet_owner || ObjectIsAttackable()).
- /// Returns the display name for a given guid (or null if unknown).
+ /// Returns retail's NAME_APPROPRIATE display name for a guid.
/// Returns the health fill fraction [0..1] for a given guid.
/// Returns true if real health has been received for a guid
/// (so a re-selected, already-known target shows its bar immediately).
@@ -342,7 +343,26 @@ public sealed class SelectedObjectController : IRetainedPanelController
=> _splitQuantity.SetFromText(text);
private void OnSplitQuantityChanged()
- => _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ {
+ _stackSizeEntry?.SetText(_splitQuantity.Value.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ // UIElement_Scrollbar owns its live thumb position while dragging and only
+ // broadcasts message 0xA. Entry/selection changes explicitly write attr 0x86.
+ if (!_changingSplitFromSlider)
+ _stackSizeSlider?.SetScalarPosition(_splitQuantity.Ratio);
+ }
+
+ private void OnStackSliderChanged(float position)
+ {
+ _changingSplitFromSlider = true;
+ try
+ {
+ _splitQuantity.SetFromSliderRatio(position);
+ }
+ finally
+ {
+ _changingSplitFromSlider = false;
+ }
+ }
private void OnObjectUpdated(ClientObject updated)
{
diff --git a/src/AcDream.App/UI/UiField.cs b/src/AcDream.App/UI/UiField.cs
index 123b4f1a..0bb021eb 100644
--- a/src/AcDream.App/UI/UiField.cs
+++ b/src/AcDream.App/UI/UiField.cs
@@ -33,6 +33,8 @@ public sealed class UiField : UiElement
public int MaxCharacters { get; set; } = 0xFFFF;
public bool OneLine { get; set; } = true;
public bool Selectable { get; set; }
+ public bool Centered { get; set; }
+ public bool RightAligned { get; set; }
public bool ClearOnSubmit { get; set; } = true;
public bool RecordHistory { get; set; } = true;
public Func? CharacterFilter { get; set; }
@@ -272,7 +274,7 @@ public sealed class UiField : UiElement
/// FindPixelsFromPos inverse. Accounts for the horizontal scroll offset.
private int HitCharX(float localX)
{
- float target = localX - Padding + _scrollX;
+ float target = localX - Padding - TextAlignmentOffset() + _scrollX;
if (target <= 0f) return 0;
int best = 0;
float bestDist = float.MaxValue;
@@ -324,6 +326,7 @@ public sealed class UiField : UiElement
if (caretX - _scrollX > visibleW) _scrollX = caretX - visibleW;
if (caretX < _scrollX) _scrollX = caretX;
_scrollX = Math.Clamp(_scrollX, 0f, MathF.Max(0f, fullW - visibleW));
+ float alignX = TextAlignmentOffset();
// Visible character window [start, end).
int start = 0;
@@ -335,15 +338,15 @@ public sealed class UiField : UiElement
if (HasSelection)
{
var (lo, hi) = SelSpan();
- float h0 = MathF.Max(MeasureTo(lo) - _scrollX, 0f);
- float h1 = MathF.Min(MeasureTo(hi) - _scrollX, visibleW);
+ float h0 = MathF.Max(alignX + MeasureTo(lo) - _scrollX, 0f);
+ float h1 = MathF.Min(alignX + MeasureTo(hi) - _scrollX, visibleW);
if (h1 > h0) ctx.DrawFill(Padding + h0, ty, h1 - h0, lh, SelectionColor);
}
if (end > start)
{
string vis = _text.Substring(start, end - start);
- float vx = Padding + (MeasureTo(start) - _scrollX);
+ float vx = Padding + alignX + (MeasureTo(start) - _scrollX);
if (DatFont is { } df2) ctx.DrawStringDat(df2, vis, vx, ty, TextColor);
else ctx.DrawString(vis, vx, ty, TextColor, Font);
}
@@ -351,12 +354,21 @@ public sealed class UiField : UiElement
if (_focused)
{
// Caret on TOP of the text → submitted after the text in the same bucket.
- float cx = Padding + (caretX - _scrollX);
+ float cx = Padding + alignX + (caretX - _scrollX);
if (cx >= Padding - 1f && cx <= Width - Padding + 1f)
ctx.DrawFill(cx, ty, 1f, lh, TextColor);
}
}
+ private float TextAlignmentOffset()
+ {
+ float visibleW = MathF.Max(1f, Width - 2f * Padding);
+ float spare = MathF.Max(0f, visibleW - MeasureTo(_text.Length));
+ if (RightAligned) return spare;
+ if (Centered) return spare * 0.5f;
+ return 0f;
+ }
+
// ── Auto-repeat ──────────────────────────────────────────────────────
protected override void OnTick(double deltaSeconds)
diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs
index c0e24ed8..fbd57a09 100644
--- a/src/AcDream.App/UI/UiScrollbar.cs
+++ b/src/AcDream.App/UI/UiScrollbar.cs
@@ -29,10 +29,14 @@ public sealed class UiScrollbar : UiElement
/// Optional scalar mode used by retail's horizontal stack-split control. When set,
/// the bar reflects/drives one normalized value rather than a .
///
- public Func? ScalarValue { get; set; }
+ public float ScalarPosition { get; private set; }
public Action? ScalarChanged { get; set; }
public bool Horizontal { get; set; }
+ /// Programmatically set retail scrollbar attribute 0x86 without broadcasting.
+ public void SetScalarPosition(float position)
+ => ScalarPosition = Math.Clamp(position, 0f, 1f);
+
/// RenderSurface id → (GL tex, w, h). 0 id = skip.
public Func? SpriteResolve { get; set; }
@@ -95,12 +99,12 @@ public sealed class UiScrollbar : UiElement
protected override void OnDraw(UiRenderContext ctx)
{
if (SpriteResolve is not { } resolve) return;
- if (Horizontal && ScalarValue is not null)
+ if (Horizontal)
{
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
float thumbWidth = ScalarThumbWidth(resolve);
float travel = MathF.Max(0f, Width - thumbWidth);
- float x = travel * Math.Clamp(ScalarValue(), 0f, 1f);
+ float x = travel * ScalarPosition;
DrawSprite(ctx, resolve, ThumbSprite, x, 0f, thumbWidth, Height);
return;
}
@@ -162,7 +166,7 @@ public sealed class UiScrollbar : UiElement
public override bool OnEvent(in UiEvent e)
{
- if (Horizontal && ScalarValue is not null && ScalarChanged is not null)
+ if (Horizontal && ScalarChanged is not null)
return OnScalarEvent(e);
if (Model is not { } m) return false;
@@ -228,7 +232,7 @@ public sealed class UiScrollbar : UiElement
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
- float thumbX = travel * Math.Clamp(ScalarValue!(), 0f, 1f);
+ float thumbX = travel * ScalarPosition;
float x = e.Data1;
if (x >= thumbX && x <= thumbX + thumbWidth)
{
@@ -237,7 +241,7 @@ public sealed class UiScrollbar : UiElement
else
{
_dragOffsetX = thumbWidth * 0.5f;
- ScalarChanged!(Math.Clamp((x - _dragOffsetX) / travel, 0f, 1f));
+ ChangeScalarPosition((x - _dragOffsetX) / travel);
}
_draggingThumb = true;
return true;
@@ -247,7 +251,7 @@ public sealed class UiScrollbar : UiElement
{
float thumbWidth = ScalarThumbWidth(SpriteResolve);
float travel = MathF.Max(1f, Width - thumbWidth);
- ScalarChanged!(Math.Clamp(((float)e.Data1 - _dragOffsetX) / travel, 0f, 1f));
+ ChangeScalarPosition(((float)e.Data1 - _dragOffsetX) / travel);
return true;
}
@@ -268,4 +272,10 @@ public sealed class UiScrollbar : UiElement
}
return MathF.Min(16f, Width);
}
+
+ private void ChangeScalarPosition(float position)
+ {
+ SetScalarPosition(position);
+ ScalarChanged?.Invoke(ScalarPosition);
+ }
}
diff --git a/src/AcDream.Core.Net/Messages/CreateObject.cs b/src/AcDream.Core.Net/Messages/CreateObject.cs
index 45bceb6e..1875e999 100644
--- a/src/AcDream.Core.Net/Messages/CreateObject.cs
+++ b/src/AcDream.Core.Net/Messages/CreateObject.cs
@@ -194,7 +194,10 @@ public static class CreateObject
// an explicitly transmitted zero (the enum's undefined/default value).
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
- byte? CombatUse = null);
+ byte? CombatUse = null,
+ // PublicWeenieDesc._plural_name, gated by WeenieHeader flag 0x1.
+ // ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) selects this for stacks.
+ string? PluralName = null);
///
/// The relevant subset of the server-sent MovementData /
@@ -653,6 +656,7 @@ public static class CreateObject
string? name = null;
uint? itemType = null;
uint weenieFlags = 0;
+ string? pluralName = null;
uint iconId = 0;
uint weenieClassId = 0;
int? wValue = null;
@@ -708,7 +712,7 @@ public static class CreateObject
// bit field width
// --------- ------------------ -----
// 0x04000000 (objDescFlags) weenieFlags2 u32 (skip)
- // 0x00000001 PluralName String16L (skip)
+ // 0x00000001 PluralName String16L CAPTURE
// 0x00000002 ItemsCapacity u8 (skip)
// 0x00000004 ContainersCapacity u8 (skip)
// 0x00000100 AmmoType u16 (skip)
@@ -774,7 +778,7 @@ public static class CreateObject
}
if ((weenieFlags & 0x00000001u) != 0) // PluralName
- _ = ReadString16L(body, ref pos);
+ pluralName = ReadString16L(body, ref pos);
if ((weenieFlags & 0x00000002u) != 0) // ItemsCapacity u8
{
@@ -969,7 +973,8 @@ public static class CreateObject
Priority: wPriority, Structure: wStructure, MaxStructure: wMaxStructure,
Workmanship: wWorkmanship,
RadarBlipColor: radarBlipColor, RadarBehavior: radarBehavior,
- CombatUse: combatUse);
+ CombatUse: combatUse,
+ PluralName: pluralName);
// Local helper: if we ran out of fields past PhysicsData, still
// return the useful prefix (guid/position/setup/animParts/textures/palettes/scale/motion).
diff --git a/src/AcDream.Core.Net/ObjectTableWiring.cs b/src/AcDream.Core.Net/ObjectTableWiring.cs
index 18403f89..03d22e47 100644
--- a/src/AcDream.Core.Net/ObjectTableWiring.cs
+++ b/src/AcDream.Core.Net/ObjectTableWiring.cs
@@ -87,5 +87,6 @@ public static class ObjectTableWiring
RadarBlipColor: s.RadarBlipColor,
RadarBehavior: s.RadarBehavior,
PublicWeenieBitfield: s.ObjectDescriptionFlags,
- CombatUse: s.CombatUse);
+ CombatUse: s.CombatUse,
+ PluralName: s.PluralName);
}
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 6bfcb314..05e016ec 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -123,7 +123,8 @@ public sealed class WorldSession : IDisposable
// undefined/default value.
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
- byte? CombatUse = null);
+ byte? CombatUse = null,
+ string? PluralName = null);
///
/// Projects the wire-level CreateObject result into the stable session
@@ -175,7 +176,8 @@ public sealed class WorldSession : IDisposable
ServerControlSequence: parsed.ServerControlSequence,
RadarBlipColor: parsed.RadarBlipColor,
RadarBehavior: parsed.RadarBehavior,
- CombatUse: parsed.CombatUse);
+ CombatUse: parsed.CombatUse,
+ PluralName: parsed.PluralName);
/// Fires when the session finishes parsing a CreateObject.
public event Action? EntitySpawned;
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index 807933c3..2eeda4d6 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -145,6 +145,8 @@ public sealed class ClientObject
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = "";
+ /// Retail PublicWeenieDesc._plural_name; empty falls back to singular.
+ public string PluralName { get; set; } = "";
public ItemType Type { get; set; }
public EquipMask ValidLocations { get; set; }
public EquipMask CurrentlyEquippedLocation { get; set; }
@@ -194,6 +196,19 @@ public sealed class ClientObject
public int MaxStructure { get; set; }
public float Workmanship { get; set; } // 0..10 (fractional on the wire)
public PropertyBundle Properties { get; } = new();
+
+ ///
+ /// Ports ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0:
+ /// stacked objects prefer ; when the wire omitted it,
+ /// retail appends s, or es when the singular already ends in lowercase s.
+ ///
+ public string GetAppropriateName()
+ {
+ if (StackSize <= 1) return Name;
+ if (!string.IsNullOrEmpty(PluralName)) return PluralName;
+ if (string.IsNullOrEmpty(Name)) return Name;
+ return Name[^1] == 's' ? Name + "es" : Name + "s";
+ }
}
///
@@ -233,7 +248,8 @@ public readonly record struct WeenieData(
byte? RadarBlipColor = null,
byte? RadarBehavior = null,
uint? PublicWeenieBitfield = null,
- byte? CombatUse = null);
+ byte? CombatUse = null,
+ string? PluralName = null);
///
/// Retail ITEM_USEABLE helpers (acclient.h:6478, ItemUses::* at 0x004fccd0).
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 3dcc560e..3406de63 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -366,6 +366,7 @@ public sealed class ClientObjectTable
uint oldContainer = obj.ContainerId;
if (!string.IsNullOrEmpty(d.Name)) obj.Name = d.Name!;
+ if (!string.IsNullOrEmpty(d.PluralName)) obj.PluralName = d.PluralName!;
if (d.Type is { } t) obj.Type = t;
// WeenieClassId arrives on every CreateObject (fixed prefix) and is never
// legitimately 0 for a real weenie; the != 0 guard avoids clobbering a known
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index da9d1696..8456bed9 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -228,7 +228,8 @@ public class DatWidgetFactoryTests
{
ImportedLayout layout = FixtureLoader.LoadToolbar();
- Assert.IsType(layout.FindElement(0x100001A3u));
+ var entry = Assert.IsType(layout.FindElement(0x100001A3u));
+ Assert.True(entry.RightAligned);
var bar = Assert.IsType(layout.FindElement(0x100001A4u));
Assert.True(bar.Horizontal);
Assert.Equal(0x06004CF6u, bar.TrackSprite);
diff --git a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
index 9aa6b17c..fc2ef0e1 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SelectedObjectControllerTests.cs
@@ -283,17 +283,32 @@ public class SelectedObjectControllerTests
Assert.Equal("17", entry.Text);
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal(17u, h.SplitQuantity.Maximum);
- Assert.Equal(1f, slider.ScalarValue!());
+ Assert.Equal(1f, slider.ScalarPosition);
Assert.Equal("17 Healing Kits", nameEl.Children.OfType().Single().LinesProvider().Single().Text);
+ slider.SetScalarPosition(0.5f);
slider.ScalarChanged!(0.5f);
Assert.Equal(9u, h.SplitQuantity.Value);
Assert.Equal("9", entry.Text);
+ Assert.Equal(0.5f, slider.ScalarPosition);
- entry.SetText("999");
- entry.Submit();
+ slider.SetScalarPosition(0f);
+ slider.ScalarChanged!(0f);
+ Assert.Equal(1u, h.SplitQuantity.Value);
+ Assert.Equal("1", entry.Text);
+ Assert.Equal(0f, slider.ScalarPosition);
+
+ slider.SetScalarPosition(1f);
+ slider.ScalarChanged!(1f);
Assert.Equal(17u, h.SplitQuantity.Value);
Assert.Equal("17", entry.Text);
+ Assert.Equal(1f, slider.ScalarPosition);
+
+ entry.SetText("4");
+ entry.Submit();
+ Assert.Equal(4u, h.SplitQuantity.Value);
+ Assert.Equal("4", entry.Text);
+ Assert.Equal(4f / 17f, slider.ScalarPosition, 5);
}
[Fact]
diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
index a0468a75..779cb5d9 100644
--- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
@@ -88,15 +88,17 @@ public class UiScrollbarTests
Width = 90f,
Height = 14f,
Horizontal = true,
- ScalarValue = () => value,
ScalarChanged = next => value = next,
};
+ bar.SetScalarPosition(1f);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseDown, Data1: 8)));
Assert.Equal(0f, value, 3);
+ Assert.Equal(0f, bar.ScalarPosition, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseMove, Data1: 45)));
Assert.Equal(0.5f, value, 3);
+ Assert.Equal(0.5f, bar.ScalarPosition, 3);
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
}
diff --git a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
index 33135723..79fef282 100644
--- a/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
+++ b/tests/AcDream.Core.Net.Tests/Messages/CreateObjectTests.cs
@@ -549,6 +549,23 @@ public sealed class CreateObjectTests
Assert.Equal(0x500000F0u, parsed.Value.ContainerId);
}
+ [Fact]
+ public void WeenieHeader_pluralName_isPreserved()
+ {
+ byte[] body = BuildMinimalCreateObjectWithWeenieHeader(
+ guid: 0x50000023u,
+ name: "Pyreal Scarab",
+ itemType: (uint)ItemType.Misc,
+ weenieFlags: 0x00000001u | 0x00001000u,
+ stackSize: 2,
+ pluralName: "Pyreal Scarabs");
+
+ var parsed = CreateObject.TryParse(body);
+
+ Assert.NotNull(parsed);
+ Assert.Equal("Pyreal Scarabs", parsed.Value.PluralName);
+ }
+
private static byte[] BuildMinimalCreateObjectWithWeenieHeader(
uint guid,
string name,
@@ -580,6 +597,7 @@ public sealed class CreateObjectTests
uint? currentWieldedLocation = null,
uint? priority = null,
float? workmanship = null,
+ string? pluralName = null,
byte? radarBlipColor = null,
byte? radarBehavior = null,
byte? combatUse = null,
@@ -621,7 +639,7 @@ public sealed class CreateObjectTests
// WorldObject_Networking.cs:87-206. Each field is written only when
// its weenieFlags bit is set, matching the parser's walker exactly.
// Fields not parameterized above default to 0.
- if ((weenieFlags & 0x00000001u) != 0) { /* PluralName — not parameterized */ }
+ if ((weenieFlags & 0x00000001u) != 0) WriteString16L(bytes, pluralName ?? "");
if ((weenieFlags & 0x00000002u) != 0) bytes.Add(itemsCapacity ?? 0); // ItemsCapacity u8
if ((weenieFlags & 0x00000004u) != 0) bytes.Add(containersCapacity ?? 0); // ContainersCapacity u8
if ((weenieFlags & 0x00000100u) != 0) WriteU16(bytes, 0); // AmmoType u16
diff --git a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
index e3aa9393..81e338b3 100644
--- a/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
+++ b/tests/AcDream.Core.Net.Tests/ObjectTableWiringTests.cs
@@ -22,7 +22,7 @@ public sealed class ObjectTableWiringTests
{
// Every EntitySpawn item field is set to a DISTINCT recognisable value so
// a positional transposition in ObjectTableWiring.ToWeenieData would trip
- // at least one Assert. All 22 WeenieData fields are verified below.
+ // at least one Assert. Every mapped WeenieData field is verified below.
var spawn = new WorldSession.EntitySpawn(
Guid: 0x00000600u,
Position: null,
@@ -63,6 +63,7 @@ public sealed class ObjectTableWiringTests
RadarBehavior = 3,
ObjectDescriptionFlags = 0x20800200u,
CombatUse = 2,
+ PluralName = "Iron Swords",
};
var d = ObjectTableWiring.ToWeenieData(spawn);
@@ -108,6 +109,7 @@ public sealed class ObjectTableWiringTests
Assert.Equal((byte)3, d.RadarBehavior);
Assert.Equal(0x20800200u, d.PublicWeenieBitfield);
Assert.Equal((byte)2, d.CombatUse);
+ Assert.Equal("Iron Swords", d.PluralName);
}
// -------------------------------------------------------------------------
diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
index 58f8f33c..14603126 100644
--- a/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
+++ b/tests/AcDream.Core.Net.Tests/WorldSessionRadarTests.cs
@@ -28,6 +28,17 @@ public sealed class WorldSessionRadarTests
Assert.Null(spawn.RadarBehavior);
}
+ [Fact]
+ public void ToEntitySpawn_PreservesPluralName()
+ {
+ var spawn = WorldSession.ToEntitySpawn(MinimalParsed() with
+ {
+ PluralName = "Pyreal Scarabs",
+ });
+
+ Assert.Equal("Pyreal Scarabs", spawn.PluralName);
+ }
+
private static CreateObject.Parsed MinimalParsed() => new(
Guid: 0x50000001u,
Position: null,
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectNameTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectNameTests.cs
new file mode 100644
index 00000000..d4401849
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectNameTests.cs
@@ -0,0 +1,45 @@
+using AcDream.Core.Items;
+using Xunit;
+
+namespace AcDream.Core.Tests.Items;
+
+public sealed class ClientObjectNameTests
+{
+ [Fact]
+ public void AppropriateName_usesWirePluralForStack()
+ {
+ var item = new ClientObject
+ {
+ Name = "Pyreal Scarab",
+ PluralName = "Pyreal Scarabs",
+ StackSize = 2,
+ };
+
+ Assert.Equal("Pyreal Scarabs", item.GetAppropriateName());
+ }
+
+ [Theory]
+ [InlineData("Arrow", "Arrows")]
+ [InlineData("Pyreal", "Pyreals")]
+ [InlineData("Compass", "Compasses")]
+ public void AppropriateName_withoutWirePlural_usesRetailSuffixFallback(
+ string singular, string expected)
+ {
+ var item = new ClientObject { Name = singular, StackSize = 2 };
+
+ Assert.Equal(expected, item.GetAppropriateName());
+ }
+
+ [Fact]
+ public void AppropriateName_forSingleItem_remainsSingular()
+ {
+ var item = new ClientObject
+ {
+ Name = "Pyreal Scarab",
+ PluralName = "Pyreal Scarabs",
+ StackSize = 1,
+ };
+
+ Assert.Equal("Pyreal Scarab", item.GetAppropriateName());
+ }
+}
diff --git a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
index 7a4cc900..3261c4de 100644
--- a/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
+++ b/tests/AcDream.Core.Tests/Items/ClientObjectTableTests.cs
@@ -277,6 +277,20 @@ public sealed class ClientObjectTableTests
Assert.Equal((uint)ItemType.Creature, item.TargetType);
}
+ [Fact]
+ public void Ingest_PluralName_PreservesWireValue()
+ {
+ var table = new ClientObjectTable();
+
+ table.Ingest(FullWeenie(0x500000B9u, name: "Pyreal Scarab", stack: 2) with
+ {
+ PluralName = "Pyreal Scarabs",
+ });
+
+ Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.PluralName);
+ Assert.Equal("Pyreal Scarabs", table.Get(0x500000B9u)!.GetAppropriateName());
+ }
+
[Fact]
public void Ingest_RadarMetadata_PatchesOnlyPresentWireFields()
{