diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index ea1dac1e..c309de47 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -83,7 +83,7 @@ W1 plan: [`docs/superpowers/plans/2026-06-02-unified-cell-graph-stage1.md`](../s
| I.4 | `ChatPanel` input field + slash commands — Enter-to-submit input field; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on focus. | Live ✓ |
| I.5 | Holtburger inbound chat parity + Windows-1252 codec — `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. | Tests ✓ |
| I.6 | TurbineChat codec + `ChatChannelInfo` — full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** | Tests ✓ |
-| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. | Tests ✓ |
+| I.7 | `CombatChatTranslator` — retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `KillLanded`; `AttackDone` is control-only and deliberately silent. | Tests ✓ |
| K | Input architecture — `Action` enum, `KeyChord`, `KeyBindings`, multicast `InputDispatcher` with scope-stack + modal capture, retail-default keymap (152 bindings), `keybinds.json` persistence, F11 Settings panel with click-to-rebind + conflict detection, main menu bar + View menu | Live ✓ |
| L.0 | Full retail-style Settings interface — F11 tabbed panel with 6 tabs (Keybinds + Display + Audio + Gameplay + Chat + Character). `settings.json` at `%LOCALAPPDATA%\acdream\`, per-toon `Character` keying (swapped on EnterWorld). Display GL knobs (Resolution / Fullscreen / VSync / FOV / ShowFps) + Audio (Master / SFX) live-wired; Gameplay / Chat / Character settings persist for server-sync wiring later. Tab API extension to `IPanelRenderer`; chat Copy mode (read-only multi-line); per-panel layout reset; FramebufferResize handler keeps GL viewport + camera aspect + panel positions in sync. | Live ✓ |
| C.1 | PES particle system + sky-pass refinements — retail-faithful `ParticleEmitterInfo` unpack with all 13 motion integrators (`Particle::Init`/`Update` ports of `0x0051c290`/`0x0051c930`), `PhysicsScriptRunner` with `CallPES` self-loop semantics, `ParticleHookSink` with `EmitterDied` cleanup, instanced billboard `ParticleRenderer` with material-derived blend (DAT emitters never default additive — pulled from particle GfxObj surface), global back-to-front sort, BC clipmap alpha-keying, AttachLocal `is_parent_local=1` live-parent follow via `UpdateEmitterAnchor`. Sky pass: `Translucent+ClipMap` → alpha-blend cloud sheet (matches `D3DPolyRender::SetSurface` `0x0059c4d0`), raw-`Additive` fog-skip (matches `0x0059c882`), per-keyframe `SkyObjectReplace` Translucency/Luminosity/MaxBright divide-by-100, bit `0x01` pre/post-scene split (matches `GameSky::CreateDeletePhysicsObjects` `0x005073c0`), Setup-backed (`0x020xxxxx`) sky objects via `SetupMesh.Flatten`, persistent GL sampler objects (Wrap + ClampToEdge) replace per-frame wrap-mode mutation (ported from WorldBuilder's `OpenGLGraphicsDevice`), post-scene Z-offset gated on `(Properties & 4) != 0 && (Properties & 8) == 0` per `GameSky::UpdatePosition` `0x00506dd0`. Sky-PES playback disabled by default (named-retail proves `GameSky` drops `pes_id`); `ACDREAM_ENABLE_SKY_PES=1` opens the experimental path. 1325 → 1331 tests. | Live ✓ |
@@ -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 now mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, binds authored high/medium/low buttons and desired-power slider, and routes mouse plus keyboard through one `CombatAttackController`. The corrective pass ports local.dat StringInfo labels, `gmCombatUI::PostInit` runtime option captions, interactive `UIOption_Checkbox` controls, structural horizontal-scrollbar track/thumb roles, and `ClientCombatSystem::UpdateTargetTracking` so Keep in View rotates the retail chase-camera boom around the player toward the selected attack target. Matching retail x86 recovered exact 1.0-second normal and 0.8-second dual-wield power-up times; the same constants retire the guessed jump charge. Keybinding schema v3 migrates attack actions from one-shot Press to transition-emitting Hold. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`; AP-24/AP-95 retired, 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 now include the right-to-left red charge meter, silent control-only `AttackDone(ActionCancelled)`, persistent Dead cycles despite zero-velocity position updates, and target-frame Keep in View that preserves manual viewer-offset orbit. Matching retail x86 recovered exact 1.0-second normal and 0.8-second dual-wield power-up times. 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-11-combat-live-gate-corrections-pseudocode.md`; AP-24/AP-95 retired, AP-110 narrowed, AP-112 records the remaining advanced/Recklessness and command-interpreter seams.
- **✓ 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.
@@ -580,7 +580,7 @@ the way retail + holtburger expect.
- **✓ SHIPPED — I.4 — `ChatPanel` input field + slash commands.** Enter-to-submit input field on `ChatPanel`; `ChatInputParser` recognises `/say` `/t` `/tell` `/r` `/g` `/f` `/a` `/m` `/p` `/v` `/cv` `/lfg` `/trade` `/role` `/society` `/olthoi`; `ChatVM.LastIncomingTellSender` tracks for `/r` reply. `ImGui.WantCaptureKeyboard` already suppresses WASD on input focus. Commit `f14296c`.
- **✓ SHIPPED — I.5 — Holtburger inbound chat parity + Windows-1252.** `EmoteText (0x01E0)`, `SoulEmote (0x01E2)`, `ServerMessage (0xF7E0)`, `PlayerKilled (0x019E)` parsers + `WeenieError` routing through `GameEventWiring`. Global string codec switch from `Encoding.ASCII` to `Encoding.GetEncoding(1252)` so accented names round-trip per retail + holtburger. Commit `ff5ed9e`.
- **✓ SHIPPED — I.6 — TurbineChat codec + `ChatChannelInfo`.** Full `0xF7DE` codec with three payload variants (`EventSendToRoom`, `RequestSendToRoomById`, `Response`), UTF-16LE strings with variable-length prefix, `SetTurbineChatChannels (0x0295)` parser, unified `ChatChannelInfo` (Legacy + Turbine variants), `TurbineChatState`. **ACE doesn't host a TurbineChat server — codec is ready when retail-emulating servers exist.** Commit `ca968fc`.
-- **✓ SHIPPED — I.7 — `CombatChatTranslator`.** Retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to `CombatState`'s `DamageTaken` / `DamageDealtAccepted` / `EvadedIncoming` / `MissedOutgoing` / `AttackDone` / `KillLanded`; templates ported verbatim from holtburger `panels/chat.rs:221-308`. Commit `3d26c8e`.
+- **✓ SHIPPED — I.7 — `CombatChatTranslator`.** Retail-faithful combat-text formatters into `ChatLog` ("You hit drudge for 50 slashing damage (87%)"). Subscribes to visible damage/evasion/miss/kill events; `AttackDone` was removed from chat after named retail + ACE proved its nonzero final status is control-only. Commit `3d26c8e`, corrected 2026-07-11.
- **✓ SHIPPED — I.8 — Docs alignment.** Roadmap (this file) + `docs/ISSUES.md` issues #14-#20 closed + `memory/project_chat_pipeline.md` crib + `MEMORY.md` index entry + `CLAUDE.md` UI strategy paragraph all updated to reflect Phase I shipped state. Commit `(this commit)`.
**Acceptance (verified 2026-04-25):**
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index fffc0759..e7f21637 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -462,7 +462,10 @@ include dungeons.
`gmCombatUI` LayoutDesc `0x21000073` now pops for Melee/Missile and shares one
press/hold/release attack-request owner across DAT buttons and keybindings.
Retail x86 resolves full charge to 1.0 s (0.8 s dual wield), also retiring the
- guessed jump-charge timing. See `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`.
+ guessed jump-charge timing. The corrective gate ports the right-to-left red
+ meter, silent AttackDone control status, persistent corpse motion, and
+ target-frame Keep in View with retained manual orbit. See
+ `docs/research/2026-07-11-combat-live-gate-corrections-pseudocode.md`.
- **L.1b** — Command router + motion-state cleanup (prereq for L.1c).
**Freeze on landing:**
diff --git a/docs/research/2026-07-11-combat-live-gate-corrections-pseudocode.md b/docs/research/2026-07-11-combat-live-gate-corrections-pseudocode.md
new file mode 100644
index 00000000..e50a2631
--- /dev/null
+++ b/docs/research/2026-07-11-combat-live-gate-corrections-pseudocode.md
@@ -0,0 +1,74 @@
+# Combat live-gate corrections pseudocode
+
+Sources: Sept 2013 EoR named retail client and ACE server source.
+
+- `ClientCombatSystem::HandleAttackDoneEvent` (`0x0056C500`)
+- `gmCombatUI::RecvNotice_SetPowerbarLevel` (`0x004CC0E0`)
+- `CPhysicsObj::MoveOrTeleport` (`0x00516330`)
+- `CameraSet::Rotate` (`0x00458310`)
+- ACE `Player.OnAttackDone`, `Player_Melee.cs`
+- ACE `WeenieError.YouChargedTooFar`, `Player_Move.cs`
+
+## Attack completion versus visible errors
+
+```text
+HandleAttackDoneEvent(sequence, error):
+ consume error as combat-controller state
+ decide whether repeat may continue or the attack aborts
+ do not write an error line to chat
+
+ACE OnAttackDone(final sequence):
+ send AttackDone(ActionCancelled) to reset the client power meter
+ if the player must see a failure:
+ send a separate WeenieError event
+```
+
+Therefore `AttackDone(ActionCancelled)` is silent. A separate
+`YouChargedTooFar` (`0x003D`) remains visible and is formatted by the ordinary
+WeenieError path. ACE emits it when the charge exceeds 15 metres; target
+selection itself is not range-limited by retail.
+
+## Power meter direction
+
+```text
+RecvNotice_SetPowerbarLevel(level):
+ find nested meter 0x10000050
+ set meter scalar attribute 0x69 to level
+
+authored meter direction = 1
+visible fill = rightmost (width * clamp(level)) pixels
+```
+
+The red meter grows from the Power label at the right toward Speed at the
+left. The green desired-power thumb remains an independent scrollbar child.
+Clipping the rightmost portion must retain the full tiled image's UV offset.
+
+## Persistent death motion
+
+```text
+on UpdatePosition(position, velocity):
+ update physics pose and velocity
+ do not derive an interpreted motion command from velocity
+
+on UpdateMotion(Dead):
+ apply the persistent Dead motion cycle through the motion interpreter
+```
+
+A corpse legitimately sends zero velocity. Turning that velocity into Ready
+overrides the server-authored Dead cycle and makes the creature stand again.
+Retail keeps position/velocity transport separate from interpreted motion.
+
+## Keep in View orbit
+
+See `2026-07-11-combat-target-camera-pseudocode.md`. The corrective detail is:
+
+```text
+targetHeadingFrame = frame looking from player pivot toward target point
+viewerOffset.xy = the independently rotatable camera boom offset
+eye = pivot + targetHeadingFrame.transform(viewerOffset)
+forward = normalize(pivot - eye) // LOOK_AT_PIVOT
+```
+
+Tracking changes the frame that transforms `viewerOffset`; it does not reset
+or discard the offset. Manual camera orbit therefore remains usable while
+Keep in View is active.
diff --git a/docs/research/2026-07-11-combat-target-camera-pseudocode.md b/docs/research/2026-07-11-combat-target-camera-pseudocode.md
index e8460908..57615bcd 100644
--- a/docs/research/2026-07-11-combat-target-camera-pseudocode.md
+++ b/docs/research/2026-07-11-combat-target-camera-pseudocode.md
@@ -36,6 +36,10 @@ CameraSet.TrackTarget(targetId):
SetTargetForOffset(viewerOffset), while targeting:
camera.targetStatus = LOOK_AT_OBJECT | LOOK_AT_PIVOT
+
+CameraSet.Rotate(angle), including while targeting:
+ viewerOffset.xy = rotate(viewerOffset.xy, angle)
+ SetTargetForOffset(viewerOffset)
```
## Per-frame pose
@@ -60,14 +64,17 @@ damp current viewer toward (desiredEye, desiredForward)
The target therefore changes the direction of the boom around the player; it
does not replace the player as the boom's pivot. `LOOK_AT_PIVOT` keeps the
player centered while the target-facing boom keeps the combat target in view.
+The viewer offset remains independently rotatable: manual camera orbit rotates
+that offset inside the target-facing frame rather than being discarded or
+snapped back to zero.
## Port mapping
- `GameWindow.GetCombatCameraTargetPoint` applies the option/mode/valid-target
gates and transforms the target-local 0.5 metre Z offset.
- `RetailChaseCamera.ComputeTrackedHeading` ports the pivot-to-target direction.
-- Existing `RetailChaseCamera` boom construction and damping consume that
- heading unchanged.
+- `RetailChaseCamera.ComputeDesiredPose` ports the heading-frame transform and
+ preserves the viewer-offset yaw while tracking.
- The diagnostic legacy `ChaseCamera` remains intentionally non-retail under
existing divergence row TS-19; the production retail camera owns this behavior.
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index fe68e6b9..914dbb50 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5854,23 +5854,11 @@ public sealed class GameWindow : IDisposable
if (update.Velocity is { } svel)
{
rmState.Body.Velocity = svel;
- // Only use the < 0.2 m/s stop signal when velocity was
- // explicitly provided (i.e. server sent HasVelocity + tiny
- // value = "I'm definitely stopped"). Absent velocity field
- // carries no stop information for our ACE.
- if (svel.LengthSquared() < 0.04f)
- {
- rmState.Motion.StopCompletely();
- if (_animatedEntities.TryGetValue(entity.Id, out var aeForStop)
- && aeForStop.Sequencer is not null)
- {
- uint curStyle = aeForStop.Sequencer.CurrentStyle;
- uint readyCmd = (curStyle & 0xFF000000u) != 0
- ? ((curStyle & 0xFF000000u) | 0x01000003u)
- : 0x41000003u;
- aeForStop.Sequencer.SetCycle(curStyle, readyCmd);
- }
- }
+ // Position updates carry physics velocity only. Retail
+ // CPhysicsObj::MoveOrTeleport never derives an animation
+ // command from it; UpdateMotion owns the interpreted state.
+ // In particular, a dead creature legitimately broadcasts
+ // zero velocity while its Dead cycle must remain persistent.
}
else if (!IsPlayerGuid(update.Guid) && rmState.HasServerVelocity
&& !snapSuppressedByStick && rmState.Airborne)
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
index 307569f1..8ef3daf2 100644
--- a/src/AcDream.App/Rendering/RetailChaseCamera.cs
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -156,7 +156,8 @@ public sealed class RetailChaseCamera : ICamera
// CameraSet::TrackTarget 0x00458280 and CameraManager::UpdateCamera
// 0x00456826-0x00456915.
Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);
- Vector3 heading = ComputeTrackedHeading(pivotWorld, trackedTargetPoint)
+ Vector3? trackedHeading = ComputeTrackedHeading(pivotWorld, trackedTargetPoint);
+ Vector3 heading = trackedHeading
?? ComputeHeading(
avgVel,
playerYaw + YawOffset,
@@ -164,15 +165,14 @@ public sealed class RetailChaseCamera : ICamera
contactPlaneNormal,
CameraDiagnostics.AlignToSlope);
- // 3. Orthonormal heading-frame basis.
- var (forward, _, up) = BuildBasis(heading);
-
- // 4. Target pose.
- float horizontal = Distance * MathF.Cos(Pitch);
- float vertical = Distance * MathF.Sin(Pitch);
- // viewer_offset = -horizontal along forward + vertical along up.
- Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
- Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
+ // 3-4. Target pose. Retail TrackTarget changes the frame that
+ // transforms viewer_offset; it does not replace that offset. CameraSet::Rotate
+ // (0x00458310) therefore continues to orbit viewer_offset.x/y while the
+ // target supplies the frame heading. Without this local rotation, enabling
+ // Keep in View snaps the camera behind the target and disables RMB orbit.
+ float viewerYawOffset = trackedHeading.HasValue ? YawOffset : 0f;
+ (Vector3 targetEye, Vector3 targetForward) = ComputeDesiredPose(
+ pivotWorld, heading, Distance, Pitch, viewerYawOffset);
// 5. Stateful sought position (#180). Retail CameraManager::UpdateCamera
// (0x00456660) interpolates FROM THE CURRENT SWEPT VIEWER toward the
@@ -380,6 +380,35 @@ public sealed class RetailChaseCamera : ICamera
: Vector3.Normalize(towardTarget);
}
+ ///
+ /// Ports retail's heading-frame transform of viewer_offset in
+ /// CameraManager::UpdateCamera (0x00456660), including the X/Y rotation
+ /// retained by CameraSet::Rotate (0x00458310) while target tracking is active.
+ /// The returned forward always looks back at the player pivot, matching
+ /// LOOK_AT_PIVOT.
+ ///
+ internal static (Vector3 eye, Vector3 forward) ComputeDesiredPose(
+ Vector3 pivotWorld,
+ Vector3 heading,
+ float distance,
+ float pitch,
+ float viewerYawOffset)
+ {
+ var (frameForward, frameRight, frameUp) = BuildBasis(heading);
+
+ // AC's frame-local +Y points opposite our screen-right basis. Rotating
+ // viewer_offset.x/y by +yaw therefore produces this boom direction.
+ Vector3 boomForward = Vector3.Normalize(
+ frameForward * MathF.Cos(viewerYawOffset)
+ - frameRight * MathF.Sin(viewerYawOffset));
+
+ float horizontal = distance * MathF.Cos(pitch);
+ float vertical = distance * MathF.Sin(pitch);
+ Vector3 eye = pivotWorld - boomForward * horizontal + frameUp * vertical;
+ Vector3 forward = Vector3.Normalize(pivotWorld - eye);
+ return (eye, forward);
+ }
+
///
/// Build an orthonormal basis with forward = heading. World
/// up is (0, 0, 1); if heading is near-parallel to it
diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
index 46d5925c..fcd1f9bb 100644
--- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
+++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs
@@ -177,6 +177,10 @@ public static class DatWidgetFactory
.OrderByDescending(child => child.ReadOrder)
.FirstOrDefault();
bar.ScalarFillSprite = fill is null ? 0u : DefaultImage(fill);
+ // gmCombatUI meter 0x10000050 grows from the Power end at the
+ // right toward Speed at the left (retail screenshot oracle +
+ // gmCombatUI::RecvNotice_SetPowerbarLevel 0x004CC0E0).
+ bar.ScalarFillFromRight = meter?.Id == 0x10000050u;
return bar;
}
diff --git a/src/AcDream.App/UI/UiScrollbar.cs b/src/AcDream.App/UI/UiScrollbar.cs
index d8510cbb..a3bbe87c 100644
--- a/src/AcDream.App/UI/UiScrollbar.cs
+++ b/src/AcDream.App/UI/UiScrollbar.cs
@@ -41,6 +41,11 @@ public sealed class UiScrollbar : UiElement
///
public Func ScalarFill { get; set; } = () => null;
public uint ScalarFillSprite { get; set; }
+ ///
+ /// Draw the scalar meter from the power end (right) toward the speed end
+ /// (left). This is the authored gmCombatUI power meter direction.
+ ///
+ public bool ScalarFillFromRight { get; set; }
/// Programmatically set retail scrollbar attribute 0x86 without broadcasting.
public void SetScalarPosition(float position)
@@ -105,6 +110,14 @@ public sealed class UiScrollbar : UiElement
return (y, h);
}
+ /// Returns the clipped scalar-meter span in local pixels.
+ public static (float x, float width) ScalarFillRect(
+ float totalWidth, float fill, bool fromRight)
+ {
+ float visibleWidth = totalWidth * Math.Clamp(fill, 0f, 1f);
+ return (fromRight ? totalWidth - visibleWidth : 0f, visibleWidth);
+ }
+
protected override void OnDraw(UiRenderContext ctx)
{
if (SpriteResolve is not { } resolve) return;
@@ -113,8 +126,12 @@ public sealed class UiScrollbar : UiElement
DrawTiled(ctx, resolve, TrackSprite, 0f, 0f, Width, Height);
if (ScalarFill() is float fill && ScalarFillSprite != 0)
{
- float visibleWidth = Width * Math.Clamp(fill, 0f, 1f);
- DrawTiled(ctx, resolve, ScalarFillSprite, 0f, 0f, visibleWidth, Height);
+ 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);
}
float thumbWidth = ScalarThumbWidth(resolve);
float travel = MathF.Max(0f, Width - thumbWidth);
@@ -178,6 +195,25 @@ public sealed class UiScrollbar : UiElement
ctx.DrawSprite(tex, x, y, w, h, 0f, 0f, w / tw, h / th, Vector4.One);
}
+ ///
+ /// 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.
+ ///
+ private void DrawTiledRightClipped(
+ UiRenderContext ctx,
+ Func resolve,
+ uint id,
+ float x,
+ float w,
+ float h)
+ {
+ 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);
+ }
+
public override bool OnEvent(in UiEvent e)
{
if (Horizontal && ScalarChanged is not null)
diff --git a/src/AcDream.Core/Chat/CombatChatTranslator.cs b/src/AcDream.Core/Chat/CombatChatTranslator.cs
index 48fbf862..4bec9247 100644
--- a/src/AcDream.Core/Chat/CombatChatTranslator.cs
+++ b/src/AcDream.Core/Chat/CombatChatTranslator.cs
@@ -47,7 +47,6 @@ public sealed class CombatChatTranslator : IDisposable
private readonly Action _onTaken;
private readonly Action _onMissed;
private readonly Action _onEvaded;
- private readonly Action _onAttackDone;
private readonly Action _onKill;
private bool _disposed;
@@ -61,14 +60,12 @@ public sealed class CombatChatTranslator : IDisposable
_onTaken = HandleDamageTaken;
_onMissed = HandleMissedOutgoing;
_onEvaded = HandleEvadedIncoming;
- _onAttackDone = HandleAttackDone;
_onKill = HandleKillLanded;
_combat.DamageDealtAccepted += _onDealt;
_combat.DamageTaken += _onTaken;
_combat.MissedOutgoing += _onMissed;
_combat.EvadedIncoming += _onEvaded;
- _combat.AttackDone += _onAttackDone;
_combat.KillLanded += _onKill;
}
@@ -80,7 +77,6 @@ public sealed class CombatChatTranslator : IDisposable
_combat.DamageTaken -= _onTaken;
_combat.MissedOutgoing -= _onMissed;
_combat.EvadedIncoming -= _onEvaded;
- _combat.AttackDone -= _onAttackDone;
_combat.KillLanded -= _onKill;
}
@@ -141,25 +137,6 @@ public sealed class CombatChatTranslator : IDisposable
_chat.OnCombatLine($"You evaded {attackerName}'s attack.", CombatLineKind.Info);
}
- private void HandleAttackDone(uint attackSequence, uint weenieError)
- {
- if (weenieError == 0)
- {
- // chat.rs:223-228 — silent on a clean finish (debug tag in
- // holtburger). We mirror: AttackDone with no error doesn't
- // need a chat line; the engine state already advanced.
- return;
- }
- // chat.rs:230-234 — "Attack sequence finished with {:?}.".
- // {:?} formats the WeenieError variant name; without that enum
- // mapped client-side we surface the hex code (matches the style
- // of ChatLog.OnWeenieError).
- var line = "Attack sequence finished with WeenieError 0x"
- + weenieError.ToString("X4", CultureInfo.InvariantCulture)
- + ".";
- _chat.OnCombatLine(line, CombatLineKind.Error);
- }
-
private void HandleKillLanded(string victimName, uint victimGuid)
{
// chat.rs:301-303 — KillerNotification: "{death_message}".
diff --git a/src/AcDream.Core/Chat/WeenieErrorMessages.cs b/src/AcDream.Core/Chat/WeenieErrorMessages.cs
index a64ad752..c1ea85c5 100644
--- a/src/AcDream.Core/Chat/WeenieErrorMessages.cs
+++ b/src/AcDream.Core/Chat/WeenieErrorMessages.cs
@@ -92,6 +92,8 @@ public static class WeenieErrorMessages
[0x0535] = "You do not have the authority within your allegiance to do that.",
// Movement / teleport / housing
+ [0x0036] = "Action cancelled!", // ActionCancelled
+ [0x003D] = "You charged too far!", // YouChargedTooFar
[0x0498] = "You have moved too far!", // YouHaveMovedTooFar
[0x0499] = "That is not a valid destination!", // TeleToInvalidPosition
[0x0532] = "You must wait 30 days after purchasing a house before you may purchase another with any character on the same account.",
diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
index 843a4722..44a92015 100644
--- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
@@ -141,6 +141,33 @@ public class RetailChaseCameraTests
Assert.Null(RetailChaseCamera.ComputeTrackedHeading(pivot, pivot));
}
+ [Fact]
+ public void DesiredPose_TrackedHeadingWithNoOrbit_PlacesBoomBehindTargetDirection()
+ {
+ var pivot = new Vector3(10f, 20f, 1.5f);
+
+ var (eye, forward) = RetailChaseCamera.ComputeDesiredPose(
+ pivot, Vector3.UnitX, distance: 5f, pitch: 0f, viewerYawOffset: 0f);
+
+ Assert.Equal(new Vector3(5f, 20f, 1.5f), eye);
+ Assert.Equal(Vector3.UnitX, forward);
+ }
+
+ [Fact]
+ public void DesiredPose_TrackedHeadingRetainsViewerOffsetOrbitAndLooksAtPivot()
+ {
+ var pivot = new Vector3(10f, 20f, 1.5f);
+
+ var (eye, forward) = RetailChaseCamera.ComputeDesiredPose(
+ pivot, Vector3.UnitX, distance: 5f, pitch: 0f,
+ viewerYawOffset: MathF.PI / 2f);
+
+ Assert.Equal(10f, eye.X, 5);
+ Assert.Equal(15f, eye.Y, 5);
+ Assert.Equal(1.5f, eye.Z, 5);
+ Assert.Equal(Vector3.Normalize(pivot - eye), forward);
+ }
+
[Fact]
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/CombatLayoutConformanceTests.cs
index 09bd01bc..8bee8956 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.True(power.ScalarFillFromRight);
var repeat = Assert.IsType(
layout.FindElement(CombatUiController.RepeatAttacksId));
diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
index cd9b4b0f..99499358 100644
--- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs
@@ -490,6 +490,7 @@ public class DatWidgetFactoryTests
Assert.Equal(0x06001919u, bar.TrackSprite);
Assert.Equal(0x06001923u, bar.ThumbSprite);
Assert.Equal(0x06001200u, bar.ScalarFillSprite);
+ Assert.True(bar.ScalarFillFromRight);
}
private static ElementInfo TextInfo(params (uint Id, UiPropertyValue Value)[] properties)
diff --git a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
index 779cb5d9..c9e32e32 100644
--- a/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
+++ b/tests/AcDream.App.Tests/UI/UiScrollbarTests.cs
@@ -102,4 +102,16 @@ public class UiScrollbarTests
Assert.True(bar.OnEvent(new UiEvent(0u, bar, UiEventType.MouseUp, Data1: 45)));
}
+
+ [Theory]
+ [InlineData(0f, 100f, 0f)]
+ [InlineData(0.5f, 50f, 50f)]
+ [InlineData(1f, 0f, 100f)]
+ public void ScalarFillRect_CombatPower_GrowsRightToLeft(
+ float fill, float expectedX, float expectedWidth)
+ {
+ var (x, width) = UiScrollbar.ScalarFillRect(100f, fill, fromRight: true);
+ Assert.Equal(expectedX, x, 3);
+ Assert.Equal(expectedWidth, width, 3);
+ }
}
diff --git a/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
index 3d61897e..197d03ef 100644
--- a/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
+++ b/tests/AcDream.Core.Tests/Chat/CombatChatTranslatorTests.cs
@@ -91,23 +91,20 @@ public sealed class CombatChatTranslatorTests
}
[Fact]
- public void AttackDone_NonZeroError_EmitsErrorLine()
+ public void AttackDone_NonZeroControlStatus_EmitsNothing()
{
var (chat, combat, _) = Setup();
- combat.OnAttackDone(attackSequence: 7, weenieError: 0x1234u);
+ combat.OnAttackDone(attackSequence: 7, weenieError: 0x0036u);
- var entry = Assert.Single(chat.Snapshot());
- Assert.Equal(ChatKind.Combat, entry.Kind);
- Assert.Equal(CombatLineKind.Error, entry.CombatKind);
- Assert.Contains("Attack sequence finished with", entry.Text);
- Assert.Contains("0x1234", entry.Text);
+ // Retail HandleAttackDoneEvent consumes this only as control state.
+ // ACE deliberately uses ActionCancelled to reset the meter and sends
+ // a separate WeenieError event for any player-visible failure.
+ Assert.Empty(chat.Snapshot());
}
[Fact]
public void AttackDone_ZeroError_EmitsNothing()
{
- // Holtburger silently logs a debug-tag line; we omit since chat
- // panel doesn't surface debug entries.
var (chat, combat, _) = Setup();
combat.OnAttackDone(attackSequence: 7, weenieError: 0u);
diff --git a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
index fbc7de8f..bb446db2 100644
--- a/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
+++ b/tests/AcDream.Core.Tests/Chat/WeenieErrorMessagesTests.cs
@@ -96,6 +96,12 @@ public sealed class WeenieErrorMessagesTests
WeenieErrorMessages.Format(0x050F, null));
}
+ [Theory]
+ [InlineData(0x0036u, "Action cancelled!")]
+ [InlineData(0x003Du, "You charged too far!")]
+ public void Format_CombatMovementErrors(uint code, string expected)
+ => Assert.Equal(expected, WeenieErrorMessages.Format(code, null));
+
// ── unknown codes — graceful fallback preserves debug info ───────
[Fact]