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.hppcommon/include/kh2coop/InputMailbox.hppruntime/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.exebuild/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-savemacro that uses confirm/down keys and then waits for KH2 to enter a room
Friend-slot gameplay control
- Send mailbox-driven input pulses to
Friend1andFriend2 - Send convenience
moveandpressactions 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, anddpaddirections
These commands use the mailbox + input-collector hook path rather than foreground-window key injection.
Current Limitation
That means:
- The
load-save/boot-load-savecommands are still menu-key driven, so they need window focus.player-pressalso works on the title menu and the save list (D-paddown/up,crossto confirm) with no focus, since the DLL's mailbox timers run on wall-clock time, not gameplay frames (2026-10-02). The scenario runner'sbootstep 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.dllfrombuild/rig/dll/, so the DLL can be rebuilt while instances are running. Nothing is written to the game directory.--dll PATHinjects a different build. - Logs.
launchsetsKH2COOP_LOG_DIRfor the child, so the DLL logs tobuild/rig/logs/kh2coop_inject_<pid>.log.inject --pidinto a process the rig didn't launch logs tokh2coop_inject_<pid>.login the game directory. - Ownership. Launched processes are recorded as (pid, creation time) in
build/rig/owned.txt.killonly terminates those; anything else is reported inskippedUnownedand left alone, because it means James is playing. - Options:
--game-dir DIR(orKH2_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-injectis refused, including through restart and boot-load-save. Attaching withinject --pidprotects 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 Nmutes an instance's Windows audio session (--offunmutes). 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.
restartandboot-load-savekill 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. clipwrites BMP frames, encodes them withffmpeg(must be onPATH), then deletes the frames (--keep-frameskeeps them). It reportsgameFpsBeforeandgameFpsDuringCapture; 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/08with 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, and05/04spawns 1. Battle state0x2A11404reads 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 0makes Sora untouchable (team 0 is in no attack's hit mask), so combat-room tests don't end in a game over.pokeonly writes to rig-launched instances. - Enemy detection:
kh2ctl statelists actors whose objentry type (objentry+0x04) is 3 (boss) or 4 (mob).kh2ctl entitieslists 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-msruns 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:
- Runs
restart(refuses if an unowned KH2 is running) - Waits for KH2 to reach title/loading state
- Drives the save menu
- 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,acancel,circle,bsquare,xtriangle,y,menul1,lbr1,rb,lockonstart,select,backdup,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.pyscripts/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.
