docs: C4 route 2 connected gate — the real @pklite recipe

Rewrites the visual gate now that 69ba9486 makes the ForcePosition lever exist.

Records the finding that would otherwise cause a false pass: admin teleports
(@teleto/@teletome/@teleloc/@movetome) advance ObjectTeleport, not
ObjectForcePosition (PositionPack.cs:49-52), so they exercise route 3. ACE
advances ObjectForcePosition in exactly two places and only the PK Lite
entry-collision bump is reachable by command.

Flags the one-shot nature of the test: entering PK Lite is a persistent
character state change, and DoPKLite @0x0057A490 rejects every later attempt
once IsPlayerKiller @0x0058C910 is true. The two no-op checks come first so the
state-changing step is last.

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

View file

@ -1,135 +1,136 @@
# C4 route 2 — ForcePosition: connected visual gate (2026-08-03)
The user-facing acceptance test for route 2. Route 2 is code-complete and
suite-green before this runs; this document is the hand-off.
Hand-off for the user-facing acceptance test. Two commits are in scope:
## Why the obvious recipe does NOT work
- `9966b531` — C4 route 2, ForcePosition through the canonical placement.
- `69ba9486` — retail's `@pklite` client command, the lever this gate needs.
The route-2 contract's acceptance line says *"ACE `@teleport`-style
displacement"*. That is wrong about which route it exercises, and following it
would have produced a false pass.
Complete Release suite green at 10,867 / 4 skipped / 0 failed. Both Opus
reviews PASS on route 2's final diff.
Verified in ACE this session: `PositionPack`'s constructor
(`references/ACE/Source/ACE.Server/Network/Structure/PositionPack.cs:36-57`)
advances `ObjectTeleport` when `adminMove == true` (lines 49-52) and only ever
*reads* `ObjectForcePosition` (line 54). Every admin move command —
`@teleto`, `@teletome`, `@teleloc`, `@movetome` — routes through
`Player_Location.cs:654 Teleport()` / `SendUpdatePosition(true)` and therefore
advances **TELEPORT_TS, not FORCE_POSITION_TS**. Those commands exercise
**route 3**, not route 2.
## Why `@pklite` is the lever
`SequenceType.ObjectForcePosition` is advanced at exactly **two** places in the
whole ACE tree:
ACE advances `SequenceType.ObjectForcePosition` in exactly two places
(`Player.cs:1148`, `Player_Tick.cs:488`). The second is an anti-cheat
rubber-band that needs the server to believe you are fly-hacking — no command
path reaches it. So the first is the only usable trigger:
`Player.HandleActionEnterPkLite` bumps the sequence when entering PK Lite finds
you physically overlapping something (`Player.cs:1130-1150`, gated on
`allow_pkl_bump`, default true).
1. `references/ACE/Source/ACE.Server/WorldObjects/Player.cs:1148` — the PK Lite
entry collision bump.
2. `references/ACE/Source/ACE.Server/WorldObjects/Player_Tick.cs:488` — the
anti-cheat z-position rubber-band.
**Admin teleports do NOT produce a ForcePosition.** `@teleto`, `@teletome`,
`@teleloc`, `@movetome` all route through `Teleport()` / `SendUpdatePosition(true)`
and advance `ObjectTeleport` instead (`PositionPack.cs:49-52`). Those exercise
route 3. Testing route 2 with them would give a false pass.
(2) requires the server to believe you are fly-hacking — same landblock, a
claimed Z more than 10 units above your last ground contact, more than a second
after your last jump, Jump skill under 1000, and still flagged airborne. It is
not reachable by legitimate play and there is no command path into it. Do not
build the gate on it.
## Read this before you start — the state change is one-way
So (1) is the gate.
Entering PK Lite is a **persistent character state change**. Once `+Acdream` is
PKLite, `ClientCommunicationSystem::DoPKLite` @0x0057A490 rejects every later
`@pklite` (its `IsPlayerKiller` check @0x0058C910 is true for the PK bit `0x20`
OR the PKLite bit `0x2000000`). Retail's own help text says the status reverts
only by dying in a PK Lite battle and logging off.
## The only reliable lever is deferred — what that means
Practical consequence: **the bump test is effectively one shot per character.**
Do the two no-op checks first, and consider a throwaway character for step 3 if
you want to keep `+Acdream` NPK.
The reachable trigger is the PK Lite entry-collision bump, which requires
retail's `@pklite`. That is a **client** command, not a server one — ACE has no
`pklite` text-command handler, so typing `@pklite` into chat today forwards as
inert text. Retail's client turns it into a game action instead:
Also possible: `+Acdream` may already be PK or PKLite, in which case step 3
answers with the rejection message immediately. That is still a valid test of
the gate — just not of the bump.
- `ClientCommunicationSystem::DoPKLite` @`0x0057A490`
(`acclient_2013_pseudo_c.txt:390106`) — rejects with error `0x507` when
`ACCWeenieObject::IsPlayerKiller` @`0x0058C910` is true (PK bit `0x20` OR
PKLite bit `0x2000000`), otherwise calls
- `CM_Character::Event_EnterPKLite` @`0x006A13F0` (line 680071) — a 12-byte
parameterless game action, opcode `0x28F`, no payload.
## Launch
acdream does not implement it, and **the user deferred implementing it
(2026-08-03).** Scoping is preserved in this session's research so it can be
picked up cheaply: ~40 lines of production code across `ClientCommandId`,
`RetailClientCommandCatalog`, `ClientCommandRequests.BuildParameterless`,
`WorldSession`, `ClientCommandController`, `LiveSessionCommandRouter`, and
`LiveSessionRuntimeFactory` — every pattern already exists, including
`WeenieError 0x0507`.
Release, retail UI, per the project's standing rule for visual gates.
**Consequence, stated plainly: route 2's own behaviour is NOT visually
verifiable in this campaign.** Nothing a user can do makes ACE emit a
ForcePosition. Route 2's acceptance therefore rests on its automated Runtime /
App / Headless tests and the complete Release suite. The connected pass below
is a **regression check on the blast radius**, not acceptance of the new route.
Do not record it as the latter.
```bash
dotnet build -c Release
```
## What the connected pass actually covers
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_RETAIL_UI = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
```
Launch acdream in **Release** with `ACDREAM_RETAIL_UI=1` against the local ACE
at `127.0.0.1:9000`.
## Step 1 — `@pklite` argument guard (changes nothing)
Route 2 deletes `PlayerMovementController.BlipPosition` and
`HeadlessSessionWorldProjection.BlipLocalPlayer`, removes App's generic-tail
double-write for the local player, and re-routes the local player's accepted
placement through the canonical Runtime SetPosition transaction. So the things
to confirm are that ordinary play is untouched:
Type `@pklite foo`.
- Ordinary running, turning, walk/run toggle. No tethering, no rubber-band, no
drift against the server.
- A jump and a landing.
- Walking through a doorway into an interior and back out.
- A portal recall, and a portal into a dungeon.
- Two-client observation: `+Acdream` seen from the retail client moves smoothly
and lands where acdream shows it.
Expect a local message pointing you at `@help pklite`, and **nothing sent to
the server**. Retail's `DoPKLite` returns early on any trailing argument text
without building the game action.
Anything wrong there is route 2's fault even though route 2 did not intend to
touch it.
## Step 2 — the command is recognised, not forwarded as chat (changes nothing)
## Optional cheap shot (may not fire)
Type `/pklite` and `@pklite` and confirm neither appears in chat as literal
text. Before this commit they fell through to the server-text path, and ACE —
which has no `pklite` command handler — silently ignored them. Both forms must
now resolve as a client command.
The second ACE call site is the anti-cheat z-position rubber-band
(`Player_Tick.cs:459-490`). It needs: same landblock as your last ground
contact, a claimed Z more than 10 units above it, more than a second since your
last jump, Jump skill under 1000, and the server still flagging you airborne.
There is no command path into it and normal play cannot satisfy it (falling
makes the Z delta negative, and walking up terrain keeps refreshing the ground
position), but a `@teleloc` straight up ~15 units within the same landblock is
a two-minute experiment with a definitive tell: ACE's console logs
`z-pos hacking detected for +Acdream` at `Player_Tick.cs:486` immediately
before it force-bumps you.
If `+Acdream` is already PK or PKLite you will get *"Only Non-Player Killers
may enter PK Lite"* here. That is the correct retail rejection (WeenieError
`0x507`) and proves the gate works; it also means the bump in step 3 is not
available on this character.
If that line appears, you have a genuine ForcePosition and the checks below
apply. If it does not, the route is untested by observation — which is the
expected outcome.
## Step 3 — the ForcePosition bump (one shot; changes character state)
**If a ForcePosition does occur, must be true:**
- You end up at the corrected position and stay there. No visible double-apply,
no snap-then-yank-back over one or two frames.
- **Your facing does not change.** Retail's force branch replaces the
destination heading with your current heading before placing
(`HandleReceivedPosition` @`0x00453FD0`, `Frame::set_heading` at
`0x00454068`).
- Exactly **one** outbound `AutonomousPosition` for the correction.
1. Bring a second character online standing somewhere reachable. The retail
client in parallel is ideal — it doubles as the two-client observation.
2. On `+Acdream`, `@teleto <OtherCharacterName>`. This is an ACE server command
and forwards as text. You should land in physical overlap.
3. **Immediately** type `@pklite`. Do not let time pass — ordinary collision
resolution will otherwise have already pushed you apart, leaving nothing to
bump.
4. Wait ~1-2 s for the PK Lite entry animation. ACE then runs
`ethereal_check_for_collisions()`, resolves with
`SetPositionSimple(..., sliding: true)`, bumps `ObjectForcePosition`, and
sends the correction.
**The two named behaviour changes:**
You should see a short slide off the other character. That slide is the
ForcePosition.
1. **The ack now fires after the canonical commit, not before it.** Previously
the client told ACE "got it, I'm here" before deciding where "here" was.
Symptom of a regression: ACE re-sending corrections, or a visible fight
between client and server position after the bump.
## What to watch — in priority order
2. **The ForcePosition route no longer re-arms the constraint leash.** Retail's
force branch returns at `0x0045409D`, ahead of all three `ConstrainTo` call
sites (`0x00454272`, `0x0045418A`, `0x004541EC`); our old `BlipPosition`
re-armed the leash citing a branch it was not on. #167 was a leash bug, so
this is the change most worth your eyes if you get a bump. Symptom of a
problem: after the bump, movement feels tethered, rubber-bands back toward
the pre-bump spot, or the leash trips on ordinary running shortly after.
**1. Your facing must not change.** Retail's force branch replaces the
destination heading with your current heading before placing
(`HandleReceivedPosition` @0x00453FD0, `Frame::set_heading` @0x00454068). If
your character or camera snaps to a new heading on the bump, that is a failure.
## Known, deliberately unchanged
**2. No double-apply.** You end at the corrected position and stay there. A
visible snap-then-yank-back over one or two frames means the old duplicate
writer is somehow still live.
Retail's `SmartBox::PlayerPositionUpdated` (@`0x00453870`) gates its
`set_viewer` / `LScape::update_viewpoint` re-seat on
`distance >= GetAutonomyBlipDistance` (`0x004538C0-0x004538E2`). We publish the
render root unconditionally. Not changed in this slice; recorded here so the
next reader does not rediscover it as a bug.
**3. The leash.** This is the change most worth your eyes, because #167 was a
leash bug. Route 2 stopped re-arming the constraint leash on this route —
retail's force branch returns at `0x0045409D`, ahead of all three `ConstrainTo`
sites, and our old code cited a branch it was not on. After the bump, run
around normally for a bit. Symptoms of a problem: movement feels tethered,
rubber-bands toward the pre-bump spot, or the leash trips during ordinary
running shortly afterwards.
**4. Blast radius.** Route 2 deleted `BlipPosition` and `BlipLocalPlayer` and
re-routed the local player's accepted placement through the canonical
transaction, so confirm ordinary play is untouched: running and turning,
walk/run toggle, a jump and landing, a doorway into an interior and back out,
a portal recall, and a dungeon portal. Anything wrong there is route 2's fault
even though route 2 did not intend to touch it.
## Known and deliberate
- **AD-62.** A ForcePosition our async collision publication cannot carry to a
committed placement is not re-applied. Retail has no park — its world is
fully resident and its placement synchronous — so the state is unreachable
there. In practice the next accepted Position (ACE broadcasts at 5-10 Hz)
carries the corrected pose forward.
- **Acceptance item 2 is not met** (#292): the App-layer double-write check is
a source pin, and "the committed projection moves the render entity" is
uncovered at any layer. Recorded, not claimed.
- Retail's `SmartBox::PlayerPositionUpdated` @0x00453870 gates its viewer
re-seat on `distance >= GetAutonomyBlipDistance`; we publish the render root
unconditionally. Not changed in this slice.