fix(combat): #298 — admit player targets to melee/missile attack and the camera

Selecting a PKLite player and attacking did nothing: with auto-target on it
retargeted to the nearest monster, with auto-target off it logged
"combat: attack ignored; no creature target found". Spells on the same target
worked, which was the clue.

Root cause: CombatTargetPolicy.IsHostileMonster:31-33 rejects any candidate
carrying BfPlayer BEFORE reaching ObjectIsAttackable, so the both-PKLite pool
match at SelectedObjectHealthPolicy.cs:70-71 was unreachable for players. Melee
and missile targeting never supported player targets at all — the gate is named
IsHostileMonster and does exactly what it says. Nobody could hit it until
69ba9486 made PK Lite reachable.

Retail uses ONE predicate for monsters and players, with no player exclusion:
ClientCombatSystem::ExecuteAttack @0x0056BB70 gates unconditionally on
ObjectIsAttackable @0x0056A600 (creature type, Free-PK short-circuit on either
side, then IsPlayer -> bothPK || bothPKLite, else BF_ATTACKABLE with pets
excluded). acdream already ported that predicate verbatim; it was simply
unreachable.

The fix SPLITS the two concerns rather than relaxing the shared predicate:
explicit-target admission routes through ObjectIsAttackable, while auto-target
ACQUISITION keeps the monster-only gate. That is required by register row
IA-19 — explicit product direction that Auto Target must never select NPCs,
players or pets. IA-19 is not overridden here; its own justification promises
"manual player-selection commands remain available", and that promise was never
implemented, so this makes the row true. Review confirmed no path lets
auto-acquisition select a player: every automatic Select is fed by a
FindClosest* filtered through IsHostileMonster.

Review also found a second site with the same bug, which the first pass froze in
place on my instruction: retail gates combat-camera tracking on the SAME
predicate as the attack. ClientCombatSystem::UpdateTargetTracking @0x0056A950
reads GetAttackTarget() then gates CameraSet::TrackTarget on ObjectIsAttackable.
Ours used the monster-only gate, so with ViewCombatTarget on by default the
attack would land while the camera refused to track the opponent — user-visible
in exactly the duel this fix enables. GetCombatCameraTargetPoint now uses the
wide predicate. IA-19 does not reach the camera: it performs no acquisition,
only presentation on an already-chosen target. The first pass had added a
source comment asserting IA-19 covered it; that comment and the matching text in
docs/ISSUES.md are corrected, since a wrong citation is how a real divergence
becomes invisible.

Depends on 9b1e6fc6 (#297): the both-PKLite arm needs the LOCAL player's own bit
to be live. Review confirmed both admission sites read ClientObjectTable on every
call, so this is not inert in production.

Newly reachable and now pinned: ObjectIsAttackable's pet-exclusion arm, which
CombatTargetPolicy rejected before it could ever run.

Follow-ups filed: #304 (SelectionInteractionController.GetSelectedOrClosestCombatTarget
has no production caller — one of the two widened call sites is dead code),
#305 (HeadlessGameplayOperations has the identical pre-existing bug, so the
graphical/headless hosts now diverge).

Gates: complete Release solution 10,904 passed / 4 skipped / 0 failed (baseline
10,900). Adversarial + retail-conformance review PASS after one FAIL round; the
predicate was re-verified branch-for-branch against 0x0056A600 since it goes
live here for the first time. Camera fix discrimination-verified by revert.
Connected acceptance NOT run — needs a live two-client PKLite session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 21:29:15 +02:00
parent 9b1e6fc637
commit bc0077a55f
11 changed files with 410 additions and 25 deletions

View file

@ -38,7 +38,7 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
&& IsHostileMonster(selected))
&& IsAttackableExplicitTarget(selected))
{
return selected;
}
@ -89,8 +89,52 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
return best;
}
/// <summary>
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
/// non-player, non-pet, attackable monsters. Backs
/// <see cref="FindClosestHostileMonster"/> only — never explicit
/// selection.
/// </summary>
private bool IsHostileMonster(uint serverGuid)
{
if (!TryGetLiveCombatCandidate(serverGuid, out ClientObject? candidate))
return false;
uint playerGuid = _player.ServerGuid;
return (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
}
/// <summary>
/// Explicit-target admission for a user-issued attack (#298). Retail
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> gates
/// unconditionally on <c>ObjectIsAttackable @ 0x0056A600</c>, with no
/// player exclusion — a compatible-PK player is a valid attack target.
/// This is deliberately a different, wider predicate than
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
/// automatic acquisition per register row IA-19.
/// </summary>
private bool IsAttackableExplicitTarget(uint serverGuid)
{
if (!TryGetLiveCombatCandidate(serverGuid, out ClientObject? candidate))
return false;
uint playerGuid = _player.ServerGuid;
return SelectedObjectHealthPolicy.ObjectIsAttackable(
playerGuid,
_objects.Get(playerGuid),
serverGuid,
candidate);
}
private bool TryGetLiveCombatCandidate(
uint serverGuid,
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out ClientObject? candidate)
{
candidate = null;
uint playerGuid = _player.ServerGuid;
if (serverGuid == playerGuid
|| !_liveEntities.TryGetInteractionEligibleRecord(
@ -107,12 +151,7 @@ internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
return false;
}
ClientObject? candidate = _objects.Get(serverGuid);
return candidate is not null
&& (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
candidate = _objects.Get(serverGuid);
return candidate is not null;
}
}

View file

@ -104,10 +104,17 @@ internal sealed class SelectionInteractionController
_items.PlaceIn3D(payload, target);
}
/// <summary>
/// Explicit selection uses the wider <c>ObjectIsAttackable</c>-backed
/// admission (#298) so a compatible-PK player is a valid attack target;
/// falling back to auto-acquisition still uses the narrower
/// <see cref="IWorldSelectionQuery.IsHostileMonster"/> monster-only
/// policy (register row IA-19).
/// </summary>
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
&& _query.IsHostileMonster(selected))
&& _query.IsAttackableTarget(selected))
{
return selected;
}

View file

@ -44,6 +44,7 @@ internal interface IWorldSelectionQuery
string Describe(uint serverGuid);
bool IsCreature(uint serverGuid);
bool IsHostileMonster(uint serverGuid);
bool IsAttackableTarget(uint serverGuid);
ClosestCombatTarget? FindClosestHostileMonster();
bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid);
@ -229,6 +230,14 @@ internal sealed class WorldSelectionQuery
return (GetItemType(serverGuid) & ItemType.Creature) != 0;
}
/// <summary>
/// Automatic-acquisition eligibility (register row IA-19): narrowed to
/// non-player, non-pet, attackable monsters. Backs
/// <see cref="FindClosestHostileMonster"/> only — never explicit
/// selection, and never the combat camera (retail gates camera tracking
/// on <c>ObjectIsAttackable</c>, not this narrower policy — see
/// <see cref="GetCombatCameraTargetPoint"/>).
/// </summary>
public bool IsHostileMonster(uint serverGuid)
=> IsCreature(serverGuid)
&& CombatTargetPolicy.IsHostileMonster(
@ -236,6 +245,27 @@ internal sealed class WorldSelectionQuery
_objects.Get(_playerGuid()),
_objects.Get(serverGuid));
/// <summary>
/// Explicit-target admission for a user-issued attack (#298), and the
/// combat camera's tracking gate. Retail
/// <c>ClientCombatSystem::ExecuteAttack @ 0x0056BB70</c> and
/// <c>UpdateTargetTracking @ 0x0056A950</c> both gate unconditionally on
/// <c>ObjectIsAttackable @ 0x0056A600</c>, with no player exclusion — a
/// compatible-PK player is a valid attack target and a valid camera
/// target. This is deliberately a different, wider predicate than
/// <see cref="IsHostileMonster"/>, which stays narrowed to monsters for
/// AUTOMATIC ACQUISITION ONLY per register row IA-19 — IA-19 does not
/// reach explicit selection or the camera (a presentation gate on an
/// already-chosen target, not an acquisition path).
/// </summary>
public bool IsAttackableTarget(uint serverGuid)
=> IsCreature(serverGuid)
&& SelectedObjectHealthPolicy.ObjectIsAttackable(
_playerGuid(),
_objects.Get(_playerGuid()),
serverGuid,
_objects.Get(serverGuid));
public bool ShouldShowHealth(uint serverGuid)
=> SelectedObjectHealthPolicy.ShouldQueryHealth(
_playerGuid(),
@ -261,8 +291,18 @@ internal sealed class WorldSelectionQuery
return best;
}
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>
/// on <c>ObjectIsAttackable @ 0x0056A600</c> — the SAME wide predicate as
/// <c>ExecuteAttack</c>, not the narrower automatic-acquisition policy.
/// The camera performs no acquisition of its own; it only tracks a
/// target the player already selected, so routing it through
/// <see cref="IsAttackableTarget"/> does not touch register row IA-19
/// (which scopes itself to automatic acquisition).
/// </summary>
public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
=> IsHostileMonster(serverGuid)
=> IsAttackableTarget(serverGuid)
&& TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)
? target.Entity.Position
+ Vector3.Transform(new Vector3(0f, 0f, 0.5f), target.Entity.Rotation)