AFSIM 2.9.0 深度解析·进阶卷第 1 章 插件源码精读:16 个 WSF 插件的架构与实现

AFSIM 2.9.0 深度解析·进阶卷第 1 章 插件源码精读:16 个 WSF 插件的架构与实现

基础系列第 26 章把"怎样写一个可加载的 AFSIM 插件"拆成了二进制契约、扩展生命周期、对象注册、语法、构建与部署几条线。那一章解决的是"从零开始"的问题:入口符号叫什么、注册函数放在哪里、静态构建与动态构建有什么区别。本章换一个视角:不再从空白工程出发,而是打开 src/wsf_plugins 下的 16 个真实插件,看同一套插件契约如何在覆盖分析、标注、部署、防空指挥控制、空战、飞行动力学、互操作这些完全不同的领域里落地。

这样做有两个直接收益。

第一,插件契约只有在真实工程里才会暴露边界。一个最小示例只需要 WsfPluginVersion、WsfPluginSetup 和一条 RegisterFeature,看起来非常整齐;但 16 个插件里既有"只有脚本类型、没有语法文件"的轻量扩展,也有"目录名和目标名不一致"的混合工程,还有"根本不导出 Register_*、只能按插件加载"的例外。把这些差异放在一起看,才能判断哪些是契约允许的写法,哪些是 2.9.0 的历史包袱。

第二,插件源码是理解 AFSIM 扩展设计的最佳样本。核心库定义"什么是平台、传感器、处理器、事件",插件则回答"怎样在不改主程序的前提下增加一种新能力"。覆盖插件把网格点变成临时平台,标注插件把场景输入变成事件管道消息,备选位置插件把平台组件塞进 PreInitialize,多分辨率插件在平台初始化时替换组件,IADS 插件在场景层注册十二种处理器。每一个都在同一个扩展框架内选择了不同的接入点,这些选择背后是对生命周期、所有权和运行顺序的判断。

本章的边界需要先说明。

  • 本章讨论的是 WSF 动态插件 ,也就是由 WsfPluginManager 扫描目录、校验版本、调用 WsfPluginSetup 的那条线。WKF GUI 插件、Warlock 实时插件和 Wizard 编辑器插件属于另一套 ABI,放在第 08 章展开。
  • 本章不逐个深入 16 个插件的领域模型。电子战、IADS/C2、互操作、飞行动力学、空战行为分别由第 02~06 章展开;本章只在这里建立全景、共同骨架和代表插件的源码地图。
  • 本章不重复第 26 章已经讲透的 ABI 细节。遇到版本号、编译器指纹、入口符号等概念,只做必要回顾,重点放在 16 个真实工程的组织方式上。

1.0 为什么是 16 个插件,而不是 16 个孤立项目

src/wsf_plugins 下有 16 个插件目录,C/C++ 源码合计约 34.4 万行。这个数字很容易被误读成"16 个规模相当的项目"。实际情况恰好相反:最大的 wsf_oms_uci 包含约 181 万行 C/C++ 代码,其中绝大部分是从 UCI/ISM/XS 规范生成或随源码引入的代码;最小的 wsf_annotation 只有约 700 行核心代码。wsf_six_dof 和 wsf_p6dof 各自接近 9 万行,wsf_iads_c2_lib 约 6.3 万行,而 wsf_simdis 只有约 600 行。把它们放在同一张表里比较行数没有意义,有意义的是比较它们的接入方式。

先看目录本身的组织。每个插件目录都有一个名为 wsf_module 的标记文件,内容只有一行注释:

text 复制代码
# This file exists to include this directory in cmake processing

这个文件不是库,也不是配置,而是 CMake 发现模块根目录的"锚点"。顶层 src/CMakeLists.txt 在 :140-145 用 */wsf_module 找出所有模块根,再在 :155-165 递归收集每个模块下的 wsf_cmake_extension.cmake。因此 wsf_plugins/wsf_module 的作用是把 wsf_plugins 这个父目录纳入扫描,而每个插件目录里的 wsf_module 则让该插件自己成为一个可被发现的扩展单元。源码里没有"插件注册表"这样的中心文件,插件是通过文件系统标记和 CMake 扫描被发现的。

一个标准插件的目录结构大致如下:

text 复制代码
wsf_plugins/wsf_coverage/
├── wsf_module                     # CMake 发现锚点
├── wsf_cmake_extension.cmake      # 扩展元数据:名字、类型、源码路径
├── CMakeLists.txt                 # 顶层安装与文档配置
├── grammar/wsf_coverage.ag        # 输入语法描述
├── source/
│   ├── WsfCoverageRegistration.cpp # Register_* + 插件入口
│   ├── WsfCoverage*.cpp/.hpp       # 领域实现
│   └── CMakeLists.txt
├── test/                          # 单元测试
└── test_mission/                  # 场景级测试

但 16 个插件并不都长这样。wsf_scenario_analyzer 没有 grammar 目录,因为它只注册脚本类型;wsf_multiresolution 的语法文件由 Python 模板生成;wsf_simdis 不设置 WSF_EXT_TYPE,因此在构建系统里退化为普通库,而不是可被 dlopen 的插件;wsf_iads_c2_lib 同时构建一个静态支撑库和一个名为 wsf_iads_c2 的插件目标。这些差异不是偶然,而是同一个扩展框架在不同历史阶段、不同集成需求下留下的分层。

1.0.1 三条轴:接入深度、宿主形态、领域耦合

要理解 16 个插件,单按字母顺序或行数排序都不够。更有效的分类是三条轴。

第一条轴是接入深度,也就是插件向 AFSIM 内核伸进去多深。

  • 最浅的一层只注册脚本类型,例如 wsf_scenario_analyzer。它没有场景扩展、没有仿真扩展、没有语法文件,只把一组检查函数挂到脚本环境里;场景分析逻辑由外部 check suite 脚本调用。
  • 中间一层注册对象类型或组件,例如 wsf_alternate_locations、wsf_annotation、wsf_coverage。它们要么给平台增加一个组件,要么把场景输入转成事件,要么在仿真里创建网格平台。
  • 最深的一层直接注册新的平台部件、处理器、移动器甚至武器,例如 wsf_iads_c2_lib、wsf_six_dof、wsf_p6dof、wsf_air_combat、wsf_brawler、wsf_fires。这些插件必须理解平台初始化、部件生命周期、传感器/武器接口和事件调度,才能把领域模型接进主循环。

接入深度决定了插件需要关心哪些生命周期回调。只注册脚本类型的插件可以在 AddedToApplication 完成全部工作;注册对象类型的插件必须在 AddedToScenario 把类型表挂到场景上;需要运行期状态的插件还要实现 SimulationCreated、Initialize、Start、Complete。

第二条轴是宿主形态,也就是插件最终以什么形式被加载。

  • 绝大多数插件是真正的动态插件:wsf_cmake_extension.cmake 在 WSF_PLUGIN_BUILD 打开时把 WSF_EXT_TYPE 设为 plugin,构建成共享库,安装到 <app>_plugins 或 wsf_plugins,由 WsfPluginManager 扫描并调用 WsfPluginSetup。
  • wsf_simdis 是链接期可选工程。它的 wsf_cmake_extension.cmake 没有设置 WSF_EXT_TYPE,optional_projects.cmake:34 默认按 lib 处理;构建时它被直接链进可执行文件,注册代码由生成的 wsf_extensions.hpp 调用,不经过运行期插件管理器。
  • wsf_multiresolution 是"只能按插件加载"的例外。它没有 Register_* 自由函数,只导出 WsfPluginVersion 和 WsfPluginSetup;静态构建时生成的 WSF_REGISTER_EXTENSION(APP, wsf_multiresolution) 会引用一个不存在的符号。这个问题在第 1.7 节展开。
  • wsf_iads_c2_lib 和 wsf_scenario_analyzer 是混合工程:一个目录里既有静态支撑库,又有薄插件目标。目录名、目标名和扩展名不一定一致。

第三条轴是领域耦合 ,也就是插件是否依赖 wsf_mil、event_pipe、event_output 等外部模块。

  • wsf_air_combat 把 wsf_mil、wsf_brawler、wsf_p6dof、wsf_six_dof 都声明为必需依赖,缺一个就场景构造失败;event_pipe 是可选依赖。
  • wsf_oms_uci 依赖 wsf_mil,因为 UCI 组件要映射到传感器、武器等军事对象。
  • wsf_coverage、wsf_sosm、wsf_simdis、wsf_scenario_analyzer 不声明任何扩展依赖,它们要么只依赖核心接口,要么把依赖留在链接库里。
  • wsf_annotation、wsf_alternate_locations、wsf_air_combat 把 event_pipe 或 event_output 声明为可选依赖,缺少时插件仍能加载,只是不产生对应输出。

把这三条轴放在一起,16 个插件就不再是 16 个孤立项目,而是一张"扩展深度 × 交付形态 × 依赖强度"的地图。后面的章节会反复回到这张地图:一个插件的行为异常,往往不是领域代码写错,而是它在三条轴上的位置被误解了。

1.1 16 个插件的全景表

下面的表把 16 个目录按字母顺序列出,重点不是行数,而是"它解决什么问题、以什么形态交付、从哪里进入 AFSIM"。行数取自 .cpp/.hpp 文件统计,包含子库、生成代码和测试支撑代码;不同插件的统计口径不完全一致,只用于量级参考。

插件目录 解决的问题 交付形态 注册入口与量级
wsf_air_combat 空战态势感知处理器框架,按 Perceive/Assess/Predict 组织空战决策 真插件,依赖 wsf_mil、wsf_brawler、wsf_p6dof、wsf_six_dof,可选依赖 event_pipe source/WsfAirCombat.cpp:44,约 2.6 万行
wsf_alternate_locations 平台在初始化时按权重选择备选位置,或相对参考平台做 LLA/ARA 偏移 真插件,平台组件工厂 source/WsfAltLoc.cpp:126,约 1.4 千行
wsf_annotation 把场景级装饰、POI、距离环转成事件管道消息 真插件,事件管道生产者 source/WsfAnnotation.cpp:83,约 0.7 千行
wsf_argo8 接入外部 ARGO8 移动器 真插件,移动器类型 source/WsfARGO8_Interface.cpp:33,约 2.7 千行
wsf_brawler Brawler 飞行员/意识模型,含处理器、移动器、燃油模型 真插件,依赖 wsf_mil source/WsfBrawler.cpp:57,约 2.5 万行
wsf_coverage 传感器覆盖网格与覆盖效能指标(MOE) 真插件,场景扩展 + 仿真扩展 + 4 个类型表 source/WsfCoverageRegistration.cpp:40,约 1.6 万行
wsf_fires 弹道火力表、火力移动器、发射计算机 真插件,依赖 wsf_mil source/FiresTables.cpp:30,约 3.6 千行
wsf_iads_c2_lib 防空指挥控制管理器:资产、战斗、传感器、武器管理 混合工程:静态支撑库 + 插件目标 wsf_iads_c2 source/WsfIADSC2UnclassProcRegistration.cpp:80,约 6.3 万行
wsf_multiresolution 多保真度组件容器与多 run 保真度表 只按插件加载;无 Register_* source/MultiresolutionPluginRegistration.cpp:34,约 2.1 千行
wsf_oms_uci OMS/UCI 接口组件:传感器、武器、IRST、ESM、AMTI、计算机 真插件,目标名 wsf_oms_uci,注册符号 Register_wsf_uci source/WsfUCI.cpp:90,约 1.8 百万行(含生成 UCI)
wsf_p6dof 伪六自由度移动器、制导、燃油、编队与机动 真插件,依赖 wsf_mil、event_pipe source/WsfP6DOF.cpp:132,约 8.3 万行
wsf_scenario_analyzer 脚本级场景一致性检查 混合工程:静态库 + 薄插件;无语法文件 source/ScenarioAnalyzerPluginRegistration.cpp:48,约 3.3 千行
wsf_scenario_analyzer_iads_c2 IADS/C2 场景分析扩展 真插件,依赖 wsf_scenario_analyzer、wsf_iads_c2 source/ScenarioAnalyzerIADSC2.cpp:1870,约 1.9 千行
wsf_simdis 输出 SIMDIS ASCII Scenario Input(ASI) 链接期可选工程,不是 dlopen 插件 source/WsfSIMDIS_Interface.cpp:41,约 0.6 千行
wsf_six_dof 六自由度刚体/质点移动器、环境、编队与机动 真插件,依赖 wsf_mil、event_pipe;不注册 feature source/WsfSixDOF.cpp:145,约 8.9 万行
wsf_sosm 用 SOSM 光谱模型计算红外/光学传感器探测 真插件,注册 WSF_SOSM_SENSOR 核心传感器类型 source/WsfSOSM_Interface.cpp:35,约 1.1 万行

这张表里至少有五个"看起来应该一样,实际不一样"的点,值得先标出来。

第一,wsf_module 不是库。 每个插件目录里的 wsf_module 都只是让 CMake 把这个目录纳入模块扫描的标记文件。wsf_plugins/wsf_module 本身也不是一个库,而是让 wsf_plugins 成为模块根的锚点。源码里没有"插件清单"文件,src/CMakeLists.txt:140-165 的两次 glob 才是插件的发现机制。

第二,WSF_EXT_TYPE 决定交付形态。 标准插件在 wsf_cmake_extension.cmake 里写:

cmake 复制代码
set(WSF_EXT_NAME wsf_coverage)
if(WSF_PLUGIN_BUILD)
   set(WSF_EXT_TYPE plugin)
endif()
set(WSF_EXT_SOURCE_PATH .)

optional_projects.cmake:28-48 读取这段元数据;如果没有设置 WSF_EXT_TYPE,默认值在 :34 是 lib。lib 和 plugin 在构建系统里走两个分支:lib 会被生成 WSF_REGISTER_EXTENSION 并链进主程序,plugin 在共享构建下不会生成静态注册代码,而是作为运行期共享库安装。wsf_simdis 没有设置 WSF_EXT_TYPE,所以它是链接期工程;这不是笔误,而是它的交付方式。

第三,不是所有插件都导出 Register_*。 wsf_multiresolution 只导出 WsfPluginVersion 和 WsfPluginSetup,没有 Register_wsf_multiresolution;wsf_oms_uci 的注册符号叫 Register_wsf_uci,和目录名、扩展名都不一致;wsf_scenario_analyzer_iads_c2 的注册函数没有导出宏。这些差异会直接影响静态构建、符号查找和排障,第 1.7 节会逐个解释。

第四,行数不能代表复杂度。 wsf_oms_uci 的 181 万行里,绝大部分是 UCI/ISM/XS 生成代码;真正把 UCI 组件映射到 AFSIM 传感器、武器的适配层只有约 9 千行,加上脚本绑定约 6 千行。wsf_six_dof 的 8.9 万行则几乎全是飞行动力学、环境、编队和测试模型。读插件源码时,先问"这行数里有多少是生成代码、多少是领域实现",比直接看总行数有用。

第五,插件的领域耦合差异很大。 wsf_coverage 只依赖核心 WsfSensor/WsfPlatform 接口,因此可以在没有 wsf_mil 的场景里工作;wsf_air_combat 则把 wsf_mil、wsf_brawler、wsf_p6dof、wsf_six_dof 全部声明为必需依赖,缺一个就会在场景构造阶段失败。这不是"谁更高级",而是插件在扩展深度轴上的位置不同:越靠近领域模型,越需要把依赖显式化。

1.1.1 四类插件,而不是十六种写法

按"插件向内核注册什么"来分,16 个插件可以归成四类。

第一类:对象类型与组件扩展。 代表是 wsf_alternate_locations、wsf_coverage、wsf_multiresolution、wsf_sosm。它们在场景层注册类型表或组件工厂,让输入语言出现新的块或命令。wsf_alternate_locations 注册的是一个平台组件工厂;wsf_coverage 注册四个类型表(网格、覆盖、MOE、输出);wsf_multiresolution 注册九个组件类型表;wsf_sosm 注册一个新的传感器核心类型 WSF_SOSM_SENSOR。这类插件的共同点是:必须在输入解析开始前完成类型注册 ,注册窗口通常只有 AddedToScenario。

第二类:平台部件与行为模型。 代表是 wsf_six_dof、wsf_p6dof、wsf_brawler、wsf_fires、wsf_air_combat、wsf_iads_c2_lib。它们注册移动器、处理器、武器或管理器类型,直接参与仿真主循环。这类插件最关心 Initialize、Initialize2、PreInitialize、Update 等平台生命周期,以及传感器/武器/通信的交互接口。它们往往依赖 wsf_mil,因为雷达、武器、任务等对象在军事扩展里。

第三类:事件与可视化输出。 代表是 wsf_annotation、wsf_simdis。wsf_annotation 不注册任何核心模型类型,只在 Start 时向事件管道发送一批时间为 0 的标注消息;wsf_simdis 订阅传感器更新事件,把状态写成 SIMDIS ASI 文件。这类插件的接入点不是对象类型表,而是观察者或事件管道;它们的"功能"往往表现为输出而不是仿真状态。

第四类:脚本与分析工具。 代表是 wsf_scenario_analyzer、wsf_scenario_analyzer_iads_c2。它们只注册脚本类型,不注册语法、不注册场景扩展、不注册仿真扩展。场景分析逻辑由外部 check suite 脚本调用,结果以 ScenarioAnalyzerMessage 数组返回。这类插件最轻,但也最容易被误判为"没有生效",因为它们没有可见的输入命令或输出文件。

这四类不是互斥的。wsf_coverage 同时有类型表、仿真扩展和观察者;wsf_iads_c2_lib 同时注册处理器类型、脚本类型和事件;wsf_air_combat 既有处理器类型,也有脚本类型和事件管道观察者。分类的意义在于确定"这个插件最应该在哪个生命周期回调里找问题",而不是给它贴一个固定标签。

1.1.2 阅读顺序:先看骨架,再看差异

如果从第一行源码开始顺序读,16 个插件会变成 16 条互不相干的阅读路线。更高效的做法是分三层。

第一层,读共同骨架。 任选一个标准插件(推荐 wsf_coverage 或 wsf_p6dof),把 wsf_module、wsf_cmake_extension.cmake、Register_*、WsfPluginVersion/WsfPluginSetup、ApplicationExtension、ScenarioExtension、SimulationExtension 这条链走通。第 1.2~1.5 节就是这条骨架。

第二层,读例外。 直接看 wsf_multiresolution、wsf_oms_uci、wsf_simdis、wsf_six_dof、wsf_scenario_analyzer 这几个"不标准"的工程,理解 WSF_EXT_TYPE、注册符号、静态/动态构建、依赖排序的边界。第 1.6~1.7 节会集中处理这些例外。

第三层,读代表插件的领域实现。 覆盖分析看 wsf_coverage,标注与部署看 wsf_annotation 和 wsf_alternate_locations,多保真度看 wsf_multiresolution,光谱传感器看 wsf_sosm,场景分析看 wsf_scenario_analyzer。第 1.9~1.11 节按这个顺序展开。

这条阅读路线的好处是:先建立"插件在 AFSIM 里如何被装载、如何注册、何时初始化"的骨架,再去看每个领域插件在骨架上加了什么。否则很容易把某个插件的特殊写法当成通用规则,或者把通用生命周期当成某个插件的私有逻辑。

下一节从构建系统开始,先回答一个最基础的问题:一个插件目录是如何被 CMake 发现、如何决定编译成共享库还是静态库、又如何被安装到运行目录的。

1.2 统一骨架:CMake 如何发现、分类和安装插件

插件的第一个分水岭不在 C++ 代码里,而在构建系统里。同一个 Register_* 函数,在不同的 WSF_EXT_TYPE、不同的 BUILD_SHARED_LIBS 下会走上完全不同的交付路径。理解这条路径,才能解释"为什么源码里明明有插件,运行时却找不到""为什么静态构建下插件目录完全不起作用""为什么某些插件在静态构建里会链接失败"。

1.2.1 三个顶层开关

AFSIM 的构建系统用三个开关控制插件生态。

第一个是 WSF_PLUGIN_BUILD。它在 src/CMakeLists.txt:45 定义,默认值为 TRUE:

cmake 复制代码
option(WSF_PLUGIN_BUILD "Build plugins as shared libraries" TRUE)

打开时,src/CMakeLists.txt:274-279 把 BUILD_SHARED_LIBS 设为 TRUE,所有插件目标默认构建成共享库;关闭时,src/CMakeLists.txt:280-301 切到静态构建,MSVC 从 /MD 改为 /MT,Unix 构建静态归档。这个开关不是"是否编译插件",而是"插件以共享库还是静态库交付"。

第二个是 WSF_ADD_EXTENSION_PATH。它在 src/CMakeLists.txt:59 提供给外部工程注入额外扩展目录,:147-154 把这些目录加入模块根列表。这样二次开发者不必把插件放进 src/wsf_plugins,也能让顶层构建发现它。

第三个是 ENABLE_EXTENSION_DEFAULT。它在 src/CMakeLists.txt:65-67 给每个扩展选项提供默认值,决定 BUILD_WITH_<project> 初始是开还是关。默认打开意味着"发现即可构建",但用户仍可以在 CMake 缓存里逐个关闭。

1.2.2 两次 glob:模块根与扩展元数据

插件的发现分两步。

第一步,src/CMakeLists.txt:140-145 用 */wsf_module 找出所有模块根:

cmake 复制代码
file(GLOB MODULE_ROOTS "${CMAKE_CURRENT_SOURCE_DIR}/*/wsf_module")

wsf_plugins/wsf_module 这个标记文件让 wsf_plugins 成为模块根;每个插件目录里的 wsf_module 则让插件自己成为可扫描的模块。注意这里匹配的是"目录下存在名为 wsf_module 的文件",而不是"目录名以 wsf_ 开头"。因此一个外部扩展只要在自己的目录里放一个 wsf_module,就能被纳入扫描。

第二步,src/CMakeLists.txt:155-165 在每个模块根下递归收集 wsf_cmake_extension.cmake:

cmake 复制代码
file(GLOB MODULE_FILES "${MODULE_ROOT}/*/wsf_cmake_extension.cmake")

递归是有意的:一个扩展目录内部可能还有子扩展。CMake 不读取 C++ 源码,也不维护插件清单,它只认这两个文件标记。这也解释了一个常见问题:插件目录里少一个 wsf_module,CMake 根本不会发现它;少一个 wsf_cmake_extension.cmake,即使发现了模块根也不会构建它。

1.2.3 元数据文件:三个变量决定交付形态

每个插件的 wsf_cmake_extension.cmake 通常只有几行,但决定了很多事情。以 wsf_coverage 为例(wsf_plugins/wsf_coverage/wsf_cmake_extension.cmake:10-13):

cmake 复制代码
set(WSF_EXT_NAME wsf_coverage)
if(WSF_PLUGIN_BUILD)
   set(WSF_EXT_TYPE plugin)
endif()
set(WSF_EXT_SOURCE_PATH .)

WSF_EXT_NAME 是扩展的内部名字,后续会用于 BUILD_WITH_<name> 选项、注册函数名 Register_<name>、扩展名和安装目录。WSF_EXT_TYPE 决定构建分支:plugin、lib 或 exe。WSF_EXT_SOURCE_PATH 指向真正的源码目录,. 表示当前目录;混合工程可以指向 source 或别处。

core/wsf/optional_projects.cmake:28-48 读取这三个变量:

  • optional_projects.cmake:34 先给 WSF_EXT_TYPE 一个默认值 lib;
  • optional_projects.cmake:40 把每个目录的 wsf_cmake_extension.cmake include 进来,让上面三行生效;
  • optional_projects.cmake:44 拼出 PROJROOT = EXT_DIR/WSF_EXT_SOURCE_PATH;
  • optional_projects.cmake:46 调用 add_wsf_optional_project 创建 BUILD_WITH_<PROJNAME> 缓存变量和全局属性。

这里是第一个容易踩的坑:没有设置 WSF_EXT_TYPE 的插件会被当成 lib 。wsf_plugins/wsf_simdis/wsf_cmake_extension.cmake:11-12 只设置了名字和源码路径,没有 WSF_EXT_TYPE,所以它在构建系统里是普通库,不是运行期插件。这不是遗漏,而是它选择的交付方式:SIMDIS 输出在链接期直接进 mission,由生成的注册头调用,不经过 dlopen。

1.2.4 三个构建分支:lib、plugin、exe

optional_projects.cmake 根据 WSF_EXT_TYPE 走三个分支。

lib 分支 (:69-90):调用 add_subdirectory 构建目标;如果目标是静态库或共享库,生成 WSF_REGISTER_EXTENSION(APP, NAME) 代码;最后把所有非 plugin 扩展链接进主程序(:152-157)。lib 的语义是"编译进主程序的扩展",它既可以是静态库,也可以是共享库,但注册发生在链接期,不依赖运行期插件管理器。

plugin 分支 (optional_projects.cmake:92-112):同样 add_subdirectory,但只有在目标被构建成静态库时才生成注册代码(:101-108)。在默认的共享构建下,plugin 目标不进入 wsf_extensions.hpp,也不被 link_wsf_optional_projects 链接;它被安装成独立的 .dll/.so,由运行期插件管理器加载。

exe 分支 (optional_projects.cmake:114-135):用于构建独立可执行文件,例如某些工具或测试程序。它和插件运行期加载没有直接关系,但共享同一套可选工程发现机制。

这三个分支的差别可以用一张表概括:

WSF_EXT_TYPE 默认 WSF_PLUGIN_BUILD=ON 静态构建 注册方式 典型插件
lib 共享库,但被链接进主程序 静态库,链接进主程序 生成 wsf_extensions.hpp,链接期注册 wsf_simdis
plugin 独立共享库,安装到插件目录 静态库,生成注册代码并链接 动态:WsfPluginSetup;静态:生成注册 大多数插件
exe 独立可执行文件 同左 不适用 工具/测试目标

这张表解释了两个常见现象。第一,默认构建下,lib 和 plugin 都可能是共享库,但只有 plugin 会被安装到 <app>_plugins/wsf_plugins 并由 WsfPluginManager 扫描;lib 的共享库是主程序的一部分。第二,静态构建下,plugin 目标会退化成静态库并生成注册代码,WsfPluginVersion/WsfPluginSetup 仍然被编译,但永远不会被调用。

1.2.5 目标模板与安装:从源码目录到运行目录

插件目标的实际创建通常通过 wsf_project_template.cmake 的宏完成。它负责 add_library、链接 ${WSF_LIBS}、生成导出头、设置目录属性,并在安装阶段根据类型选择 swdev_plugin_install 或 swdev_lib_install(cmake/Modules/wsf_project_template.cmake:178-184)。

swdev_plugin_install 在 cmake/Modules/swdev_project.cmake:96-117 做两件事:

  1. 安装到 ${INSTALL_PLUGIN_PATH}/<PLUGIN_DIR>,默认 INSTALL_PLUGIN_PATH 是 .;
  2. 构建后把共享库复制到 $<TARGET_FILE_DIR>/<PLUGIN_DIR>,让开发者从构建树直接运行程序时也能找到插件。

PLUGIN_DIR 由插件自己决定。wsf_plugins/wsf_coverage/source/CMakeLists.txt:38-42 使用 mission_plugins:

cmake 复制代码
if(WSF_PLUGIN_BUILD)
   swdev_plugin_install(${PROJECT_NAME} mission_plugins)
else()
   swdev_lib_install(${PROJECT_NAME})
endif()

这意味着 coverage 插件在默认构建下会被复制到 mission.exe 同级的 mission_plugins/ 目录。WsfApplication.cpp:104-111 的默认搜索路径正好包含 ../<app>_plugins 和 ../wsf_plugins,所以 mission 能自动发现它。反过来,如果插件安装到了 warlock_plugins,mission 不会去那里找;第 1.7 节会把这条搜索路径展开。

1.2.6 语法文件也走构建系统

插件的 .ag 文件不是编译输入,但它需要被复制到运行目录,供 grammar_interface 扩展收集。wsf_grammar_file 宏(core/wsf/CMakeLists.txt:19)负责两件事:把 .ag 复制到构建目录的 grammar/,并把文件加入源文件列表让 IDE 显示。它不做代码生成,也不参与运行期解析。

插件的 source/CMakeLists.txt 通常会这样写:

cmake 复制代码
file(GLOB SRCS *.cpp *.hpp)
wsf_grammar_file(SRCS "${CMAKE_CURRENT_SOURCE_DIR}/../grammar/wsf_coverage.ag")
add_library(${PROJECT_NAME} ${SRCS})

漏掉 wsf_grammar_file 不会影响运行时命令解析------因为运行期解析由 ProcessInput() 完成------但会让构建目录缺少 .ag,编辑器补全和 --ide-output 的语法部分就不完整。这个"运行正常、编辑器和 IDE 不正常"的分裂,是插件工程里最常见的"看起来没问题"的故障之一。

1.2.7 一个最小插件目录的构建清单

把上面的内容压缩成一个可执行的检查清单:

  1. 目录下必须有 wsf_module 和 wsf_cmake_extension.cmake,后者设置 WSF_EXT_NAME、WSF_EXT_TYPE、WSF_EXT_SOURCE_PATH;要做运行期插件,WSF_EXT_TYPE 必须在 WSF_PLUGIN_BUILD 为真时设为 plugin。
  2. source/CMakeLists.txt 创建目标、链接 ${WSF_LIBS}、生成导出头,并按类型选择 swdev_plugin_install 或 swdev_lib_install。
  3. 有语法文件时调用 wsf_grammar_file;否则运行不受影响,但 IDE 元数据缺失。
  4. 安装目录必须与宿主搜索路径匹配:mission 默认找 mission_plugins 和 wsf_plugins,Warlock/Wizard 另有自己的目录;静态构建的 plugin 不走运行期插件管理器。

这套骨架在 13 个标准插件里几乎一模一样。真正需要单独理解的,是那些不按骨架走的工程:wsf_multiresolution 没有 Register_*,wsf_oms_uci 的符号名和目录名不一致,wsf_simdis 根本不走运行期插件路径,wsf_six_dof 不注册 feature。它们不是"坏例子",而是这套构建系统在历史演进中留下的不同交付方式。下一节进入运行期,看看这些共享库是怎样被 WsfApplication 找到、校验并调用的。

1.3 启动链:从可执行文件到 WsfPluginSetup

CMake 只决定插件长什么样、被安装到哪里。真正把插件接进 AFSIM 的,是进程启动时的那一小段代码。mission 的 main 函数在 mission/source/mission.cpp:80 开始,先初始化日志,再构造 WsfStandardApplication,然后注册内置扩展、可选扩展和 XIO 接口,最后才处理命令行、加载场景、创建仿真。插件加载发生在 WsfApplication 构造期间,也就是说,在任何场景文件被读取之前,插件已经完成了版本校验和注册。

这个顺序非常关键。它意味着插件可以在 WsfPluginSetup 里访问 WsfApplication 的脚本类型系统、日志系统和测试环境,但不能访问 WsfScenario、平台、传感器或仿真时钟------这些对象要到更晚才存在。很多"插件里拿不到场景"的问题,不是插件写错,而是把代码放错了生命周期。

1.3.1 WsfApplication 构造期的顺序

WsfApplication 的构造函数在 core/wsf/source/WsfApplication.cpp:47。它大致做以下几件事:

  1. 单实例检查(:62-68):一个进程只能有一个 WsfApplication,重复构造会直接终止。
  2. 创建扩展列表 mExtensionList(:77)。
  3. 注册默认 feature(:79,实现 :235-277):操作系统、CPU、地址、构建类型等。
  4. 创建系统日志 WsfSystemLog(:81)。
  5. 注册属性容器、脚本类型、脚本管理器(:84-86)。
  6. 初始化测试环境(:92)。这一步被特意提前,因为插件在 WsfPluginSetup 里可能检查 IsTestingEnabled()。
  7. 创建 WsfPluginManager(:94)。
  8. 解析插件搜索路径(:97-118)。
  9. 调用 LoadPlugins()(:120)。

注意第 6 步在第 7 步之前。测试环境必须在插件加载前就绪,否则插件无法在测试模式下改变行为。这个细节在源码注释里有明确说明(:90-91),也是理解"为什么插件能看到测试标志"的关键。

1.3.2 搜索路径:环境变量是替换,不是追加

WsfApplication.cpp:97-118 的搜索路径逻辑如下:

  1. 读取环境变量 WSF_PLUGIN_PATH(:97)。
  2. 如果非空,只把该字符串作为一个搜索项加入列表(:100),默认路径完全不加入。
  3. 如果为空,先使用构造参数传入的路径;若参数为空,追加两个默认值(:104-111):
    • ../<application_name>_plugins
    • ../wsf_plugins
  4. 每个路径都相对于可执行文件目录解析(:115)。

这里有三个坑:WSF_PLUGIN_PATH 是替换语义,设置后 ../mission_plugins 和 ../wsf_plugins 都不再搜索;它的值不会按分隔符拆分,多个目录会被当成一个不存在的路径静默跳过(WsfPluginManager.cpp:85);Windows 上设为空串也会走替换分支,结果是 "Plugins Loaded:" 为空。路径最终相对 exe 目录解析(tools/util/source/UtPath.cpp:252-257),所以从构建树和安装目录运行看到的插件集可能不同。

1.3.3 扫描:递归目录、文件名去重

WsfPluginManager::LoadPlugins 在 core/wsf/source/WsfPluginManager.cpp:72-102。它先打印插件 API 版本信息(:76-80),然后对每个搜索路径判断是文件还是目录:

  • 文件:直接 LoadPlugin(:89-93);
  • 目录:调用 LoadAll(:95-99)。

UtPluginManager::LoadAll(tools/util/source/UtPluginManager.cpp:173-213)会递归扫描目录,过滤 *.dll 和 *.so(:38-42、:182-187),默认递归深度 1000(tools/util/source/UtScanDir.hpp:62-65),跳过以 . 开头的子目录(UtScanDir.cpp:119)。加载成功的插件插入 mLoadedPlugins(UtPluginManager.cpp:206)。

去重键是文件名字符串 (UtPluginManager.cpp:224、:415),不是规范化后的真实路径。这意味着同一个 DLL 用两种不同路径写法可以被初始化两次:LoadLibrary 只增加引用计数,WsfPluginSetup 会再执行一遍。插件里的 ExtensionIsRegistered 幂等检查能挡住重复注册,但不是所有插件都写了这个检查;重复执行 RegisterFeature 可能产生重复 feature,重复注册扩展则会抛 WsfDuplicateExtension。

目录枚举顺序就是加载顺序。Windows 用 FindFirstFile,Linux 用 scandir(..., nullptr)(UtScanDir.cpp:47),都没有显式排序。在依赖排序发生之前,插件之间的注册顺序可能随文件系统变化。这也是为什么插件不应该依赖"另一个插件一定先加载",而应该通过 ExtensionDepends 声明依赖。

1.3.4 版本与编译器校验:两道门

WsfPluginManager::LoadPluginInitialize(WsfPluginManager.cpp:105-149)对每个共享库做三件事:

  1. 查找 WsfPluginVersion 符号(:108);
  2. 调用它,拿到 UtPluginVersion,然后 ValidateVersion(:113-115);
  3. 校验通过后查找 WsfPluginSetup(:117),并立即调用(:120)。

UtPluginManager::ValidateVersion(tools/util/source/UtPluginManager.cpp:85-120)先比较 major/minor,要求完全相等 (:85-93),再比较编译器指纹(:95-120)。AFSIM 2.9.0 的 WSF_PLUGIN_API_MAJOR_VERSION/MINOR_VERSION 来自 WsfPlugin.hpp:18-20,值就是 2/9;编译器指纹由 UtPluginCompilerVersionString() 生成,格式是:

text 复制代码
<win|lnx>_<compiler>_<32bit|64bit>_<release|debug>[-hwe]

平台、位数、构建类型必须完全一致。Windows 上 MSVC 版本用 _MSC_VER/100 比较,VS2015/2017/2019 都是 19,因此互认;Linux 上要求插件编译器版本不高于宿主。PROMOTE_HARDWARE_EXCEPTIONS 默认打开(src/CMakeLists.txt:41),会给构建类型加 -hwe,插件和宿主不一致也会被拒绝。

这两道门的失败表现不同:

  • 版本不匹配:日志报 Version mismatch.,插件不 Setup;
  • 编译器不匹配:日志报 Compiler mismatch.,插件不 Setup;
  • 符号缺失:日志报找不到 WsfPluginVersion 或 WsfPluginSetup。

版本/编译器失败时,DLL 已经被加载,DllMain 已经执行,而且不会立即卸载(UtPluginManager.cpp:409-417 先把它放进 map,析构时才 FreeLibrary)。这意味着插件的静态初始化代码可能已经产生了副作用,即使它最终没有被接受。排查这类问题时,不能只看"插件没加载",还要看 DLL 的全局构造是否做了危险操作。

1.3.5 入口协议:三个名字,两个必需

一个标准 WSF 插件只要求两个导出符号:

cpp 复制代码
extern "C" void WsfPluginVersion(UtPluginVersion& aVersion);
extern "C" void WsfPluginSetup(WsfApplication& aApplication);

wsf_coverage 的实现是 WsfCoverageRegistration.cpp:50-59:

cpp 复制代码
extern "C"
{
   UT_PLUGIN_EXPORT void WsfPluginVersion(UtPluginVersion& aVersion)
   {
      aVersion = UtPluginVersion(WSF_PLUGIN_API_MAJOR_VERSION,
                                 WSF_PLUGIN_API_MINOR_VERSION,
                                 WSF_PLUGIN_API_COMPILER_STRING);
   }

   UT_PLUGIN_EXPORT void WsfPluginSetup(WsfApplication& aApplication)
   {
      Register_wsf_coverage(aApplication);
   }
}

Register_wsf_coverage 本身在 :40,负责 ExtensionIsRegistered 检查、RegisterFeature 和 RegisterExtension。WsfPluginSetup 只是把它转接给加载器。这种"入口函数 + 注册函数"的分离在 16 个插件里很常见,因为同一个注册函数还要服务于静态构建的 WSF_REGISTER_EXTENSION 宏。

WsfPluginManager 还尝试查找一个可选的 WSF_PluginComplete 符号(WsfPluginManager.cpp:57),但全树没有插件实现它;而且回调发生在 WsfApplication 已经删除扩展列表和系统日志之后(WsfApplication.cpp:127-131)。因此这个钩子在 2.9.0 里实际上是不可用的。插件清理应该放在 WsfPluginSetup 注册的扩展的 Complete/析构里,而不是指望插件级 complete 回调。

还有一个容易忽略的细节:WsfPluginManager 覆写了 UtPluginManager 的 LoadPluginInitialize,因此它会严格检查 WsfPluginVersion;而通用 UtPluginManager::LoadPluginInitialize 即使找不到入口符号也可能返回 true(UtPluginManager.cpp:452-459)。这意味着用通用插件管理器加载一个任意 DLL,可能被计为"已加载",但 AFSIM 的 WSF 插件不会出现这种假成功。

1.3.6 插件加载与扩展注册的先后顺序

插件加载发生在 main 的 WsfStandardApplication 构造期间,而 RegisterBuiltinExtensions 和 RegisterOptionalExtensions 在 mission/source/mission.cpp:90-93,也就是构造之后。因此:

  1. 插件的 WsfPluginSetup 先执行;
  2. 内置扩展和静态可选扩展随后注册;
  3. WsfScenario 构造时,ScenarioCreated 回调按扩展排序后的顺序执行;
  4. 场景输入解析时,扩展的 ProcessInput 按 map 字典序轮询,而不是按注册顺序;
  5. 仿真创建时,场景扩展克隆出仿真扩展,仿真扩展按插入顺序初始化。

这个顺序带来两个常见误解。

误解一:插件先加载,所以插件扩展先处理输入。 实际上输入处理顺序由 WsfScenario::ProcessExtensionInput 的 map 字典序决定(WsfScenario.cpp:857-867),不是插件加载顺序,也不是依赖顺序。一个插件如果依赖另一个插件先解析某个命令,不能靠"我先加载"来保证。

误解二:插件加载成功就等于功能可用。 加载成功只说明版本/编译器/入口符号通过;扩展是否注册、类型表是否挂上、输入命令是否被识别,要在后续生命周期里才会暴露。最典型的是 wsf_multiresolution:它能被加载,WsfPluginSetup 也能执行,但它没有 Register_* 自由函数,静态构建下的生成的注册代码会找不到符号。这类问题必须在构建期或启动日志里查,而不是等场景报 unknown command。

下一节把"加载"之后发生的事情拆开:Register_* 到底注册了什么,Application、Scenario、Simulation 三级扩展各自负责什么,以及 16 个插件为什么选择了不同的注册层级。

1.4 注册链:Register_* 到底注册了什么

WsfPluginSetup 只做一件事:把插件自己的注册函数接进 WsfApplication。真正的注册动作发生在 Register_<name> 里。这个函数通常只有十几行,但它同时承担四个职责:幂等检查、feature 注册、扩展对象注册、依赖声明。理解这四件事的区别,才能看懂 16 个插件为什么在注册链上长得不一样。

1.4.1 一个标准注册函数

以 wsf_coverage 为例(wsf_plugins/wsf_coverage/source/WsfCoverageRegistration.cpp:40-48):

cpp 复制代码
void WSF_COVERAGE_EXPORT Register_wsf_coverage(WsfApplication& aApplication)
{
   if (!aApplication.ExtensionIsRegistered("wsf_coverage"))
   {
      aApplication.RegisterFeature("wsf_coverage");
      aApplication.RegisterFeature("coverage", "wsf_coverage");
      aApplication.RegisterExtension(
         "wsf_coverage",
         ut::make_unique<ApplicationExtension>());
   }
}

这四行分别对应四个层面。

第一,ExtensionIsRegistered 是幂等保护。 动态构建下,插件只会被 WsfPluginSetup 调用一次;但静态构建、重复路径、手工调用都可能让同一个注册函数执行多次。重复 RegisterExtension 会抛 WsfDuplicateExtension(WsfExtensionList.cpp:20-28),所以标准插件都会先检查。

第二,RegisterFeature 注册的是"功能名",不是扩展对象。 feature 用于日志、版本信息和用户查询,可以有一个或多个别名。coverage 注册了 wsf_coverage 和别名 coverage;wsf_air_combat 注册 air_combat;wsf_six_dof 甚至没有注册 feature。feature 注册没有去重检查(WsfApplication.cpp:224-230),重复注册会产生重复项。它不是加载成功与否的判据,只是可发现性元数据。

第三,RegisterExtension 注册的是扩展对象。 WsfApplication::RegisterExtension 在 WsfApplication.cpp:173-178 把 unique_ptr 交给 WsfExtensionList,随后调用扩展的 AddedToApplication。如果同名扩展已经存在,WsfExtensionList::AddExtension 会抛异常。扩展名通常与 feature 同名,但不是强制的;例如 wsf_oms_uci 的 feature 名是 wsf_oms_uci,注册符号却叫 Register_wsf_uci。

第四,ExtensionDepends 声明依赖。 它可以在注册函数里、AddedToApplication 里或任何扩展初始化阶段调用,但只有在后续 SortExtensions 执行时才会生效。依赖不是"加载顺序",而是"排序约束";第 1.6 节会专门讲它的生效边界。

1.4.2 三级扩展:Application、Scenario、Simulation

AFSIM 的扩展分为三级,每级解决不同生命周期的问题。

Application 扩展 (WsfApplicationExtension)活在整个进程内,负责在每个场景创建时派生场景扩展。它的关键回调是 ScenarioCreated 和 SimulationCreated。WsfDefaultApplicationExtension<SCENARIO_EXTENSION>(WsfApplicationExtension.hpp:130-138)是最常用的模板:它在 ScenarioCreated 里调用 WsfPrivate::RegisterScenarioExtension,把一个新的场景扩展挂到场景上。coverage、altloc、annotation 等插件都用这个模板。

Scenario 扩展 (WsfScenarioExtension)活在一份场景内,负责输入解析、类型表注册和场景级状态。它的关键回调是 AddedToScenario、ProcessInput、FileLoaded、Complete、Complete2、SimulationCreated。WsfDefaultScenarioExtension<SIMULATION_EXTENSION>(WsfScenarioExtension.hpp:96-104)在 SimulationCreated 里注册一个仿真扩展;WsfCloneableScenarioExtension(:73-86)则持有一个仿真扩展原型,每次创建仿真时克隆一份,适合需要"每次运行都重置状态"的插件。coverage 用的是 cloneable 版本。

Simulation 扩展 (WsfSimulationExtension)活在一次仿真内,负责运行期状态、观察者、事件和输出。它的关键回调是 AddedToSimulation、Initialize、PrepareExtension、PlatformsInitialized、PendingStart、Start、Complete。仿真扩展不参与输入解析,也不应该持有场景对象的裸指针;它的生命周期比场景短,但可能被多次创建。

三级扩展的关系可以画成一条链:

text 复制代码
Register_<name>(WsfApplication&)
  └─ RegisterExtension(name, ApplicationExtension)
       └─ ScenarioCreated(scenario)
            └─ RegisterScenarioExtension(name, ScenarioExtension)
                 ├─ AddedToScenario()          注册类型表/组件工厂
                 ├─ ProcessInput()             解析场景输入
                 ├─ Complete()/Complete2()     校验配置
                 └─ SimulationCreated(sim)
                      └─ RegisterSimulationExtension(name, SimulationExtension)
                           ├─ Initialize()
                           ├─ PendingStart()
                           ├─ Start()
                           └─ Complete()

这张图是理解插件行为的骨架。一个插件的问题通常可以定位到某一级:类型表没注册,看 AddedToScenario;输入命令不识别,看 ProcessInput;运行期没有数据,看 SimulationCreated 之后的 Initialize/PendingStart;输出文件为空,看 Start/Complete。

1.4.3 为什么 16 个插件选择的层级不同

不是每个插件都需要三级扩展。选择哪一级,取决于它要修改什么。

只注册脚本类型:Application 级就够。 wsf_scenario_analyzer 的 ScenarioAnalyzerExtension 在 AddedToApplication 调用 ScenarioAnalyzerRegisterScriptTypes(ScenarioAnalyzerPluginRegistration.cpp:39-46),没有场景扩展和仿真扩展。它不需要解析输入,也不需要在仿真里保存状态;脚本调用时通过 GetSIMULATION 拿当前仿真。

只注册类型表或组件工厂:Scenario 级。 wsf_alternate_locations 在 AddedToScenario 添加 ComponentTypes(WsfAltLoc.cpp:88-92),ComponentTypes 构造函数注册组件工厂(:64-75)。它不需要仿真扩展,因为位置选择发生在平台的 PreInitialize 里,组件自己就能完成。

需要运行期状态或观察者:Simulation 级。 wsf_coverage 的场景扩展在 AddedToScenario 注册四个类型表(WsfCoverageScenarioExtension.cpp:32-40),在 SimulationCreated 克隆出仿真扩展;仿真扩展负责 ProcessInput、Initialize、PendingStart、Complete(WsfCoverageSimulationExtension.cpp:32-147)。覆盖区间是运行期数据,必须放在仿真级。

需要事件管道:Scenario 级注册事件 schema,Simulation 级发消息。 wsf_annotation 的 WsfAnnotationExtension 在 AddedToScenario 查找 WsfEventPipeExtension 并注册事件(WsfAnnotation.cpp:63-70),在 SimulationCreated 注册一个带 EventPipeInterface 的仿真扩展(:77-81)。标注消息在仿真 Start 时发送,所以必须有仿真级对象。

多层注册:IADS 这类体系插件。 wsf_iads_c2_lib 的应用扩展注册脚本类型并创建场景扩展(WsfIADSC2UnclassProcRegistration.cpp:55);场景扩展把十二种处理器类型加入类型表(WsfIADSC2ScenarioExtension.cpp:53-75);每个处理器还有自己的 Create 和 RegisterScriptTypes 工厂。它不是"一个插件注册一个对象",而是"一个插件注册一族对象",所以注册代码分散在多个文件里。

这张层级表可以用来看任何一个插件:

插件 Application 扩展 Scenario 扩展 Simulation 扩展 主要注册内容
wsf_coverage 有 有 有(cloneable) 4 个类型表、覆盖对象、观察者
wsf_annotation 有 有 有 事件 schema、事件管道日志器
wsf_alternate_locations 有 有 有 组件工厂、初始化事件
wsf_scenario_analyzer 有 无 无 脚本类型
wsf_multiresolution 有(2 个) 有 无 9 个组件类型表、多 run 表
wsf_sosm 有 有 无 WSF_SOSM_SENSOR 类型、SOSM 管理器
wsf_iads_c2_lib 有 有 视处理器而定 12 种处理器类型、脚本类型
wsf_six_dof 有 有 无 移动器类型、环境、编队
wsf_p6dof 有 有 无 移动器/制导/燃油/武器类型
wsf_air_combat 有 有 有 处理器类型、观察者、事件管道
wsf_simdis 链接期注册 有 无 观察者、ASI 写入器
wsf_oms_uci 有 有 无 UCI 组件类型、接口

"无仿真扩展"不等于"没有运行期行为"。wsf_six_dof 的移动器在平台初始化阶段工作,不需要单独的仿真扩展;wsf_multiresolution 在平台 PreInitialize 替换组件,也不需要仿真扩展。选择层级的标准不是"功能复杂不复杂",而是"状态属于进程、场景还是仿真"。

1.4.4 注册窗口:类型表必须在输入开始前挂上

所有插件里,最严格的注册窗口是类型表 。WsfObjectTypeList 在输入加载完成后会冻结,加载完成后再 Add 类型会抛 ScenarioChange 异常。因此:

  • 类型的原型必须在 AddedToScenario 或更早注册;
  • 输入里引用类型时,类型表已经存在;
  • 插件不能在 SimulationCreated 或运行期动态增加新类型,只能克隆已有类型。

wsf_coverage 的四个类型表在 WsfCoverageScenarioExtension::AddedToScenario 注册(WsfCoverageScenarioExtension.cpp:32-40),所以输入里的 grid、coverage、moe 块能被解析。wsf_multiresolution 的九个类型表在 WsfMultiresolutionTypesRegistration::AddedToScenario 注册(WsfMultiresolutionTypes.cpp:14-27),所以 multiresolution_sensor 这类块能出现在平台定义里。wsf_alternate_locations 的组件类型表在 AddedToScenario 注册(WsfAltLoc.cpp:88-92),所以 alternate_locations 块能在平台或平台类型里使用。

反过来,如果一个插件在 SimulationCreated 才注册类型,输入解析早已结束,场景文本里的引用会报 unknown type;新类型只对后续手工克隆可见。这个错误在"插件加载日志正常、输入却报 unknown command"的场景里非常常见。

1.4.5 重复注册与命名冲突

注册链上有三个不同的"重复"问题,表现完全不同。

扩展重复。 同名 RegisterExtension 会抛 WsfDuplicateExtension(WsfExtensionList.cpp:20-28),没有日志,只有异常文本。插件必须自己用 ExtensionIsRegistered 保护。coverage、altloc 等标准插件都写了这个检查;wsf_six_dof 没有注册 feature,但仍在 Register_wsf_six_dof 里做了扩展检查。

feature 重复。 RegisterFeature 不去重,重复注册会产生重复项。它不会阻止程序运行,但会让版本/功能列表出现重复。插件别名(如 coverage 指向 wsf_coverage)应该只注册一次。

类型/命令重复。 两个插件注册同名类型或同名命令时,类型表会抛重复类型异常,命令解析则取决于 ProcessInput 的轮询顺序。WsfScenario::ProcessExtensionInput 按 map 字典序轮询(WsfScenario.cpp:857-867),不是按依赖顺序;两个插件都认领同一个命令时,先被轮询到的扩展赢。这类冲突不会在加载期报错,只会在特定场景下表现为"命令被另一个插件吃掉"。

下一节沿着注册链继续往下,进入三级生命周期的具体回调:AddedToScenario、ProcessInput、Complete、SimulationCreated、Initialize、Start、Complete 分别在什么时候被调用,以及 16 个插件在这些回调里各自做了什么。

1.5 三级生命周期:九个回调的精确时序

插件作者最容易犯的错误,不是某个 API 用错,而是把代码放在错误的生命周期回调里。同一个"注册类型"动作,放在 AddedToScenario 里能用,放在 SimulationCreated 里就报 unknown type;同一个"读取平台"动作,放在 Initialize 里可能读到空列表,放在 PendingStart 里才正确。要避免这类错误,必须把三级扩展的对象归属和九个回调的触发点对齐。

AFSIM 的扩展不是一层,而是三层嵌套:

text 复制代码
WsfApplication(进程级,一个)
  └── WsfApplicationExtension(进程级,N 个)
        └── 在 ScenarioCreated 中注册
WsfScenario(场景级,一个应用可创建多个)
  └── WsfScenarioExtension(场景级,N 个)
        └── 在 SimulationCreated 中注册
WsfSimulation(仿真级,一个场景可创建多个)
  └── WsfSimulationExtension(仿真级,N 个)

三层各有自己的容器和生命周期。应用层用 WsfApplication::RegisterExtension(WsfApplication.cpp:173-178),场景层用 WsfScenario::RegisterExtension(WsfScenario.cpp:828-833),仿真层用 WsfSimulation::RegisterExtension(WsfSimulation.cpp:1554)。三个容器的元素类型分别是 WsfApplicationExtension、WsfScenarioExtension、WsfSimulationExtension,彼此不继承,只通过"上层在回调里创建下层"建立联系。

1.5.1 九个回调与触发点

下表按真实调用顺序排列,行号是对应的触发位置,不是虚函数的声明位置。

阶段 回调 触发位置 此时可做什么
插件加载 WsfPluginSetup WsfPluginManager.cpp:134-148 调用 Register_*,向应用注册扩展
应用注册 AddedToApplication WsfApplication.cpp:173-178 注册脚本类型、应用级资源
场景创建 ScenarioCreated WsfApplication.cpp:286-301 向新场景注册场景扩展
场景注册 AddedToScenario WsfScenario.cpp:828-833 注册类型表、组件工厂
输入解析 ProcessInput WsfScenario.cpp:857-867 认领并解析命令
文件载入 FileLoaded WsfScenario.cpp 输入循环 按文件做增量处理
输入结束 Complete WsfScenario.cpp:732-738 校验、构建常量数据
输入结束 Complete2 WsfScenario.cpp:740-743 跨扩展交叉链接
仿真创建 SimulationCreated WsfScenario.cpp:752-767 注册仿真扩展,不能读平台
仿真初始化 Initialize WsfSimulation.cpp:614-618 初始化本扩展资源
仿真初始化 PrepareExtension WsfSimulation.cpp:617 可重入的资源准备(快照)
平台初始化后 PlatformsInitialized WsfSimulation.cpp:1269-1272 平台已存在,可建立索引
待启动 PendingStart WsfSimulation.cpp:640-643 解析引用、最终校验
启动 Start WsfSimulation.cpp:920-923 启动时钟前的最后动作
结束 Complete WsfSimulation.cpp:868-871 写文件、释放资源

这张表里有两个容易被忽略的细节。第一,PrepareExtension 不只在 Initialize 里调用;WsfSimulation::PrepareSimulation 也会调用它(WsfSimulation.cpp:505-513),用于从快照恢复仿真。第二,WsfScenarioExtension 也有 Complete,但它和 WsfSimulationExtension::Complete 是两个完全不同的函数,前者在输入结束时调用,后者在仿真结束时调用。只写 Complete 而不注明层级,是阅读插件代码时最常见的歧义来源。

1.5.2 应用层:只做全局的事

应用层扩展的数量最少,职责也最窄。WsfApplication::ScenarioCreated 在调用任何扩展之前,先执行 mScriptTypesPtr->Initialize()(WsfApplication.cpp:288),然后才按依赖顺序遍历应用扩展、调用它们的 ScenarioCreated(WsfApplication.cpp:292-300)。这个顺序说明:脚本类型系统在场景创建之前就已经定型。

因此脚本类只能在 AddedToApplication 注册,不能在 AddedToScenario 注册。WsfScenarioExtension::AddedToScenario 的注释写得很直接:"Script classes must NOT be registered here because they are global to the entire application. They should be registered in WsfApplication::AddedToApplication."(WsfScenarioExtension.cpp:43-46)。如果违反这条规则,脚本类会挂到场景私有的类型表上,而仿真使用的是应用级类型表的副本,结果就是"场景能解析、脚本运行时报 unknown method"。

应用层还有一个常被忽视的限制:一个进程只有一个应用扩展实例,但可以创建多个场景。ScenarioCreated 会被每个新场景各调用一次,所以在这个回调里不能把场景指针存成成员变量并假设它一直有效。coverage 的写法是每次调用都往传入的 aScenario 注册一个新的场景扩展:

cpp 复制代码
void ScenarioCreated(WsfScenario& aScenario) override
{
   aScenario.RegisterExtension(GetExtensionName(),
                               ut::make_unique<wsf::coverage::ScenarioExtension>());
}

(WsfCoverageRegistration.cpp:32-35)

这也是 WsfDefaultApplicationExtension<T> 适配器的默认行为:把 ScenarioCreated 转发为"注册一个 T 场景扩展"。alternate_locations 用的就是它(WsfAltLoc.cpp:128-131),所以插件作者不需要自己写应用扩展类。

1.5.3 场景层:输入的主战场

场景层是插件代码最集中的地方。它承担四件事:注册类型、解析命令、校验输入、把场景数据交给仿真。

AddedToScenario 在 RegisterExtension 内部同步调用(WsfScenario.cpp:828-833),早于任何输入行。这里适合注册类型表和组件工厂。alternate_locations 在这里添加 ComponentTypes 类型表并注册 ComponentFactory(WsfAltLoc.cpp:88-92),所以 alternate_locations 块既能出现在平台里,也能出现在平台类型里。

ProcessInput 每个命令行调用一次,直到某个扩展返回 true。分派逻辑在 WsfScenario::ProcessExtensionInput:

cpp 复制代码
for (const auto& i : *mExtensionListPtr)
{
   if (static_cast<WsfScenarioExtension*>(i.second.get())->ProcessInput(aInput))
   {
      return true;
   }
}
return false;

(WsfScenario.cpp:857-867)

这里的 mExtensionListPtr 内部是 std::map<std::string, ...>(WsfExtensionList.hpp:64),迭代顺序是扩展名字典序,不是依赖顺序,也不是注册顺序。这一点非常重要:两个插件如果都认领同一个命令,谁先被问到只取决于名字的字母序。ExtensionDepends 只影响 Complete 之后的顺序,不影响输入分派。插件不能假设"我依赖的插件已经处理过这一行"。

Complete 是所有输入(包括 include 和延迟输入)都处理完之后的第一轮收尾。WsfScenario::CompleteLoad 先排序扩展、初始化地形、解析延迟输入(WsfScenario.cpp:675-689),再调用每个扩展的 Complete(:732-738),最后调用 Complete2(:740-743)。延迟输入的存在意味着:平台可能在 ProcessInput 阶段只被记录、尚未真正创建,Complete 才是第一次能安全访问完整输入图的时机。

Complete2 是跨扩展收尾。此时所有扩展的 Complete 都已返回,任何一个扩展的常量数据都已建立,适合做"我需要另一个扩展的最终结果"的链接。SixDOF 的编队扩展在这里校验编队拓扑并计算初始成员运动学(WsfSixDOF_FormationScenarioExtension.cpp:99-110),因为它需要所有编队定义和平台定义都已就位。把这类逻辑放在 Complete 里,会依赖扩展名的字母序,属于隐性 bug。

场景扩展还有一个 FileLoaded 回调,每个输入文件载入后触发。它适合做"按文件"的统计或日志,不适合做跨文件校验,因为此时后续文件还没读。

1.5.4 仿真层:从场景数据到运行实例

WsfScenario::SimulationCreated 在 WsfSimulation::Initialize 的第一行被调用(WsfSimulation.cpp:539),此时仿真对象刚刚构造,时钟、平台列表、脚本上下文都还没有初始化。这个方法只应该做一件事:把场景扩展持有的输入数据复制/移动到一个新的仿真扩展里,并注册到仿真。

alternate_locations 是标准写法:

cpp 复制代码
void ScenarioExtension::SimulationCreated(WsfSimulation& aSimulation)
{
   aSimulation.RegisterExtension(GetExtensionName(),
                                 ut::make_unique<wsf::altloc::SimulationExtension>(mData));
}

(WsfAltLoc.cpp:120-123)

mData 是按值复制的输入数据,所以每个仿真拿到独立副本。这一点对多仿真运行至关重要:同一个场景可以初始化多个仿真,仿真之间不能共享可变状态。

如果扩展需要支持"同一场景多次运行",必须使用可克隆模式。coverage 的场景扩展继承 WsfCloneableScenarioExtension(WsfCoverageScenarioExtension.hpp:22),它持有一个 WsfCloneableSimulationExtension 原型;每次 SimulationCreated 都调用 ut::clone(mPrototypePtr) 生成新实例(WsfScenarioExtension.cpp:160-164)。仿真扩展再实现 Clone()(WsfCoverageSimulationExtension.hpp:41)。反过来,WsfLegacyScenarioExtension 只支持一次仿真:它把指针交给第一个仿真后就把成员置空(WsfScenarioExtension.hpp:117-143),第二次 SimulationCreated 什么也不注册。旧插件在多运行场景下"第二次运行没有数据",根源通常就在这里。

仿真层的回调顺序是固定的:

  1. SimulationCreated:注册仿真扩展,不能访问平台。
  2. Initialize:初始化扩展自身资源;所有扩展按依赖顺序调用(WsfSimulation.cpp:614-618)。
  3. PrepareExtension:与 Initialize 紧邻调用,但会在快照恢复时再次调用(WsfSimulation.cpp:505-513)。
  4. PlatformsInitialized:所有输入平台完成 Platform::Initialize 之后、Initialize2 之前(WsfSimulation.cpp:1269-1272)。
  5. PendingStart:初始化完成、仿真尚未启动,可以解析平台引用(WsfSimulation.cpp:640-643)。
  6. Start:时钟启动前(WsfSimulation.cpp:920-923)。
  7. Complete:平台已删除、事件队列已清空之后(WsfSimulation.cpp:868-871)。

coverage 把这条链用得很典型。Initialize 只初始化网格和覆盖对象(WsfCoverageSimulationExtension.cpp:94-111);PendingStart 才解析资产平台、准备网格平台、分类平台(:113-134);Complete 写网格数据文件并完成统计(:137-145)。如果把这些工作提前到 Initialize,平台列表还是空的,资产解析必然失败。

1.5.5 时序错误的五种典型症状

把上面的规则压缩成症状对照表,排障时可以直接反查:

症状 最可能放错的回调 原因
输入报 unknown type / unknown command 类型注册放在 SimulationCreated 之后 输入解析早已结束
脚本报 unknown method 脚本类注册在 AddedToScenario 脚本类型系统是应用级的
Initialize 里平台数为 0 在 SimulationCreated 或 Initialize 早期读平台 平台尚未加入仿真
资产/引用解析失败 在 Initialize 而非 PendingStart 解析 平台存在但尚未完成初始化
第二次运行数据为空 使用 WsfLegacyScenarioExtension 扩展只移交一次
依赖插件的数据没准备好 在 Complete 而非 Complete2 交叉链接 Complete 顺序受依赖影响但对方未必完成

还有一个不在表里的陷阱:WsfSimulation::Initialize 会在扩展初始化之前检查场景与仿真的脚本作用域是否一致,不一致时打印 "The simulation has changed script definitions which may degrade performance."(WsfSimulation.cpp:571-580)。这条警告通常意味着插件在仿真创建后往脚本上下文里加了变量或方法。正确做法仍然是把脚本定义放到应用层。

时序问题之所以难查,是因为大部分错误不会在加载期抛异常,而是表现为"某个列表是空的""某个名字找不到"。下一节进入扩展排序本身:ExtensionDepends 如何工作,为什么它在 2.9.0 的场景层有一个已知缺陷,以及插件应该如何规避。

1.6 扩展依赖:排序器、必需性与 2.9.0 的三个盲区

上一节反复提到"按依赖顺序调用",但依赖顺序本身并不是自动成立的。AFSIM 用 WsfExtensionList 保存扩展和依赖,在需要时做一次稳定拓扑排序。排序器很小,只有几十行,却是插件协作的核心;它的三个盲区也解释了为什么很多插件宁愿在代码里手工查找对方,也不愿声明依赖。

1.6.1 数据结构与排序算法

WsfExtensionList 内部有两个容器:std::map<std::string, std::unique_ptr<WsfExtension>> mExtensions 负责按名字查找,std::vector<std::string> mExtensionOrder 负责保存顺序。注册时先插入 map,再把名字追加到 vector 末尾(WsfExtensionList.cpp:20-29)。所以未排序时的顺序就是注册顺序 ,而 ProcessExtensionInput 遍历的是 map,得到的是字典序。同一份扩展列表,在不同代码路径下有两种不同的顺序,这是理解后续所有排序问题的前提。

依赖保存在 std::vector<std::pair<std::string, Dependency>> 里,每项记录"谁依赖谁"以及 mRequired(WsfExtensionList.hpp:51-62)。ExtensionDepends(dependent, dependency, required) 只追加记录,不立即排序。

排序入口是 SortExtensions(WsfExtensionList.cpp:83-101):

cpp 复制代码
std::set<std::string>    allExtensions(mExtensionOrder.begin(), mExtensionOrder.end());
std::vector<std::string> newOrder;
std::set<std::string>    included, adding;
for (const auto& name : mExtensionOrder)
{
   if (included.find(name) == included.end())
   {
      if (!SortExtensionsP(allExtensions, newOrder, included, adding, name, true))
      {
         throw WsfException("Extension sort error");
      }
   }
}
mExtensionOrder = newOrder;

SortExtensionsP 是一个递归深度优先的拓扑排序(WsfExtensionList.cpp:104-149)。它做四件事:

  1. 如果当前节点已在 adding 集合里,说明发现环,打印 "Circular extension dependency." 并返回 false(:109-115)。
  2. 如果当前节点已加入 included,直接返回 true(:116-119)。
  3. 如果依赖名不在 allExtensions 里:必需依赖打印 "Extension dependency missing." 并返回 false;可选依赖直接忽略(:120-131)。
  4. 递归处理当前节点的所有依赖,成功后把当前节点加入 included 并追加到 newOrder(:132-146)。

外层按 mExtensionOrder 的原始顺序遍历,因此这是一个稳定 排序:没有依赖关系的扩展保持注册顺序。排序失败会抛 WsfException("Extension sort error"),不会静默降级。

1.6.2 必需依赖与可选依赖的真实语义

aRequired 不是"建议"与"必须"的语气区别,而是两种完全不同的行为:

  • required = true:依赖名必须已经在同一个扩展列表里注册。否则排序直接失败,场景创建抛异常。它表达的是"没有它我就不能工作"。
  • required = false:依赖存在时排到它后面;不存在时忽略。它表达的是"如果它在,先让它初始化;它不在也没关系"。

这两个语义在插件源码里有清晰的分工。alternate_locations 依赖 event_output 时用 false(WsfAltLoc.cpp:134),因为事件输出是可选的观测通道;air_combat 依赖 wsf_mil、wsf_brawler、wsf_p6dof、wsf_six_dof 时全部用 true(WsfAirCombat.cpp:49-61),因为它同时使用这四者的类型和接口。

但 required = true 有一个前提:依赖必须已经注册。插件不能只写一行 ExtensionDepends("my_plugin", "wsf_mil", true) 就假设 wsf_mil 一定存在。air_combat 的写法是先用宏把依赖"拉"进来:

cpp 复制代码
WSF_REGISTER_EXTENSION(aApplication, wsf_mil);
aApplication.RegisterExtension("wsf_air_combat", ut::make_unique<ApplicationExtension>());
aApplication.ExtensionDepends("wsf_air_combat", "wsf_mil", true);

(WsfAirCombat.cpp:49-52)

WSF_REGISTER_EXTENSION 展开为"声明 Register_##NAME 并调用它"(WsfApplication.hpp:212-214)。所以 WSF_REGISTER_EXTENSION(aApplication, wsf_mil) 等价于直接调用 Register_wsf_mil(aApplication)(WsfMil.cpp:833)。这既是注册,也是链接期依赖:插件目标必须能解析 Register_wsf_mil 符号,否则加载失败。它把"运行时依赖"提前成了"链接依赖",比单纯声明 ExtensionDepends 更强。

这也解释了一个部署现象:wsf_air_combat 不能单独部署。它注册时会主动注册 wsf_brawler、wsf_p6dof、wsf_six_dof,因此加载 air_combat 的动态库必须能解析这三个插件的注册符号;如果构建时没有把对应库链接进来,加载阶段就会报未解析符号,而不是等到场景创建才报依赖缺失。

1.6.3 盲区一:场景层的排序被跳过

应用层的排序发生在 WsfApplication::ScenarioCreated:先 mExtensionListPtr->SortExtensions()(WsfApplication.cpp:291),再按排好的顺序调用每个应用扩展的 ScenarioCreated(:292-300)。此时所有插件都已加载,依赖都已登记,排序是有效的。

场景层的排序理论上发生在 WsfScenario::CompleteLoad:

cpp 复制代码
if (!mExtensionsInOrder)
{
   mExtensionListPtr->SortExtensions();
}

(WsfScenario.cpp:675-678)

问题在于 mExtensionsInOrder 在更早的时候就被置为 true 了。WsfScenario 构造函数在注册四个核心扩展之后创建 UtInput,并调用 ConfigureInput(*mInputPtr)(WsfScenario.cpp:168);ConfigureInput 里有一段:

cpp 复制代码
if (!mExtensionsInOrder)
{
   SortExtensions();
}

(WsfScenario.cpp:1312-1316)

SortExtensions() 把 mExtensionsInOrder 设为 true(:1319-1322)。此时场景扩展列表里只有构造函数注册的 comm_network_manager、gravity_model、los_manager、script_observer,应用扩展还没有机会注册任何场景扩展。随后构造函数最后一行才调用 aApplication.ScenarioCreated(*this)(WsfScenario.cpp:175),插件在这里注册场景扩展。等 CompleteLoad 执行到 :675 时,mExtensionsInOrder 已经是 true,排序被整个跳过。

结果是:场景层的 ExtensionDepends 在 2.9.0 中基本不生效 。场景扩展的 Complete 顺序等于注册顺序:四个核心扩展在前,插件场景扩展按应用扩展的 ScenarioCreated 顺序追加。这个顺序恰好与大多数应用依赖一致,所以问题很少暴露;但只要两个插件在场景层声明了依赖,而它们的应用扩展注册顺序相反,Complete 就会按错误顺序执行。16 个插件里没有任何一处调用 WsfScenario::ExtensionDepends(WsfScenario.cpp:839-847 只有定义,没有外部调用),这也从侧面说明该 API 在实践中被绕开了。

1.6.4 盲区二:仿真层根本没有排序

WsfSimulation 的扩展列表是一个裸的 WsfExtensionList mExtensionList(WsfSimulation.hpp:690)。WsfSimulation::RegisterExtension 只做 AddExtension 和 AddedToSimulationP(WsfSimulation.cpp:1554-1559),整个 WsfSimulation.cpp 里没有任何 SortExtensions() 调用。WsfSimulation 也没有公开 ExtensionDepends 方法。

因此仿真扩展的所有回调都按注册顺序 执行。注册顺序由场景扩展的 SimulationCreated 调用顺序决定,而后者又由场景扩展列表顺序决定。依赖链在这里变成了一条隐式的"注册先后"约束:想让 A 的 Initialize 早于 B,只能让 A 的 SimulationCreated 先执行,或者让 A 在 B 注册之前就被注册。

这条约束在 16 个插件里没有造成明显问题,因为大多数仿真扩展彼此独立。但它意味着一个常见写法是危险的:在 Initialize 里调用 GetSimulation().GetExtension("other_plugin") 并假设对方已经初始化。对方可能已经构造、已经注册,但 Initialize 还没跑。正确做法是把跨扩展的初始化协调放到 PlatformsInitialized 或 PendingStart,或者在 Initialize 里只建立弱引用、不读对方状态。

1.6.5 盲区三:晚登记的依赖不会触发重排

应用层排序只在 WsfApplication::ScenarioCreated 开头执行一次。如果某个扩展在 ScenarioCreated 期间又调用 aApplication.ExtensionDepends(...),这条依赖会进入列表,但不会触发重新排序;它要等到下一次场景创建才会生效。wsf_space 就踩在这个边界上:它在场景扩展的 AddedToScenario 里追加两条应用依赖:

cpp 复制代码
scenario.GetApplication().ExtensionDepends("space_event_output", "space_observer", true);
scenario.GetApplication().ExtensionDepends("space_event_output", "event_output", true);

(WsfSpace.cpp:301-302)

这两行执行时,WsfApplication::ScenarioCreated 的排序早已结束,应用扩展的 ScenarioCreated 正在逐个运行。依赖记录虽然写入了列表,但本次场景的扩展顺序不会改变。如果 space_event_output 恰好排在 event_output 前面,它的 ScenarioCreated 会先执行,依赖并没有起到作用。单场景运行时这个问题几乎不可见;多场景或换一个插件加载顺序时才可能暴露。

综合三个盲区,可以得到一条实用规则:只依赖 required = false 的可选能力探测,不要把关键初始化顺序寄托在 ExtensionDepends 上 。需要另一个插件的数据时,先 FindExtension 判空,再在 Complete2/PendingStart 里做延迟绑定;需要确保类型已注册时,用 WSF_REGISTER_EXTENSION 把注册变成链接依赖。

1.6.6 插件里的实际依赖图

把 16 个插件的 ExtensionDepends 汇总,可以得到一张以 wsf_mil 为中心的依赖图:

text 复制代码
wsf_mil ──┬── wsf_air_combat(必需)
          ├── wsf_brawler(必需)
          ├── wsf_p6dof(必需)
          ├── wsf_six_dof(必需)
          ├── wsf_fires(必需)
          ├── wsf_iads_c2(必需)
          └── wsf_oms_uci(必需)

event_pipe ──── wsf_annotation / wsf_air_combat / wsf_six_dof / wsf_p6dof(可选)
event_output ── wsf_altloc / wsf_iads_c2(可选)

(依据:WsfMil.cpp:840-844、WsfAirCombat.cpp:49-63、WsfBrawler.cpp:64、WsfP6DOF.cpp:139-140、WsfSixDOF.cpp:151-152、FiresTables.cpp:39、WsfIADSC2UnclassProcRegistration.cpp:86-88、WsfUCI.cpp:97、WsfAnnotation.cpp:89、WsfAltLoc.cpp:134)

这张图有三个值得注意的特征。

第一,wsf_mil 是绝大多数领域插件的硬依赖,因为它提供武器、传感器、通信等基础类型。wsf_mil 自己又依赖 ext_interface(必需)和 dis_interface/event_output/event_pipe/csv_event_output(可选)(WsfMil.cpp:840-844)。所以加载任何军事插件,实际上都会把 wsf_mil 及其基础接口一起拉进来。

第二,wsf_coverage、wsf_annotation、wsf_multiresolution、wsf_scenario_analyzer 这些"分析类"插件没有硬依赖 wsf_mil(annotation 只可选依赖 event_pipe),因为它们操作的是通用平台、传感器和事件,不要求军事模型。这也是它们能在纯核心构建里使用的原因。

第三,wsf_simdis 根本不在依赖图里。它没有 Register_*,不注册扩展,只是提供链接期接口(详见 §1.7)。依赖图只描述"注册了扩展的插件",对这类库式插件不适用。

下一节转向交付形态:同一个插件目录,什么时候生成 .so/.dll,什么时候生成静态库,什么时候直接编进可执行文件;以及 wsf_simdis、wsf_multiresolution 这些不符合标准注册模式的例外到底怎样工作。

1.7 交付形态:动态插件、静态库与"假插件"

同一个插件目录,在不同构建选项下会变成三种完全不同的东西:运行时加载的动态库、链接进主程序的静态库、或者只提供符号供其他组件链接的普通库。很多"插件明明编译成功却加载不上"的问题,根源不是代码,而是构建形态和搜索路径不匹配。

1.7.1 一个开关决定全局

顶层 CMakeLists.txt 只有一个相关开关:

cmake 复制代码
option(WSF_PLUGIN_BUILD "Enable plug-ins" TRUE) # Default is shared/plug-in build

(CMakeLists.txt:45)

它在 :274-277 直接决定 BUILD_SHARED_LIBS:

cmake 复制代码
if(WSF_PLUGIN_BUILD)
   set(BUILD_SHARED_LIBS TRUE)
   set(INSTALL_LIB_PATH "bin/lib")
   ...

所有插件的 CMakeLists.txt 都用不带类型参数的 add_library,例如 coverage 的 add_library(${PROJECT_NAME} ${SRCS})(wsf_plugins/wsf_coverage/source/CMakeLists.txt:23)。没有 STATIC/SHARED 关键字时,CMake 遵循 BUILD_SHARED_LIBS:开则共享库,关则静态库。插件源码不需要为两种形态写两套构建脚本。

WSF_PLUGIN_BUILD=ON 时,每个插件的 wsf_cmake_extension.cmake 再把 WSF_EXT_TYPE 设为 plugin:

cmake 复制代码
set(WSF_EXT_NAME wsf_coverage)
if(WSF_PLUGIN_BUILD)
   set(WSF_EXT_TYPE plugin)
endif()
set(WSF_EXT_SOURCE_PATH .)

(wsf_plugins/wsf_coverage/wsf_cmake_extension.cmake:10-14)

plugin 类型在 optional_projects.cmake 里有两个效果:一是不生成 wsf_extensions.hpp 中的启动注册调用,二是不被 link_wsf_optional_projects 链接进应用(optional_projects.cmake:143-163 只处理非 plugin 类型)。插件因此完全靠运行时扫描目录加载。

WSF_PLUGIN_BUILD=OFF 时,WSF_EXT_TYPE 保持默认值 lib(optional_projects.cmake:34),插件目标变成静态库,wsf_extensions.hpp 会为它生成一段注册代码,应用启动时直接调用 Register_wsf_coverage:

cpp 复制代码
#if ((!defined(WSF_INCLUDE_EXTENSIONS) && !defined(WSF_EXCLUDE_EXTENSION_wsf_coverage)) || \
     ( defined(WSF_INCLUDE_EXTENSIONS) &&  defined(WSF_INCLUDE_EXTENSION_wsf_coverage)))
   PrintOptionalExtensionIncluded("wsf_coverage", listNote);
   WSF_REGISTER_EXTENSION(aApplication, wsf_coverage);
#endif

(生成逻辑见 optional_projects.cmake:82-100,模板见 wsf_extensions.hpp.in:80-85)

mission 的 main 显式调用了这两个注册入口:

cpp 复制代码
WsfStandardApplication app("mission", argc, argv);
RegisterBuiltinExtensions(app);
RegisterOptionalExtensions(app);

(mission/source/mission.cpp:87-93)

所以静态构建下没有"插件目录"这个概念:所有启用的插件在链接期就成为可执行文件的一部分,WsfPluginManager 即使扫不到任何动态库,功能也齐全。反过来,动态构建下 RegisterOptionalExtensions 里只会包含 lib/exe 类型的项目,标准 plugin 类型不会出现,功能完全依赖目录扫描。

1.7.2 动态插件的安装位置与搜索规则

动态插件默认装到可执行文件同级的 <应用名>_plugins 目录。coverage 的安装语句是:

cmake 复制代码
if(WSF_PLUGIN_BUILD)
   swdev_plugin_install(${PROJECT_NAME} mission_plugins)
else()
   swdev_lib_install(${PROJECT_NAME})
endif()

(wsf_plugins/wsf_coverage/source/CMakeLists.txt:38-42)

swdev_plugin_install 先按普通库安装,再把目标文件复制到构建目录下的 mission_plugins 子目录(cmake/Modules/swdev_project.cmake:102-115)。INSTALL_PLUGIN_PATH 被固定为 bin(CMakeLists.txt:220-223),所以安装树里的最终位置是 bin/mission_plugins/。

应用侧默认搜索三个位置,实际代码只有两行:

cpp 复制代码
pluginPaths.push_back("../" + GetApplicationName() + "_plugins");
pluginPaths.emplace_back("../wsf_plugins");
for (const auto& pp : pluginPaths)
{
   UtPath path(UtPath::GetExePath() + pp);
   mPluginManagerPtr->AddPluginSearchPath(path.GetSystemPath());
}

(WsfApplication.cpp:108-116)

这里有一个容易看错的路径技巧。GetExePath() 返回的是可执行文件的完整路径 ,不是目录(tools/util/source/UtPath.cpp:114-125)。UtPath 的 operator+ 会把最后一段当成目录名再做 .. 归一化,因此 bin/mission 加上 ../mission_plugins 得到的是 bin/mission_plugins,而不是 bin/../mission_plugins。换句话说,../<app>_plugins 的真实含义是"可执行文件旁边的 <app>_plugins 目录"。

搜索路径的行为还有两个细节:

  • WSF_PLUGIN_PATH 环境变量一旦设置,会替代 默认路径,而不是追加(WsfApplication.cpp:97-103)。设置它以后,mission_plugins 和 wsf_plugins 都不再被扫描。
  • AddPluginSearchPath 的 aRecursive 默认是 false(WsfPluginManager.cpp:66-69),UtPluginManager::LoadAll 只扫描给定目录下的 *.dll/*.so,不进入子目录(UtPluginManager.cpp:173-213)。所以把插件放进 mission_plugins/subdir/ 是加载不到的。

wsf_oms_uci 是唯一一个装到 wsf_plugins 而不是 mission_plugins 的标准插件(wsf_plugins/wsf_oms_uci/source/CMakeLists.txt:66-70)。这不会导致加载失败,因为默认搜索路径同时包含 wsf_plugins;但它意味着 mission_plugins 不是"所有插件都在这里"的唯一目录,排查加载问题时两个目录都要看。

1.7.3 加载协议:版本、编译器、入口

WsfPluginManager::LoadPlugins 对每个搜索路径先 Stat:是文件就直接加载,是目录就遍历(WsfPluginManager.cpp:72-102)。加载单个库时依次取三个符号:

  1. WsfPluginVersion:不存在则警告 "Unable to find WsfPluginVersion symbol."(WsfPluginManager.cpp:105-125)。
  2. 版本校验:主次版本必须一致,编译器指纹也必须一致,否则警告 "Version mismatch." 或 "Compiler mismatch."(:127-149;校验实现在 UtPluginManager.cpp:85-120)。
  3. WsfPluginSetup:不存在则警告 "Unable to find WsfPluginSetup function."(:133-148)。

WsfPluginComplete 是可选的,只有导出时才在全部插件加载完后调用(WsfPluginManager.cpp:57-63)。这意味着插件之间的初始化不应依赖"另一个插件已经 Setup 完成",除非它确实导出了 WsfPluginComplete 并在那里做二次协调。标准 16 个插件没有一个依赖这个钩子。

编译器指纹不是编译器版本号,而是平台、编译器、位数、构建类型和硬件扩展的组合串。它解决的是 ABI 兼容问题:用 MSVC 2019 编译的 mission.exe 不能加载用 MSVC 2022 编译的插件,Debug 构建也不能加载 Release 插件。这个检查在加载期完成,比运行到某个虚函数才崩溃要安全得多。

1.7.4 五个不符合标准形态的插件

16 个插件里有五个不能按"有 Register_*、装到 mission_plugins、运行时扫描"的标准路径理解。它们不是错误,而是不同历史阶段和不同交付需求留下的形态。

wsf_multiresolution:没有 Register_* 符号。 它的注册逻辑直接写在 WsfPluginSetup 里:

cpp 复制代码
WSF_MULTIRESOLUTION_EXPORT void WsfPluginSetup(WsfApplication& aApplication)
{
   aApplication.RegisterFeature(wsf::multiresolution::cFEATURE_NAME, wsf::multiresolution::cEXTENSION_NAME);
   aApplication.RegisterExtension(wsf::multiresolution::cEXTENSION_NAME,
                                  ut::make_unique<RegisterMultiresolutionComponent>());
   aApplication.RegisterExtension("multiresolution_multirun_table", ut::make_unique<RegisterMultirunTableExtension>());
}

(MultiresolutionPluginRegistration.cpp:41-48)

动态构建下这没有问题:加载器只找 WsfPluginVersion 和 WsfPluginSetup,不要求 Register_*。但静态构建下,wsf_extensions.hpp 会按项目名生成 WSF_REGISTER_EXTENSION(aApplication, wsf_multiresolution)(optional_projects.cmake:82-100),链接器需要 Register_wsf_multiresolution 符号;源码里没有这个符号。因此 BUILD_WITH_wsf_multiresolution=ON 且 WSF_PLUGIN_BUILD=OFF 的组合会在链接期失败。这是 2.9.0 的一个真实构建缺陷,不是配置错误。

wsf_oms_uci:项目名与注册符号名不一致。 扩展名是 wsf_oms_uci(wsf_plugins/wsf_oms_uci/wsf_cmake_extension.cmake:10),导出的函数却叫 Register_wsf_uci(WsfUCI.cpp:90)。动态加载不受影响,因为 WsfPluginSetup 直接调用了 Register_wsf_uci(:255-258)。但静态构建同样会生成 Register_wsf_oms_uci 调用,而全源码树里没有这个符号。它与 multiresolution 属于同一类静态构建缺陷。

wsf_simdis:根本不按插件安装。 它的 wsf_cmake_extension.cmake 没有设置 WSF_EXT_TYPE plugin,源码 CMake 也始终调用 swdev_lib_install(wsf_plugins/wsf_simdis/source/CMakeLists.txt:28-33)。所以它被当作普通库链接进 mission,Register_wsf_simdis 由 wsf_extensions.hpp 在启动时调用,而不是由插件管理器扫描。虽然它导出了完整的 WsfPluginVersion/WsfPluginSetup(WsfSIMDIS_Interface.cpp:49-66),这两个符号在正常构建里不会被用到。把它复制到 mission_plugins 反而可能导致重复注册。

wsf_iads_c2_lib:一个目录,两个目标。 父项目名是 wsf_iads_c2_lib,但 wsf_cmake_extension.cmake 声明的扩展名是 wsf_iads_c2;源码目录同时构建静态库 wsf_iads_c2_lib 和插件目标 wsf_iads_c2(wsf_plugins/wsf_iads_c2_lib/CMakeLists.txt:11-17、wsf_plugins/wsf_iads_c2_lib/source/CMakeLists.txt:21-40)。静态库承载全部实现,插件目标只包含注册文件并链接静态库。这样设计的好处是其他组件(如 wsf_scenario_analyzer_iads_c2)可以链接实现库而不必加载插件;坏处是看 CMake 日志时会出现两个名字相近的目标,容易误判哪个是插件。

wsf_scenario_analyzer:插件 + 静态库 + 数据文件。 它同样把实现放在 _lib 静态库里,插件目标只负责注册(wsf_plugins/wsf_scenario_analyzer/source/CMakeLists.txt:12-28);此外还把 check_suites 和 session_note_suites 复制到构建目录并安装到 resources/data(:33-65)。这类插件的"安装"不只是复制一个动态库,还包括数据目录;只复制 .dll 会导致运行时找不到检查套件。

把五个例外汇总:

插件 标准注册符号 动态安装目录 静态构建 特殊点
wsf_multiresolution 无 wsf_plugins 链接失败 注册写在 WsfPluginSetup
wsf_oms_uci Register_wsf_uci wsf_plugins 链接失败 项目名/符号名不一致
wsf_simdis Register_wsf_simdis 不安装为插件 正常链接 只作为库链接
wsf_iads_c2_lib Register_wsf_iads_c2 mission_plugins 正常 静态库 + 插件双目标
wsf_scenario_analyzer Register_wsf_scenario_analyzer mission_plugins 正常 依赖数据目录

这张表解释了一个常见困惑:为什么有的插件在 mission_plugins 里找不到,却仍然能用(simdis);为什么有的插件复制过去两份会导致启动报重复扩展(simdis 被链接一次、又被扫描一次);为什么 WSF_PLUGIN_BUILD=OFF 时某些可选插件编不过(multiresolution、oms_uci)。

1.7.5 选择哪种交付形态

三种形态没有绝对优劣,取决于部署边界:

  • 动态插件 适合需要独立升级、按需裁剪的场景。主程序不需要重新链接,替换 .dll 即可;代价是加载期版本校验、目录部署和 ABI 约束。
  • 静态链接适合交付单一可执行文件、或目标环境不允许动态加载的场景。所有插件在启动时注册,没有搜索路径问题;代价是任何插件变更都要重新链接主程序,可执行文件体积也会膨胀。
  • 库式组件(simdis、IADS 实现库)适合"功能必须存在,但不希望它成为一个可插拔单元"的场景。它随主程序一起构建和链接,注册由生成代码完成。

部署时最容易犯的错误是"混用":在动态构建下把 wsf_simdis 复制到 mission_plugins,或者在静态构建下期待 WSF_PLUGIN_PATH 能换一套插件。前者造成重复注册,后者根本不会走插件管理器。判断当前构建形态的最快方法是看 mission 启动日志里的 "Plugins Loaded:" 和 "Extensions Included:" 两段:前者列出运行时扫描到的库,后者列出编译期链接进来的扩展。

下一节进入插件的另一半契约:语法文件与 IDE 元数据。它们不参与运行,但决定了插件在 Wizard 里是否能被正确补全;而 2.9.0 的语法分发机制有几个与构建形态强相关的坑。

1.8 语法与 IDE 契约:19 个 .ag 文件如何抵达编辑器

插件的运行契约由 C++ 决定:ProcessInput 认不认某条命令,与语法文件无关。但用户感知到的契约由语法文件决定:在 Wizard 里输入命令时有没有补全、参数提示对不对、块有没有正确闭合。两者是两条独立通道,插件作者必须同时维护。16 个插件里,14 个提供了 .ag 文件,共 19 个;剩下两个分析类插件完全不提供语法。这个分布本身就是一种设计判断。

1.8.1 谁有语法,谁没有

把 wsf_plugins/*/grammar/*.ag 列出来,可以得到一张很整齐的表:

插件 .ag 数量 说明
wsf_multiresolution 6 主语法 + 结构模板 + 平台模板 + 根模板
wsf_air_combat 1 空战命令
wsf_alternate_locations 1 备选位置
wsf_annotation 1 标注
wsf_argo8 1 Argo8 运动模型
wsf_brawler 1 Brawler 处理器/运动/燃料
wsf_coverage 1 覆盖分析
wsf_fires 1 火力表
wsf_iads_c2_lib 1 IADS C2 非密处理器
wsf_oms_uci 1 OMS/UCI
wsf_p6dof 1 伪六自由度
wsf_simdis 1 SIMDIS 输出
wsf_six_dof 1 六自由度
wsf_sosm 1 SOSM 传感器
wsf_scenario_analyzer 0 不解析场景命令
wsf_scenario_analyzer_iads_c2 0 不解析场景命令

13 个插件各有一个 .ag,multiresolution 一个插件占 6 个,两个 scenario analyzer 插件没有语法。没有语法的原因不是疏忽:它们的输入来自 Wizard 的会话数据或脚本调用,而不是场景文本里的命令块。给它们硬编一份 .ag 反而会误导编辑器,让用户以为可以在场景里写根本不存在的命令。

multiresolution 需要 6 个文件,是因为它的语法不是一组固定命令,而是模板 :multiresolution_struct_template.ag、platform_named_template.ag、platform_unnamed_template.ag、root_template.ag 分别描述不同插入位置的模板结构,wsf_multiresolution.ag 描述插件自身的命令,wsf_multirun_table.ag 描述多运行表。这种"模板语法"超出了普通命令补全的范围,也说明 .ag 并不是只能写命令列表。

1.8.2 构建期:复制、收集、安装

wsf_grammar_file 是一个很轻的 CMake 宏(core/wsf/CMakeLists.txt:19-27):

cmake 复制代码
macro(wsf_grammar_file SOURCES_VAR GRAMMAR_FILE_PATH)
   get_filename_component(GRAMMAR_FILE_NAME "${GRAMMAR_FILE_PATH}" NAME)
   add_custom_command(OUTPUT "${CMAKE_BINARY_DIR}/grammar/${GRAMMAR_FILE_NAME}"
                      COMMAND ${CMAKE_COMMAND} -E copy "${GRAMMAR_FILE_PATH}" "${CMAKE_BINARY_DIR}/grammar/${GRAMMAR_FILE_NAME}"
                      MAIN_DEPENDENCY "${GRAMMAR_FILE_PATH}")
   set(${SOURCES_VAR} ${${SOURCES_VAR}} ${GRAMMAR_FILE_PATH})
   set_property(GLOBAL APPEND PROPERTY WSF_GRAMMAR_FILES "${GRAMMAR_FILE_PATH}")
endmacro()

它做三件事:把 .ag 复制到构建目录的 grammar/,把源文件加入目标源列表让 IDE 显示,把路径登记到全局属性 WSF_GRAMMAR_FILES。没有编译、没有代码生成、没有链接。coverage 的源码 CMake 在 add_library 之前调用它(wsf_plugins/wsf_coverage/source/CMakeLists.txt:21),所以 .ag 会成为库目标的一个源文件;对构建系统来说,它和一张图片、一个资源文件没有区别。

复制到 ${CMAKE_BINARY_DIR}/grammar/ 是为了构建树调试 。构建树里的可执行文件在 ${CMAKE_BINARY_DIR}/bin/,运行时的第三个搜索位置是 <exe>/../grammar,正好指向 ${CMAKE_BINARY_DIR}/grammar/。这样不安装也能在构建目录里直接运行 mission 并拿到完整语法。

安装树走另一条路。顶层在所有可选项目都加入之后,把收集到的全部语法装到 bin/grammar(CMakeLists.txt:404-408);mission 自己又装了一份到 bin/mission_grammar(mission/CMakeLists.txt:45-46)。插件模板还会把 grammar/ 原样装到 wsf_plugins/<name>/grammar(cmake/Modules/wsf_project_template.cmake:186-188),但运行时不会去插件目录找语法;这一份只是给源码分发和人工查阅用的。

1.8.3 运行期:三个位置、三段拼接

WsfGrammarExtension::GetGrammarFiles 决定语法从哪里来(WsfGrammarInterface.cpp:119-179):

cpp 复制代码
UtPath exePath(argv[0]);
exePath.Up();

UtPath installedGrammarPath = exePath + (GetApplication().GetApplicationName() + "_grammar");
if (installedGrammarPath.Stat() != UtPath::cDIRECTORY)
{
   installedGrammarPath = exePath + "grammar";
}
bool hasInstalledGrammars = installedGrammarPath.Stat() == UtPath::cDIRECTORY;
if (!hasInstalledGrammars)
{
   installedGrammarPath = exePath + "../grammar";
   hasInstalledGrammars = installedGrammarPath.Stat() == UtPath::cDIRECTORY;
}

搜索顺序是:

  1. <exe目录>/<应用名>_grammar,例如 bin/mission_grammar。
  2. <exe目录>/grammar,例如 bin/grammar。
  3. <exe目录>/../grammar,即构建树的 build/grammar。

只有第一个存在的目录会被扫描;找到之后不再检查后面的目录。扫描规则是目录下所有 *.ag,不递归 (AddGrammarDirectory 内部用 UtScanDir::ScanDir,WsfGrammarInterface.cpp:106-117)。

找到文件后,语法文本按三段拼接(WsfGrammarInterface.cpp:147-176):

  1. 依次读取所有 .ag 文件的全文,直接 insert 到 mGrammarText。
  2. 按应用扩展顺序调用每个扩展的 PrintGrammar(std::ostream&),把输出追加到末尾。
  3. 把通过 AddGrammarText() 注册的字符串逐个追加,然后清空待处理列表。

拼接是纯字符串操作,不去重、不解析、不加分隔符。前一个文件末尾如果没有换行,后一个文件的第一个规则就会粘在上一行末尾,形成一条语法错误。这就是为什么 .ag 文件的最后一个字符应该是换行。

PrintGrammar 的默认实现是空函数(WsfApplicationExtension.cpp:113),AddGrammarText 在 16 个插件里也没有任何调用。这两个 API 是给"语法需要根据编译选项或运行时配置动态生成"的场景准备的;标准插件全部选择了静态 .ag,因为静态文件可以被 Wizard 缓存、可以被版本控制、可以被人工审阅。

1.8.4 --ide-output:一次运行导出四类元数据

Wizard 不读 .ag 文件,也不链接 WsfGrammarExtension。它的做法是运行目标可执行文件 ,让它自己汇报(wizard/lib/source/core/WsfExe.cpp:77):

cpp 复制代码
auto processPtr = UtProcess::Execute(mExePath.GetSystemPath(), "--ide-output " + tempFileName);
if (processPtr)
{
   processPtr->Wait();
   std::ifstream exeDataFile(tempFileName.c_str());

应用侧由 WsfGrammarExtension::ProcessCommandLine 识别这个参数(WsfGrammarInterface.cpp:54-71):它把运行模式设为 cWRITE_GRAMMAR,记录输出文件名,返回 2 表示参数已消费。随后 ProcessCommandLineCommands 写出文件并直接退出:

cpp 复制代码
if (!mIDE_DataOutputFile.empty())
{
   OutputIDE_Data(mIDE_DataOutputFile);
   exit(0xAF1de);
}

(WsfGrammarInterface.cpp:78-84)

退出码 0xAF1de 是 IDE 协议的一部分。它不是正常仿真结束,也不是错误,而是"元数据已写完,不要再往下走"。如果插件在更早的命令行处理阶段调用了 exit,或者应用在 ProcessCommandLineCommands 之前崩溃,Wizard 会看到一个空文件或旧文件,表现为"补全突然消失"。

输出文件由带标记的块组成,核心是四块:

text 复制代码
^^^grammar
...(所有 .ag 文本 + PrintGrammar 输出 + AddGrammarText 文本)...
$$$grammar

^^^version
2.9.0
$$$version

^^^application
mission
$$$application

^^^features
profiling
$$$features

^^^grammar 块里除了语法文本,还夹带 # BEGIN_SCRIPT_DATA 注释和大量 (aux-script-class ...) 表单(WsfGrammarInterface.cpp:187-230)。这些表单不是手写的,而是遍历 UtScriptTypes 的类表反射出来的:类是否可构造、可克隆、是否容器、支持哪些比较运算、父类链、每个方法的返回类型、参数类型、是否静态、是否变参。

这解释了一个常见现象:插件在 mission 里能用,在 Wizard 里没有补全 。原因通常不是 .ag 没装,而是 Wizard 查询的可执行文件不是加载了该插件的那个。WsfExe 对指定的 mExePath 执行 --ide-output;如果插件只装到了 mission_plugins,而 Wizard 配置的目标是 warlock,那么导出的脚本类表里就没有插件类,语法块里也没有插件的 .ag 内容。解决办法是让 Wizard 指向正确的可执行文件,或把插件部署到该应用的插件目录。

另一个现象是补全有,但运行时报 unknown command 。这几乎总是 .ag 与 ProcessInput 不一致:语法文件写了 foo,C++ 只认 bar。由于语法不参与运行,编辑器不会发现这个矛盾;只有实际加载场景才会暴露。反向的不一致更隐蔽:C++ 认,语法没写,功能正常但编辑器一直画红线,用户会以为插件坏了。

1.8.5 四个与插件强相关的语法坑

坑一:语法文件放错目录。 运行时只扫描 <app>_grammar、grammar、../grammar 三个位置,不扫描 mission_plugins/wsf_coverage/grammar。把 .ag 和 .dll 一起复制到插件目录,插件能加载,但补全没有。正确做法是依赖安装规则,或手工把 .ag 放进 bin/mission_grammar。

坑二:运行时目录只取第一个存在的。 如果 bin/mission_grammar 存在但缺了某个插件的 .ag,运行时会停在这个目录,不会再去 bin/grammar 补齐。安装时必须保证 <app>_grammar 是全量 的,或者干脆删掉它让运行时回退到 bin/grammar。只放一部分文件比完全不放更危险。

坑三:Wizard 缓存的是上次导出的结果。 WsfExe 在加载可执行文件时执行一次 --ide-output,之后使用缓存。替换插件后不重启 Wizard、不重新加载可执行文件,看到的仍是旧语法和旧脚本类表。排查"代码改了但补全没变"时,先确认缓存。

坑四:块结束标记必须配对。 语法 DSL 里的 end_xxx 不是可选装饰。漏写会让编辑器把后续所有内容都当成块内内容,补全整体错位;多写则会在块外产生一个孤立的结束标记。C++ 解析器不会读这些标记,所以错误只在编辑器里出现,运行时完全正常。

wsf_grammar_check 扩展可以在构建期或测试期把 .ag 解析成真正的解析器,验证语法本身是否合法(core/wsf_grammar_check/source/WsfGrammarCheck.cpp:28-40)。它检查的是"语法文件能否构成解析器",不是"语法与 ProcessInput 是否一致"。后者没有自动检查手段,只能靠人工对照或回归测试。

下一节开始逐个精读代表插件。第一个是 wsf_coverage:它把网格点变成临时平台,把覆盖统计变成观测者回调,是"分析类插件"最完整的样本。

1.9 wsf_coverage 精读:把网格变成平台,把探测变成覆盖

wsf_coverage 是 16 个插件里设计最完整、也最容易被误用的一个。它的输入看起来像一份分析配置,运行方式却完全是仿真式的:网格点被实例化成平台,传感器照常探测,覆盖统计只是这些探测事件的副产物。理解这一点,才能理解它为什么能复用传感器模型、为什么平台数会爆炸、以及为什么它只支持传感器覆盖而不支持通信覆盖。

1.9.1 三层对象模型

插件在场景层注册了四张类型表(WsfCoverageScenarioExtension.cpp:32-39):

  • GridTypes:网格类型,五种。
  • CoverageTypes:覆盖类型,只有 WSF_SENSOR_COVERAGE 一种(WsfCoverageTypes.cpp:26-31)。
  • MeasureTypes:度量类型,八种(WsfCoverageMeasureTypes.cpp:35-46)。
  • MeasureOutputTypes:输出类型,三种(data、grid_stats、lat_lon_stats)。

这四张表对应输入里的四类块:

text 复制代码
grid <name> <grid-type> ... end_grid
coverage <name> <coverage-type>
   grid <grid-name>
   assets ... end_assets
   moe <name> <measure-type> ... end_moe
end_coverage

一个 Coverage 对象持有三样东西:一个 Grid、一组自由资产(free assets)、一组 Measure。网格负责"在哪里算",资产负责"谁参与",度量负责"算什么"。这三者都是 WsfObject 派生类,都可以被克隆,因此同一个场景可以创建多个仿真、每个仿真拿到独立副本。

Grid 和 Coverage 的关系不是组合,而是按名字引用 。Coverage::Initialize 里通过 FindGrid 从仿真扩展的网格表里取指针(WsfCoverage.cpp:133-145)。所以 grid 命令里的名字必须与某个 grid 块的名字一致;名字写错会在初始化时报错,而不是输入解析时。

1.9.2 五种网格的语义差异

五个网格类型不是"不同的画法",而是三种不同的点集来源:

类型 点从哪来 是否创建平台 结构化 典型用途
WSF_LAT_LON_GRID 经纬度范围 + 间距 是 是 区域栅格覆盖
WSF_DISTANCE_STEPPED_GRID 起点 + 米制步长 + 步数 是 是 等距离采样
WSF_ZONE_BASED_GRID 区域内按经纬度间距 是 是 多边形/圆形区域
WSF_COMPOSITE_GRID 多个子网格的并集 视子网格 多分量 多个分离区域
WSF_EXISTING_PLATFORM_GRID 已有平台的位置 否 否 用真实平台位置采样

前四种在 PreparePlatforms 里复制平台类型、设置位置、加入仿真。以 RectangularGrid 为例(WsfCoverageRectangularGrid.cpp:93-133):

cpp 复制代码
WsfPlatform* typePtr = aSimulation.GetScenario().GetPlatformTypes().Find(mPlatformType);
...
std::string side = "coverage_grid_" + GetName();
for (auto& iter : mPoints)
{
   auto gridPlatPtr = ut::clone(typePtr);
   gridPlatPtr->SetName(GetPlatformName(iter.first));
   gridPlatPtr->SetCentralPoint(GetCentralBody());
   gridPlatPtr->SetLocationLLA(iter.second.GetLatitude(),
                               iter.second.GetLongitude(),
                               iter.second.GetAltitude());
   gridPlatPtr->SetSide(side);
   aSimulation.AddPlatform(gridPlatPtr.release());
}

这段代码解释了三个现象:

  1. 网格点会出现在平台列表里 。每个点都是一个真实 WsfPlatform,会参与传感器探测、会出现在事件输出里、会占用平台索引。
  2. 网格平台有统一的 side (coverage_grid_<gridname>),方便在可视化工具里整体隐藏或删除。
  3. 平台类型必须存在 。asset 的第一个参数是平台类型名,不是设备名;类型找不到会直接抛 Initialization error.。

WSF_EXISTING_PLATFORM_GRID 是唯一不创建平台的网格。它把输入里的 platform <name> <device> 解析成点集,点的位置取平台初始位置(WsfCoverageExistingPlatformGrid.hpp:39-56)。因为平台可以移动,这个网格的"点"只在初始化时确定;后续平台移动不会改变网格几何,但探测事件仍按平台当前状态产生。

WSF_COMPOSITE_GRID 本身不创建平台,它把 PreparePlatforms 转发给每个子网格(WsfCoverageCompositeGrid.cpp:72-90)。子网格必须自己创建平台,且名称不能冲突;父网格的 central_body 和 suppress_grid_platforms 会被子网格继承。

1.9.3 一段可运行的输入

下面这段输入把"一个传感器平台对一个区域的可探测时间百分比"表达出来。它假设场景里已经定义了 SAM_1 平台类型,并且该类型上有一个名为 radar 的传感器。

text 复制代码
platform_type grid_point WSF_PLATFORM
end_platform_type

grid test_grid WSF_LAT_LON_GRID
   latitude_span 30n 40n
   longitude_span 110e 120e
   spacing 0.1 degrees
   altitude 10000 m msl
   asset grid_point none
end_grid

coverage test_coverage WSF_SENSOR_COVERAGE
   grid test_grid
   start_time 0 s
   end_time 3600 s
   assets
      platform_type SAM_1 radar
   end_assets
   moe coverage_time WSF_COVERAGE_TIME_MOE
      subtype percent
      output grid_stats
         file coverage.csv
      end_output
   end_moe
end_coverage

几个参数的含义需要拆开:

  • asset grid_point none:网格平台使用 grid_point 类型,设备名为 none。none 是 cNULL_COVERAGE_DEVICE_NAME(WsfCoverageAsset.hpp:27),表示这一侧的资产不提供设备,探测由另一侧完成。
  • platform_type SAM_1 radar:自由资产是场景里所有 SAM_1 类型平台,参与交互的设备是每个平台上的 radar 传感器。资产规格支持 platform、platform_type、category、group 四种写法(WsfCoverageAssetSpecification.hpp:37-52)。
  • moe coverage_time ... subtype percent:度量类型是 WSF_COVERAGE_TIME_MOE,子类型 percent 表示输出"被覆盖时间占整个覆盖区间的百分比"。
  • output grid_stats ... file coverage.csv:输出类型 grid_stats 按网格统计(最大、最小、均值等),写成一个 CSV。

0.1 degrees 的间距在 10°×10° 区域上产生 101×101 ≈ 10,201 个网格平台。这个数字不是小数:每个平台都要初始化、加入平台列表、参与探测过滤。把间距减半,平台数变成四倍。覆盖分析的第一条性能规则就是"先算平台数,再跑仿真"。

1.9.4 从初始化到结束的完整调用链

coverage 的运行链可以按时间顺序拆成七步。

第一步:仿真扩展初始化。 SimulationExtension::Initialize 遍历所有 Coverage 对象,调用 Coverage::Initialize;再初始化所有网格(WsfCoverageSimulationExtension.cpp:94-111)。Coverage::Initialize 做五件事(WsfCoverage.cpp:133-158):

  1. 确保过滤文件唯一(AssureFilterFileUniqueness)。
  2. 找到网格(FindGrid),并生成两个分类名:<coverage>_<grid>_grid_asset 与 <coverage>_<grid>_free_asset。
  3. 检查空设备配置(NullDeviceCheck)。
  4. 初始化覆盖时间区间(InitializeCoverageInterval)。
  5. 初始化度量(InitializeMeasures),再调用子类的 InitializeP。

InitializeP 是子类挂接观测者的地方。SensorCoverage::InitializeP 只做一件事:把 OnSensorDetectionChanged 连接到 WsfObserver::SensorDetectionChanged(WsfSensorCoverage.cpp:28-33)。此时还没有平台,观测者不会触发。

第二步:解析资产。 ResolveAssets 把输入里的资产规格展开成具体的 FreeAsset 集合(WsfCoverage.cpp:162-195)。platform 解析成单平台,platform_type 展开成该类型的所有平台,category 展开成分类成员,group 展开成组内平台。解析失败会返回 false,仿真扩展抛 "Unable to resolve coverage assets."(WsfCoverageSimulationExtension.cpp:113-120)。

第三步:创建网格平台。 仿真扩展对每个网格调用 PreparePlatforms(WsfCoverageSimulationExtension.cpp:124),也就是上一节的克隆循环。此时网格点才真正成为平台。

第四步:分类。 CategorizePlatforms 给每个平台打上"网格资产""自由资产"或"非资产"的标签(WsfCoverage.cpp:203-207)。分类不是装饰:IsAsset、IsFreeAsset、IsGridAsset 都靠它判断(WsfCoverage.hpp:141-158),传感器也靠它忽略无关平台。

第五步:待启动。 Coverage::PendingStart 做四件事(WsfCoverage.cpp:209-251):

  • 如果 start_time > 0,在开始时刻安排一个一次性事件调用 StartInterval;否则立即开始。
  • 在 end_time 安排事件调用 Finalize。
  • 初始化所有区间约束。
  • 调用子类 PendingStartP。

SensorCoverage::PendingStartP 会给网格平台上的传感器添加忽略分类(WsfSensorCoverage.cpp:104-170):网格资产传感器忽略其他网格资产和非资产;不是参与设备的传感器还要忽略自由资产。这是覆盖分析能在上万平台上跑得动的关键优化------它把"不关心的探测"挡在传感器内部,而不是等观测者回调再过滤。

第六步:运行期收集区间。 传感器每次探测状态变化都会触发 OnSensorDetectionChanged(WsfSensorCoverage.cpp:178-218)。它先检查探测者和被探测者是否都是资产,再要求一方是自由资产、另一方是网格资产,然后调用 ProcessInterval。ProcessInterval 再核对自由资产是否在解析结果里、网格资产是否与网格记录一致,最后根据 aResult.Detected() 调用 AddIntervalStartToData 或 AddIntervalEndToData(WsfSensorCoverage.cpp:226-253)。

区间开始和结束会触发三组回调:AccessIntervalStart、AccessIntervalEnd、AccessIntervalComplete(WsfCoverage.hpp:73-92)。前两组不管区间最终是否被过滤都会通知;AccessIntervalComplete 只在区间通过 IntervalConstraints 后通知。度量对象订阅这些回调,自己累计统计量。

第七步:结束。 Coverage::Finalize 关闭所有未闭合区间、通知度量收集完成、断开观测者、写原始数据、完成约束、写 overlay(WsfCoverage.cpp:254-287)。仿真扩展的 Complete 还会写网格数据文件(WsfCoverageSimulationExtension.cpp:137-145)。Finalize 有 mFinalized 保护,即使覆盖区间结束事件和仿真结束事件都触发,也只执行一次。

1.9.5 八种度量各自回答什么问题

MeasureTypes 注册了八种度量(WsfCoverageMeasureTypes.cpp:35-46),它们不是同一指标的不同输出格式,而是不同的统计问题:

度量类型 回答的问题
WSF_ACCESS_DURATION_MOE 每次访问持续多久:均值、最小、最大、标准差、超阈值百分比、总和
WSF_COVERAGE_TIME_MOE 总共被覆盖多久:总时长、百分比、超阈值时长/百分比、按周期归一化
WSF_N_ASSET_COVERAGE_MOE 同时有多少个资产能覆盖:均值、最小、最大、超阈值百分比、唯一资产数
WSF_NUMBER_OF_ACCESSES_MOE 发生了多少次访问:总数、时间窗内次数、按周期归一化
WSF_NUMBER_OF_GAPS_MOE 出现了多少个覆盖空隙:总数、时间窗内、按周期归一化
WSF_REVISIT_TIME_MOE 两次访问之间隔多久:均值、最小、最大、标准差、低于阈值百分比
WSF_SIMPLE_COVERAGE_MOE 最简单的二值覆盖:有访问即 1,无访问即 0
WSF_TIME_AVERAGE_GAP_MOE 平均空隙时长

这些度量都继承 Measure,通过订阅 Coverage 的回调工作,而不是自己去扫描平台。新增一种度量不需要改 Coverage 或 Grid,只需要派生 Measure、实现统计逻辑、在 MeasureTypes 注册。这是插件内部最清晰的一条扩展轴。

三种输出类型解决"结果写到哪里、怎么写":

  • data:逐区间原始数据,适合后续用 Python/Excel 分析。
  • grid_stats:按网格汇总的统计值,适合快速看覆盖图。
  • lat_lon_stats:按经纬度分箱统计,适合与地理数据叠加。

grid_stats 的 file 是相对路径,最终会落在 Coverage 的 output_dir 下。不指定 output_dir 时写入当前工作目录;多覆盖对象共用同一个目录时,文件名必须自己保证不冲突。

1.9.6 设计边界与常见误用

coverage 的能力边界由 CoverageTypes 决定:只有 WSF_SENSOR_COVERAGE(WsfCoverageTypes.cpp:26-31)。它观察的是 WsfObserver::SensorDetectionChanged,不是通信、不是武器、不是电子战。想要"通信覆盖",不能靠换一个度量类型解决,必须新增一个 Coverage 子类并订阅通信事件;插件没有提供这个子类。

四个常见误用:

误用一:把覆盖分析当成轻量后处理。 它会在仿真里创建上万个平台,每个平台都有完整的平台生命周期。10,000 个网格点意味着 10,000 次 Platform::Initialize、10,000 个平台索引、10,000 份传感器状态。覆盖分析的计算量主要花在平台管理上,不是花在统计上。

误用二:网格平台类型带运动模型。 asset grid_point 指向的平台类型如果有 mover,网格点会离开原位。覆盖分析假设网格平台停在网格点上;要动的是自由资产,不是网格资产。网格平台类型应该是一个静态平台,最多带一个用于探测的传感器。

误用三:设备名写成平台名。 asset 的第二个参数是设备名,platform_type 资产的第二个参数也是设备名。asset grid_point radar 表示"网格平台上的 radar 设备",不是"名为 radar 的平台"。设备不存在时,VerifyDeviceP 返回 false,初始化失败。

误用四:忽略 none 的配对规则。 传感器覆盖要求每一对资产中只有一方提供设备。SensorCoverage::AreNullDevicesAllowed 明确规定:要么所有网格资产为 none,要么所有自由资产为 none,不能两边都有设备,也不能两边都为 none(WsfSensorCoverage.cpp:35-76)。这条规则不是形式主义:两边都有设备会产生重复的探测对,统计会翻倍。

1.9.7 它为什么是一个"好插件样本"

从插件架构角度看,coverage 展示了四件值得学习的事:

  1. 类型表分离。网格、覆盖、度量、输出各自一张表,输入语法按类型分派,新增类型不需要改解析器。
  2. 生命周期分层 。场景层只注册类型,仿真层 Initialize 建资源、PendingStart 解析平台、Complete 写文件,每一步都放在正确的回调里。
  3. 观测者优先 。它不修改传感器、不修改平台,只订阅 SensorDetectionChanged,把"探测"翻译成"访问区间"。这保证了覆盖分析与传感器模型解耦。
  4. 可克隆设计 。场景扩展继承 WsfCloneableScenarioExtension,仿真扩展实现 Clone(),因此同一个场景可以多次运行而互不污染。

它的局限也同样清晰:只有传感器覆盖、网格平台开销大、资产在 PendingStart 后不再重新解析。理解这些边界,比记住某个命令的拼写更重要。

下一节看两个更轻的插件:wsf_annotation 把场景输入变成事件管道消息,wsf_alternate_locations 把平台组件塞进 PreInitialize。它们代码量小,但分别展示了"只读观测"和"组件注入"两种完全不同的扩展手法。

1.10 轻量插件的两种手法:wsf_annotation 与 wsf_alternate_locations

同样是 1,000 行上下的规模,这两个插件走的是相反的路。wsf_annotation 不碰仿真状态:它把场景文件里的 visual_elements 块解析成一组只读数据,等仿真启动时原样发给事件管道,供 Warlock、Mystic 之类的可视化程序画图。wsf_alternate_locations 则直接改仿真状态:它给平台挂一个组件,在 PreInitialize 里按权重抽一个初始位置,然后改写平台的经纬高和航向。

一个是"把输入翻译成消息",一个是"把组件塞进平台生命周期"。把两者放在一起读,能看清一个关键判断:扩展点不是越晚越好,也不是越早越好,而是由数据所有权决定的。

1.10.1 wsf_annotation 的工程形态:没有 source/CMakeLists.txt 的插件

第 1.1 节给出的标准结构是 source/CMakeLists.txt 负责建库、顶层 CMakeLists.txt 负责安装。wsf_annotation 打破了这个结构:它没有 source/CMakeLists.txt,构建定义全部在顶层。

cmake 复制代码
# wsf_plugins/wsf_annotation/wsf_cmake_extension.cmake:10-14
set(WSF_EXT_NAME wsf_annotation)
if(WSF_PLUGIN_BUILD)
   set(WSF_EXT_TYPE plugin)
endif()
set(WSF_EXT_SOURCE_PATH .)

WSF_EXT_SOURCE_PATH 被设为 .,意思是"源码就在扩展根目录下",而不是惯例的 source。顶层 CMakeLists.txt 于是直接调用 wsf_project_template:

cmake 复制代码
# wsf_plugins/wsf_annotation/CMakeLists.txt:27-41(节选)
wsf_project_template(${PROJECT_NAME} PLUGIN
HDRS source/WsfAnnotation.hpp source/WsfAnnotationDataTypes.hpp
     source/WsfAnnotationEventPipe.hpp ${EVENT_PIPE_INCLUDES}
SRCS source/WsfAnnotation.cpp source/WsfAnnotationEventPipe.cpp
GRAMMARS grammar/wsf_annotation.ag
NOTICES README.md wsf_annotation_events.utpack
)

这说明 wsf_project_template 并不强制"源码目录必须叫 source",它接受任意相对路径;WSF_EXT_SOURCE_PATH 只影响 CMake 在哪个目录寻找扩展定义。标准结构是约定,不是契约。维护一个插件时,先看 wsf_cmake_extension.cmake 的三个变量,再看 wsf_project_template 的实参,比按目录名猜更可靠。

更值得注意的是 CMakeLists.txt:14-24:

cmake 复制代码
include(${WSF_ROOT}/wsf/event_pipe.cmake)
set(EVENT_PIPE_INCLUDES)
set(EVENT_PIPE_SOURCES)
add_event_pipe_schema(${CMAKE_CURRENT_SOURCE_DIR}/wsf_annotation_events.utpack
                      "${wsf_SOURCE_DIR}/../event_pipe/wsf.utpack"
                      ""
                      "WsfEventPipeClasses.hpp"
                      "WsfEventPipe"
                      "WsfAnnotationEventPipe"
                      EVENT_PIPE_INCLUDES
                      EVENT_PIPE_SOURCES)

add_event_pipe_schema 在构建期读取两个 schema:核心的 event_pipe/wsf.utpack 和插件自己的 wsf_annotation_events.utpack,生成 WsfAnnotationEventPipeClasses.hpp 与对应实现。这意味着事件消息的序列化代码不是手写的,而是 schema 编译出来的;插件的消息定义与核心事件管道共享同一套编码规则。第 1.8 节讲过 .ag 语法文件的安装,这里是另一条"输入文件在构建期生成代码"的路径:.ag 生成 IDE 语法提示,.utpack 生成 C++ 消息类。

wsf_annotation_events.utpack:10-64 定义了三条消息:MsgAnnotationDecoration(message 1)、MsgAnnotationPoi(message 2)、MsgAnnotationRangeRing(message 3),都继承 MsgBase。字段类型覆盖 string、double、int、bool、Vec3f,其中布尔字段用 bit: 0/1/2 打包。Settings { message_id_or: 6 } 把本地消息号与全局消息号做按位或,避免与核心事件号冲突。这是一套紧凑的 IDL,不是"随便定义个结构体"。

1.10.2 注册链:先挂场景扩展,再挂仿真扩展

wsf_annotation 的入口函数只有十几行,但把三层关系交代得很清楚:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotation.cpp:83-91
void WSF_ANNOTATION_EXPORT Register_wsf_annotation(WsfApplication& aApplication)
{
   if (!aApplication.ExtensionIsRegistered(cEXTENSION_NAME))
   {
      aApplication.RegisterFeature("annotation", cEXTENSION_NAME);
      aApplication.RegisterExtension(cEXTENSION_NAME, ut::make_unique<AnnotationApplicationExtension>());
      aApplication.ExtensionDepends(cEXTENSION_NAME, "event_pipe", false);
   }
}

RegisterFeature("annotation", "wsf_annotation") 把用户可见的特性名和扩展名分开;ExtensionDepends(..., "event_pipe", false) 的第三个参数 false 表示可选依赖------有 event_pipe 就排在它后面,没有也不阻止加载。这正是第 1.6 节讨论的依赖声明方式,只不过这里的依赖是"软"的:没有事件管道时标注插件仍然能读输入,只是不会产生输出。

AnnotationApplicationExtension 在 ScenarioCreated 时把场景扩展挂到场景上:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotation.cpp:31-38
class AnnotationApplicationExtension : public WsfApplicationExtension
{
public:
   void ScenarioCreated(WsfScenario& aScenario) override
   {
      aScenario.RegisterExtension(GetExtensionName(), ut::make_unique<WsfAnnotationExtension>());
   }
};

这一步发生在特性注册之后、场景输入解析之前。也正是在这个窗口里,WsfAnnotationExtension::AddedToScenario 可以找到 event_pipe 的场景扩展并注册消息:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotation.cpp:63-70
void WsfAnnotationExtension::AddedToScenario()
{
   WsfEventPipeExtension* eventPipePtr = WsfEventPipeExtension::Find(GetScenario());
   if (eventPipePtr)
   {
      wsf::annotation::EventPipe::RegisterEvents(*eventPipePtr);
   }
}

event_pipe 是核心内置扩展,wsf_extensions.hpp 在 RegisterBuiltinExtensions 中先于插件注册它(core/wsf/wsf_extensions.hpp.in:108-110),因此这里能稳定地 Find 到。如果事件管道由另一个可选插件提供,这个写法就不安全 :AddedToScenario 早于依赖排序完成(第 1.6 节),Find 可能返回空,插件会静默跳过注册。

RegisterEvents 做三件事:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.cpp:40-45
void wsf::annotation::EventPipe::RegisterEvents(WsfEventPipeExtension& aEventPipeExtension)
{
   aEventPipeExtension.AddSchema(cWsfAnnotationEventPipe_SCHEMA);
   aEventPipeExtension.RegisterEventOption(cANNOTATION_ID, true);
   aEventPipeExtension.AddCallback(aEventPipeExtension.RegisterExtensionMessages.Connect(&EventPipe::RegisterMessages));
}

AddSchema 让 event_pipe 输入块能校验 annotation 事件选项;RegisterEventOption 把 ANNOTATION 注册为可订阅事件,默认开启;AddCallback 在事件管道序列化器创建时回调 RegisterMessages,把插件生成的消息类型注册进 UtPackSerializer。三步缺一不可:只加 schema 不注册消息,订阅了也解不出;只注册消息不加 schema,输入阶段就会被判为未知事件。

1.10.3 输入解析:一个可复用的 mixin 和三类注解

WsfAnnotationExtension 同时继承 WsfScenarioExtension 和 WsfAnnotationInput:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotation.hpp:24-44(节选)
class WsfAnnotationInput
{
public:
   bool ProcessAnnotationInput(UtInput& aInput);
protected:
   void ProcessDecorationInput(UtInput& aInput);   // decoration
   void ProcessPoiInput(UtInput& aInput);          // poi
   void ProcessRangeRingInput(UtInput& aInput);    // range_ring
   AnnotationData::AnnotationInfo mAnnotationInfo;
};
class WsfAnnotationExtension : public WsfScenarioExtension, public WsfAnnotationInput { /* ... */ };

这种拆法的意图是让别的应用(例如 Warlock 的编辑器或某个工具)复用同一套 visual_elements 解析,而不必依赖完整的场景扩展。但 2.9.0 源码里没有任何第二个类使用 WsfAnnotationInput(全仓检索只在 WsfAnnotation.cpp 和它自己的头文件里出现)。它是"为复用而设计、但没有实际复用者"的 API。读到这里应该记住:mixin 的存在不等于生态里真的有人用;判断一个接口是否稳定,要看调用点,不要看命名。

ProcessAnnotationInput 只认一个顶层命令 visual_elements,块内按 decoration、poi、range_ring 分派(WsfAnnotation.cpp:104-137)。语法文件里也是同样的结构:

shell 复制代码
# wsf_plugins/wsf_annotation/grammar/wsf_annotation.ag:117
   visual_elements <annotation>* end_visual_elements

.ag 里还有一段自嘲式注释(grammar/wsf_annotation.ag:100-102):按直觉,poi 规则不需要 type 关键字,但"the type keyword is necessary for correct syntax highlighting even if not used"。这是语法文件与解析器不同步的典型痕迹:高亮需要类型标注,运行时解析器并不使用它。

三类注解的解析各有边界:

  • decoration <entity>:实体名必须唯一,重复定义直接抛 BadValue;随后读 text(带引号)、preset(预置图标名)或 image(图片路径),三者设置同一个 mType,后写的覆盖先写的(WsfAnnotation.cpp:139-176)。解析器不校验 preset 的取值是否合法,非法值要到可视化端才暴露。
  • poi <name>:支持 position <lat> <lon> 或 mgrs_coordinate <string>,后者走 UtEllipsoidalEarth::ConvertMGRSToLL;altitude 可省略;bullseye 是布尔标记。bullseye 的高度必须为 0,否则抛错(WsfAnnotation.cpp:178-227)。这是把领域约束放在输入阶段的正面例子:等到仿真跑起来再检查,错误已经传播到结果里了。
  • range_ring <name>:支持 entity 引用、标签/颜色/线宽、center_radius、additional_rings、additional_radii、角度标签、center_angle/angle_width/radial_rate 和 align_north。数值范围都在解析时校验:线宽 1--5、中心半径大于 1 米、附加环 0--20、角度 0--2π(WsfAnnotation.cpp:229-325)。

range_ring 的 entity 分支里有一个位置不太对劲的重复检查:它在读到 entity 时才遍历已有环,判断环名是否重复(WsfAnnotation.cpp:237-248)。如果一个 range_ring 块不写 entity,重复名不会被发现。这个缺陷不影响常见用法(环通常都绑定实体),但它说明校验代码放在哪个分支,决定了哪条输入路径受保护。写解析器时,重复性检查应该放在块结束处,而不是某个可选命令的分支里。

1.10.4 场景到仿真的交接:值拷贝,而不是指针

WsfAnnotationExtension::SimulationCreated 把整个 mAnnotationInfo 按值传给仿真扩展:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotation.cpp:77-81
void WsfAnnotationExtension::SimulationCreated(WsfSimulation& aSimulation)
{
   aSimulation.RegisterExtension("wsf_annotation_eventpipe",
                                 ut::make_unique<wsf::annotation::EventPipeInterface>(mAnnotationInfo));
}

构造函数也是按值保存:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.hpp:50-67
class EventPipeInterface : public WsfSimulationExtension
{
public:
   explicit EventPipeInterface(const AnnotationData::AnnotationInfo& aAnnotationInfo)
      : mEventPipePtr(nullptr)
      , mAnnotationInfo(aAnnotationInfo)
   {
   }
   void AddedToSimulation() override;
   void Start() override;
private:
   std::unique_ptr<EventPipe>     mEventPipePtr;
   AnnotationData::AnnotationInfo mAnnotationInfo;
};

这里没有任何指向场景扩展的指针。原因是生命周期:场景对象和仿真对象的生存期不保证嵌套,WsfSimulation 可以在场景销毁后继续存在(批量运行、Wizard 多次试跑都是这种模式)。如果仿真扩展持有 WsfAnnotationExtension*,场景一销毁就是悬空指针。值拷贝把"输入已经冻结"这件事编码进了类型里:标注在场景加载后不再变化,所以复制一份最便宜也最安全。

代价是内存翻倍。对标注这种几十条记录的数据无所谓;对覆盖分析那种上万网格点的数据,就不能照抄这个模式。第 1.9 节的 WsfCoverageSimulationExtension 选择在仿真侧重新建资源,而不是复制场景的全部网格,就是同一个判断在不同数据规模下的两种答案。

1.10.5 事件管道的挂接:AddedToSimulation 与 Start

EventPipeInterface 的两个回调分工明确:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.cpp:121-129
void wsf::annotation::EventPipeInterface::AddedToSimulation()
{
   WsfEventPipeInterface* eventPipePtr = WsfEventPipeInterface::Find(GetSimulation());
   if (eventPipePtr != nullptr)
   {
      mEventPipePtr = ut::make_unique<EventPipe>(eventPipePtr);
      eventPipePtr->AddLogger(mEventPipePtr.get());
   }
}
cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.cpp:108-114
void wsf::annotation::EventPipeInterface::Start()
{
   if (mEventPipePtr)
   {
      mEventPipePtr->AddMessages(mAnnotationInfo);
   }
}

为什么不在 AddedToSimulation 里直接发消息?因为事件管道接口在仿真创建阶段可能还没准备好输出文件,消息发出去也没人接收。Start 是仿真正式开始的时刻,所有扩展都已完成初始化,输出通道已经打开。这个顺序与第 1.5 节的生命周期表一致:AddedToSimulation 用来建立连接,Start 用来产生第一批数据。

EventPipe::Send 把时间戳固定为 0:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.hpp:34-37
void Send(double aSimTime, std::unique_ptr<WsfEventPipe::MsgBase> aMessagePtr)
{
   mEventPipeInterfacePtr->SendImmediate(aSimTime, false, std::move(aMessagePtr));
}

调用处全部是 Send(0, ...)(WsfAnnotationEventPipe.cpp:71,79,103)。这不是偷懒:标注是场景元数据,不是仿真事件,没有"发生时刻"。用 0 表示"在时间轴开始之前就已经存在",消费端据此把它们当作静态图层处理。如果改成当前仿真时间,回放时标注会突然出现,语义就错了。

AddMessages 还有一个前置条件:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.cpp:59-62
void wsf::annotation::EventPipe::AddMessages(const AnnotationData::AnnotationInfo& aAnnotationInfo)
{
   if (mEventPipeInterfacePtr && !mEventPipeInterfacePtr->GetOutputFileName().empty())
   {

没有配置 event_pipe 输出文件时,整段消息发送被跳过。这解释了一个常见现象:场景里写了 visual_elements,Warlock 里却看不到标注。不是解析失败,而是没有打开事件管道输出。插件选择静默跳过而不是报错,因为"没有输出文件"在批处理运行中是合法配置;但对可视化调试来说,这个静默行为很容易误导。

1.10.6 一个没有赋值的字段:RangeRing 的经纬度

AnnotationData::RangeRingData 有 mLat 和 mLon 两个字段,默认值 0(WsfAnnotationDataTypes.hpp:55-61)。消息里也照发:

cpp 复制代码
// wsf_plugins/wsf_annotation/source/WsfAnnotationEventPipe.cpp:86-87
rangeRingMsg->lat(rangeRingIt.mLat);
rangeRingMsg->lon(rangeRingIt.mLon);

但 ProcessRangeRingInput 从头到尾没有解析过位置:它读 entity、颜色、半径、角度,就是不读 position(WsfAnnotation.cpp:229-325)。所以这两个字段永远是 0。消费端也确实不用它们:Warlock 的 GetRangeRingProperties 只取样式和半径,环的锚点来自 referenceName 对应的实体(warlock/plugins/Annotation/source/AnnotationSimEvents.cpp:27-53)。

这是 schema 演进的遗留:早期版本可能允许独立坐标的环,后来改成必须绑定实体,字段为了兼容没有删。它给插件作者两个提醒:

  1. 消息里有字段,不代表字段有值。 生成代码不会告诉你哪些字段是死字段,只有读生产端才能确认。
  2. 删除字段要同时改 schema、生成代码和所有消费端。 保留一个恒为 0 的字段,比破坏二进制兼容便宜,但会把"字段含义"变成考古问题。

1.10.7 wsf_alternate_locations:把行为装进组件

wsf_alternate_locations 解决的是另一个问题:同一型平台在场景里可能从多个备选位置出发,每个位置带权重,运行时按权重抽一个。它没有把逻辑写进平台或 mover,而是实现为一个平台组件。

组件类的身份在头文件里定义:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.hpp:49-61
class Component : public WsfObject, public WsfPlatformComponent
{
public:
   enum
   {
      cCOMPONENT = 20181205
   };

   static constexpr const char* cNAME    = "AlternateLocationsComponent";
   static constexpr const char* cCOMMAND = "alternate_locations";

   static constexpr double cINVALID_DRAW           = -1.0;
   static constexpr int    cINVALID_LOCATION_INDEX = -1;

cCOMPONENT 是一个全局唯一的角色号,用来在组件列表里按角色查找;cNAME 是组件类型名,cCOMMAND 是输入块名。三者分工不同:角色号用于 FindComponentByRole,名字用于类型系统和克隆,命令用于解析。组件同时继承 WsfObject(可克隆、可命名)和 WsfPlatformComponent(可挂到平台、参与生命周期),这是 AFSIM 组件的标准双重身份。

注册发生在场景扩展的 AddedToScenario:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLoc.cpp:64-75
class ComponentTypes : public WsfObjectTypeList<Component>
{
public:
   ComponentTypes(WsfScenario& aScenario)
      : WsfObjectTypeList<Component>(aScenario, cREDEFINITION_ALLOWED, Component::cCOMMAND)
   {
      SetSingularBaseType();
      // Allows for definition inside platform, platform_type blocks.
      aScenario.RegisterComponentFactory(ut::make_unique<ComponentFactory>());
   }
};
cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLoc.cpp:88-92
void ScenarioExtension::AddedToScenario()
{
   WsfScenario& scenario = GetScenario();
   scenario.AddTypeList(ut::make_unique<ComponentTypes>(scenario));
}

两件事必须同时做:AddTypeList 让 alternate_locations 成为一个可被解析的块类型,RegisterComponentFactory 让这个块可以出现在 platform/platform_type 内部。只做前者,块只能定义在顶层;只做后者,解析器不知道块名对应哪个类。注释里那句 "Allows for definition inside platform, platform_type blocks" 是这套机制最简短的说明。

插件的注册函数用 WsfDefaultApplicationExtension<ScenarioExtension> 包装场景扩展,并把扩展名直接取为仿真扩展的常量:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLoc.cpp:126-136
void UT_PLUGIN_EXPORT Register_wsf_alternate_locations(WsfApplication& aApplication)
{
   using namespace wsf::altloc;
   if (!aApplication.ExtensionIsRegistered(SimulationExtension::cEXTENSION))
   {
      aApplication.RegisterFeature("alternate_locations", SimulationExtension::cEXTENSION);
      aApplication.RegisterExtension(SimulationExtension::cEXTENSION,
                                     ut::make_unique<WsfDefaultApplicationExtension<ScenarioExtension>>());
      aApplication.ExtensionDepends(SimulationExtension::cEXTENSION, "event_output", false);
   }
}

注意 ExtensionDepends(..., "event_output", false):事件输出是可选依赖,因为插件在没有 event_output 时也能工作,只是不写 ALTERNATE_LOCATIONS_INITIALIZED 记录。这与 annotation 对 event_pipe 的软依赖是同一手法。

1.10.8 输入状态机:四种块类型与延迟解析

alternate_locations 块有四种合法形态,解析器用一个 BlockType 状态机保证互斥:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.hpp:40-47
enum class BlockType
{
   LOCAL,
   REF_LOC,
   OFF_ARA,
   OFF_LLA,
   NONE
};
  • LOCAL:块内直接列多个 location,每个位置可带 weight。
  • REF_LOC:块内先写 reference <platform>,再列与参考平台数量相同的 location,逐个对应。
  • OFF_LLA:offset_lla <lat> <lon>,可选 altitude、heading,相对参考平台做经纬度偏移。
  • OFF_ARA:offset_ara <angle> <radius>,可选 altitude、heading,沿大圆按方位/距离偏移。

状态机在 ProcessInput 里逐条命令推进(WsfAltLocComponent.cpp:87-319)。几种非法组合被明确拒绝:

  • 同一个块只能有一个 offset(:204-211, :241-248)。
  • location 不能和 offset 混用(:156-174)。
  • 引用其他平台的块不能再带权重(:143-151)。
  • 空块直接报错(:259-262)。
  • 用 offset 的块必须 reference 另一个平台(:310-315)。

权重规则分两种情况。没有参考平台时,如果所有 location 都没写权重,平均分配并令总和为 1;只要有一个写了权重,就按写出的值求和,总和必须为正(WsfAltLocComponent.cpp:267-293)。有参考平台时,位置数量必须与参考块一致,权重和直接继承参考块(:295-308)。这套规则把"权重是相对值还是绝对值"这个歧义消掉了:要么全不写,要么全写;部分写不会被自动补全。

引用平台时有一个关键细节:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:117-126
// 4.2 Add to Deferred input
std::string platformName;
aInput.ReadCommand(platformName);
WsfScenario& scenario = WsfScenario::FromInput(aInput);
scenario.GetDeferredInput().Requires("platform", platformName);

WsfPlatform* platformPtr = scenario.GetInputPlatformByName(platformName);
assert(platformPtr != nullptr);
WsfPlatformComponentList& componentList = const_cast<WsfPlatformComponentList&>(platformPtr->GetComponents());
referenceComponent                      = static_cast<Component*>(componentList.FindComponent(cNAME));

Requires("platform", platformName) 登记一个延迟输入依赖:被引用的平台可以出现在场景文件的后面,等所有平台都解析完再回来完成这个块。GetInputPlatformByName 拿的是场景里的平台原型,不是仿真里的平台实例;const_cast 是因为输入阶段需要向原型挂组件。这个原型随后会被克隆进仿真,所以运行时还要按名字重新找一次组件(见下一小节)。

延迟输入是 AFSIM 处理前向引用的标准机制,但它有边界:Requires 只保证"平台会存在",不保证"平台上的组件已经解析完"。因此这里先 FindComponent,找不到就抛 BadValue,把错误留在输入阶段,而不是等到运行时空指针。

1.10.9 PreInitialize:抽点、偏移、贴地、改写

真正的选点在 Component::PreInitialize 里完成(WsfAltLocComponent.cpp:386-626)。这个钩子由平台在初始化时调用:

cpp 复制代码
// core/wsf/source/WsfPlatform.cpp:314-331(节选)
ok &= WsfComponentFactoryList<WsfPlatform>::PreInitialize(GetScenario(), aSimTime, *this);

std::vector<WsfPlatformComponent*> preComponents(GetComponentsByInitializationOrder());
for (auto& preComponent : preComponents)
{
   if (!preComponent->PreInitialize(aSimTime))
   {
      // 记录平台/组件名,置 ok = false
   }
}

这段顺序很重要:工厂先补组件,组件再按初始化顺序执行 PreInitialize,之后才是组件的 Initialize(:333-344)。altloc 选在这个窗口改写位置,有两个好处:

  1. 此时所有输入已经读完,包括延迟引用的平台,名字解析可用。
  2. 此时 mover、传感器、处理器还没进入 Initialize,它们看到的就是最终初始位置。

如果改在 ProcessInput 里直接设置位置,延迟引用的平台可能还没解析完;如果改在 Initialize 里,mover 可能已经根据旧位置算出了初始状态。PreInitialize 是唯一同时满足"输入完整"和"下游未初始化"的窗口。

具体流程:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:394-402
if (mBlockType == BlockType::LOCAL)
{
   GetRandomLocation();
   if (mLocationIndex == cINVALID_LOCATION_INDEX)
   {
      return false;
   }
   location = mLocations[mLocationIndex];
}

GetRandomLocation 用扩展提供的随机数做加权抽样:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:354-384(节选)
if (mLocationIndex == cINVALID_LOCATION_INDEX)
{
   auto& extension = SimulationExtension::Get(*GetComponentParent()->GetSimulation());
   mDraw = extension.GetDraw() * mWeightsSum;

   double tSum = 0.0;
   for (unsigned int i = 0; i < mLocations.size(); ++i)
   {
      tSum += mLocations[i].weight;
      if (mDraw <= tSum) { mLocationIndex = i; break; }
   }
   if (mLocationIndex == cINVALID_LOCATION_INDEX) { mLocationIndex = 0; }
}
return mLocationIndex;

这里用 mLocationIndex 做了惰性缓存:一旦抽过,后续调用返回同一个结果。mDraw 是 [0,1) 的随机数乘以权重和,再与累积权重比较。末尾的兜底 mLocationIndex = 0 处理浮点误差导致的"没有落入任何区间"------理论上不该发生,但浮点加法不保证 tSum 恰好等于 mWeightsSum。

参考平台模式在运行时重新解析:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:405-426(节选)
auto referencePlatPtr = simulation->GetPlatformByName(mReferenceName);
if (referencePlatPtr)
{
   mReferenceComponent =
      static_cast<Component*>(referencePlatPtr->FindComponentByRole(cCOMPONENT_ROLE<Component>()));
   mLocationIndex = mReferenceComponent->GetRandomLocation();
}
else
{
   // 被引用平台可能因 platform_availability 未实例化
   ut::log::error() << "Referenced platform ... does not exist!";
   return false;
}

注意它没有复用输入阶段的 referenceComponent 指针,而是按名字重新找仿真平台和组件。输入阶段拿到的是场景原型,仿真阶段拿到的是克隆实例,两者不是同一个对象。任何跨阶段的引用都必须在仿真侧重新解析,这是 AFSIM 场景/仿真分离模型最容易踩的坑之一。

参考模式还共享随机抽样结果:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:428-432
if (mLocationIndex == cINVALID_LOCATION_INDEX)
{
   return false;
}
mDraw = mReferenceComponent->mDraw;

mDraw 被覆盖成参考组件的抽样值,保证引用平台和被引用平台落在同一个索引上。否则两个平台各自抽一次,会出现"A 抽到位置 2、B 引用了 A 却抽到位置 1"的不一致。

偏移模式在参考位置的基础上计算:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:458-478(节选)
case BlockType::OFF_ARA:
{
   location = mReferenceComponent->mLocations[mLocationIndex];
   UtEllipsoidalEarth::ExtrapolateGreatEllipsePosition(location.lat, location.lon,
                                                       (mOffset.ARA.angle * UtMath::cDEG_PER_RAD),
                                                       mOffset.ARA.radius,
                                                       location.lat, location.lon);
   if (mOffset.ARA.altSet) { location.alt += mOffset.ARA.alt; location.altSet = true; }
   if (mOffset.ARA.hedSet) { location.hed = mOffset.ARA.hed; location.hedSet = true; }
   break;
}

OFF_LLA 直接给经纬度加偏移,OFF_ARA 用大圆外推。两者都支持绝对高度和绝对航向覆盖,不写就沿用参考位置的值。

AGL 高度在最后统一换算:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:490-496
if (location.altRef == AltitudeReference::AGL)
{
   wsf::Terrain terrain(simulation->GetTerrainInterface());
   float        elevation;
   terrain.GetElevInterp(location.lat, location.lon, elevation);
   location.alt += (double)elevation;
}

地形插值发生在位置最终确定之后、写入平台之前。这意味着 AGL 高度依赖地形数据;没有加载地形时,GetElevInterp 返回什么由地形接口决定,通常是 0。场景里用 AGL 却不配置地形,平台会贴在海平面。

最后是改写:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:498-502
platform->SetLocationLLA(location.lat, location.lon, location.alt);

double yaw, pitch, roll;
platform->GetOrientationNED(yaw, pitch, roll);
platform->SetOrientationNED(location.hedSet ? location.hed : yaw, 0.0, 0.0);

位置直接覆盖;航向只在 hedSet 时覆盖,否则保留平台原航向;俯仰和滚转强制归零。这个选择是合理的:备选位置描述的是"从哪儿出发",不是"以什么姿态出发",残留的俯仰/滚转会让飞机一开始就处于异常姿态。

改写完成后触发观察者:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocComponent.cpp:623
WsfObserver::Event(simulation)(aSimTime, platform, this);

这个观察者不是核心 WsfObserver 里预定义的,而是插件自己往命名空间里加的:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocSimulationExtension.hpp:22-27
namespace WsfObserver
{
using EventCallback = UtCallbackListN<void(double, WsfPlatform*, wsf::altloc::Component*)>;

WSF_ALTERNATE_LOCATIONS_EXPORT EventCallback& Event(const WsfSimulation* aSimulationPtr);
} // namespace WsfObserver
cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocSimulationExtension.cpp:19-22
WsfObserver::EventCallback& WsfObserver::Event(const WsfSimulation* aSimulationPtr)
{
   return wsf::altloc::SimulationExtension::Get(*aSimulationPtr).GetObserver();
}

这是插件扩展观察者体系的两种方式之一:往 WsfObserver 命名空间加一个自由函数,内部转发到仿真扩展持有的回调列表。好处是调用点看起来和核心观察者完全一致,坏处是它依赖链接期的符号可见性------静态构建时如果插件库没有被正确链接,这个符号就找不到(第 1.7 节列过静态构建的坑)。

1.10.10 全局抽样与事件输出

随机数来自仿真扩展,而不是组件自己:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocSimulationExtension.cpp:44-57
double SimulationExtension::GetDraw()
{
   if (mData.mGlobalDrawEnabled)
   {
      if (mData.mGlobalDraw == Component::cINVALID_DRAW)
      {
         mData.mGlobalDraw = GetSimulation().GetRandom().Uniform(0.0, 1.0);
      }

      return mData.mGlobalDraw;
   }

   return GetSimulation().GetRandom().Uniform(0.0, 1.0);
}

默认每个组件独立抽一次;打开 alternate_locations_use_global_draw 后,整个仿真只抽一次,所有组件共用。这个开关的用途是相关性分析:想让所有平台"同进同退"时用全局抽样,想独立随机时用局部抽样。alternate_locations_global_draw 还可以直接指定抽样值,用于复现某一次结果,不用去猜随机种子。

SimulationExtension::AddedToSimulation 把事件挂到 event_output:

cpp 复制代码
// wsf_plugins/wsf_alternate_locations/source/WsfAltLocSimulationExtension.cpp:29-37
void SimulationExtension::AddedToSimulation()
{
   // If the event_output extension is available, hook in the handlers for our events.
   WsfEventOutput* eventOutputPtr = WsfEventOutput::Find(GetSimulation());
   if (eventOutputPtr != nullptr)
   {
      eventOutputPtr->AddEvent<ComponentInitialized>("ALTERNATE_LOCATIONS_INITIALIZED", mAltLocEventCallback);
   }
}

ComponentInitialized 是插件定义的事件类,Print/PrintCSV 负责文本和 CSV 两种格式(WsfAltLocEventResults.cpp:18-83, :116-218)。CSV 有 30 列,包含抽样类型、抽样值、权重和、位置索引、参考平台、偏移量、最终位置和航向。这个事件是事后审计"为什么这个平台出现在这里"的唯一线索;没有它,加权抽样就是一个黑盒。

1.10.11 两种手法的对照

把两个插件放在一张表里:

维度 wsf_annotation wsf_alternate_locations
解决的问题 把场景标注导出给可视化端 按权重选择平台初始位置
场景阶段的状态 只读的 AnnotationInfo 每个平台挂一个 Component
仿真阶段的状态 EventPipeInterface 持有一份值拷贝 SimulationExtension 持有全局选项,组件持有抽样结果
是否修改平台 否 是,改写 LLA 和航向
关键钩子 AddedToScenario、SimulationCreated、Start AddedToScenario、PreInitialize、AddedToSimulation
外部依赖 event_pipe(可选) event_output(可选)
输入表面 全局 visual_elements 块 平台内 alternate_locations 块 + 全局选项
主要风险 没有事件管道输出时静默无输出 位置改写依赖组件 PreInitialize 顺序

选择哪一种,取决于你要改的是"数据"还是"状态":

  • 如果插件只是把已有信息翻译成另一种表示,走 annotation 的路:场景阶段解析成只读结构,仿真阶段值拷贝,在 Start 一次性发出。不要碰平台,不要改组件。
  • 如果插件要改变平台的初始条件,走 altloc 的路:定义组件、注册工厂、在 PreInitialize 改写。不要在输入阶段改,因为前向引用和克隆会让你的修改丢失。
  • 如果两者都需要,拆成两个插件或两个扩展,不要让一个类同时承担解析器和状态修改器。annotation 的 WsfAnnotationInput mixin 已经把"解析"单独拆出来了,altloc 则把"组件"和"场景扩展"分开,都是这个原则的体现。

最后是一个容易忽略的共同点:两个插件都把"可选依赖"声明为 false,并且在依赖缺失时走降级路径------annotation 不注册事件、altloc 不挂事件输出。降级不是静默失败,而是功能子集:核心功能仍然可用,只是少了输出。插件设计里,这比强制依赖更常见,也更难测试,因为默认路径往往没人跑。

1.11 三个边缘形态:scenario_analyzer、multiresolution、sosm

16 个插件里,前面已经详细看了 coverage、annotation、alternate_locations。剩下三个不属于任何常规形态:scenario_analyzer 是"静态库的插件门面",multiresolution 是"没有 Register_* 的插件",sosm 是"把外部模型包装成核心传感器类型"。它们各自暴露了插件契约的一条边界。

1.11.1 scenario_analyzer:插件只是一层注册壳

这个插件的 source/CMakeLists.txt 把源码分成两半:

cmake 复制代码
# wsf_plugins/wsf_scenario_analyzer/source/CMakeLists.txt:17-34
# Grab all source except plugin registration (Avoid editing this every time a new file is added)
FILE(GLOB SRCS *.?pp)
LIST(REMOVE_ITEM SRCS ${CMAKE_CURRENT_SOURCE_DIR}/ScenarioAnalyzerPluginRegistration.cpp)

add_library(${PROJECT_STATIC_LIB_NAME} STATIC ${SRCS})
# ...
add_library(${PROJECT_NAME} ScenarioAnalyzerPluginRegistration.cpp)
generate_export_header(${PROJECT_NAME})
target_link_libraries(${PROJECT_NAME} ${PROJECT_STATIC_LIB_NAME})

wsf_scenario_analyzer_lib 是静态库,装着全部检查逻辑;wsf_scenario_analyzer 是插件目标,只包含 ScenarioAnalyzerPluginRegistration.cpp 一个文件。注册文件也不注册场景扩展或仿真扩展,只在应用扩展的 AddedToApplication 里注册脚本类型:

cpp 复制代码
// wsf_plugins/wsf_scenario_analyzer/source/ScenarioAnalyzerPluginRegistration.cpp:39-55
class WSF_SCENARIO_ANALYZER_EXPORT ScenarioAnalyzerExtension : public WsfApplicationExtension
{
   public:
      void AddedToApplication(WsfApplication& aApplication) override
      {
         ScenarioAnalyzerRegisterScriptTypes(aApplication);
      }
};

void WSF_SCENARIO_ANALYZER_EXPORT Register_wsf_scenario_analyzer(WsfApplication& aApplication)
{
   if (!aApplication.ExtensionIsRegistered("wsf_scenario_analyzer"))
   {
      aApplication.RegisterFeature("wsf_scenario_analyzer_plugin", "wsf_scenario_analyzer");
      aApplication.RegisterExtension("wsf_scenario_analyzer", ut::make_unique<ScenarioAnalyzerExtension>());
   }
}

它提供的检查覆盖指挥链、通信链路、传感器与跟踪处理器连接、跟踪器清除间隔、平台速度是否超出 mover 能力、武器数量等(清单见 wsf_plugins/wsf_scenario_analyzer/source/ScenarioAnalyzerPlugin.hpp:45-63),由脚本在仿真初始化后调用,不参与场景解析。

这个插件的交付方式也不同:swdev_plugin_install(${PROJECT_NAME} mission_plugins) 把它装到 mission_plugins,不是 <app>_plugins;检查套件数据另外装到 resources/data/wsf_scenario_analyzer/(wsf_plugins/wsf_scenario_analyzer/source/CMakeLists.txt:52-65)。它面向的是 Mission/Wizard 这类工具链,不是通用仿真应用。插件目录名、目标名、安装目录三者可以完全解耦 ,wsf_plugins 只是源码树的分类,不是运行时目录的承诺。

1.11.2 multiresolution:没有 Register_* 的插件

大多数插件导出一个 Register_<name>(WsfApplication&),再在 WsfPluginSetup 里调用它。wsf_multiresolution 跳过了这一层,直接在 WsfPluginSetup 里注册:

cpp 复制代码
// wsf_plugins/wsf_multiresolution/source/MultiresolutionPluginRegistration.cpp:40-47
WSF_MULTIRESOLUTION_EXPORT void WsfPluginSetup(WsfApplication& aApplication)
{
   aApplication.RegisterFeature(wsf::multiresolution::cFEATURE_NAME, wsf::multiresolution::cEXTENSION_NAME);
   aApplication.RegisterExtension(wsf::multiresolution::cEXTENSION_NAME,
                                  ut::make_unique<RegisterMultiresolutionComponent>());
   aApplication.RegisterExtension("multiresolution_multirun_table", ut::make_unique<RegisterMultirunTableExtension>());
}

动态加载不受影响:插件管理器只要求 WsfPluginVersion 和 WsfPluginSetup 两个导出符号,Register_* 只是项目内部约定。但静态构建会出问题。静态注册宏的定义是:

cpp 复制代码
// core/wsf/source/WsfApplication.hpp:212-214
#define WSF_REGISTER_EXTENSION(APP, NAME)                     \
   extern void Register_##NAME(WsfApplication& aApplication); \
   Register_##NAME(APP);

生成出来的 RegisterBuiltinExtensions / RegisterOptionalExtensions 会展开成 Register_wsf_multiresolution(aApplication),而这个符号在插件里不存在,链接阶段直接失败。所以这个插件只能按动态插件交付。它没有违反 ABI,违反的是 AFSIM 自己的静态注册约定。

它注册了两个场景扩展:一个是多分辨率类型注册,一个是多运行表。插件还带 6 个 .ag 文件,但顶层 CMakeLists.txt:27-33 只把 wsf_multiresolution.ag 和 wsf_multirun_table.ag 交给 wsf_project_template 安装,另外四个模板和 generate_grammar.py 是构建期生成语法的输入,不随运行时安装。源码里有 6 个 .ag,用户机器上只会有 2 个,统计插件语法文件数量时要把"生成用"和"安装用"分开。

1.11.3 sosm:把外部模型包成核心传感器类型

wsf_sosm 包装的是 Spectral Optical Sensor Model。注册链路很普通:Register_wsf_sosm 注册特性 sosm,用 WsfDefaultApplicationExtension<WsfSOSM_Interface> 挂场景扩展(WsfSOSM_Interface.cpp:35-41)。特殊的是场景扩展做的事:

cpp 复制代码
// wsf_plugins/wsf_sosm/source/WsfSOSM_Interface.cpp:61-68
void WsfSOSM_Interface::AddedToScenario()
{
   mManagerPtr = new SOSM_Manager(GetScenario().GetAtmosphere());
   // Subscribe to receive notification when SOSM references a file (so we can log it in our log file).
   mCallbacks.Add(mManagerPtr->FileReferenced.Connect(&WsfSOSM_Interface::FileReferenced, this));
   // Register the sensor type
   WsfSensorTypes::Get(GetScenario()).AddCoreType("WSF_SOSM_SENSOR", ut::make_unique<WsfSOSM_Sensor>(GetScenario()));
}

AddCoreType 而不是 AddType,意味着 WSF_SOSM_SENSOR 和内置传感器类型处在同一张核心类型表里,用户写法与 WSF_RADAR_SENSOR 完全一致。语法文件也按这个身份描述:

xml 复制代码
# wsf_plugins/wsf_sosm/grammar/wsf_sosm.ag:12-24(节选)
(struct WSF_SOSM_SENSOR_mode :base_type WSF_SENSOR_MODE
{ sosm_sensor_type <string> | ranging_time <Time>
| ranging_time_track_quality <real>
| target_solar_illumination_angle <Angle> <Angle>
| <:Antenna> | <:Receiver> | <receiver-block> | <WSF_SENSOR_MODE> })

模式类型继承 WSF_SENSOR_MODE,因此 mode、mode_template 这些通用命令自动可用,插件只需要描述 SOSM 特有的参数。核心传感器 API 提供"模式、天线、接收机、探测报告"这套骨架,插件填的是物理模型。

SOSM 的计算内核放在独立的 sosm 子项目里,以 LIBRARY 形式构建(wsf_plugins/wsf_sosm/sosm/CMakeLists.txt:20-26),插件负责把它适配成 WsfSensor。这是一层典型的防腐设计:外部模型不依赖 AFSIM 类型系统,插件承担翻译。代价是插件与核心传感器 API 强耦合,核心升级时适配层必须跟着改。

1.11.4 三个边缘形态说明了什么

把这三个插件和前面的 coverage、annotation、altloc 放在一起,可以确认插件契约的真实边界:

  1. ABI 只约束两个符号。 WsfPluginVersion 和 WsfPluginSetup 之外的一切都是项目约定。没有 Register_* 可以动态加载,只注册脚本类型也算插件,插件目标只含一个注册文件也成立。
  2. CMake 约定是约定,不是契约。 WSF_EXT_SOURCE_PATH 可以是 .,安装目录可以是 mission_plugins,语法文件可以只安装一部分。读一个不熟悉的插件,先读 wsf_cmake_extension.cmake 和顶层 CMakeLists.txt,不要按标准模板推断。
  3. 静态构建是另一套规则。 动态加载只看入口符号,静态注册看 Register_* 符号是否存在。一个插件能在动态模式下工作,不代表能进静态构建的扩展表。
  4. 分类与分发是独立维度。 sosm 的 NO_EXPORT、scenario_analyzer 的工具链定位,都不影响它们作为插件工作,但决定了它们出现在哪个产品里。

1.12 排错:构建、加载与生效

插件问题分三段:CMake 发现(构建)、运行期加载(加载)、扩展注册(生效)。按此顺序查最快。

症状 常见根因 检查点
CMake 里没有 BUILD_WITH_<name> 目录缺 wsf_module,或缺 wsf_cmake_extension.cmake CMakeLists.txt:140-165
有选项但没有插件 DLL WSF_EXT_TYPE 未设为 plugin,按默认 lib 编入主程序 optional_projects.cmake:34;wsf_simdis/wsf_cmake_extension.cmake:11-12
DLL 存在但 "Plugins Loaded:" 为空 安装目录不在搜索路径;WSF_PLUGIN_PATH 替换默认且不拆分 WsfApplication.cpp:97-118;WsfPluginManager.cpp:85
日志报 Version mismatch. 或 Compiler mismatch. API 2.9 或编译器指纹(含 -hwe)不一致 UtPluginManager.cpp:85-120
插件已加载,但命令未识别或 feature 重复 RegisterFeature/类型表未执行;同一 DLL 以不同路径加载两次且注册无去重 WsfApplication.cpp:224-230;UtPluginManager.cpp:224
启动即崩,但日志没有插件错误 DLL 在版本校验前已执行全局构造或 DllMain UtPluginManager.cpp:409-417

1.13 全章回顾:四条可操作的判断

写到这里,可以把整章压缩成四条判断:

  1. 发现与语法都靠表,不靠硬编码。 wsf_module + wsf_cmake_extension.cmake 决定 CMake 能不能找到插件;WsfObjectTypeList、RegisterComponentFactory、AddCoreType 决定输入语法认不认新类型。新增插件先放对前两个文件,新增语法先建类型表。
  2. 生命周期不保证顺序,场景与仿真必须分开。 场景排序在输入配置阶段完成,仿真扩展从未排序;场景阶段拿到原型,仿真阶段拿到克隆,跨阶段指针一律在仿真侧按名字重解析。
  3. 扩展点由数据所有权决定,可选依赖要能降级。 只读数据放场景扩展并值拷贝;改平台初始状态放组件 PreInitialize;建仿真资源放 Initialize/PendingStart。缺 event_pipe 时 annotation 仍能解析,缺 event_output 时 altloc 仍能选点。
  4. 交付形态必须实测。 动态插件、静态链接、只装库、装到 mission_plugins,四种形态都存在;构建配置改一个变量,插件可见性就可能完全不同。

这四条不是从文档里抄的,而是从源码的分支、断言、注释和 CMake 条件里读出来的。它们也解释了为什么第 26 章的最小插件示例只能算起点:示例展示的是契约的下限,真实工程展示的是契约的边界。

1.14 小结

本章从 16 个插件的目录与构建形态出发,逐层读了三类代表:coverage 的"类型表 + 观测者 + 网格平台"体系,annotation 的"只读输入 + 事件管道"体系,alternate_locations 的"组件注入 + PreInitialize 改写"体系;又用 scenario_analyzer、multiresolution、sosm 三个边缘形态确认了契约的边界。

最重要的结论不是"某个插件有多少行",而是:插件是 AFSIM 里唯一可以在不改核心代码的前提下同时扩展输入语法、场景状态、仿真状态和输出通道的机制。它的代价是生命周期顺序、场景/仿真分离和交付形态这三处必须自己负责。理解了这三处,再看任何领域插件------电子战、IADS、互操作、飞行动力学------都只是在同一张骨架上填不同的领域模型。

下一章进入电子战与射频对抗:从 wsf_ew 的干扰链路、射频传播和干扰效果模型开始,看插件如何把"电磁频谱"这件事拆成可计算的组件。

相关推荐
愚农搬码1 小时前
低代码表单与在线问卷不是一回事:产品定位和技术边界
后端
武子康1 小时前
固定时间步已经开了,为什么游戏回放还会走出不同位置?
人工智能·后端·agent
70后写java的老码农1 小时前
换模型只改三行配置:LangChain4j + Spring Boot 3.5 实战,一次分别接入 9 家模型服务
后端·ai编程
愚农搬码1 小时前
2026 年开源表单设计器选型:哪个更好用?
后端
vipxieliang2 小时前
ValidX 正则表达式预编译优化:Pattern 复用策略
java·spring boot·后端
vipxieliang2 小时前
OpenTelemetry 生产实践:Skyscanner 24 集群管理经验与 Mastodon 小团队方案
后端
明月_清风2 小时前
为什么 Skill 很难商业化?Bot 才是 AI 时代真正的商业机会
人工智能·后端·agent
苍何2 小时前
DeepSeek 的 Super-agent 设计,和我们的工程实践
后端
挖掘狂人2 小时前
缓存和数据库的一致性,别停在 “延时双删”
java·后端·程序员