From 256c1930bda44fae1f7b9b17be0f6046061bb91d Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 12 Jul 2026 21:14:14 +0200 Subject: [PATCH] fix(combat): restore retail power bar layers Preserve the authored gray track, trained-Recklessness range, live bright charge meter, and independent desired-power thumb while keeping Speed and Power over gray side regions. Correct retail text justification value 2 to left alignment and retain direct RenderSurface decoding in the texture inspection tool used to verify the assets. Co-Authored-By: Codex --- .../retail-divergence-register.md | 2 +- docs/plans/2026-04-11-roadmap.md | 2 +- docs/research/2026-06-15-layoutdesc-format.md | 2 +- ...2026-07-11-retail-combat-bar-pseudocode.md | 18 ++++++- src/AcDream.App/Rendering/GameWindow.cs | 3 +- src/AcDream.App/Studio/FixtureProvider.cs | 1 + .../UI/Layout/CombatUiController.cs | 35 ++++++++++-- src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 10 ++++ src/AcDream.App/UI/Layout/ElementReader.cs | 5 +- src/AcDream.App/UI/Layout/LayoutImporter.cs | 7 +-- src/AcDream.App/UI/RetailUiRuntime.cs | 4 +- src/AcDream.App/UI/UiScrollbar.cs | 54 ++++++++++++++----- .../UI/Layout/CombatLayoutConformanceTests.cs | 1 + .../UI/Layout/CombatUiControllerTests.cs | 41 +++++++++++++- .../UI/Layout/DatWidgetFactoryTests.cs | 14 ++++- .../UI/Layout/UiPropertyBagTests.cs | 14 +++++ .../AcDream.App.Tests/UI/UiScrollbarTests.cs | 13 +++++ tools/TextureDump/Program.cs | 6 ++- 18 files changed, 200 insertions(+), 32 deletions(-) diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index a41d5b87..30f23372 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -213,7 +213,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps. | AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` | | AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, spellbook/effects/favorite spell bars, residual social/examine/floating chat, quests/map/vitae/options/smartbox, vendor/trade/salvage/tinkering, and D.6 nameplates/floaters | D.5/D.6 roadmap; retained layout registration set | Basic `gmCombatUI` now covers the active M2 melee/missile loop; Wave 10 lands each remaining surface against authoritative state | Large portions of retail gameplay still have no production UI | Named `gm*UI::PostInit` methods and LayoutDesc catalog | | ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` | -| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and the trained-Recklessness submeter | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the two omitted seams require the jump-command owner and skill-advancement state rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained Recklessness lacks its green range overlay | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430`; `RecvNotice_SetCombatMode @ 0x004CC620` | +| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, trained-Recklessness range, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls | `src/AcDream.App/Combat/CombatAttackController.cs` | The M2 attack contract and authored basic panel are live; the remaining seam requires the jump/movement command owner rather than a UI-local guess | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does | `ClientCombatSystem::StartAttackRequest @ 0x0056C040` | ## 4. Temporary stopgap (TS) — 38 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 7997b027..64c96d99 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. - **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 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. +- **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 layered gray/dark-red/live-bright-red power track, exact Speed-left/Power-right justification, trained-Recklessness range, 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 now records only the remaining attack-start command-interpreter seam. - **✓ 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-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md index e3fb8b45..3ad705d7 100644 --- a/docs/research/2026-06-15-layoutdesc-format.md +++ b/docs/research/2026-06-15-layoutdesc-format.md @@ -102,7 +102,7 @@ Concrete subclasses (`DatReaderWriter.Types.*`): |-----|-------------------|---------|-----------| | `0x1A` | `ArrayBaseProperty` (contains `DataIdBaseProperty`) | **Font DID** — array with one item; the inner `DataIdBaseProperty.Value` is the font dat object id | `UIElement_Text::SetFontDIDHelper(this, 0x1a, ...)` @`0x46829e` | | `0x1B` | `ArrayBaseProperty` (contains `ColorBaseProperty`) | **Font color** — array with one item; `ColorARGB {R,G,B,A}` | `UIElement_Text::SetFontColorHelper(this, 0x1b, ...)` @`0x4682c2` | -| `0x14` | `EnumBaseProperty` | **Horizontal justification** | `UIElement_Text::SetHorizontalJustification` @`0x467200` | +| `0x14` | `EnumBaseProperty` | **Horizontal justification** — `1` center, `3/5` right, every other value (including constructor default `2`) left | `UIElement_Text::CalcJustification` @`0x467260` | | `0x15` | `EnumBaseProperty` | **Vertical justification** | `UIElement_Text::SetVerticalJustification` @`0x467230` | | `0x1C` / `0x1D` | `ArrayBaseProperty` | Tag font color / tag font | (secondary font style for in-text tags) | | `0x16` | `BoolBaseProperty` | Some text flag | | diff --git a/docs/research/2026-07-11-retail-combat-bar-pseudocode.md b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md index 02cdc1a8..51643487 100644 --- a/docs/research/2026-07-11-retail-combat-bar-pseudocode.md +++ b/docs/research/2026-07-11-retail-combat-bar-pseudocode.md @@ -171,10 +171,26 @@ applies the associated player option. track = scrollbar DirectState image thumb = structural child element 1 live power fill = nested meter child 0x10000050 / image child 2 +trained Recklessness range = nested meter child 0x100005EF ``` These roles are identified by authored element ids, never by resolved width. The combat thumb is a 12x14 green jewel; inheritance reflow can make its effective child geometry wide enough that a width heuristic incorrectly tiles the jewel as the background. The base track is `0x060074CA`, thumb -`0x06001923`, and live red fill `0x06001200`. +`0x06001923`, live bright-red fill `0x06001200`, and the trained- +Recklessness dark-red range `0x0600715E`. + +The three visual layers remain separate. The gray scrollbar track spans the +whole authored control. The dark-red Recklessness range is visible only when +skill `0x32` has advancement class `Trained` or higher; retail re-evaluates it +when scalar message `0x0A` is received. The meter fill is driven independently +by `RecvNotice_SetPowerbarLevel`, while the green thumb is driven by +`RecvNotice_DesiredAttackPowerChanged`. + +The Speed and Power text rectangles are later siblings of the scrollbar. Their +inner edges define the visible meter interval so the authored gray track stays +behind both captions. Speed's raw property `0x14` value is `2`, which is left +justification: `UIElement_Text::CalcJustification @ 0x00467260` centers only +value `1`, right-aligns values `3` and `5`, and treats every other value as left. +Power uses right justification. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 3f3a2ae3..033cbbd9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -2135,7 +2135,8 @@ public sealed class GameWindow : IDisposable Combat, _combatAttackController, () => _persistedGameplay, - SetRetailCombatGameplay), + SetRetailCombatGameplay, + () => LocalPlayer.GetSkill(0x32u)?.Status >= 2u), Toolbar: new AcDream.App.UI.ToolbarRuntimeBindings( Objects, () => Shortcuts, diff --git a/src/AcDream.App/Studio/FixtureProvider.cs b/src/AcDream.App/Studio/FixtureProvider.cs index e4e65aae..364d68b6 100644 --- a/src/AcDream.App/Studio/FixtureProvider.cs +++ b/src/AcDream.App/Studio/FixtureProvider.cs @@ -105,6 +105,7 @@ public static class FixtureProvider attacks, () => gameplay, value => gameplay = value, + () => true, new CombatUiLabels( "Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View", "High", "Medium", "Low"), diff --git a/src/AcDream.App/UI/Layout/CombatUiController.cs b/src/AcDream.App/UI/Layout/CombatUiController.cs index 0eb7e517..0d69a568 100644 --- a/src/AcDream.App/UI/Layout/CombatUiController.cs +++ b/src/AcDream.App/UI/Layout/CombatUiController.cs @@ -66,6 +66,7 @@ public sealed class CombatUiController : IRetainedPanelController CombatAttackController attacks, Func gameplay, Action setGameplay, + Func recklessnessTrained, CombatUiLabels labels, Action setWindowVisible) { @@ -94,6 +95,7 @@ public sealed class CombatUiController : IRetainedPanelController _powerControl.SetScalarPosition(_attacks.DesiredPower); _powerControl.ScalarChanged = _attacks.SetDesiredPower; _powerControl.ScalarFill = () => _attacks.PowerBarLevel; + _powerControl.ScalarRangeVisible = recklessnessTrained; BindAttackButton(_high, AttackHeight.High); BindAttackButton(_medium, AttackHeight.Medium); @@ -105,8 +107,11 @@ public sealed class CombatUiController : IRetainedPanelController _repeatAttacks.Label = labels.RepeatAttacks; _autoTarget.Label = labels.AutoTarget; _keepInView.Label = labels.KeepInView; - SetStaticText(layout.FindElement(SpeedLabelId) as UiText, labels.Speed); - SetStaticText(layout.FindElement(PowerLabelId) as UiText, labels.Power); + UiText? speedLabel = layout.FindElement(SpeedLabelId) as UiText; + UiText? powerLabel = layout.FindElement(PowerLabelId) as UiText; + SetStaticText(speedLabel, labels.Speed, rightAligned: false); + SetStaticText(powerLabel, labels.Power, rightAligned: true); + ConfigurePowerRange(speedLabel, powerLabel); _repeatAttacks.OnClick = () => _setGameplay(_gameplay() with { AutoRepeatAttack = _repeatAttacks.Selected }); @@ -126,6 +131,7 @@ public sealed class CombatUiController : IRetainedPanelController CombatAttackController attacks, Func gameplay, Action setGameplay, + Func recklessnessTrained, CombatUiLabels labels, Action setWindowVisible) { @@ -134,6 +140,7 @@ public sealed class CombatUiController : IRetainedPanelController ArgumentNullException.ThrowIfNull(attacks); ArgumentNullException.ThrowIfNull(gameplay); ArgumentNullException.ThrowIfNull(setGameplay); + ArgumentNullException.ThrowIfNull(recklessnessTrained); ArgumentNullException.ThrowIfNull(labels); ArgumentNullException.ThrowIfNull(setWindowVisible); @@ -151,7 +158,8 @@ public sealed class CombatUiController : IRetainedPanelController return new CombatUiController( layout, basic, advanced, power, high, medium, low, repeatAttacks, autoTarget, keepInView, - combat, attacks, gameplay, setGameplay, labels, setWindowVisible); + combat, attacks, gameplay, setGameplay, recklessnessTrained, + labels, setWindowVisible); } public void SyncVisibility() => OnCombatModeChanged(_combat.CurrentMode); @@ -190,7 +198,23 @@ public sealed class CombatUiController : IRetainedPanelController _keepInView.Selected = gameplay.ViewCombatTarget; } - private static void SetStaticText(UiText? text, string value) + private void ConfigurePowerRange(UiText? speedLabel, UiText? powerLabel) + { + if (speedLabel is null || powerLabel is null) return; + + // All three elements are siblings in gmCombatUI's basic page. Keeping + // the meter between the authored text rectangles leaves the base gray + // track behind Speed/Power while the dark/live red textures occupy the + // semantic power range between them. + float left = speedLabel.Left + speedLabel.Width - _powerControl.Left; + float right = powerLabel.Left - _powerControl.Left; + _powerControl.ScalarRangeLeft = Math.Clamp(left, 0f, _powerControl.Width); + _powerControl.ScalarRangeWidth = Math.Max( + 0f, Math.Clamp(right, 0f, _powerControl.Width) + - _powerControl.ScalarRangeLeft); + } + + private static void SetStaticText(UiText? text, string value, bool rightAligned) { if (text is null) return; // Retail UIElement_Text starts with zero margins; these 15px-high @@ -198,6 +222,8 @@ public sealed class CombatUiController : IRetainedPanelController // generic 4px inset would leave less than one glyph row and clip them all. text.OneLine = true; text.Padding = 0f; + text.Centered = false; + text.RightAligned = rightAligned; UiText.Line[] line = [new UiText.Line(value, text.DefaultColor)]; text.LinesProvider = () => line; } @@ -212,6 +238,7 @@ public sealed class CombatUiController : IRetainedPanelController _attacks.StateChanged -= OnAttackStateChanged; _powerControl.ScalarChanged = null; _powerControl.ScalarFill = () => null; + _powerControl.ScalarRangeVisible = () => false; _high.OnPressed = null; _high.OnReleased = null; _medium.OnPressed = null; diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index fcd1f9bb..aa49c7b6 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -177,6 +177,16 @@ public static class DatWidgetFactory .OrderByDescending(child => child.ReadOrder) .FirstOrDefault(); bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill); + + // gmCombatUI preserves a second authored meter child for the + // trained-Recklessness range. The controller owns its visibility, + // exactly like gmCombatUI::ListenToElementMessage @ 0x004CC430; + // the widget only retains the media because it consumes DAT children. + ElementInfo? scalarRange = meter?.Children.FirstOrDefault( + child => child.Id == 0x100005EFu); + bar.ScalarRangeSprite = scalarRange is null + ? 0u + : DefaultImage(scalarRange); // gmCombatUI meter 0x10000050 grows from the Power end at the // right toward Speed at the left (retail screenshot oracle + // gmCombatUI::RecvNotice_SetPowerbarLevel 0x004CC0E0). diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 20b82622..23741a17 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -5,7 +5,8 @@ namespace AcDream.App.UI.Layout; /// /// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification). -/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right. +/// Retail CalcJustification @ 0x00467260 treats 1 as Center, +/// 3/5 as Right, and every other value (including constructor default 2) as Left. /// public enum HJustify : byte { Left = 0, Center = 1, Right = 2 } @@ -348,7 +349,7 @@ public static class ElementReader { info.HJustify = horizontal.UnsignedValue switch { - 0u => HJustify.Left, + 0u or 2u => HJustify.Left, 3u or 5u => HJustify.Right, _ => HJustify.Center, }; diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 4bd32efa..07573e5f 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -435,7 +435,8 @@ public static class LayoutImporter if (sd.Properties is not null) { // HorizontalJustification (0x14): EnumBaseProperty. - // Retail values: 0=Left, 1=Center, 3=Right, 5=Right (treat 5 as Right per spec). + // Retail CalcJustification @ 0x00467260: 1=Center, 3/5=Right, + // every other value (including constructor default 2)=Left. // Only update if still at the default (Center); derived-wins handled in Merge. if (info.HJustify == HJustify.Center && sd.Properties.TryGetValue(0x14u, out var hRaw) @@ -443,11 +444,11 @@ public static class LayoutImporter { info.HJustify = hEnum.Value switch { - 0u => HJustify.Left, + 0u or 2u => HJustify.Left, 1u => HJustify.Center, 3u => HJustify.Right, 5u => HJustify.Right, - _ => HJustify.Center, // unknown → center (safe default) + _ => HJustify.Left, }; } diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs index 8c03695b..47c39922 100644 --- a/src/AcDream.App/UI/RetailUiRuntime.cs +++ b/src/AcDream.App/UI/RetailUiRuntime.cs @@ -40,7 +40,8 @@ public sealed record CombatRuntimeBindings( CombatState State, CombatAttackController Attacks, Func Gameplay, - Action SetGameplay); + Action SetGameplay, + Func RecklessnessTrained); public sealed record ToolbarRuntimeBindings( ClientObjectTable Objects, @@ -480,6 +481,7 @@ public sealed class RetailUiRuntime : IDisposable _bindings.Combat.Attacks, _bindings.Combat.Gameplay, _bindings.Combat.SetGameplay, + _bindings.Combat.RecklessnessTrained, labels, visible => { diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs index a3bbe87c..cf0a2db8 100644 --- a/src/AcDream.App/UI/UiScrollbar.cs +++ b/src/AcDream.App/UI/UiScrollbar.cs @@ -42,6 +42,14 @@ public sealed class UiScrollbar : UiElement public Func ScalarFill { get; set; } = () => null; public uint ScalarFillSprite { get; set; } /// + /// Optional authored texture beneath the live scalar fill. gmCombatUI uses + /// element 0x100005EF for the trained-Recklessness range. + /// + public uint ScalarRangeSprite { get; set; } + public Func ScalarRangeVisible { get; set; } = () => false; + public float ScalarRangeLeft { get; set; } + public float ScalarRangeWidth { get; set; } = float.PositiveInfinity; + /// /// Draw the scalar meter from the power end (right) toward the speed end /// (left). This is the authored gmCombatUI power meter direction. /// @@ -113,9 +121,15 @@ public sealed class UiScrollbar : UiElement /// Returns the clipped scalar-meter span in local pixels. public static (float x, float width) ScalarFillRect( float totalWidth, float fill, bool fromRight) + => ScalarFillRect(0f, totalWidth, fill, fromRight); + + /// Returns a clipped scalar-meter span inside an authored sub-range. + public static (float x, float width) ScalarFillRect( + float rangeLeft, float rangeWidth, float fill, bool fromRight) { - float visibleWidth = totalWidth * Math.Clamp(fill, 0f, 1f); - return (fromRight ? totalWidth - visibleWidth : 0f, visibleWidth); + float safeWidth = MathF.Max(0f, rangeWidth); + float visibleWidth = safeWidth * Math.Clamp(fill, 0f, 1f); + return (fromRight ? rangeLeft + safeWidth - visibleWidth : rangeLeft, visibleWidth); } protected override void OnDraw(UiRenderContext ctx) @@ -124,14 +138,16 @@ public sealed class UiScrollbar : UiElement if (Horizontal) { DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height); + (float rangeLeft, float rangeWidth) = ScalarRangeRect(); + if (ScalarRangeVisible() && ScalarRangeSprite != 0) + DrawTiled(ctx, resolve, ScalarRangeSprite, + rangeLeft, 0f, rangeWidth, Height); if (ScalarFill() is float fill && ScalarFillSprite != 0) { - var (fillX, visibleWidth) = ScalarFillRect(Width, fill, ScalarFillFromRight); - if (ScalarFillFromRight) - DrawTiledRightClipped( - ctx, resolve, ScalarFillSprite, fillX, visibleWidth, Height); - else - DrawTiled(ctx, resolve, ScalarFillSprite, 0f, 0f, visibleWidth, Height); + var (fillX, visibleWidth) = ScalarFillRect( + rangeLeft, rangeWidth, fill, ScalarFillFromRight); + DrawTiledClipped(ctx, resolve, ScalarFillSprite, + rangeLeft, fillX, visibleWidth, Height); } float thumbWidth = ScalarThumbWidth(resolve); float travel = MathF.Max(0f, Width - thumbWidth); @@ -196,14 +212,14 @@ public sealed class UiScrollbar : UiElement } /// - /// Draws the rightmost portion of a full-width tiled scalar image. UVs - /// start at the same offset they would have in the uncut image, matching - /// retail meter clipping rather than restarting the wave at the clip edge. + /// Draws a clipped portion of a tiled range without restarting the texture + /// phase at the clip edge. This mirrors retail meter child clipping. /// - private void DrawTiledRightClipped( + private void DrawTiledClipped( UiRenderContext ctx, Func resolve, uint id, + float rangeLeft, float x, float w, float h) @@ -211,7 +227,19 @@ public sealed class UiScrollbar : UiElement if (id == 0 || w <= 0f || h <= 0f) return; var (tex, tw, th) = resolve(id); if (tex == 0 || tw == 0 || th == 0) return; - ctx.DrawSprite(tex, x, 0f, w, h, x / tw, 0f, (x + w) / tw, h / th, Vector4.One); + float u0 = (x - rangeLeft) / tw; + float u1 = u0 + w / tw; + ctx.DrawSprite(tex, x, 0f, w, h, u0, 0f, u1, h / th, Vector4.One); + } + + private (float left, float width) ScalarRangeRect() + { + float left = Math.Clamp(ScalarRangeLeft, 0f, Width); + float requestedWidth = float.IsPositiveInfinity(ScalarRangeWidth) + ? Width - left + : MathF.Max(0f, ScalarRangeWidth); + float width = Math.Clamp(requestedWidth, 0f, Width - left); + return (left, width); } public override bool OnEvent(in UiEvent e) diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs index 8bee8956..b4ece67b 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs @@ -27,6 +27,7 @@ public sealed class CombatLayoutConformanceTests Assert.Equal(0x060074CAu, power.TrackSprite); Assert.Equal(0x06001923u, power.ThumbSprite); Assert.Equal(0x06001200u, power.ScalarFillSprite); + Assert.Equal(0x0600715Eu, power.ScalarRangeSprite); Assert.True(power.ScalarFillFromRight); var repeat = Assert.IsType( diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs index ee3e6d09..16d54036 100644 --- a/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/CombatUiControllerTests.cs @@ -21,6 +21,7 @@ public sealed class CombatUiControllerTests GameplaySettings gameplay = GameplaySettings.Default; using var controller = CombatUiController.Bind( layout, combat, attacks, () => gameplay, value => gameplay = value, + () => false, Labels, visibility.Add)!; controller.SyncVisibility(); @@ -45,12 +46,16 @@ public sealed class CombatUiControllerTests GameplaySettings gameplay = GameplaySettings.Default; using var controller = CombatUiController.Bind( layout, combat, attacks, () => gameplay, value => gameplay = value, + () => true, Labels, _ => { })!; combat.SetCombatMode(CombatMode.Melee); high.OnEvent(new UiEvent(0, high, UiEventType.MouseDown, Data1: 2, Data2: 2)); now = 4.5d; Assert.Equal(0.5f, power.ScalarFill()!.Value, 3); + Assert.True(power.ScalarRangeVisible()); + Assert.Equal(104f, power.ScalarRangeLeft, 3); + Assert.Equal(299f, power.ScalarRangeWidth, 3); high.OnEvent(new UiEvent(0, high, UiEventType.MouseUp, Data1: 2, Data2: 2)); var attack = Assert.Single(sent); @@ -69,6 +74,7 @@ public sealed class CombatUiControllerTests GameplaySettings gameplay = GameplaySettings.Default; using var controller = CombatUiController.Bind( layout, combat, attacks, () => gameplay, value => gameplay = value, + () => false, Labels, _ => { })!; var autoTarget = Assert.IsType(layout.FindElement(CombatUiController.AutoTargetId)); @@ -79,6 +85,28 @@ public sealed class CombatUiControllerTests Assert.False(gameplay.AutoTarget); } + [Fact] + public void AuthoredPowerLabels_KeepGrayTrackAtSides_AndUseRetailAlignment() + { + var combat = new CombatState(); + using var attacks = CreateAttacks(combat, () => 0d, []); + var (layout, _, _, power, _, _, _) = BuildLayout(); + GameplaySettings gameplay = GameplaySettings.Default; + using var controller = CombatUiController.Bind( + layout, combat, attacks, () => gameplay, value => gameplay = value, + () => true, Labels, _ => { })!; + + var speed = Assert.IsType(layout.FindElement(CombatUiController.SpeedLabelId)); + var powerLabel = Assert.IsType(layout.FindElement(CombatUiController.PowerLabelId)); + Assert.False(speed.Centered); + Assert.False(speed.RightAligned); + Assert.False(powerLabel.Centered); + Assert.True(powerLabel.RightAligned); + Assert.Equal(speed.Left + speed.Width - power.Left, power.ScalarRangeLeft, 3); + Assert.Equal(powerLabel.Left - power.Left - power.ScalarRangeLeft, + power.ScalarRangeWidth, 3); + } + private static readonly CombatUiLabels Labels = new( "Speed", "Power", "Repeat Attacks", "Auto Target", "Keep in View", "High", "Medium", "Low"); @@ -110,7 +138,16 @@ public sealed class CombatUiControllerTests var root = new UiPanel { Width = 610, Height = 90 }; var basic = new UiPanel(); var advanced = new UiPanel(); - var power = new UiScrollbar { Width = 507, Height = 14, Horizontal = true }; + var power = new UiScrollbar + { + Left = 13, + Top = 14, + Width = 507, + Height = 14, + Horizontal = true, + }; + var speedLabel = new UiText { Left = 17, Top = 14, Width = 100, Height = 15 }; + var powerLabel = new UiText { Left = 416, Top = 14, Width = 100, Height = 15 }; UiButton Button(uint id) => new( new ElementInfo { Id = id, Type = 1, Width = 72, Height = 19 }, NoTex) { @@ -137,6 +174,8 @@ public sealed class CombatUiControllerTests [CombatUiController.BasicPanelId] = basic, [CombatUiController.AdvancedPanelId] = advanced, [CombatUiController.PowerControlId] = power, + [CombatUiController.SpeedLabelId] = speedLabel, + [CombatUiController.PowerLabelId] = powerLabel, [CombatUiController.HighButtonId] = high, [CombatUiController.MediumButtonId] = medium, [CombatUiController.LowButtonId] = low, diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 99499358..c7eeb5c8 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -466,13 +466,24 @@ public class DatWidgetFactoryTests fill.StateMedia[""] = (0x06001200u, 1); fill.States[UiStateInfo.DirectStateId] = new UiStateInfo { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x06001200u, 1) }; + var range = new ElementInfo + { + Id = 0x100005EFu, + Type = 3, + X = 63, + Width = 407, + Height = 14, + }; + range.StateMedia[""] = (0x0600715Eu, 1); + range.States[UiStateInfo.DirectStateId] = new UiStateInfo + { Id = UiStateInfo.DirectStateId, Image = new UiImageMedia(0x0600715Eu, 1) }; var meter = new ElementInfo { Id = CombatUiController.PowerControlId + 1, Type = 7, Width = 507, Height = 14, - Children = [fill], + Children = [fill, range], }; var info = new ElementInfo { @@ -490,6 +501,7 @@ public class DatWidgetFactoryTests Assert.Equal(0x06001919u, bar.TrackSprite); Assert.Equal(0x06001923u, bar.ThumbSprite); Assert.Equal(0x06001200u, bar.ScalarFillSprite); + Assert.Equal(0x0600715Eu, bar.ScalarRangeSprite); Assert.True(bar.ScalarFillFromRight); } diff --git a/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs b/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs index 42222252..bdf48702 100644 --- a/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/UiPropertyBagTests.cs @@ -60,6 +60,20 @@ public sealed class UiPropertyBagTests .Properties.Values[0x14u].UnsignedValue); } + [Fact] + public void ElementMerge_RetailTextDefaultTwo_IsLeftJustified() + { + var info = new ElementInfo(); + info.States[UiStateInfo.DirectStateId] = State( + UiStateInfo.DirectStateId, + "", + (0x14u, Enum(2u))); + + var merged = ElementReader.Merge(new ElementInfo(), info); + + Assert.Equal(HJustify.Left, merged.HJustify); + } + [Fact] public void ConvertProperty_PreservesNestedRetailValues() { diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs index c9e32e32..b093ab8e 100644 --- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs +++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs @@ -114,4 +114,17 @@ public class UiScrollbarTests Assert.Equal(expectedX, x, 3); Assert.Equal(expectedWidth, width, 3); } + + [Theory] + [InlineData(0f, 403f, 0f)] + [InlineData(0.5f, 253.5f, 149.5f)] + [InlineData(1f, 104f, 299f)] + public void ScalarFillRect_CombatPower_StaysBetweenAuthoredLabels( + float fill, float expectedX, float expectedWidth) + { + var (x, width) = UiScrollbar.ScalarFillRect( + rangeLeft: 104f, rangeWidth: 299f, fill, fromRight: true); + Assert.Equal(expectedX, x, 3); + Assert.Equal(expectedWidth, width, 3); + } } diff --git a/tools/TextureDump/Program.cs b/tools/TextureDump/Program.cs index f8ab4db2..ae163462 100644 --- a/tools/TextureDump/Program.cs +++ b/tools/TextureDump/Program.cs @@ -44,7 +44,8 @@ foreach (var id in ids) RenderSurface? rs = null; uint lookupId = id; - if (dats.TryGet(id, out var surface) && surface is not null) + if ((id & 0xFF000000u) == 0x08000000u + && dats.TryGet(id, out var surface) && surface is not null) { lookupId = (uint)surface.OrigTextureId; var color = surface.ColorValue is null @@ -54,7 +55,8 @@ foreach (var id in ids) } // SurfaceTexture wrapper (0x05xxxxxx) → first inner RenderSurface (0x06xxxxxx). - if (dats.TryGet(lookupId, out var st) && st is not null && st.Textures.Count > 0) + if ((lookupId & 0xFF000000u) == 0x05000000u + && dats.TryGet(lookupId, out var st) && st is not null && st.Textures.Count > 0) { uint rsid = (uint)st.Textures[0]; Console.WriteLine($" SurfaceTexture wrapper, {st.Textures.Count} mip(s), first = 0x{rsid:X8}");