diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index f1b7f8ae..ea1dac1e 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 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, and structural horizontal-scrollbar track/thumb roles. 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`; 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 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. - **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158. - **✓ SHIPPED — D.5.4 — Client object/item data model (foundation).** Shipped 2026-06-18 (`b506f53`..`a33e897`, 11 commits). Renamed `ItemRepository`→`ClientObjectTable` / `ItemInstance`→`ClientObject`; broadened the table to hold EVERY server object (retail `weenie_object_table` shape). `CreateObject` is now the canonical merge-upsert (`ClientObjectTable.Ingest`, retail `SetWeenieDesc` semantics) via a new Core.Net `ObjectTableWiring` (off GameWindow); `DeleteObject` evicts; `PlayerDescription` is a membership manifest (`RecordMembership`); live container-membership index (`GetContents`, retail `object_inventory_table`). `_liveEntityInfoByGuid` retired (selection/describe resolve from the one table). Root fix: the old enrich-existing-only `EnrichItem` dropped `CreateObject`s for items with no `PlayerDescription` stub — live-Coldeve 4/6 hotbar slots blank; items are now created, not dropped. **Crux resolved:** retail is TWO tables (`object_table` + `weenie_object_table`), NOT one — acdream's `WorldEntity` (3D system) + `ClientObjectTable` (data/UI) split was already architecturally faithful; the fix was the ingestion path, not a table unification. 2671 tests green. - **Roadmap correction (2026-07-10):** the completion order is now the architecture-first campaign in `docs/superpowers/plans/2026-07-10-retail-ui-fidelity-completion.md`. Retail `gmToolbarUI` is object-only: preserve `ShortCutData.index_`, `objectID_`, and `spellID_`, but do not invent spell glyphs on this bar. `PlayerModule::favorite_spells_[8]` feeds separate spell bars. diff --git a/docs/research/2026-07-11-combat-target-camera-pseudocode.md b/docs/research/2026-07-11-combat-target-camera-pseudocode.md new file mode 100644 index 00000000..e8460908 --- /dev/null +++ b/docs/research/2026-07-11-combat-target-camera-pseudocode.md @@ -0,0 +1,77 @@ +# Retail combat target camera pseudocode + +Source: Sept 2013 EoR named retail client. + +- `ClientCombatSystem::UpdateTargetTracking` (`0x0056A950`) +- `ClientCombatSystem::TrackTarget` (`0x0056AED0`) +- `CameraSet::TrackTarget` (`0x00458280`) +- `CameraSet::SetTargetForOffset` (`0x004576E0`) +- `CameraManager::SetTargetObject` (`0x00456380`) +- `CameraManager::UpdateCamera` (`0x00456660`) + +## Option consumer + +```text +UpdateTargetTracking(): + if ViewCombatTarget is enabled + and combat mode is Melee or Missile + and current attack target is valid: + camera.TrackTarget(attackTarget) + else: + camera.TrackTarget(0) +``` + +## Camera state + +```text +CameraSet.TrackTarget(targetId): + if targetId != 0: + targeting = true + camera.SetTargetObject(targetId, wholeObject) + camera.targetOffset = (0, 0, 0.5 metres) + else: + targeting = false + + SetTargetForOffset(camera.viewerOffset) + +SetTargetForOffset(viewerOffset), while targeting: + camera.targetStatus = LOOK_AT_OBJECT | LOOK_AT_PIVOT +``` + +## Per-frame pose + +```text +pivot = player part position + transformed pivotOffset +direction = zero + +if targetStatus contains LOOK_AT_OBJECT and target object exists: + targetPoint = target.position localToGlobal targetOffset + direction += normalize(targetPoint - pivot) + +headingFrame = frameWhoseForwardIs(direction) +desiredEye = pivot + headingFrame.transformVector(viewerOffset) + +if targetStatus contains LOOK_AT_PIVOT: + desiredForward = normalize(pivot - desiredEye) + +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. + +## 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. +- The diagnostic legacy `ChaseCamera` remains intentionally non-retail under + existing divergence row TS-19; the production retail camera owns this behavior. + +The ACE and ACME reference trees are not present in this worktree (only the +in-tree WorldBuilder reference is available), so no interpretation-aid cross +check was possible. The named retail functions above are complete for this +behavior and remain the ground truth. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0a230e23..fe68e6b9 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8415,13 +8415,15 @@ public sealed class GameWindow : IDisposable // player visibly rises in frame without the camera // swinging vertically (was the symptom of using raw // velocity-vector heading). + System.Numerics.Vector3? trackedCombatTarget = GetCombatCameraTargetPoint(); _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw, playerVelocity: _playerController.BodyVelocity, isOnGround: result.IsOnGround, contactPlaneNormal: _playerController.ContactPlane.Normal, dt: (float)dt, cellId: _playerController.CellId, - selfEntityId: _playerController.LocalEntityId); + selfEntityId: _playerController.LocalEntityId, + trackedTargetPoint: trackedCombatTarget); // Send outbound movement messages to the live server. if (_liveSession is not null) @@ -12021,6 +12023,26 @@ public sealed class GameWindow : IDisposable return SelectClosestCombatTarget(showToast: false); } + /// + /// Resolves retail's combat-camera target. ClientCombatSystem:: + /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only + /// for melee/missile combat with the option enabled and a valid attack + /// target. TrackTarget applies target-local offset (0,0,0.5). + /// + private System.Numerics.Vector3? GetCombatCameraTargetPoint() + { + if (!_persistedGameplay.ViewCombatTarget + || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode) + || _selection.SelectedObjectId is not uint selected + || !IsLiveCreatureTarget(selected) + || !_entitiesByServerGuid.TryGetValue(selected, out var target)) + return null; + + return target.Position + + System.Numerics.Vector3.Transform( + new System.Numerics.Vector3(0f, 0f, 0.5f), target.Rotation); + } + // ============================================================ // Phase B.4b — outbound Use handler. Wires three input actions // (LMB click select, LMB-double-click select+use, R hotkey diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs index 0eb4b474..307569f1 100644 --- a/src/AcDream.App/Rendering/RetailChaseCamera.cs +++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs @@ -143,28 +143,31 @@ public sealed class RetailChaseCamera : ICamera Vector3 contactPlaneNormal, float dt, uint cellId = 0, - uint selfEntityId = 0) + uint selfEntityId = 0, + Vector3? trackedTargetPoint = null) { // 1. Push velocity into 5-frame ring, get average. PushVelocity(_velocityRing, ref _velocityCount, playerVelocity); Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount); - // 2. Heading vector — player's facing projected onto the contact - // plane (grounded) or world XY (airborne). See ComputeHeading - // doc + retail decomp :95644-95795 for why this is facing-based - // rather than velocity-based. - Vector3 heading = ComputeHeading( - avgVel, - playerYaw + YawOffset, - isOnGround, - contactPlaneNormal, - CameraDiagnostics.AlignToSlope); + // 2. Heading vector. TrackTarget uses the pivot-to-target direction; + // otherwise this is the player's facing projected onto the contact + // plane (grounded) or world XY (airborne). See named retail + // CameraSet::TrackTarget 0x00458280 and CameraManager::UpdateCamera + // 0x00456826-0x00456915. + Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight); + Vector3 heading = ComputeTrackedHeading(pivotWorld, trackedTargetPoint) + ?? ComputeHeading( + avgVel, + playerYaw + YawOffset, + isOnGround, + contactPlaneNormal, + CameraDiagnostics.AlignToSlope); // 3. Orthonormal heading-frame basis. var (forward, _, up) = BuildBasis(heading); // 4. Target pose. - Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight); float horizontal = Distance * MathF.Cos(Pitch); float vertical = Distance * MathF.Sin(Pitch); // viewer_offset = -horizontal along forward + vertical along up. @@ -359,6 +362,24 @@ public sealed class RetailChaseCamera : ICamera return Vector3.Normalize(projected); } + /// + /// Port of the LOOK_AT_OBJECT direction contribution in retail + /// CameraManager::UpdateCamera (0x00456826-0x00456915). The caller + /// supplies the transformed world-space target point, including retail's + /// local (0,0,0.5) target offset. A coincident point cannot define a + /// heading, so the ordinary player-facing path remains active. + /// + internal static Vector3? ComputeTrackedHeading(Vector3 pivotWorld, Vector3? trackedTargetPoint) + { + if (trackedTargetPoint is not Vector3 targetPoint) + return null; + + Vector3 towardTarget = targetPoint - pivotWorld; + return towardTarget.LengthSquared() < 1e-8f + ? null + : Vector3.Normalize(towardTarget); + } + /// /// Build an orthonormal basis with forward = heading. World /// up is (0, 0, 1); if heading is near-parallel to it diff --git a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs index db57c369..843a4722 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs @@ -116,6 +116,31 @@ public class RetailChaseCameraTests // ── Basis from heading ──────────────────────────────────────────── + [Fact] + public void TrackedHeading_UsesPivotToRetailTargetOffsetPoint() + { + // The target is ten metres east and one metre below the player's + // 1.5 metre camera pivot after TrackTarget's 0.5 metre offset. + var pivot = new Vector3(0f, 0f, 1.5f); + var targetPoint = new Vector3(10f, 0f, 0.5f); + Vector3 expected = Vector3.Normalize(new Vector3(10f, 0f, -1f)); + + Vector3 heading = RetailChaseCamera.ComputeTrackedHeading(pivot, targetPoint)!.Value; + + Assert.Equal(expected.X, heading.X, 5); + Assert.Equal(expected.Y, heading.Y, 5); + Assert.Equal(expected.Z, heading.Z, 5); + } + + [Fact] + public void TrackedHeading_MissingOrCoincidentTarget_FallsBack() + { + var pivot = new Vector3(1f, 2f, 3f); + + Assert.Null(RetailChaseCamera.ComputeTrackedHeading(pivot, null)); + Assert.Null(RetailChaseCamera.ComputeTrackedHeading(pivot, pivot)); + } + [Fact] public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded() {