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 <codex@openai.com>
This commit is contained in:
parent
0f2d98c501
commit
256c1930bd
18 changed files with 200 additions and 32 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 | |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ public sealed class CombatUiController : IRetainedPanelController
|
|||
CombatAttackController attacks,
|
||||
Func<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
Func<bool> recklessnessTrained,
|
||||
CombatUiLabels labels,
|
||||
Action<bool> 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<GameplaySettings> gameplay,
|
||||
Action<GameplaySettings> setGameplay,
|
||||
Func<bool> recklessnessTrained,
|
||||
CombatUiLabels labels,
|
||||
Action<bool> 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;
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ namespace AcDream.App.UI.Layout;
|
|||
|
||||
/// <summary>
|
||||
/// Horizontal text justification read from dat property 0x14 (UIElement HorizontalJustification).
|
||||
/// Values match the retail enum: 0=Left, 1=Center, 3/5=Right.
|
||||
/// Retail <c>CalcJustification @ 0x00467260</c> treats 1 as Center,
|
||||
/// 3/5 as Right, and every other value (including constructor default 2) as Left.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ public sealed record CombatRuntimeBindings(
|
|||
CombatState State,
|
||||
CombatAttackController Attacks,
|
||||
Func<GameplaySettings> Gameplay,
|
||||
Action<GameplaySettings> SetGameplay);
|
||||
Action<GameplaySettings> SetGameplay,
|
||||
Func<bool> 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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -42,6 +42,14 @@ public sealed class UiScrollbar : UiElement
|
|||
public Func<float?> ScalarFill { get; set; } = () => null;
|
||||
public uint ScalarFillSprite { get; set; }
|
||||
/// <summary>
|
||||
/// Optional authored texture beneath the live scalar fill. gmCombatUI uses
|
||||
/// element <c>0x100005EF</c> for the trained-Recklessness range.
|
||||
/// </summary>
|
||||
public uint ScalarRangeSprite { get; set; }
|
||||
public Func<bool> ScalarRangeVisible { get; set; } = () => false;
|
||||
public float ScalarRangeLeft { get; set; }
|
||||
public float ScalarRangeWidth { get; set; } = float.PositiveInfinity;
|
||||
/// <summary>
|
||||
/// Draw the scalar meter from the power end (right) toward the speed end
|
||||
/// (left). This is the authored gmCombatUI power meter direction.
|
||||
/// </summary>
|
||||
|
|
@ -113,9 +121,15 @@ public sealed class UiScrollbar : UiElement
|
|||
/// <summary>Returns the clipped scalar-meter span in local pixels.</summary>
|
||||
public static (float x, float width) ScalarFillRect(
|
||||
float totalWidth, float fill, bool fromRight)
|
||||
=> ScalarFillRect(0f, totalWidth, fill, fromRight);
|
||||
|
||||
/// <summary>Returns a clipped scalar-meter span inside an authored sub-range.</summary>
|
||||
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
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void DrawTiledRightClipped(
|
||||
private void DrawTiledClipped(
|
||||
UiRenderContext ctx,
|
||||
Func<uint, (uint tex, int w, int h)> 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)
|
||||
|
|
|
|||
|
|
@ -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<UiButton>(
|
||||
|
|
|
|||
|
|
@ -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<UiButton>(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<UiText>(layout.FindElement(CombatUiController.SpeedLabelId));
|
||||
var powerLabel = Assert.IsType<UiText>(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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,7 +44,8 @@ foreach (var id in ids)
|
|||
|
||||
RenderSurface? rs = null;
|
||||
uint lookupId = id;
|
||||
if (dats.TryGet<Surface>(id, out var surface) && surface is not null)
|
||||
if ((id & 0xFF000000u) == 0x08000000u
|
||||
&& dats.TryGet<Surface>(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<SurfaceTexture>(lookupId, out var st) && st is not null && st.Textures.Count > 0)
|
||||
if ((lookupId & 0xFF000000u) == 0x05000000u
|
||||
&& dats.TryGet<SurfaceTexture>(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}");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue