docs: file #297-#299 — PK Lite gaps exposed by @pklite

The user found three symptoms live within minutes of 69ba9486 making PK Lite
reachable for the first time. Two independent root causes, neither a C4 route 2
regression (verified by diff: 9966b531 touched none of the gates, and all three
predate it).

#297 (HIGH) — PublicWeenieBitfield is written once at CreateObject and never
refreshed. ACE's only PK-change message is PropertyInt 134 over 0x02CE; we
store it but never translate it into the bitfield, and ACE never re-sends a
PWD (EnqueueBroadcastUpdateObject has zero live callers), so a client cannot
learn PK status from the bitfield after login. Both sides of the collision test
read the frozen value, so CollisionExemption's "both PKLite -> collide" rule
never fires. Retail's missing port is PublicWeenieDesc::SetPlayerKillerStatus
@0x005AC7C0, driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86.

#298 (MEDIUM-HIGH, blocked on #297) — CombatTargetPolicy.IsHostileMonster
rejects BfPlayer before reaching ObjectIsAttackable, so the PKLite pool match
we already ported correctly is unreachable for players. Retail uses ONE
predicate for monsters and players with no exclusion. Critically: the naive fix
is wrong — the same predicate backs auto-target acquisition and the combat
camera, and relaxing it would violate register row IA-19's explicit product
direction. The fix must SPLIT explicit-target admission from auto-acquisition,
which is what IA-19's own unimplemented promise already describes.

#299 (LOW) — CollisionExemption checks only the target's IsImpenetrable while
retail short-circuits on mover OR target, and the class doc asserts the
opposite. Found during the investigation; not symptom-causing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-03 19:24:08 +02:00
parent d980456fd9
commit e3b766d952

View file

@ -436,6 +436,100 @@ contract: [`2026-08-03-c4-route-2-contract.md`](research/2026-08-03-c4-route-2-c
conflated with the dropped case. Filed from the C4 route-2 round-3
adversarial review.
## PK Lite gaps exposed by `@pklite` — 2026-08-03
All three found live by the user minutes after `69ba9486` made PK Lite
reachable for the first time. **None is a C4 route 2 regression** — verified by
diff: `9966b531` touched none of `CollisionExemption`, `CombatTargetPolicy`,
`SelectedObjectHealthPolicy`, `EntityCollisionFlags`, `ObjectTableWiring` or
`CreateObject`, and all three gates predate it (`3361a8d7`, `2644d1d5`,
`0f2d98c5`). `@pklite` made pre-existing behaviour reachable; it did not create
it. Do #297 FIRST — #298 depends on it.
- **#297 — OPEN — PublicWeenieBitfield is frozen at CreateObject, so a PK
status change never reaches the client. HIGH.** User symptom: after `@pklite`,
the local player walks straight through other PKLite players.
ACE's only PK-change message is `GameMessagePublicUpdatePropertyInt` (0x02CE)
carrying `PropertyInt.PlayerKillerStatus`(134) = `PKLite`(0x40)
(`Player.cs:1153` -> `Player_Properties.cs:1122-1132` ->
`WorldObject_Networking.cs:1413-1442`). The PWD `ObjectDescriptionFlag` bits
are recomputed only inside serialization, and `EnqueueBroadcastUpdateObject`
(`WorldObject.cs:662-665`) has zero live callers — so no PWD re-send ever
happens and a client CANNOT learn PK status from the bitfield after login.
We parse and store the property (`ObjectTableWiring.cs:41-47`, landing in
`Properties.Ints[134]`) but never translate it: `ClientObject.PublicWeenieBitfield`
has exactly one writer (`ClientObjectTable.cs:891`) fed solely from the 0xF745
CreateObject parse (`CreateObject.cs:827`). Both sides of the collision test
then read that frozen value — mover via `EntityCollisionFlags.cs:133-139` /
`LiveSessionEventRouter.cs:419-442`, target via
`LiveEntityCollisionBuilder.cs:151-153` — so `CollisionExemption.cs:117-122`
("4c. both PKLite -> collide") never fires and `:125` exempts.
**Retail is the exact port we are missing:** `ACCWeenieObject::OnStatUpdated`
@0x0058DF20 `case 0x86:` calls `PublicWeenieDesc::SetPlayerKillerStatus`
@0x005AC7C0, which rewrites `pwd._bitfield` in place — PK `|0x20`, PKLite
`|0x2000000`, Free `|0x200000`, mutually exclusive, else clear all three.
`IsPKLite` @0x0058C8A0 reads `(_bitfield >> 0x19) & 1`. Entry points are
`Handle_Qualities__PrivateUpdateInt` @0x00558FD0 (0x02CD, self) and
`Handle_Qualities__UpdateInt` @0x00558D60 (0x02CE, remote).
Fix shape: port `SetPlayerKillerStatus` as a bitfield rewrite on PropertyInt
134, applied from BOTH routes. `RecomputePvpStatus` already reacts to
`ObjectUpdated` so the mover side follows for free; the TARGET side needs a
second edge because shadow-registry `EntityCollisionFlags` are frozen at
registration. Cheap falsification test: a relog fixes it for the local player
only, since a fresh CreateObject carries the real bit.
Unaudited: whether making the bitfield mutable disturbs its item-shaped
readers (`ToolbarController.cs:633`, `ItemInteractionController.cs:1272`,
`AppraisalUiController.cs:543`, `ItemAppraisalTextFormatter.cs:949,:1110`).
Note the vivid target indicator (`WorldSelectionQuery.cs:271-298`) reads the
spawn PWD bits directly and is stale by the same mechanism.
- **#298 — OPEN — melee/missile attack admission excludes players by
construction. MEDIUM-HIGH. Blocked on #297.** User symptom: selecting a
PKLite player and attacking retargets to the nearest monster (auto-target on)
or does nothing (auto-target off, logging
`combat: attack ignored; no creature target found`,
`LiveCombatAttackOperations.cs:187`).
`CombatTargetPolicy.IsHostileMonster:31-33` rejects any candidate carrying
`SelectedObjectHealthPolicy.BfPlayer` before reaching `ObjectIsAttackable`,
so the PKLite pool match at `SelectedObjectHealthPolicy.cs:70-71` is
unreachable for player targets.
**Retail has ONE predicate for monsters and players, with no player
exclusion:** `ClientCombatSystem::ExecuteAttack` @0x0056BB70 gates
unconditionally on `ObjectIsAttackable` @0x0056A600, which checks creature
type, the `0x200000` bits, then `IsPlayer(): (bothPK) || (bothPKLite)`, else
`BF_ATTACKABLE` with pets excluded. We already have that predicate ported
verbatim and correctly at `SelectedObjectHealthPolicy.cs:41-78` — it is
simply unreachable.
**DO NOT fix by relaxing the shared predicate.** `IsHostileMonster` also
backs auto-target ACQUISITION (`CombatAttackTargetSource.cs:80`,
`WorldSelectionQuery.cs:255`) and the combat camera
(`WorldSelectionQuery.cs:264-269`), and relaxing it would violate register row
**IA-19**, explicit product direction that auto-target must never select
NPCs, players or pets. Retail's own auto-target DOES admit players
(@0x0056C040 pc:377318-377327), so retail and IA-19 genuinely disagree here.
Fix shape: SPLIT explicit-target admission (-> `ObjectIsAttackable`,
retail-exact) from auto-acquisition (-> keep `IsHostileMonster`, IA-19
intact). IA-19's own text already promises "manual player-selection commands
remain available"; that promise is currently unimplemented, which is the real
gap. Not affected: the health bar (`SelectedObjectHealthPolicy.cs:32` already
admits `BfPlayer`) and the vivid target indicator.
Correct model to copy: spells already work on PKLite players because
`RetailSpellTargetPolicy.cs:40-46` treats `BF_PLAYER` as an ACCEPT and never
calls `ObjectIsAttackable` — the client checks target-TYPE compatibility and
lets the server arbitrate PK legality (retail
`ClientMagicSystem::ObjectCompatibleWithSpellTargetType` @0x00567230).
- **#299 — OPEN — CollisionExemption misses retail's mover-side
IsImpenetrable branch, and its doc comment asserts the opposite. LOW.**
`CollisionExemption.cs:103` checks only the TARGET's `IsImpenetrable`, and the
class doc at `:33-39` claims "retail's pseudo-C only checks the target's
`IsImpenetrable()`; acdream follows retail." The pseudo-C at pc:276824-276827
has TWO short-circuit branches — mover `state & IS_IMPENETRABLE (0x80)` OR
target `IsImpenetrable()` — either alone exempting. We are missing the mover
branch, and the comment blames ACE (`PhysicsObj.cs:403-405`) for an addition
that is actually retail-faithful. Found during the #297/#298 investigation;
not symptom-causing. Fix the code and the comment together.
## C3c placement cutover — 2026-08-02
- **#276 — OPEN — SpawnPlacementSettler discards the settle's resolved