控制管道協議
PowerShell 客戶端(BeDD.psm1)只是這個協議的一個客戶端;任何能打開命名管道的程序都能以同樣方式與遊戲通信。
Invoke-BeDD @{ op = '...' } 原樣發送一個請求。
請求與回覆
\\.\pipe\<pipeName>(僅本機,默認 BeDD),UTF-8,每個方向每行一個 JSON 對象。id 會原樣帶回。回覆是
{"ok":true,...} 或 {"ok":false,"error":"..."}。輸入類操作在遊戲線程分發完輸入後才回復(timeoutMs,默認 5000)。
協議版本(ping.protocol)保持為 1:較新的操作通過調用來探測(見下文)。
| op | 字段 | |
|---|---|---|
ping | protocol、pid、hwnd、client{w,h}、cursor{x,y}、realForeground、managed | |
state | ping 的字段 + stats{synthetic, swallowedInput, swallowedFocus, passed, realForegroundSeen, api, gameInput, loopbackRebinds, serverQueriesRefused, audio, tapLoopback, send, journal}、clients(當前連接的控制管道客戶端數)、hooks{name: installed}(各功能安裝的每個遊戲鉤子;功能關閉時為 false) | |
key | key(名字)或 vk;action press|down|up;holdMs | 名字:字母、數字、ESC、ENTER、BACK、TAB、SPACE、SHIFT、CTRL、ALT、LEFT/UP/RIGHT/DOWN、HOME、END、PGUP、PGDN、INSERT、DELETE、F1..F24、SLASH、GRAVE、MINUS、EQUALS |
text | text;delayMs | 每個 UTF-16 單元一個 WM_CHAR |
mouse | action move|down|up|click|wheel;x,y(客戶區像素)或 rx,ry(比例);button left|right|middle;notches;holdMs | 先移動虛擬光標(給了座標時),再在虛擬 GameInput 鼠標上按按鈕/滾輪 |
raw | action move|down|up|click|wheel;dx,dy(鼠標計數);steps;button;notches;holdMs | 虛擬 GameInput 鼠標:鼠標被鎖定時轉視角 |
release | 鬆開所有按住的鍵和按鈕(不管是誰按的),並結束 input 移動覆蓋 | |
focus | 重新發送"獲得焦點"消息 | |
window | clientWidth、clientHeight | 從不激活窗口;窗口留在屏幕外 |
flush | 等到所有已投遞的輸入都分發完 |
多個客戶端
可以同時連接多個客戶端(配置 pipeInstances,默認 4;1 = 一次一個),每個由自己的線程服務。投遞窗口輸入的操作是串行的,
所以兩個客戶端的按鍵或輸入的文字不會交錯;ping、state、tap、world、look、input、send 和 stack 不加這把鎖
(它們只讀,或把工作交給客戶端線程)。一個客戶端斷開(或其進程退出)時,釋放它持有的東西,且不動其他客戶端持有的:
它的按鍵和鼠標按鈕、它發起的 button 按住、它設置的 input 覆蓋。管理窗口通過管理連接轉發的操作既不串行,也不歸屬任何客戶端。
對有按客戶端狀態的單元(src/BeDD.h):currentPipeClient() 是當前線程所服務客戶端的 id(從 1 開始,不復用;管理連接和遊戲線程上為 0)。
用它標記狀態,並在 src/Pipe.cpp 的 clientGone() 裡釋放(像 clearMoveInputOf 和 releaseGameButtonsOf 那樣),或在 enable()
裡註冊 onPipeClientGone(fn):兩者都在離開的客戶端線程上運行,在它的按鍵和按鈕釋放之後、輸入操作串行的情況下(關閉時不運行)。
給 bedboo 用的操作(bedd 傳輸)
這些操作在對應配置項打開前都是關閉的,此時回覆 {"ok":false,"code":"disabled","key":"<key>"}。錯誤可能帶 code:
disabled、not_in_level、refused、timeout、bad_args、busy。當前版本沒有的操作回覆 unknown op 'x'。
客戶端通過不帶參數調用每個操作一次來探測遊戲有什麼:unknown op = 沒有,code:"disabled" = 有但關著,ok = 有。
每個操作的無參數形式都是隻讀的。
| op | 配置項 | 無參數回覆 | 執行形式 |
|---|---|---|---|
tap | tap | enabled、pipe、attached(有讀取方連接)、link{state up|down, type remote|local, address}、journal{frames, bytes, complete}(當前加入日誌)、lastReplay(上一個讀取方回放了什麼)、replay(裝了日誌)、counters{in, out, dropped, sent, readers}、hooks | (無) |
world | gameHooks | inLevel、levelEpoch(每進入一個世界 +1)、topScreen(如 hud_screen、pause_screen)、mouseGrabbed、showingMenu、inputMode mouse|touch|gamepad|undefined、player{runtimeId, eye{x,y,z}, rot{yaw,pitch}, slot}(不在世界裡時為 null)、tick(見到的客戶端刻數) | (無) |
look | gameHooks | {inLevel, yaw, pitch, tick} | yaw、pitch、timeoutMs(1000,最多 60000) |
input | gameHooks | active、via hooks|keys、hooks{raw,state}、tick、applied | move{x,z}、jump、sneak、sprint、ttlMs(300) |
slot | gameHooks | {slot}(不在世界裡時為 null) | slot 0..8 |
action | gameHooks | {names} | name drop|dropAll|inventory|perspective|pause |
button | gameHooks | {held} | button attack|use|pick;action down|up|click;holdTicks;releaseOn blockChange |
send | allowSend | {allow}(sendAllow 裡的 id) | buf、timeoutMs(2000,最多 10000) |
stack | allowStack | {enabled, via:"raw", stats{sent, answered, rejected, failed, resyncs}} | buf、timeoutMs(2000);或 view:true |
world是客戶端線程拍的快照:界面字段每幀更新(ClientInstance::$update後置鉤子,因為暫停的單人世界不走刻),玩家字段每個客戶端刻更新。 管道線程只複製它。look在客戶端刻裡用LocalPlayer::localPlayerTurn轉動玩家,兩個角度都在 0.5 度以內時回覆yaw、pitch、tick、turns。 角度與 player_auth_input 一致:yaw 0 = +Z,90 = -X;pitch -90 = 向上,像遊戲一樣限制在 ±89.9。localPlayerTurn(d)按度 1:1 轉動, 軸互換({-dPitch, dYaw}),轉動在請求它的那一刻之後生效。錯誤:not_in_level、timeout(帶當前yaw/pitch;暫停的單人世界不走刻)、busy(被更新的look取代)、bad_args。input在遊戲自己的輸入管線裡移動本地玩家,所以每個player_auth_input都由客戶端自己構建。move是 player_auth_input 的移動向量 (x= 左 - 右,z= 前 - 後,各自限制在 [-1, 1];斜向會被歸一化)。這個覆蓋是一個"死人開關":請求後ttlMs(最多 60000)結束, 所以控制方想繼續移動時要不斷刷新;ttlMs:0、release和所屬客戶端斷開會立即結束它。via:"hooks":掛在ClientInputUpdateSystem::extractRawHIDInput(寫RawMoveInputComponent::mRawInput/mRawMove)和inputHandlerUpdatePlayerState(在原函數前後寫MoveInputComponent::mInputState/mRawInputState:MoveInputState標誌SneakDown=0、JumpDown=7、SprintDown=8、Up..Right=13..16 以及跳躍/潛行輸入邊沿 21..26,外加mAnalogMoveVector)的後置鉤子。 只在鼠標被鎖定且沒有界面顯示時生效;與按鍵綁定無關。鍵盤處理只在按鍵事件時改這些標誌,所以覆蓋結束後的第一刻會把它們清零一次。tick計數inputHandlerUpdatePlayerState的調用,applied計數寫入覆蓋的刻數。via:"keys"(某個鉤子沒裝上):默認鍵位的虛擬鍵(W/A/S/D、SPACE、LSHIFT、LCTRL)按住到截止時間。它們像key一樣經過窗口, 所以也會到達打開的界面,改過的鍵位會讓它失效。
slot、action、button:管道線程把它們排隊,下一個客戶端刻執行後回覆(timeoutMs,默認 1000):{"ok":true,"tick":n,...}, 或codenot_in_level、busy(有界面打開;action pause和button up仍可用)、timeout、bad_args、refused。 暫停的單人世界不走刻:在那裡action pause之後,用key操作(ESC)恢復。slot:ClientInputCallbacks::handleSlotSelectButtonPress;它不改格子時(1.26.51 裡鍵盤玩家就是這樣)改用默認鍵位的快捷欄鍵1..9。 客戶端自己發送mob_equipment。回覆slot和viacallback|keys。action:對應的ClientInputCallbacks::handle*ButtonPress(背包用CraftingType::Inventory)。button:虛擬 GameInput 鼠標上的左/中/右鍵,由刻監聽器在holdTicks後鬆開(click 默認 2),或者帶releaseOn:"blockChange"時,在按下時客戶端命中結果指向的方塊改變、變成空氣或不再被指向時鬆開(沒有指向方塊時為refused)。up立即生效。從不阻塞管道。回覆held和block{x,y,z}。
send:buf是一個服務端方向數據包的 base64(varint 頭 + 包體,與 bedrock-protocol 的序列化一致)。它在客戶端線程上重建 (MinecraftPackets::createPacket+readNoHeader,用客戶端 NetworkSystem 的ReflectionCtx),並通過本地玩家發送(sendNetworkPacket), 遠程服務器和單人世界都一樣;發出後回覆{packetId, name}。遊戲讀不完的字節為bad_args,什麼都不發;不在sendAllow裡的 id 為refused。state報告stats.send{sent, refused, failed, lastError}。stack:見下文。
安全
send 讓當前用戶下任何能打開管道的進程都能讓客戶端以登錄玩家身份發包,所以它默認關閉(allowSend),且限於 sendAllow
(默認 text 9 和 command_request 77:聊天和命令,和玩家能輸入的一樣)。id 1(login)、30(inventory_transaction)、144(player_auth_input)
和 147(item_stack_request)即使列出也始終被拒絕:它們歸客戶端所有,偽造的包會干擾它的預測和記賬。只在你控制的服務器上放寬 sendAllow。
allowStack 打開時,任何能打開管道的程序都能在客戶端所在的任何服務器上移動、合成、丟棄和銷燬登錄玩家的物品。tap 包含登錄玩家的整個會話。
除非測試框架需要,三者都保持關閉;管理管道從不打開它們。
stack:通過客戶端發送物品堆請求(allowStack)
| 形式 | 回覆 |
|---|---|
{op:"stack"}(探測) | {ok, enabled:true, via:"raw", stats:{sent, answered, rejected, failed, resyncs}} |
{op:"stack", buf, timeoutMs=2000} | {ok, via:"raw", requestIds:[客户端 id], map:{"<调用方 id>": 客户端 id}} |
{op:"stack", view:true} | {ok, inventory:[...], ui:[...], open?:{containerId, type, items:[...]}}:客戶端顯示的內容,每項 {slot, item, count, stackId} |
buf是一個完整 item_stack_request 包(id 147)的 base64,varint 頭加包體,與 bedrock-protocol 的序列化一致。其他包 id 以及遊戲讀不完的字節都是bad_args。- 下一個客戶端刻用
MinecraftPackets::createPacket和readNoHeader重建這個包,由本地玩家發送。 - 請求 id:每個請求獲得一個新的客戶端 id,包發出後回覆。id 是奇數、負數,從
-0x40000001往下數,遠低於客戶端自己的-1, -3, ...。 每個指向調用方 id 的格子堆 id 也會改名,包括請求自己的 id(consume,或合成產物)以及同一個包或更早包裡更早的請求(記住最近 256 個 id)。map的鍵是調用方 id 的字符串形式。 - 服務器在 item_stack_response 裡用客戶端 id 回答,bedboo 在 tap 上看到它。回答只能在包發出後到達,但可能先於
stack的回覆被讀到, 所以調用方要把未匹配的回答保留到回覆到達。 via:"raw":請求不經過客戶端的ItemStackNetManagerClient。這個遊戲版本里該管理器沒有可調用的符號(LeviLamina 頭文件裡每個成員都是 MCNAPI), 而且它的預測來自界面控制器移動物品,而不是請求動作。所以客戶端不會為這些請求做任何預測。- 重新同步:服務器接受其中一個請求時(掛鉤
ClientNetworkHandler::handle(ItemStackResponsePacket)),客戶端在下一刻發送inventory_transaction {type: inventory_mismatch}。這是客戶端自己背包不同步時發的包,服務器會回發整個背包。客戶端隨後顯示服務器的狀態, 包括打開的容器。被拒絕的請求兩邊都不改變,所以不重新同步。 - 服務器會檢查每個請求(堆 id、打開的界面、配方)。請求需要的界面(背包、箱子)必須已經在遊戲裡打開:用
action inventory、key E或使用方塊打開。
誰發什麼(bedboo bedd 傳輸)
真實客戶端擁有每個服務端方向的數據包;bedboo 把它本來要發的東西變成意圖。此表與 bedboo 的 lib/bedd/authority.js 相同。
| 類別 | 數據包 | 處理 |
|---|---|---|
| clientOwned | login、client_to_server_handshake、request_network_settings、resource_pack_client_response、client_cache_status、client_cache_blob_status、request_chunk_radius、subchunk_request、serverbound_loading_screen、set_local_player_as_initialized、network_stack_latency、tick_sync、player_skin、emote_list、respawn、player_action(respawn / dimension_change_ack / start_item_use_on / stop_item_use_on)、animate、interact mouse_over_entity | bedboo 丟棄它們(計數):客戶端自己發。重生是用戶在客戶端上點"重生" |
| translated | player_auth_input(move_vector + 標誌 → input;yaw/pitch → look;block_actions start_break/abort_break/stop_break → button attack)、mob_equipment(slot)、inventory_transaction(item_use click_block/click_air、item_release、item_use_on_entity:先 look,再 button use/attack)、interact open_inventory(action inventory)、container_close(按鍵 ESC)、item_stack_request(stack) | bedboo 的路由把它們變成操作 |
| shared | text、command_request | 通過 send 發送;後備:T 或 SLASH,然後 text,然後 ENTER |
| unsupported | book_edit、block_pick_request、crafting_event、set_default_game_type,以及其他所有 | bedboo 拋出 BeddUnsupportedError(ERR_BEDD_UNSUPPORTED) |
只有在 stack 探測不回答 ok 時,item_stack_request 才算 unsupported。
管理管道
\\.\pipe\BeDD-Manager-<会话 id>(僅當前用戶),由 BeDDManager.ps1 提供,和控制管道一樣是 JSON 行。
BEDD_MANAGER_PIPE 可以改用另一個管理窗口的管道(腳本和遊戲都讀它)。
- 客戶端(每個連接一個請求):
ping(games;關閉中時ok為 false)、adopt{pid, instance, label}(Start-BeDDGame.ps1啟動遊戲後立即調用)、list(games[]:pid、instance、label、pipe、hwnd、linked、managed、job、thumbnail)、game{pid, action: close}、shutdown。 - 遊戲(模塊):第一行是
hello{protocol, pid, hwnd, pipe, instance, label}(label來自BEDD_LABEL),回覆{"ok":true}(正在關閉的管理窗口會直接掛斷;遊戲只有被歡迎後才算已連接)。連接保持打開,管理窗口在上面發送請求:每個控制管道操作 (它每秒輪詢一次state),外加close(向遊戲窗口發WM_CLOSE)。沒有管理窗口的遊戲會啟動一個(配置managerScript); 90 秒內沒有管理窗口連上、或者徹底失去連接的遊戲會自行關閉。