diff --git a/docs/research/2026-08-03-c4-route-2-visual-gate.md b/docs/research/2026-08-03-c4-route-2-visual-gate.md index 10dca1f2..ce98a949 100644 --- a/docs/research/2026-08-03-c4-route-2-visual-gate.md +++ b/docs/research/2026-08-03-c4-route-2-visual-gate.md @@ -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 `. 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.