# C4 route 2 — ForcePosition: connected visual gate (2026-08-03) Hand-off for the user-facing acceptance test. Two commits are in scope: - `9966b531` — C4 route 2, ForcePosition through the canonical placement. - `69ba9486` — retail's `@pklite` client command, the lever this gate needs. Complete Release suite green at 10,867 / 4 skipped / 0 failed. Both Opus reviews PASS on route 2's final diff. ## Why `@pklite` is the lever 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). **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. ## Read this before you start — the state change is one-way 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. 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. 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. ## Launch Release, retail UI, per the project's standing rule for visual gates. ```bash dotnet build -c Release ``` ```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 ``` ## Step 1 — `@pklite` argument guard (changes nothing) Type `@pklite foo`. 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. ## Step 2 — the command is recognised, not forwarded as chat (changes nothing) 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. 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. ## Step 3 — the ForcePosition bump (one shot; changes character state) 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 `. 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. You should see a short slide off the other character. That slide is the ForcePosition. ## What to watch — in priority order **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. **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. **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.