# 运行时模块
> 本页是 OmsiLaunch 0.1.0-beta4 [英文原始页面](https://github.com/OmsiModsBR/OmsiLaunch/blob/63ccaf8cfc6c6d24a829d5e7a2a36ecc795e2744/docs/reference/runtime-modules.md) 的译文。英文页面为规范文本:如有出入,以英文页面和代码为准。
运行时模块(runtime module)为受管理的 OMSI 会话增加行为:这是一项随会话运行的功能(例如某张地图的 HUD,或加载优化),但不会成为 OmsiLaunch 自身的一部分。桥接层加载模块,只提供模块所声明的内容,并在会话结束或模块失败时移除模块持有的一切。本页是运行时模块的规范性参考,对应 `src/OmsiLaunch.Runtime.Modules`(清单、设置、资格判定、生命周期宿主)、规划器、控制器的会话程序(`ModuleSessionPrograms`、`ModuleSessionProcess`)、插件的模块环境,以及 [CLI](https://omsilaunch.omsimods.com.br/zh-cn/docs/reference/cli/index.md) 中与模块相关的参数。
稳定性:`EXPERIMENTAL`。桥接协议 1 已在 B 轮中经运行时验证(托管模块、内容激活、操作、`disable-module` 和 `fail-session`;带 IAT 钩子的原生组件;参见[运行时验证状态](https://omsilaunch.omsimods.com.br/zh-cn/docs/status/runtime-validation-status/index.md))。桥接协议 2(设置、默认激活、设置文件、会话程序、插件冲突、通知、`get_value`)仅经离线验证。离线门禁:`tests/OmsiLaunch.ModuleTests` 中的 `module.*` 门禁(清单、完整性、选择与激活、设置、设置文件、插件冲突、通过其作业启动的会话程序、在每个失败策略下于每个阶段注入的失败、原生组件生命周期、清理、交接)、`plugin-runtime.modules-lifecycle`(由插件环境加载的真实托管模块)、`runtime-modules.native-component`(挂钩某个导入并通过桥接层读取值的真实原生组件)、`runtime-modules.plugin-conflict` 以及 `native-hooks.self-check`(钩子引擎)。
加载是开放的:安装在 `plugins\OmsiLaunch.Modules` 之下的任何模块都可以被加载,无论由谁制作。OmsiLaunch 不为模块在 OMSI 内部的行为担保。它所保证的是[边界](#security-boundary):模块绝不在 OmsiLaunch 进程内运行,并且 OmsiLaunch 提供的任何机制都不能让模块超出 OMSI 自身的权限范围。
## 模块的存放位置
每个模块是永久插件文件夹之下的一个目录:
```text
\plugins\OmsiLaunch.Modules\\module.json
\plugins\OmsiLaunch.Modules\\.dll
\plugins\OmsiLaunch.Modules\\
```
目录名必须与模块的 `id` 相同。模块由其分发者安装;会话绝不会在此处写入、做快照或还原文件,模块也绝不会通过 OmsiLaunch 更改安装文件(清单无法声明安装覆盖层)。模块的可写数据位于其[数据目录](#data-directory)。
## 清单(`module.json`)
架构(schema)为 `omsilaunch.runtime-module/v1`。未知的键会被拒绝,因此为较新架构编写的清单会被拒绝,而不是只被理解一半。所有路径都相对于模块目录(绑定内容的路径相对于 OMSI 根目录),使用 `\` 或 `/`,不得包含 `..` 或盘符。
| 键 | 是否必需 | 含义 |
| --- | --- | --- |
| `schema` | 是 | `omsilaunch.runtime-module/v1`。 |
| `id` | 是 | 模块 ID:小写字母、数字和 `-`,2 到 48 个字符,以字母或数字开头。同时也是其目录名和操作命名空间。 |
| `version` | 是 | `major.minor.patch`,可选 `-suffix`。 |
| `name` | 否 | 显示名称(最多 128 个字符)。 |
| `bridge_protocol` | 是 | `{ "min": n, "max": n }`:模块可配合使用的桥接协议。此构建提供协议 `1` 和 `2`;模块的范围包含其中之一即被接受。 |
| `activation` | 否 | `requested`(默认):会话请求时加载。`content`:会话运行所绑定的地图时也会加载(需要 `content_binding`)。`default`(协议 2):除非被禁用,否则在每个会话中加载(参见[为会话选择模块](#choosing-modules-for-a-session));不能带有 `content_binding`。 |
| `required_capabilities` | 否 | 模块缺少就无法工作的能力(参见[能力](#capabilities))。 |
| `optional_capabilities` | 否 | 模块在可用时使用的能力;通过 `IsCapabilityAvailable` 检查。 |
| `content_binding` | 否 | `{ "map": "maps\\\\global.cfg", "files": [ { "path", "sha256" } ] }`:模块只在该地图上运行,且这些文件须保持不变。 |
| `assets` | 否 | 模块读取的文件,每项为 `{ "path", "sha256" }`。 |
| `components` | 是 | 恰好一个 `managed` 组件 `{ "kind": "managed", "path": ".dll", "sha256", "entry": "" }`;可选一个 `native` 组件(`.dll`)、`auxiliary` 组件(`.exe`),以及在协议 2 下的一个 `host` 组件(`.exe`,即模块的[会话程序](#session-programs))。 |
| `failure_policy` | 是 | `fail-session`、`disable-module` 或 `degrade`(参见[失败](#failures))。 |
| `operations` | 否 | 模块在会话运行期间公开的操作:`{ "id", "kind": "read"\|"write"\|"action", "arguments": [ { "name", "required" } ], "timeout_ms" }`。 |
| `settings` | 否 | 协议 2。模块接受的设置项(参见[设置](#settings))。 |
| `conflicts` | 否 | 协议 2。`{ "plugins": [ ".dll" ] }`:不得与该模块同时运行的 OMSI 插件 DLL(参见[与 OMSI 插件的冲突](#conflicts-with-omsi-plugins))。 |
| `timeouts` | 否 | 以毫秒计的预算:`arm_ms`、`gameplay_ready_ms`、`stop_ms`(各默认 5000,最大 30000);在协议 2 下,会话程序另有 `prepare_ms`(默认 120000,最大 1800000)和 `host_stop_ms`(默认 10000,最大 60000)。操作默认为 2000 ms。 |
| `diagnostics` | 否 | `{ "trace": true\|false }`。 |
使用协议 2 功能(`settings`、`conflicts`、激活方式 `default`、`host` 组件、`prepare_ms` 或 `host_stop_ms`)的清单必须声明 `bridge_protocol.min` 为 2。
`components` 和 `assets` 所列的每个文件都由其 SHA-256 固定。`module.json` 无法固定其自身。
```json
{
"schema": "omsilaunch.runtime-module/v1",
"id": "example-preload",
"version": "1.0.0",
"bridge_protocol": { "min": 2, "max": 2 },
"activation": "default",
"required_capabilities": ["bridge.events", "bridge.hooks.iat"],
"components": [
{ "kind": "managed", "path": "Example.Preload.dll", "sha256": "<64 hex digits>", "entry": "Example.Preload.PreloadModule" },
{ "kind": "native", "path": "example-preload.dll", "sha256": "<64 hex digits>" },
{ "kind": "host", "path": "ExamplePreloader.exe", "sha256": "<64 hex digits>" }
],
"settings": [
{ "key": "ram_budget_mb", "type": "integer", "default": "auto", "auto": true, "min": 256, "max": 12288, "flag": "example-ram" },
{ "key": "monitor", "type": "boolean", "default": false, "flag": "example-monitor" }
],
"conflicts": { "plugins": ["OldExamplePlugin.dll"] },
"failure_policy": "degrade",
"timeouts": { "prepare_ms": 300000 }
}
```
## 完整性
每个被固定的文件都必须存在,不得经由联接点(junction)或符号链接到达,并且必须具有其固定的 SHA-256;否则模块会被拒绝,错误码为 `OL_E_MODULE_INTEGRITY`。这可以发现已损坏或只更新了一半的模块。这不是信任决策:不会对照任何列表、签名或发布者来检查模块。
在模块代码启动之处会重复该检查:插件只有在模块的 `module.json` 仍具有计划所评估的 SHA-256 且每个固定项仍然成立时才会加载该模块,而控制器只有在验证了会话程序的固定项之后才会启动该程序。
## 安全边界
OmsiLaunch 不控制模块在 OMSI 内部的行为:其托管组件和原生组件运行在 OMSI 进程中,权限与任何 OMSI 插件相同。OmsiLaunch 保证以下几点:
1. OmsiLaunch 进程绝不加载模块代码。模块代码运行在 OMSI 内部、从 OMSI 内部启动的辅助程序中,或模块的会话程序中。
2. OmsiLaunch 为模块启动的程序都是模块自身目录中的文件,经与其固定项核对后,以 OmsiLaunch 固定的命令行启动。会话程序运行在一个作业对象(job object)中,该作业对象会随会话一同终止它,不允许第二个进程(它不能启动其他程序),并拒绝更改系统参数、显示设置、桌面以及注销或关机。它只继承其两个管道句柄。
3. 模块声明的一切都保持在其目录之内,其可写数据位于 `.omsilaunch\module-data\` 之内。
4. OmsiLaunch 的各通道(本地控制、启动交接、遥测、运行时命令)保持仅限当前用户并绑定到会话;其中没有任何一个允许调用方启动程序、读写安装之外的文件或更改系统设置。
5. OmsiLaunch 从不提升权限。模块以启动会话的用户的权限运行。
6. 来自模块的文本(通知、事件、值)被视为数据:有长度限制,不含控制字符,绝不被运行、打开或作为链接跟随。
## 能力
`required_capabilities` 和 `optional_capabilities` 所指名称,要么是正在运行的构建的原生能力([能力目录](https://omsilaunch.omsimods.com.br/zh-cn/docs/reference/capabilities/index.md#capability-catalog)中的原生能力列,例如 `time.read` 或 `d3d.texture`),要么是桥接功能:
| 桥接功能 | 模块获得的内容 |
| --- | --- |
| `bridge.events` | `Emit`:运行时事件 `module..`。 |
| `bridge.background-threads` | `RunBackground`:受跟踪的后台线程。 |
| `bridge.auxiliary-processes` | `StartAuxiliaryProcess`:绑定到会话的辅助进程。 |
| `bridge.hooks.iat` | 原生组件:对 `Omsi.exe` 导入的钩子(`iat:!`)。 |
| `bridge.hooks.d3d9` | 原生组件:对当前 Direct3D 9 设备方法的钩子(`d3d9:device.`)。需要原生能力 `d3d.texture`。 |
两者都不是的名称在规划时即被拒绝(`OL_E_MODULE_CAPABILITY_UNRESOLVED`)。在没有已知布局的 OMSI 2 版本上,可执行文件无法解析的原生能力会在进程内判定;如果在进程内也无法解析,则不加载该模块。
## 为会话选择模块
模块以四种方式之一加入会话;`PlannedModule.Activation` 指明是哪一种:
| 激活方式 | 时机 |
| --- | --- |
| `requested` | 会话请求了它:`LaunchSpec.Modules.Enable`、`/module:`,或所选会话配置档预设中的 `enabled: true`。 |
| `settings-file` | [设置文件](#the-settings-file)中其节内的 `enabled=true`。 |
| `content` | 其激活方式为 `content`,且会话运行其所绑定的地图。 |
| `default` | 其激活方式为 `default`。 |
通过 `LaunchSpec.Modules.Disable`、`/no-module:`、预设中的 `enabled: false` 或设置文件中的 `enabled=false`,可使模块不加入会话(计划诊断信息 `module.disabled`)。显式请求优先于设置文件。在同一请求中同时启用和禁用同一个模块会导致 `OL_E_MODULE_SETTING_INVALID`。
规划规则:被请求但无法加载的模块会使计划不可运行,并带有其 `OL_E_MODULE_*` 错误码。自行加入(设置文件、内容、默认)但无法加载的模块不会使会话停止:计划会报告 `OL_W_MODULE_NOT_LOADED`。
```text
OmsiLaunch.exe /new /map:maps\Grundorf\global.cfg /entrypoint-index:1 /no-module:example-preload
```
## 设置
模块在 `settings` 中声明其设置项;每个条目:
| 键 | 是否必需 | 含义 |
| --- | --- | --- |
| `key` | 是 | `[a-z][a-z0-9_]{0,31}`,不得为 `enabled`。 |
| `type` | 是 | `boolean`、`integer`、`choice` 或 `text`。 |
| `default` | 是 | 没有其他来源设置时的值:布尔值、整数、字符串,或在 `auto` 为 true 时为 `"auto"`。 |
| `auto` | 否 | `true`:接受值 `auto` 并传递给模块,由模块在运行时决定。 |
| `min`、`max` | 否 | 整数边界。 |
| `choices` | `choice` 类型必需 | 接受的值(`[a-z0-9][a-z0-9_.-]{0,31}`,不得为 `auto`)。 |
| `max_length` | 否 | 文本长度限制(1..4096,默认 256)。控制字符会被拒绝。 |
| `flag` | 否 | 此设置项的命令行参数,`[a-z][a-z0-9-]{2,31}`:`/:`,对布尔类型单独使用 `/` 表示 `true`。 |
| `description` | 否 | 最多 256 个字符。 |
值会被规范化:布尔值接受 `true`/`false`、`yes`/`no`、`on`/`off`、`1`/`0`;选项的匹配不区分大小写;整数会按其边界检查。
每个设置项的有效值按以下顺序确定:
1. 请求:`LaunchSpec.Modules.Settings`(`".": ""`)、`/module-setting:.=`、模块自己的参数,或所选会话配置档预设中的 `modules..settings`。与其他所有预设字段一样,命令行不能同时设置预设已设置的键(`OL_E_SESSION_PROFILE_OVERRIDE_CONFLICT`);
2. [设置文件](#the-settings-file);
3. 清单默认值。
无效的请求值,或请求了模块未声明的键,会使计划不可运行(`OL_E_MODULE_SETTING_INVALID`)。无法使用的设置文件条目会被忽略并给出 `OL_W_MODULE_SETTING_IGNORED`,针对会话不加载的模块所请求的设置也是如此。计划在 `PlannedModule.Settings` 中列出每个有效值及其来源(`request`、`settings-file` 或 `default`)。
模块会收到每个已声明键及其有效值:`IRuntimeModuleContext.Settings`、原生组件中的 `get_value("setting.")`,以及会话程序上下文中的 `settings` 对象。
### 设置文件
`\.omsilaunch\modules.ini`,位于会话配置档旁边。OmsiLaunch 只读取它。
```ini
; one section per module id
[example-preload]
enabled=true
ram_budget_mb=8192
monitor=false
```
节名是模块 ID;`key=value` 行设置设置项,`enabled` 为每个会话开启或关闭该模块;以 `;` 或 `#` 开头的行是注释。该文件最大为 256 KiB。其中无法使用的内容(不是模块 ID 的节、位于任何节之外的行、未知的键、无效的值)会以 `OL_W_MODULE_SETTING_IGNORED` 报告并被忽略。
## 会话程序
模块可以声明一个 `host` 组件:其会话程序,即来自其自身目录的可执行文件。当模块在计划中时,控制器会:
1. 验证该程序的固定项并创建模块的数据目录;
2. 在 OMSI 之前,从模块目录以命令行 `"" --omsilaunch-session ` 启动它,并保持挂起,直到它进入其作业(参见[安全边界](#security-boundary));
3. 将上下文作为第一行写入其标准输入;
4. 等待程序写出 `{"type":"ready"}`,最长 `prepare_ms`;只有此后才启动 OMSI;
5. OMSI 创建之后,写入 `{"type":"omsi-started","pid":}`;
6. 在会话结束时,OMSI 退出之后、任何文件被还原之前,写入 `{"type":"stop"}`,最多等待 `host_stop_ms`,然后结束该程序。
两个方向都使用 UTF-8 JSON,每行一个对象。上下文:
| 字段 | 含义 |
| --- | --- |
| `type` | `context`。 |
| `schema` | `omsilaunch.module-session/v1`。 |
| `session_id`、`module_id`、`module_version`、`product_version` | 标识信息。 |
| `installation_root`、`module_directory`、`data_directory` | 完整路径。 |
| `channel` | 程序可为模块在 OMSI 中的组件提供服务的命名管道:`\\.\pipe\OmsiLaunch.Module..`。程序创建该管道,将其限制为当前用户,并检查客户端是 OMSI 的进程。组件通过 `get_value("session_channel")` 或 `IRuntimeModuleContext.SessionChannelName` 读取它。 |
| `settings` | 模块的有效设置项。 |
| `world` | 会话的 `mode`、`map`、`situation`、`entrypoint_index`、`entrypoint`。 |
| `session_files` | 会话事务所拥有的、相对于安装根目录的文件。程序不得保持它们打开或映射:会话结束时它们会被还原。 |
除 `ready` 外,程序还可以写出的内容:
| 行 | 效果 |
| --- | --- |
| `{"type":"event","name":"","data":{...}}` | 运行时事件 `module..`。 |
| `{"type":"notice","data":{"title":"...","text":"..."}}` | 运行时事件 `module..notice`(参见[通知](#notices))。 |
| `{"type":"error","data":{"code":"OL_E_...","detail":"..."}}` | 带有该已登记错误码的会话诊断信息(否则为 `OL_E_MODULE_OPERATION_FAILED`)。 |
其他行、长于 64 KiB 的行以及第 10,000 行之后的任何内容都会被忽略。控制器会记录 `module..host.started`、`host.ready`(带 `prepare_ms`)、`host.failed`、`host.exited` 和 `host.killed`。
在 `prepare_ms` 内未就绪、在就绪前退出,或其固定项不成立的程序,会使其模块的准备失败:在 `fail-session` 下,会话不会启动(`OL_E_MODULE_SESSION_FAILED`);在 `disable-module` 下,该程序被结束,模块不进入 OMSI;在 `degrade` 下,模块保留(并且如果程序仍能运行,则继续运行)。会话的 `StartupTimeoutSeconds` 从 OMSI 启动时开始计时,因此准备阶段不会消耗它。
## 与 OMSI 插件的冲突
`conflicts.plugins` 指明不得与该模块同时运行的 OMSI 插件 DLL,例如安装相同钩子的旧版插件。规划时,OmsiLaunch 会读取每个 `plugins\*.opl` 的 `[dll]` 节;如果其中某个加载了冲突的 DLL,则模块被拒绝,错误码为 `OL_E_MODULE_CONFLICT`,原因中会指明该 `.opl`。在 OMSI 内部,若冲突的 DLL 仍然被加载,则该模块不会被加载。OmsiLaunch 从不更改其他插件的文件;是否关闭它由用户决定。
## 通知
名为 `notice` 的模块事件(来自 `Emit("notice", ...)`、原生组件的 `emit` 或会话程序的通知行)会由 OmsiLaunch 托盘以气泡提示的形式显示,标题中会写明模块名称。只保留 `title`(最多 63 个字符)和 `text`(最多 255 个字符),控制字符会被移除,其中的任何内容都不会被打开或运行。必须在 OMSI 启动之前(托盘尚未显示时)告知用户的会话程序,应使用其自己的窗口。
## 数据目录
`\.omsilaunch\module-data\\` 是模块针对此安装的可写目录:即 `IRuntimeModuleContext.DataDirectory`、`get_value("data_directory")` 以及会话程序的 `data_directory`。它在首次使用时创建。它不属于会话事务:模块写入其中的内容在会话结束后仍会保留。`/clear-module-data:` 会删除它(且仅删除它);当会话拥有该安装时会被拒绝,并且经由链接到达的目录会按链接本身移除,绝不跟随。
## 生命周期
| 步骤 | 位置 | 发生的事情 |
| --- | --- | --- |
| 1. 发现 | 控制器 | 读取 `plugins\OmsiLaunch.Modules\*`:清单和完整性(`ListRuntimeModulesAsync`、`/list:RuntimeModules`)。 |
| 2. 资格判定 | 控制器,规划时 | 对[加入会话](#choosing-modules-for-a-session)的模块进行评估:可用性、桥接协议、能力、内容绑定、插件冲突、设置。结果为 `SessionPlan.PlannedModules`。 |
| 3. 准备 | 控制器,启动时 | 启动并等待模块的[会话程序](#session-programs)。然后符合资格的模块通过启动交接(版本 6)传递给插件:它们的目录、每个已评估 `module.json` 的 SHA-256,以及它们的有效设置。 |
| 4. 装配 | 插件,世界启动之前 | 每个模块被重新验证并加载(托管组件位于其自己的加载上下文中)。其原生组件(如果有)先被加载并启动,然后调用 `Arm(context)`。 |
| 5. 游戏就绪 | 插件 | 在 `gameplay.entered` 之后,如果某个已加载的模块声明了 `bridge.hooks.d3d9`,桥接层会开始观察 Direct3D 设备(`d3d.ready`);然后调用原生组件的 `gameplay_ready` 和 `GameplayReady()`。对于 NEW_MAP 会话,会再次核对所绑定的地图与启动时加载的地图是否一致。 |
| 6. 运行时 | 插件 | 操作 `module..` 到达 `Execute`。 |
| 7. 停止 | 插件,然后控制器 | 会话结束(或模块失败)时调用 `Stop()`。OMSI 退出后,停止会话程序。 |
| 8. 清理 | 插件 | 模块跟踪的一切按相反顺序释放并验证已消失;原生组件最后停止,其钩子被移除;其 DLL 保持加载但处于惰性状态,直到 OMSI 退出(可能仍有线程正通过某个绕行函数返回)。 |
与产品自身的运行时操作一样,每个进程内回调都在 OMSI 的 UI 线程上运行。桥接层会对照预算度量每个回调:迟迟返回的回调会使其阶段失败(`OL_E_MODULE_PHASE_TIMEOUT`)。桥接层无法中断永不返回的回调;这样的模块会使 OMSI 停滞,会话将像其他任何会话一样在启动超时时结束,或通过 `session stop` 结束。
## 失败
失败是指任何阶段或操作中的异常或超限、进程内未解析的能力、进程内不成立的内容绑定、在 OMSI 中被加载的冲突插件,或未能就绪的会话程序。随后发生什么由模块的 `failure_policy` 决定。无论哪种情况,模块的资源都会被释放。
| 策略 | 在准备、加载、装配或游戏就绪阶段失败 | 在操作中失败 | 在停止阶段失败 |
| --- | --- | --- | --- |
| `fail-session` | 模块被释放;`module.session-failure`;会话以 `OL_E_MODULE_SESSION_FAILED` 失败,OMSI 被停止,文件被还原。世界启动之前的失败会阻止启动。 | 相同。 | 已报告(`module.failed`);会话继续直至结束。 |
| `disable-module` | 模块被释放并禁用;会话继续。 | 相同。 | 已报告;模块被释放。 |
| `degrade` | 与 `disable-module` 相同,只是会话程序的失败会让模块留在会话中。 | 失败的调用返回其错误码;模块保持活动;每个错误码只报告一次 `module.degraded`。 | 已报告;模块被释放。 |
被禁用或已失败的模块对其操作回复 `OL_E_MODULE_DISABLED`。模块失败会作为会话诊断信息(`SessionStatus.Diagnostics`)连同其 `OL_E_MODULE_*` 错误码出现,也会作为运行时事件出现。
## 清理
模块通过其上下文获取的内容都会被跟踪:用 `Track` 注册的资源、用 `RunBackground` 启动的线程、用 `StartAuxiliaryProcess` 启动的进程。在停止或失败时,桥接层先取消 `Stopping`,然后按相反顺序释放它们。辅助进程还会被放入关闭即终止(kill-on-close)的作业对象,因此即使 OMSI 被终止,它们也会随 OMSI 一同结束;会话程序的作业会使其随 OmsiLaunch 会话一同结束,即使 OmsiLaunch 自身被终止也是如此。释放之后仍然存活的资源(未退出的进程、未结束的线程)会以 `OL_E_MODULE_CLEANUP_INCOMPLETE` 报告。模块未通过其上下文创建的线程或进程不会被跟踪。
崩溃之后,模块没有任何需要恢复的内容:它们不持有安装文件,其内存、线程和绑定到作业的进程会随 OMSI 进程或 OmsiLaunch 进程一同结束。
## 原生组件与钩子
原生组件是实现 `src/OmsiLaunch.Native.x86/include/omsilaunch_module_native_v1.h` 中 C 接口的 32 位 DLL:
| 导出 | 调用时机 |
| --- | --- |
| `int omsilaunch_module_native_v1_start(const omsilaunch_bridge_v1*)` | 装配时,在托管的 `Arm` 之前。返回非零会使该阶段失败。 |
| `int omsilaunch_module_native_v1_gameplay_ready(const omsilaunch_bridge_v1*)` | 可选;在进入游戏之后。 |
| `void omsilaunch_module_native_v1_stop(void)` | 停止或失败时,在其钩子被移除之前。此后桥接表拒绝每一次调用,DLL 保持加载但处于惰性状态,直到 OMSI 退出。 |
桥接表为组件提供 `request_hook`、`emit`(带有字符串 JSON 对象的运行时事件 `module..`),以及自协议 2 起的 `get_value`。组件在使用 `get_value` 之前须读取该表的 `size`(`OMSILAUNCH_BRIDGE_V1_SIZE_WITH_GET_VALUE`)。`get_value(context, name, buffer, capacity, length)` 将值以带结尾零的 UTF-8 复制出来,并把 `*length` 设为不含结尾零所需的字节数;它返回 `0`、`1`(参数)、`2`(未知名称)、`11`(停止之后)或 `12`(缓冲区过小)。名称:
| 名称 | 值 |
| --- | --- |
| `setting.` | 已声明设置项的有效值。 |
| `data_directory` | 模块的[数据目录](#data-directory)。 |
| `module_directory` | 模块自己的目录。 |
| `session_id` | 会话 ID。 |
| `module_version` | 清单版本。 |
| `product_version` | OmsiLaunch 产品版本。 |
| `session_channel` | [会话程序](#session-programs)的管道;对没有会话程序的模块为未知。 |
钩子只能通过符号目标请求,在 `start` 或 `gameplay_ready` 期间,且目标所对应的能力须已在清单中声明:
| 目标 | 钩住的对象 | 能力 |
| --- | --- | --- |
| `iat:!` | `Omsi.exe` 的一个导入,例如 `iat:kernel32.dll!ReadFile`。 | `bridge.hooks.iat` |
| `d3d9:device.` | 当前 Direct3D 9 设备的 `Reset`、`Present`、`BeginScene` 或 `EndScene`(在设备存在时于 `gameplay_ready` 中请求)。 | `bridge.hooks.d3d9` |
`request_hook` 在 `*original` 中返回用于继续调用链的函数(绕行函数必须调用它)。其结果:`0` 成功,`1` 参数,`2` 未知目标,`3` 该位置的内容与应有内容不符,`4` 该位置被桥接层之外的其他方挂钩,`5` 内存保护,`6` 注册表已满,`7` 顺序,`8` 内存,`9` 有线程正在执行要修补的字节,`10` 尚无 Direct3D 设备,`11` 当前不允许或未声明能力。
桥接层为产品或模块安装的每个钩子,都与其所有者(模块为 `module:`)一起记录在同一个注册表中:
- 钩子只会安装在该位置应有的值之上(被导入的函数;名为 `d3d9.dll` 的模块、系统运行时或包装器的函数),或叠加在注册表自己记录的该位置最上层钩子之上。已被他人更改的位置会被拒绝(`4`),绝不覆盖。
- 同一位置上的钩子按后进先出的顺序移除;上面还有其他钩子的钩子会保留,直到那个钩子被移除。
- 产品自己的 Direct3D `Reset` 钩子在注册表中,因此模块的设备钩子会叠加在它之上。
- 清理时,桥接层在 `stop` 返回之后移除该模块的每个钩子。如果某个无法移除(他人更改了该位置),清理会报告 `OL_E_MODULE_CLEANUP_INCOMPLETE`。OMSI 运行期间,原生组件的 DLL 绝不会被卸载,因此仍在返回途中的绕行函数保持有效。
引擎还支持带跳板(trampoline)的函数序言钩子(字节更改期间其他线程会被挂起,并且会先检查确切的预期字节),但目前尚未向模块提供任何序言目标:此类目标来自构建的目标目录,并带有经过验证的字节。
## 操作
模块操作为 `module..`。它只对计划加载了该模块的会话存在(`PlannedModule.Operations`);否则 `ExecuteRuntimeAsync` 回复 `OL_E_MODULE_NOT_LOADED` 或 `OL_E_MODULE_OPERATION_UNKNOWN`。必需参数会被检查,未声明的参数会被拒绝(`OL_E_RUNTIME_ARGUMENT_REQUIRED`)。模块的类型化失败在其为已登记的公共错误码时保留该错误码;任何其他错误码都会变为 `OL_E_MODULE_OPERATION_FAILED`,模块的错误码放在 `Values["module_code"]` 中。遵循内部命名约定的结果键会被移除,与每个运行时操作相同。
```text
OmsiLaunch.exe /new /map:maps\Grundorf\global.cfg /entrypoint-index:1 /module:null-test
OmsiLaunch.exe /runtime:module.null-test.ping /runtime-arg:text=hello
```
## 编写模块(契约)
托管组件引用 `OmsiLaunch.Runtime.Modules` 和 `OmsiLaunch.Api`,并实现 `IRuntimeModule`。桥接层将这两个引用绑定到它自己已加载的程序集(无论插件运行在哪个加载上下文中),因此随模块一起分发的副本会被忽略:
| 成员 | 调用时机 |
| --- | --- |
| `void Arm(IRuntimeModuleContext context)` | 步骤 4。通过上下文获取资源。 |
| `void GameplayReady()` | 步骤 5。 |
| `IReadOnlyDictionary Execute(string operation, IReadOnlyDictionary arguments)` | 步骤 6,针对每个已声明的操作(不带 `module..` 前缀)。类型化失败时抛出 `ModuleOperationException(code, message)`。 |
| `void Stop()` | 步骤 7。 |
`IRuntimeModuleContext` 提供 `SessionId`、`ModuleId`、`ModuleDirectory`、`Settings`、`DataDirectory`、`ProductVersion`、`SessionChannelName`、`Stopping`(取消令牌)、`IsCapabilityAvailable`、`Emit`、`Track`、`RunBackground` 和 `StartAuxiliaryProcess`。其中没有任何内容会暴露地址、可执行文件或其标识。组件必须能被 32 位插件加载(AnyCPU 或 x86,.NET 6)。请固定模块的程序所加载的每个文件(对于 .NET 程序:与 `.exe` 相邻的 `.dll`),使完整性检查覆盖它们。
## 已知限制
- 原生组件可以挂钩 `Omsi.exe` 的导入和 Direct3D 设备方法;对 OMSI 函数序言和调用点的钩子目前尚未向模块提供。
- 进程内的内容绑定检查使用启动所请求的地图(NEW_MAP);对于已保存情景,绑定在规划时对照该情景的地图进行检查。
- 设备钩子是虚表(vtable)钩子。当另一个组件把设备的原始方法写回时(在回归安装实例上于游戏开始后不久观察到),设备钩子会停止被调用且无法移除;此时清理会报告 `OL_E_MODULE_CLEANUP_INCOMPLETE`,且组件保持加载。已被注册表之外的其他方挂钩的设备方法会被拒绝(4);拒绝事件会指明拥有该方法的模块。
- 模块在 OMSI 正常退出时停止;会话停止会终止 OMSI,因此会话内的模块清理只会通过失败策略发生。
- 通知在托盘存在之后、OMSI 启动之后才显示;会话程序对在此之前必须说明的任何内容,使用其自己的窗口。