acdream/docs/research/2026-08-03-c4-route-2-visual-gate.md
Erik 3ef61eb653 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>
2026-08-03 19:08:08 +02:00

6.3 KiB

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.

dotnet build -c Release
$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 <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.

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.