RPFM 服务器 WebSocket 客户端开发实战:TypeScript 与 C 完整实现指南
游戏开发桌面应用开发工具MCP 服务【免费下载链接】rpfmRusted PackFile Manager (RPFM) is a... reimplementation in Rust and Qt6 of PackFile Manager (PFM), one of the best modding tools for Total War Games.项目地址https://gitcode.com/gh_mirrors/rp/rpfm点击查看免费下载Rusted PackFile ManagerRPFM的rpfm_server是一个独立的后端进程通过本地 WebSocket 接口把 RPFM 的全部 Pack 文件管理能力暴露给外部程序——桌面 UIrpfm_ui只是它的客户端之一任何支持 WebSocket 与 JSON 的语言都能驱动同一个后端。本文基于 docs/server/client-example.md 提供的一份可直接落地的客户端实现逐段讲解连接与会话处理、请求-响应关联、Pack 与文件操作、设置读写等完整调用链并对照rpfm_server与rpfm_ipc的源码说明协议底层原理。读完本文你将能够用 TypeScript 或 C# 从零写出一个可运行、可重连、可并发发请求的 RPFM 服务端客户端并掌握将其移植到任意语言的方法。协议全景先理解你要对接的服务器在动手写客户端之前先明确rpfm_server暴露的协议形态。服务器默认监听127.0.0.1:45127端口常量定义于 rpfm_server/src/main.rs提供三个 HTTP 端点其中与本文直接相关的是 WebSocket 端点端点方法用途/wsGETWebSocket 升级承载 JSON 命令/响应协议/sessionsGETREST列出所有活动会话/mcp*MCPStreamableHttpService供 AI Agent 客户端使用服务器端 WebSocket 处理逻辑位于 rpfm_server/src/server_websocket.rs握手时接受可选的session_id查询参数升级成功后每个连接都绑定一个独立的会话Session会话拥有专属的后台线程串行处理该会话内的命令。会话之间完全隔离——一个会话里打开的 Pack 不会被另一个会话看到这正是多个客户端共享一个服务器进程却互不干扰的架构基础详见 docs/server/overview.md。协议的所有消息都遵循一个统一信封envelope其 Rust 定义在 rpfm_ipc/src/messages.rspub struct MessageT: Debug { pub id: u64, pub data: T, }对应到 JSON 就是{ id: number, data: Command 或 Response }其中id是客户端自增的请求号服务器在响应中原样回显用于把响应匹配回对应的请求data是命令或响应载荷。id只允许客户端请求使用值为0时专用于服务器主动推送的消息如连接时的SessionConnected。连接与会话处理核心客户端的骨架连接并接收 SessionConnected客户端连接ws://127.0.0.1:45127/ws后服务器会立刻主动推送一条未请求的SessionConnected消息携带本次分配的会话 ID。这条消息是客户端建立后续一切操作的锚点——有了会话 ID才能在连接意外断开后重连到原会话保住已打开的 Pack 和依赖缓存。源码中这条消息的构造见 rpfm_server/src/server_websocket.rslet session_connected_msg IpcMessage { id: 0, // Special ID for connection message data: Response::SessionConnected(session_id), };发送到线上的 JSON 形如{ id: 0, data: { SessionConnected: 42 } }文档给出的 TypeScript 客户端在onmessage回调里专门截获这一消息并保存会话 ID// Handle SessionConnected (unsolicited, id0) if (typeof msg.data object SessionConnected in msg.data) { this.sessionId msg.data.SessionConnected; console.log(Connected to session ${this.sessionId}); return; }C# 版本则在ReceiveLoop里做同样的判断// Handle SessionConnected (unsolicited, id0) if (data.ValueKind JsonValueKind.Object data.TryGetProperty(SessionConnected, out var sid)) { SessionId sid.GetInt32(); Console.WriteLine($Connected to session {SessionId}); continue; }请求-响应关联pending Map 与并发安全协议的关联机制是客户端为每条请求分配唯一自增id把resolve/reject回调放进一个 pending 表收到响应时按id从表里取出回调并执行。这样多条请求可以同时在空中飞行in-flight无需排队等待。TypeScript 实现用一个Mapnumber, { resolve, reject }private nextId 1; private pending new Mapnumber, { resolve: (resp: any) void; reject: (err: Error) void; }(); send(command: object | string): Promiseany { return new Promise((resolve, reject) { const id this.nextId; this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, data: command })); }); }收到消息时先尝试从 pending 表取出对应id的处理器若响应数据里带有Error字段则拒绝reject该 Promise否则解析resolveconst handler this.pending.get(msg.id); if (handler) { this.pending.delete(msg.id); if (typeof msg.data object Error in msg.data) { handler.reject(new Error(msg.data.Error)); } else { handler.resolve(msg.data); } }C# 版本用ConcurrentDictionaryint, TaskCompletionSourceJsonElement实现同样的关联ReceiveLoop在后台任务中持续收消息并完成对应的TaskCompletionSourceprivate readonly ConcurrentDictionaryint, TaskCompletionSourceJsonElement _pending new(); private async Task ReceiveLoop() { var buffer new byte[65536]; while (_ws.State WebSocketState.Open) { var result await _ws.ReceiveAsync(buffer, CancellationToken.None); var json Encoding.UTF8.GetString(buffer, 0, result.Count); var msg JsonDocument.Parse(json).RootElement; var data msg.GetProperty(data); // ... SessionConnected 特判 ... var id msg.GetProperty(id).GetInt32(); if (_pending.TryRemove(id, out var tcs)) { if (data.ValueKind JsonValueKind.Object data.TryGetProperty(Error, out var err)) { tcs.SetException(new Exception(err.GetString())); } else { tcs.SetResult(data); } } } }一个容易忽略的实现细节收消息与发请求不能共用一个顺序假设。由于服务器为每个命令在自己的后台线程上执行rpfm_server/src/server_websocket.rs 中每个命令都tokio::spawn一个任务把响应经 mpsc 通道送回响应到达顺序可能与发送顺序不同因此只能依赖id关联绝不能假设响应按请求顺序返回。优雅断开ClientDisconnecting断开连接时协议要求先发送ClientDisconnecting命令再关闭 socket。服务器收到后会立即清理会话而不是等待默认 300 秒的超时若这是最后一个会话服务器进程会直接退出。这正是客户端示例中disconnect方法的由来async disconnect(): Promisevoid { await this.send(ClientDisconnecting); this.ws.close(); }public async Task DisconnectAsync() { await SendAsync(ClientDisconnecting); await _ws.CloseAsync( WebSocketCloseStatus.NormalClosure, done, CancellationToken.None); }服务器的对应处理在 rpfm_server/src/server_websocket.rs它先回送Response::Success{ id: 原id, data: Success }再把graceful_disconnect置位并跳出接收循环随后立即session_manager.remove_session(session_id)若会话计数归零则 flush 遥测并std::process::exit(0)见同文件 L201-L211。如果没有发送ClientDisconnecting就断线会话会进入5 分钟宽限期DEFAULT_SESSION_TIMEOUT_SECS 300定义于 rpfm_server/src/session.rs期间若客户端携带原session_id重连则会话状态完整保留超时未重连才会被回收。完整生命周期说明见 docs/server/sessions.md。类型化便捷方法把协议命令封装成可调用 API原文档的客户端类把常用命令封装成带类型的便捷方法。这些方法名与rpfm_ipc中Command枚举的变体一一对应枚举定义见 rpfm_ipc/src/messages.rs理解这一点对排查问题很有帮助——响应 JSON 里的键名如StringContainerInfo、ContainerInfoVecRFileInfo就是Response枚举变体名见 rpfm_ipc/src/messages.rs。Pack 管理打开、列出、保存// --- Pack management --- async openPack(paths: string[]): Promise[string, any] { const resp await this.send({ OpenPackFiles: paths }); return resp.StringContainerInfo; } async listOpenPacks(): Promise[string, any][] { const resp await this.send(ListOpenPacks); return resp.VecStringContainerInfo; } async closePack(packKey: string): Promisevoid { await this.send({ ClosePack: packKey }); } async closeAllPacks(): Promisevoid { await this.send(CloseAllPacks); } async savePack(packKey: string): Promiseany { const resp await this.send({ SavePack: packKey }); return resp.ContainerInfo; } async savePackAs(packKey: string, path: string): Promiseany { const resp await this.send({ SavePackAs: [packKey, path] }); return resp.ContainerInfo; }对应到线上 JSONOpenPackFiles是 Newtype 变体{ OpenPackFiles: [/path/to/my_mod.pack] }响应为{ StringContainerInfo: [packKey, ContainerInfo] }SavePackAs是 Tuple 变体{ SavePackAs: [key, /path] }。ContainerInfo是 Pack 的容器级元数据字段包括file_name、file_path、pfh_version、pfh_file_type、bitmask、compress、timestamp详见 docs/server/ws-shared-types.md因此示例代码里才能直接访问containerInfo.file_name打印 Pack 名。值得注意pack key 是服务器为每个打开的 Pack 生成的字符串标识后续所有针对该 Pack 的操作解码、删除、提取、保存都要以它为第一个参数。打开多个 Pack 时它们会合并进当前会话ListOpenPacks返回全部已打开 Pack 的[key, ContainerInfo]数组。C# 侧对应方法如下OpenPackAsync返回StringContainerInfo属性packKey取resp[0]public async TaskJsonElement OpenPackAsync(string[] paths) { var resp await SendAsync(new { OpenPackFiles paths }); return resp.GetProperty(StringContainerInfo); } public async TaskJsonElement ListOpenPacksAsync() { var resp await SendAsync(ListOpenPacks); return resp.GetProperty(VecStringContainerInfo); } public async TaskJsonElement SavePackAsync(string packKey) { var resp await SendAsync(new { SavePack packKey }); return resp.GetProperty(ContainerInfo); }文件操作树视图、解码、删除、提取// --- File operations --- async getTreeView(packKey: string): Promise[any, any[]] { const resp await this.send({ GetPackFileDataForTreeView: packKey }); return resp.ContainerInfoVecRFileInfo; } async decodeFile(packKey: string, path: string, source PackFile): Promiseany { return this.send({ DecodePackedFile: [packKey, path, source] }); } async deleteFiles(packKey: string, paths: any[]): Promiseany[] { const resp await this.send({ DeletePackedFiles: [packKey, paths] }); return resp.VecContainerPath; } async extractFiles( packKey: string, paths: Recordstring, any[], destPath: string, asTsv false, ): Promise[string, string[]] { const resp await this.send({ ExtractPackedFiles: [packKey, paths, destPath, asTsv] }); return resp.StringVecPathBuf; }几个关键理解点GetPackFileDataForTreeView返回[ContainerInfo, RFileInfo[]]即ContainerInfoVecRFileInfoRFileInfo是单个文件的元数据path、container_name、timestamp、file_type用于在客户端渲染 Pack 目录树。DecodePackedFile的source参数是DataSource枚举取值为PackFile|GameFiles|ParentFiles|AssKitFiles|ExternalFile。默认PackFile表示从当前打开的 Pack 解码。该命令的响应是按文件类型分派的 tagged union——例如 DB 表返回{ DBRFileInfo: [DB, RFileInfo] }文本文件返回{ TextRFileInfo: [Text, RFileInfo] }共 20 种左右变体完整清单见 docs/server/ws-commands.md 的DecodePackedFile一节与 docs/server/ws-responses.md 的 File-Type Decoded Responses 表。DeletePackedFiles与ExtractPackedFiles的路径参数是ContainerPath的 tagged enum单文件写作{ File: db/units_tables/data }文件夹写作{ Folder: db/units_tables }。ExtractPackedFiles的参数是按DataSource分组的RecordDataSource, ContainerPath[]配合第四个布尔参数asTsv为true时 DB/Loc 表以 TSV 文本形式导出。该命令的 Rust 签名可对照 rpfm_ipc/src/messages.rsExtractPackedFiles(String, BTreeMapDataSource, VecContainerPath, PathBuf, bool),游戏选择async setGame(gameKey: string, rebuildDeps: boolean): Promisevoid { await this.send({ SetGameSelected: [gameKey, rebuildDeps] }); }SetGameSelected的第二个参数rebuildDeps控制是否重建依赖缓存。注意游戏选择是会话级状态只影响当前会话不会全局改变服务器docs/server/sessions.md 明确说明When you call something likeCommand::SetGameSelected, youre changing it foryoursession, not globally.。示例中使用的warhammer_3是游戏键game key支持的完整游戏列表见 docs/reference/supported-games.md。设置读写单键查询与批量快照async getSetting(key: string): Promisestring { const resp await this.send({ SettingsGetString: key }); return resp.String; } async getAllSettings(): Promise{ bools: Recordstring, boolean; ints: Recordstring, number; floats: Recordstring, number; strings: Recordstring, string; } { const resp await this.send(SettingsGetAll); const [bools, ints, floats, strings] resp.SettingsAll; return { bools, ints, floats, strings }; }设置系统分为两类命令单键 getterSettingsGetBool/SettingsGetI32/SettingsGetF32/SettingsGetString/SettingsGetPathBuf/SettingsGetVecString/SettingsGetVecRaw均以{ 命令名: key }请求返回{ Bool: ... }、{ String: ... }等类型化包装。批量快照SettingsGetAll一次拉取全部设置返回{ SettingsAll: SettingsSnapshot }。SettingsSnapshot是六个 mapbool/i32/f32/string/raw_data/vec_string其 Rust 结构定义于 rpfm_ipc/src/settings_keys.rs。一次往返替代 N 次单键调用需要多个设置时效率高得多。所有设置键都有类型化常量定义如DEFAULT_GAME default_game、LANGUAGE language、THEME、AUTOSAVE_INTERVAL、ENABLE_USAGE_TELEMETRY、AI_API_URL等见 rpfm_ipc/src/settings_keys.rs写客户端时建议以这些常量名作为key参数避免拼写错误。完整使用示例一个端到端的客户端脚本原文档提供了 TypeScript 与 C# 各一份完整示例串联了连接 → 选游戏 → 打开 Pack → 查看树 → 解码 DB 表 → 提取文件 → 保存 → 断开全流程。TypeScript 版本如下async function main() { const client new RpfmClient(); // Wait for connection await new Promisevoid((resolve) { client[ws].onopen () resolve(); }); console.log(Session ID: ${client.sessionId}); // Select a game await client.setGame(warhammer_3, true); // Open a pack file const [packKey, containerInfo] await client.openPack([ /home/user/mods/my_mod.pack ]); console.log(Opened pack: ${containerInfo.file_name} (key: ${packKey})); // Get the file tree const [info, files] await client.getTreeView(packKey); console.log(Pack contains ${files.length} files); // Decode a DB table const decoded await client.decodeFile( packKey, db/units_tables/data, PackFile ); if (DBRFileInfo in decoded) { const [db, fileInfo] decoded.DBRFileInfo; console.log(Table: ${db.table.table_name}, rows: ${db.table.table_data.length}); } // Extract files to disk const [extractPath, extractedFiles] await client.extractFiles( packKey, { PackFile: [{ File: db/units_tables/data }] }, /tmp/extracted ); console.log(Extracted ${extractedFiles.length} files to ${extractPath}); // Save and disconnect await client.savePack(packKey); await client.disconnect(); } main().catch(console.error);C# 版本流程相同差异仅在语法层面——OpenPackAsync返回的元组用packResult[0]取 pack key、[1]取容器信息DecodeFileAsync后用decoded.TryGetProperty(DBRFileInfo, out var dbResult)判断文件类型并取表数据ExtractFilesAsync的路径用Dictionarystring, object[]表达RecordDataSource, ContainerPath[]var decoded await client.DecodeFileAsync(packKey, db/units_tables/data, PackFile); if (decoded.TryGetProperty(DBRFileInfo, out var dbResult)) { var db dbResult[0]; var table db.GetProperty(table); Console.WriteLine( $Table: {table.GetProperty(table_name)}, $rows: {table.GetProperty(table_data).GetArrayLength()}); }几个实战要点解码 DB 表的内层结构DBRFileInfo的DB载荷含mysterious_byte布尔标志置 0 可能导致 WH2 崩溃、guid、table即TableInMemory含table_name、definition、table_data二维数组等见 docs/server/ws-shared-types.md 的TableInMemory与DB小节。示例中db.table.table_name就是表类型标识如units_tablesdb.table.table_data.length是行数。提取路径的语义extractFiles返回[提取根目录, 已提取文件路径数组]第一个元素即传入的destPath。TypeScript 直接访问私有成员示例通过client[ws].onopen访问私有ws字段等待连接建立——这是文档示例的写法工程化实现更推荐在类内部用onopen回调或暴露waitForConnection()方法。移植到其他语言5 步协议要点与 serde 序列化规则该协议与语言无关任何具备 WebSocket 与 JSON 支持的运行时都能实现客户端。原文档给出了五个移植步骤这里结合协议文档展开连接任意 WebSocket 库连接到ws://127.0.0.1:45127/ws若要重连已有会话追加查询参数?session_idid服务器在 rpfm_server/src/server_websocket.rs 通过WsQueryParams解析该参数并通过session_manager.get_or_create_session(requested_session_id)决定是复用会话还是新建见同文件 L83-L93。发送以{ id: number, data: command }格式发送 JSON 文本帧。无参命令直接发送字符串如ListOpenPacks带参命令用{ 命令名: 参数 }包裹。接收解析收到的 JSON按id把响应匹配回请求。注意id: 0的SessionConnected是服务器主动推送不要当作某个请求的响应。处理会话消息连接建立后立刻处理SessionConnected并保存会话 ID供后续重连使用。优雅退出关闭连接前先发送ClientDisconnecting让服务器立即回收会话资源。跨语言移植最容易踩坑的是serde 序列化约定——因为Command/Response都是 Rust 枚举其 JSON 形态由 serde 的枚举序列化规则决定Command、Response枚举定义见 [rpfm_ipc/src/messages.rs](https://link.gitcode.com/i/66304496193a46366aaec39591896d9d 与 L1393-L1394)MessageT直接#[derive(Serialize, Deserialize)]。三种变体形态的映射如下详见 docs/server/ws-protocol.mdRust 变体形态JSON 序列化示例Unit 变体无参数VariantName纯字符串NewPackNewtype 变体单参数{ VariantName: value }{ ClosePack: my_mod.pack }Tuple 变体多参数{ VariantName: [v1, v2, …] }{ SavePackAs: [key, /path/to/file] }Struct 变体{ VariantName: { field: value, … } }{ SetGameSelected: [warhammer_3, true] }这意味着线上 JSON 的形状与 Rust 客户端写同样的请求几乎一致——阅读 docs/server/ws-commands.md 的命令参考时直接照 JSON 形状构造请求即可。另外注意ContainerPath{ File: ... }/{ Folder: ... }与DataSource纯字符串枚举在参数中高频出现移植时建议封装成对应语言的类型错误响应统一是{ id: 原id, data: { Error: 消息 } }对应Response::Error(String)见 rpfm_ipc/src/messages.rs任何响应只要带Error字段就应视为对应请求被拒绝错误消息适合面向开发者展示响应键名即类型名例如StringContainerInfo、VecStringContainerInfo、ContainerInfoVecRFileInfo、DBRFileInfo、VecContainerPath、StringVecPathBuf等都是Response枚举变体名Response的完整清单见 docs/server/ws-responses.md。源码级验证客户端示例背后的实现事实本文所有协议行为均可在仓库源码中交叉验证整理如下便于读者深入会话与超时DEFAULT_SESSION_TIMEOUT_SECS 3005 分钟定义于 rpfm_server/src/session.rs会话拥有独立后台线程串行处理命令rpfm_server/src/session.rs 的模块文档有详细说明。握手与 SessionConnectedws_handler接受?session_id参数、升级后立即发送id: 0的SessionConnected见 rpfm_server/src/server_websocket.rs。ClientDisconnecting 特判在接收循环内单独匹配Command::ClientDisconnecting先回Success再清理会话、必要时退出进程见 rpfm_server/src/server_websocket.rs。命令路由除ClientDisconnecting外的命令统一转发给会话后台线程执行每个命令独立tokio::spawn响应经 mpsc 通道按id送回——这是响应顺序与请求顺序无关的根源见 rpfm_server/src/server_websocket.rs。设置快照结构SettingsSnapshot的六 map 字段定义于 rpfm_ipc/src/settings_keys.rs与SettingsGetAll响应中的SettingsAll载荷一一对应。测试文件与格式参考仓库自带各 PFH 版本测试包test_files 下的PFH0_test.pack至PFH6_test.pack以及test_decode_db、test_decode.loc等各类文件解码样例可用来验证客户端解码结果是否与预期一致。常见问题与注意事项响应顺序不可假设服务器为每个命令并发执行务必以id关联响应不要把先发先回当作事实。SessionConnected是未请求消息它没有对应的 pending 处理器客户端必须在onmessage入口处先特判否则会误当作未知响应丢弃导致拿不到会话 ID。错误就是Error响应不要依赖 HTTP 状态或连接异常判断命令失败协议层面任何{ Error: ... }都是对原请求的拒绝。例如OpenPackFiles打开不存在的路径时服务器会以Error变体回包。会话隔离两个客户端连同一个服务器互不共享状态WebSocket 客户端可凭session_id重连恢复状态MCP 客户端则每次连接都是全新会话docs/server/overview.md。解码响应的类型分派DecodePackedFile的响应是 tagged union客户端需按 JSON 顶层键如DBRFileInfo、TextRFileInfo、LocRFileInfo、Unknown分派处理不能假定返回固定结构。未知文件类型会返回Unknown对应 rpfm_ipc/src/messages.rs 中Response::Unknown变体。延伸阅读docs/server/overview.md — 服务器架构、三个端点、WebSocket 与 MCP 两条接入路径docs/server/ws-protocol.md — 消息信封、序列化约定、错误与优雅断开docs/server/ws-commands.md — 全部可发送命令的参考含参数表与 JSON 示例docs/server/ws-responses.md — 全部响应变体与载荷结构docs/server/ws-shared-types.md —ContainerInfo、RFileInfo、DB、Loc、Text等共享类型定义docs/server/sessions.md — 会话生命周期、重连示例与/sessionsREST 端点docs/server/mcp.md — 面向 AI Agent 的 MCP 接入方式rpfm_ipc/src/messages.rs — 协议三要素MessageT/Command/Response的 Rust 定义rpfm_server/src/server_websocket.rs — WebSocket 端点的完整服务端实现rpfm_ipc/src/settings_keys.rs — 全部设置键常量与SettingsSnapshot结构赞分享游戏开发桌面应用开发工具MCP 服务【免费下载链接】rpfmRusted PackFile Manager (RPFM) is a... reimplementation in Rust and Qt6 of PackFile Manager (PFM), one of the best modding tools for Total War Games.项目地址https://gitcode.com/gh_mirrors/rp/rpfm点击查看免费下载相关推荐cpp-httplib WebSocket 实战用 C 实现双端 Echo 服务器与客户端cpp httplib WebSocket 实战用 C 实现双端 Echo 服务器与客户端 WebSocket 是建立在 HTTP Upgrade 之上的后端网络如何用C语言从零实现TCP/UDP客户端服务器通信完整指南如何用C语言从零实现TCP/UDP客户端服务器通信完整指南 GitHub加速计划的c/C项目是一个面向教育目的的算法集合包含数学、机器学习、计算机科学和物理示例工程Bopscrk 项目常见问题解决方案Bopscrk 项目常见问题解决方案 1. 项目基础介绍和主要编程语言 Bopscrk 是一个用于生成针对特定目标的智能且强大的密码列表的开源项目。该项目能够将WebSocket网络通信上一篇浏览器里解密15 种加密音乐格式一次搞定下一篇5步安静电脑风扇的完整指南Fan Control风扇控制教程创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考