docs/KH2_CONTROL_CLI.md

KH2 Control CLI

kh2ctl is the first local automation surface for KH2 testing in this repo. It lives inside kh2-multiplayer rather than ../kh2-tools because the first version needs direct reuse of:

  • runtime/include/kh2coop/GameBridgePC.hpp
  • common/include/kh2coop/InputMailbox.hpp
  • runtime/include/kh2coop/KH2Offsets.hpp

Once the command surface stops moving, it can be extracted if needed.

Build

cmake --build build --target kh2ctl --config Release

Expected output path on Windows is usually one of:

  • build/Release/kh2ctl.exe
  • build/kh2ctl.exe

Internal PC2 rig packets can build kh2ctl_diagnostic (excluded from the default build). This separate product requires the same package-root cwd and bin/ location as portable launch. Every diagnostic requires one explicit positive decimal PID, a nonzero creation-time match in that package's build/rig/owned.txt, and a live native KH2 image handle retained through execution. Its commands are limited to mute, capture, player-press, player-input, peek, poke, state, entities, warp and dump; launch/kill/instances remain the ordinary portable helper's job. The full checkout CLI and distributed friend helper keep their existing behavior. See PC2_PACKET_RETARGETING.md for packaging and review requirements.

What It Can Do

Process and state control

  • Launch KH2 with the current inject DLL build loaded (no Cheat Engine)
  • Track which KH2 processes the rig launched, and kill only those
  • Restart KH2 (kill rig instances, rebuild the DLL, launch, inject)
  • Attach to the KH2 process
  • Read room state and actor state
  • Wait for title/loading
  • Wait for in-game
  • Wait for a specific world/room
  • Focus the KH2 window

Host world recovery request

kh2ctl world-resync --pid N --slot 1
kh2ctl world-resync --pid N --slot all

--pid must explicitly identify an admitted host mapping; --slot accepts 1, 2 or all. This opens only an existing compatible WorldBridge 11 mapping, without attaching to or reading/writing native game memory. A separate bounded CAS mailbox preserves the captured generation, delivery serial and host connection. A busy mailbox fails explicitly; the CLI never becomes a second producer of the DLL's packet ring. The runtime rechecks that identity before requesting the protocol 9 transaction.

queued:true,nativeConvergence:false confirms only local queue submission. Fresh capture, staged bootstrap and actual native convergence must succeed separately; dead/bootstrap ambiguity reports unavailable. Cancellation unarms native world authority, and late attachment retains bounded newer continuation. See FORCED_RESYNC.md for the contract and open native/remote acceptance. Current versions are protocol 9, AvatarBridge 2, WorldBridge 11 and CaptureChannel 1. The relay defaults to native traffic; --simulate opts into legacy simulation and cannot supply native capture.

Menu/title interaction

  • Send keyboard taps and holds to KH2
  • Run a basic load-save macro that uses confirm/down keys and then waits for KH2 to enter a room

Friend-slot gameplay control

  • Send mailbox-driven input pulses to Friend1 and Friend2
  • Send convenience move and press actions for friend slots

This reuses the existing inject mailbox path. The inject DLL must be loaded for friend-slot movement/button commands to have any visible effect.

Player / slot-0 gameplay control

  • Send native slot-0 raw pad pulses through the inject DLL's input collector
  • Move Sora with left-stick pulses
  • Drive camera with right-stick axes
  • Press raw controller buttons like confirm, cancel, cross, circle, triangle, square, l1, r1, start, and dpad directions

These commands use the mailbox + input-collector hook path rather than foreground-window key injection.

Current Limitation

That means:

  • The load-save/boot-load-save commands are still menu-key driven, so they need window focus. player-press also works on the title menu and the save list (D-pad down/up, cross to confirm) with no focus, since the DLL's mailbox timers run on wall-clock time, not gameplay frames (2026-10-02). The scenario runner's boot step uses that.
  • GameBridgePC::InjectOwnedInput(...) remains the long-term path for an external process API if we decide to move the control logic out of the inject DLL later

Friend-slot commands are more reliable because they go through InputMailbox -> kh2coop_inject.dll -> friend AI replacement.

Commands

All successful commands print a single JSON object to stdout.

Launch, inject and instances (the rig)

kh2ctl launch                   # launch KH2, inject the current DLL build
kh2ctl launch --no-inject       # refused: boot requires the save guard
kh2ctl inject --pid 1234        # inject into a running KH2
kh2ctl instances                # list KH2 processes and whether the rig owns them
kh2ctl kill --pid 1234          # kill one rig-launched instance
kh2ctl kill --all               # kill every rig-launched instance

launch starts the exe directly (steam_appid.txt in the game directory skips the launcher; Steam must be running) with CREATE_SUSPENDED, then injects with LoadLibraryW from a remote thread. The main thread resumes only after the DLL acknowledges a complete save guard and successful hook initialization. Missing/older DLLs, failed hooks, timeouts and exceptions terminate only the new owned child. It then waits for the window and reports the process id, per-PID log and hooks, with saveGuardBeforeResume:true. Revision 3 also returns preinject:{ackWaitMs,ackObserved,resumePrevCount,resumedAtTick,clock} and launcherLog, which contains the same per-PID receipt. resumedAtTick is the QPC counter sampled immediately after ResumeThread returns; clock is QPC. The DLL logs [saveguard] ack signalled with PID/QPC just before SetEvent, after full successful initialization. The first hooked Present entry logs [render] first Present; live qualification requires guard install before that entry and ack QPC strictly before resume QPC:

{"ok":true,"command":"launch","processId":70260,"dll":".../build/rig/dll/kh2coop_inject_<ms>.dll",
 "log":".../build/rig/logs/kh2coop_inject_70260.log","hooksInstalled":true,
 "hooks":["PerEntityUpdate hook installed","MovementDispatch hook installed at RVA 0x3D5E50",
          "MotionChainSetAnim hook installed at RVA 0x3C88C0","InputCollector hook installed"],"errors":[]}
  • DLL copies. Each injection loads a fresh copy of build/inject/staging/kh2coop_inject.dll from build/rig/dll/, so the DLL can be rebuilt while instances are running. Nothing is written to the game directory. --dll PATH injects a different build.
  • Logs. launch sets KH2COOP_LOG_DIR for the child, so the DLL logs to build/rig/logs/kh2coop_inject_<pid>.log. inject --pid into a process the rig didn't launch logs to kh2coop_inject_<pid>.log in the game directory.
  • Ownership. Launched processes are recorded as (pid, creation time) in build/rig/owned.txt. kill only terminates those; anything else is reported in skippedUnowned and left alone, because it means James is playing.
  • Options: --game-dir DIR (or KH2_GAME_DIR), --window-timeout-ms, --settle-ms (wait after the window appears, default 1500), --init-timeout-ms (1..60000, default 15000; bounds the pre-resume acknowledgement and the post-window hook-log check independently).
  • --no-inject is refused, including through restart and boot-load-save. Attaching with inject --pid protects only subsequent operations; it cannot retroactively protect boot writes. Suspended KH2/Steam startup still requires separate live qualification; the offline controls use a harmless test process.

Several instances

KH2 has no single-instance lock: launch can be run repeatedly, and three instances have run in-game at once (VUH-1484). Each uses ~640 MB of RAM.

  • Pick an instance with --pid. Every game command (state, wait-*, focus, tap-key, player-*, friend input, …) takes --pid N. With more than one KH2 running and no --pid, kh2ctl refuses instead of guessing, so it can't drive an instance James is playing.
  • Unfocused instances keep running and accept player-* / friend input through their own PID-keyed mailbox. Keyboard commands (tap-key, load-save) focus the target window first, so menus are driven one instance at a time.
  • Audio. kh2ctl mute --pid N mutes an instance's Windows audio session (--off unmutes). The session exists once the game has played sound, so mute after the title screen comes up.
  • Controllers. Every instance still reads every physical pad, focused or not. Per-instance pad assignment isn't built yet.
  • restart and boot-load-save kill all rig instances before launching one.

Mute

kh2ctl mute --pid 1234          # mute
kh2ctl mute --pid 1234 --off    # unmute

Screenshots, clips and the overlay (in-renderer)

kh2ctl capture --pid 1234                     # PNG -> build/rig/shots/<pid>_<ms>.png
kh2ctl capture --pid 1234 --out shot.png
kh2ctl clip --pid 1234 --seconds 4 --fps 30   # MP4 -> build/rig/clips/<pid>_<ms>.mp4
kh2ctl overlay --pid 1234 on                  # pid, frame, world/room, fps box
kh2ctl fps --pid 1234                         # present rate over 2 s

The inject DLL hooks the swapchain's Present/Present1 and copies the backbuffer on the GPU, so captures work while the window is behind other windows or unfocused. Minimized windows are untested (the game may stop presenting). KH2 renders with D3D12 (1920×1080 B8G8R8A8_UNORM, 3-buffer flip model, presents via Present1); the D3D11 path isn't implemented.

  • Requests travel through Local\kh2coop_capture_<pid> (common/include/kh2coop/CaptureChannel.hpp).
  • Copies run on the game's own direct queue, captured by hooking ExecuteCommandLists, and are read back 1–2 frames later, so the render thread doesn't wait on the GPU. PNG/BMP encoding runs on a worker thread.
  • clip writes BMP frames, encodes them with ffmpeg (must be on PATH), then deletes the frames (--keep-frames keeps them). It reports gameFpsBefore and gameFpsDuringCapture; measured 2026-10-02: 66 → 68 fps for a 4 s, 30 fps clip at 1920×1080.
  • The overlay is drawn into the backbuffer, so it shows on screen and in captures. It's off by default.

Successful capture JSON retains processId, path, width and height, and now includes expectedRequestSeq, doneSeq, nativeStatus, framesWritten, renderer and backbufferFormat. The expected sequence comes from this actual request's submission; completion fields are copied and checked against that sequence while the existing caller lease is held. Error paths do not emit a successful completion receipt. clip keeps its existing response and behavior. These are mailbox/render-path observations, not an atomic gameplay snapshot or native frame/generation attestation. Retain the actual JSON with the PNG; a historical path-only report cannot be backfilled with another capture's fields.

Existing bridge identity observations

build/Release/avatarctl.exe observe --pid 1234 --samples 2 --interval-ms 250

observe requires an explicit PID and opens only existing compatible AvatarBridge 2 and WorldBridge 11 mappings. It does not create or repair a missing mapping. Samples are bounded to 1..30 with intervals 0..1000 ms. Each sample freshly reads checked local/puppet seqlocks and brackets them with repeated world slot, generation, delivery, roster, peer-floor and authority observations. Full puppet provenance is retained, with the existing admission predicate reported separately as provenanceMatchesWorld.

ok:true means all requested mapping, bracket and slot reads were available; it does not mean tags match, authority is Network or native gameplay is ready. Stale tags and Unavailable authority remain visible. Missing, incompatible, unpublished, torn or changing observations return incomplete samples, null unavailable payloads and exit 1. No payload is cached across samples. This is semantic read-only use of the existing bridge APIs, not an OS read-only view or globally atomic snapshot. Mapping/actor incarnation and native application remain unverified. Consume 64-bit JSON identities with an integer-preserving parser.

Ten actual CLI controls over owned artificial mappings passed, including whole-mapping byte equality and teardown; they are not KH2 gameplay evidence. See build/rig/avatar_observe_owned_receipt_20261003.json.

The runtime also emits bounded [runtime-identity] schema=1 lines at existing admission, reset, retirement, retry and delivery boundaries. Fields distinguish cached admission, runtime-owned generation/delivery/roster, bridge-header authority and retained resume pin. Every line keeps nativeBootstrapReady=unverified atomicBinding=0. A 512-record cap emits an explicit gap and shutdown summary; abrupt exit may omit that summary. These observations do not prove reconnect recovery or native readiness. No retry or authority policy changed.

Warp (load a room)

kh2ctl warp --pid 1234 --world 4 --room 0x1A               # Garden of Assemblage
kh2ctl warp --pid 1234 --world 5 --room 6 --btl 2          # override one program
kh2ctl peek --pid 1234 --rva 0x717008:u8,0x9BA928:u64 --samples 40   # RE aid

peek keys each sample by RVA, without the requested type. Requesting different types at the same RVA produces duplicate JSON keys; decoding can retain only the last value. Use separate calls or reject mixed-type aliases before a batch. The scenario runner's raw occupancy collector rejects them explicitly.

warp hands the target to the DLL, which calls the game's own transition request (the function room-script Jumps use, RVA 0x152990) on the game thread at the start of a frame. Programs left out (0xFFFF) come from the save's per-room table. It returns once the room has loaded: the DLL's frame counter stalls during the load (~0.55 s) and must resume in the target room for 30 frames. World/room in NOW change as soon as the request is made, so they alone don't show the load finished. Measured 2026-10-02 with the safe-state gate: 50/50 warps across 13 rooms in Twilight Town, Hollow Bastion (incl. GoA 04/1A) and Beast's Castle, including two combat rooms (05/00, 05/06; 4 enemies each spawned on the first visit), 1.0–3.3 s each. Sora could move after every one.

  • Rooms that open on a cutscene (05/02, 05/08 with default programs) load, but Sora can't move. Avoid them as fixtures.
  • Combat rooms: the battle program alone doesn't spawn enemies; mob rooms spawn them when Sora walks in. On the BC-first-visit save, the Entrance Hall (05/00) and courtyard (05/06, doors 0–1) spawn 4–5 Heartless after ~2 s of running, and 05/04 spawns 1. Battle state 0x2A11404 reads 1 (regular) or 2 (forced). Scripted fights come from event programs: --world 5 --room 1 --evt 1 (Parlor Ambush, 8 Shadows, forced battle). --world 8 --room 0x0C --evt 1 (Attack on the Camp) plays an ~85 s cutscene with no fight on that save. Mob rooms hurt Sora, so don't idle in them.
  • Test fixtures: kh2ctl poke --pid N --addr <actor+0x4DC> --type u32 --value 0 makes Sora untouchable (team 0 is in no attack's hit mask), so combat-room tests don't end in a game over. poke only writes to rig-launched instances.
  • Enemy detection: kh2ctl state lists actors whose objentry type (objentry+0x04) is 3 (boss) or 4 (mob). kh2ctl entities lists every actor with name, type, team (actor+0x4DC: 1 party, 2 enemy) and move state.
  • Contiguous XYZ fixture write: source now supports poke --pid N --addr <actor+0x670> --type f32x3 --value "555,-1100,-2356". It requires exactly three finite, in-range decimal float32 values without whitespace, an unsigned decimal or hexadecimal address/RVA, and a valid 12-byte address span. The existing PID creation-time ownership check remains. Exactly one 12-byte WriteProcessMemory request must return success and 12 written bytes; there is no retry or rollback and no atomicity or position-persistence claim. Scalar poke behavior is unchanged. The reviewed private CLI passed 94 normal and 94 Windows-ASan controls and the bounded 004941 live control; later 012309 correctly refused a differing XYZ readback. Accepted old EXEs remain frozen; rebuild source or select the reviewed private CLI explicitly to use f32x3. See the memory-code review and later control FAIL.
  • Safe-state gate: a request is handed over only when nothing is frozen (0x2A171E8 == 0), the room is live (0x9BA8D0 != 0), no menu is open (0x7435D0 == 0xFF), timeline state is idle (0xB65210 == 0) and the active event-context pointer is null (0x2A11478 == 0). The elapsed timer (0xB64F98) remains a diagnostic: a native chest event left it at 90 after completion, so it cannot indicate whether an event is active. It's never handed over mid-load either, because hand-over only happens inside entity updates. Verified live: a warp requested in the pause menu was held, then cancelled at its timeout. During an event the frozen bitset reads 3 and the cutscene timer counts up. A held request is cancelled when --timeout-ms runs out, and the error reports the gate inputs.

Crash evidence

kh2ctl dump --pid 1234 --out hang.dmp   # minidump of a live (e.g. hung) instance
kh2ctl crash --pid 1234                 # fault a rig-launched instance on purpose

The inject DLL writes build/rig/logs/kh2coop_crash_<pid>.dmp itself when the game dies from an unhandled exception (its filter is chained ahead of the game's own). crash starts a remote thread at address 0 and only targets instances the rig launched; it exists to test the scenario runner's crash bundles (docs/SCENARIOS.md).

Hit ownership (VUH-1501)

kh2ctl hit --pid 1234 drop --on --enemies                  # client: zero all hits on enemies
kh2ctl hit --pid 1234 drop --on --attacker 0x7FF6... --victim 0x7FF6...
kh2ctl hit --pid 1234 claims --last 16                     # what the dropped hits were
kh2ctl hit --pid 1234 damage --victim 0x7FF6... --amount 7 # host: apply a claim
kh2ctl hit --pid 1234 kill --victim 0x7FF6...              # synthetic killing blow
kh2ctl hit --pid 1234 drop --off

Actor addresses come from kh2ctl entities. The DLL logs hits as they're built ([hit] lines in the inject log: attacker, atkp id, victim, damage) from a post-hook on the hit builder 0x3D23C0: the first 60, plus the first 60 whose attacker is a boss (B_ objentry), so a route's earlier fights never use up the lines that show a boss's attacks. The drop filter pre-hooks ApplyHitDamage 0x3D3BA0(victim, hit) and zeroes the hit's damage (hit+0x28) when the attacker/victim match (0 = any; --enemies limits it to objentry type 3/4 victims). Each dropped hit goes into a 64-slot claim ring with its original damage. damage calls TakeDamage 0x3D5E50(victim, -amount, 0, 1) and kill calls ApplyStatDelta 0x3D2EB0(victim, -hp, 0, 0), both on the game thread at the next frame start, and only for an actor in the current entity list. Verified 2026-10-02 in the BC courtyard: with --enemies, a 20-press combo left all Shadows at 20/20 and recorded 12 claims; replaying them with damage left each Shadow at exactly 20 minus its claimed total; kill took a Shadow from 20 to 0, and it died and left the entity list; filtering one Shadow → Sora zeroed only that Shadow's hit.

Notes: attack owner handles (ATTACK+0x10) are resolved with the engine's own 0x4AD270(handle) (byte-guarded). If that function doesn't match, the fallback matches the handle's low 24 bits against actor addresses, which is what they carry. The hit builder also builds heals: Goofy's atkp 1525 "damage" 20 restored Sora without going through ApplyStatDelta, so --enemies (or an explicit victim) keeps heals out of the filter.

Restart

kh2ctl restart              # kill rig instances, rebuild the DLL, launch + inject
kh2ctl restart --no-build
kh2ctl restart --kill       # kill rig instances only
kh2ctl restart --no-inject   # refused

restart refuses ("phase":"preflight", exit 1) when a KH2 process the rig didn't launch is running. It never runs scripts/restart-kh2.ps1, which kills every KH2 process and is only for humans.

State and waits

kh2ctl state
kh2ctl wait-title
kh2ctl wait-ingame
kh2ctl wait-room --world 2 --room 1

Window and key input

kh2ctl focus
kh2ctl tap-key --key enter
kh2ctl hold-key --key down --duration-ms 750

Keys are sent with their real scan codes (extended flag for arrows and navigation keys). KH2 reads scan codes; before 2026-10-01 kh2ctl sent scan code 0, so every key, Enter included, reached the game as the same key and the menu macros misbehaved.

Title menu (verified 2026-10-01): down then enter on a clean title opens the save list. The save list opens on the most recently used slot, not slot 1.

Save-load macro

kh2ctl load-save --slot 1
kh2ctl load-save --slot 3 --confirm-key enter --down-key down

This macro is intentionally configurable because exact menu timing is machine and game-state dependent:

  • --wake-presses
  • --wake-delay-ms
  • --step-delay-ms
  • --post-select-delay-ms
  • --final-confirm-presses
  • --load-timeout-ms

Full boot macro

kh2ctl boot-load-save --slot 1
kh2ctl boot-load-save --slot 2 --no-build

This is the first end-to-end "get me to a playable room" command. It:

  1. Runs restart (refuses if an unowned KH2 is running)
  2. Waits for KH2 to reach title/loading state
  3. Drives the save menu
  4. Waits until KH2 is in a live room again

Known broken (2026-10-01). Step 2 passes as soon as the world id reads 0xFF, which is also true during the boot logos and intro, so the menu keys land before the title menu exists. The title menu (NEW GAME / LOAD / BACK) also wraps and doesn't always start on NEW GAME, so a fixed key count can't reach LOAD reliably. The save list opens on the last-used slot, so counting down presses from slot 1 picks the wrong save. It needs a title-menu-ready signal and the cursor indexes from memory (asked of the offline lane on VUH-1488). Until then, drive the menu step by step with tap-key and check each step with a screenshot. From a clean boot, the cursor starts on NEW GAME.

wait-ingame has a false positive too: the title's idle demo (the opening monologue) reads world 1 / room 1, and that value stays in memory after the demo is skipped, so wait-ingame can pass at the title.

Player Input Commands

Raw slot-0 pulse

kh2ctl player-input --buttons confirm --duration-ms 120
kh2ctl player-input --lx 0.0 --ly 1.0 --rx 0.5 --ry 0.0 --duration-ms 500

Stick movement is camera-relative. In BC run 204320, repeated four-second --lx -1 --ly 0 pulses produced an orbit as the camera turned. A walk-away fixture must check actual world displacement; command duration alone does not show that Sora moved far from a wave. Use short forward/steering segments and read positions before claiming a distance-triggered cull.

The scenario walk_to step steers toward world x/z with 200ms native pulses using the same camera basis as the GoA approach. It records measured displacement and enemy observations; a timeout alone does not prove a cull. net_population_soldier_alive_removal.json uses this walk and an authorized warp fallback. Its passing 20261013-014659 run moved 928 units without a cull, then the GoA warp removed all three living Soldier copies on host and friend, with zero host death events. Same-room distance culling remains unclaimed.

Accepted player button names include:

  • confirm, cross, a
  • cancel, circle, b
  • square, x
  • triangle, y, menu
  • l1, lb
  • r1, rb, lockon
  • start, select, back
  • dup, ddown, dleft, dright

Raw slot = PS2 DualShock 2 layout (fixed 2026-10-02). The right stick comes first (+0x02/+0x03), then the left (+0x04/+0x05). Buttons use PS2 bit order: Select 0x0001, L3 0x0002, R3 0x0004, Start 0x0008, Up 0x0010, Right 0x0020, Down 0x0040, Left 0x0080, L2 0x0100, R2 0x0200, L1 0x0400, R1 0x0800, Triangle 0x1000, Circle 0x2000, Cross 0x4000, Square 0x8000. KH2Offsets.hpp and kh2ctl's button names now follow it. Verified: --ly 1 moves Sora, and player-press --button start opens the pause menu (open-menu id 0x0A). Before the fix, the stick labels were swapped and "start" was D-pad Up.

The DLL now retries the mailbox every ~6 frames (was ~120), so short pulses are no longer missed.

Convenience wrappers

kh2ctl player-move --x 0.0 --y 1.0 --duration-ms 750
kh2ctl player-press --button confirm --duration-ms 100

Friend Input Commands

Raw mailbox pulse

kh2ctl input --slot friend1 --lx 0.0 --ly 1.0 --duration-ms 500
kh2ctl input --slot friend2 --buttons attack,jump --duration-ms 120

Convenience wrappers

kh2ctl move --slot friend1 --x 0.5 --y 1.0 --duration-ms 750
kh2ctl press --slot friend2 --button attack --duration-ms 100

MCP Wrapper

A thin stdio MCP server is provided at:

  • tools/mcp_kh2ctl/server.py
  • scripts/run-kh2ctl-mcp.ps1

It shells out to kh2ctl and returns the parsed JSON result from each command.

Run it directly with:

python tools\mcp_kh2ctl\server.py
# or
.\scripts\run-kh2ctl-mcp.ps1

If the server cannot find the built CLI automatically, set:

$env:KH2CTL_BIN="<repo>\build\Release\kh2ctl.exe"

Separate internal clip v2

The separate kh2ctl_clip_v2 accepts exactly default3s90 or explicit16s480. V1/clip04 is unchanged. Short mode retains inline finalization. Long mode captures raw frames only within min(original deadline,start+23s), then requires independent gameplay/game/runtime/helper closure before a separate owned encode/full-decode job with an explicit deadline of at most600s. Neither gameplay90s nor JOIN600 is renewed. Second capture requires the original post-destructor release journal, raw inventory and helper closure. See tools/rig/pc2_clip_v2/README.md for contracts, controls and measured limits. New product review and native validation remain pending.

Every throwing operation after suspended encoder/verifier creation is covered by original-handle cleanup, including the first clock read before job assignment. Failure cleanup does not read the clock again: it terminates the retained process or private job and waits at most two seconds for the original process. Termination or wait uncertainty reports cleanup UNKNOWN. The new clock_after_spawn native control injects failure before job assignment and checks original absence plus survival of a separate owned fixture outside that job. This control is authored but unexecuted pending a fresh native validation slot; the earlier 13/13 result applies only to d559eaa.