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) # C4 route 2 — ForcePosition: connected visual gate (2026-08-03)
The user-facing acceptance test for route 2. Route 2 is code-complete and Hand-off for the user-facing acceptance test. Two commits are in scope:
suite-green before this runs; this document is the hand-off.
## 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 Complete Release suite green at 10,867 / 4 skipped / 0 failed. Both Opus
displacement"*. That is wrong about which route it exercises, and following it reviews PASS on route 2's final diff.
would have produced a false pass.
Verified in ACE this session: `PositionPack`'s constructor ## Why `@pklite` is the lever
(`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.
`SequenceType.ObjectForcePosition` is advanced at exactly **two** places in the ACE advances `SequenceType.ObjectForcePosition` in exactly two places
whole ACE tree: (`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 **Admin teleports do NOT produce a ForcePosition.** `@teleto`, `@teletome`,
entry collision bump. `@teleloc`, `@movetome` all route through `Teleport()` / `SendUpdatePosition(true)`
2. `references/ACE/Source/ACE.Server/WorldObjects/Player_Tick.cs:488` — the and advance `ObjectTeleport` instead (`PositionPack.cs:49-52`). Those exercise
anti-cheat z-position rubber-band. 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 ## Read this before you start — the state change is one-way
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.
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 Also possible: `+Acdream` may already be PK or PKLite, in which case step 3
retail's `@pklite`. That is a **client** command, not a server one — ACE has no answers with the rejection message immediately. That is still a valid test of
`pklite` text-command handler, so typing `@pklite` into chat today forwards as the gate — just not of the bump.
inert text. Retail's client turns it into a game action instead:
- `ClientCommunicationSystem::DoPKLite` @`0x0057A490` ## Launch
(`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.
acdream does not implement it, and **the user deferred implementing it Release, retail UI, per the project's standing rule for visual gates.
(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`.
**Consequence, stated plainly: route 2's own behaviour is NOT visually ```bash
verifiable in this campaign.** Nothing a user can do makes ACE emit a dotnet build -c Release
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.
## 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 ## Step 1 — `@pklite` argument guard (changes nothing)
at `127.0.0.1:9000`.
Route 2 deletes `PlayerMovementController.BlipPosition` and Type `@pklite foo`.
`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:
- Ordinary running, turning, walk/run toggle. No tethering, no rubber-band, no Expect a local message pointing you at `@help pklite`, and **nothing sent to
drift against the server. the server**. Retail's `DoPKLite` returns early on any trailing argument text
- A jump and a landing. without building the game action.
- 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.
Anything wrong there is route 2's fault even though route 2 did not intend to ## Step 2 — the command is recognised, not forwarded as chat (changes nothing)
touch it.
## 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 If `+Acdream` is already PK or PKLite you will get *"Only Non-Player Killers
(`Player_Tick.cs:459-490`). It needs: same landblock as your last ground may enter PK Lite"* here. That is the correct retail rejection (WeenieError
contact, a claimed Z more than 10 units above it, more than a second since your `0x507`) and proves the gate works; it also means the bump in step 3 is not
last jump, Jump skill under 1000, and the server still flagging you airborne. available on this character.
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 that line appears, you have a genuine ForcePosition and the checks below ## Step 3 — the ForcePosition bump (one shot; changes character state)
apply. If it does not, the route is untested by observation — which is the
expected outcome.
**If a ForcePosition does occur, must be true:** 1. Bring a second character online standing somewhere reachable. The retail
- You end up at the corrected position and stay there. No visible double-apply, client in parallel is ideal — it doubles as the two-client observation.
no snap-then-yank-back over one or two frames. 2. On `+Acdream`, `@teleto <OtherCharacterName>`. This is an ACE server command
- **Your facing does not change.** Retail's force branch replaces the and forwards as text. You should land in physical overlap.
destination heading with your current heading before placing 3. **Immediately** type `@pklite`. Do not let time pass — ordinary collision
(`HandleReceivedPosition` @`0x00453FD0`, `Frame::set_heading` at resolution will otherwise have already pushed you apart, leaving nothing to
`0x00454068`). bump.
- Exactly **one** outbound `AutonomousPosition` for the correction. 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 ## What to watch — in priority order
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.
2. **The ForcePosition route no longer re-arms the constraint leash.** Retail's **1. Your facing must not change.** Retail's force branch replaces the
force branch returns at `0x0045409D`, ahead of all three `ConstrainTo` call destination heading with your current heading before placing
sites (`0x00454272`, `0x0045418A`, `0x004541EC`); our old `BlipPosition` (`HandleReceivedPosition` @0x00453FD0, `Frame::set_heading` @0x00454068). If
re-armed the leash citing a branch it was not on. #167 was a leash bug, so your character or camera snaps to a new heading on the bump, that is a failure.
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.
## 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 **3. The leash.** This is the change most worth your eyes, because #167 was a
`set_viewer` / `LScape::update_viewpoint` re-seat on leash bug. Route 2 stopped re-arming the constraint leash on this route —
`distance >= GetAutonomyBlipDistance` (`0x004538C0-0x004538E2`). We publish the retail's force branch returns at `0x0045409D`, ahead of all three `ConstrainTo`
render root unconditionally. Not changed in this slice; recorded here so the sites, and our old code cited a branch it was not on. After the bump, run
next reader does not rediscover it as a bug. 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.