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:
parent
69ba9486b6
commit
3ef61eb653
1 changed files with 108 additions and 107 deletions
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue